mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-28 06:35:18 -07:00
Compare commits
240
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
192979a762 | ||
|
|
e16cc42d53 | ||
|
|
f10e965003 | ||
|
|
a8968d4081 | ||
|
|
7c4afbe4df | ||
|
|
a180fcc56f | ||
|
|
1860b8dc92 | ||
|
|
1597937535 | ||
|
|
ac41a89359 | ||
|
|
60c0fe3b92 | ||
|
|
c99828cf76 | ||
|
|
5c4b979480 | ||
|
|
2d1b0ae820 | ||
|
|
69486c2a77 | ||
|
|
e9f63d6c57 | ||
|
|
8906bee23e | ||
|
|
0dabb121c9 | ||
|
|
944ba227ca | ||
|
|
473bb3e9fb | ||
|
|
0d0b62ea93 | ||
|
|
798cd40f05 | ||
|
|
3187344f01 | ||
|
|
8efcc95606 | ||
|
|
87cab9473d | ||
|
|
7b0fbfb567 | ||
|
|
7c1ea0a1e1 | ||
|
|
b3012ed10c | ||
|
|
88536d27f7 | ||
|
|
89d6e364d4 | ||
|
|
b7781951df | ||
|
|
fe19a9ca47 | ||
|
|
439fedcbf2 | ||
|
|
0813a3d9d6 | ||
|
|
9514c6596c | ||
|
|
4e84415da7 | ||
|
|
82cd4bf2ef | ||
|
|
3c30c5bec1 | ||
|
|
c9d7bc4f27 | ||
|
|
34e17bd469 | ||
|
|
aada13a5c9 | ||
|
|
de8558d197 | ||
|
|
9d79ea367a | ||
|
|
04316f7adc | ||
|
|
4e4361d350 | ||
|
|
e9a249587c | ||
|
|
d8a9ed7d15 | ||
|
|
3dbf1c200e | ||
|
|
40fcb8d917 | ||
|
|
ad64d1c3d9 | ||
|
|
f826e45250 | ||
|
|
3d53c06c5b | ||
|
|
9835b9f6d4 | ||
|
|
a15dd30b1e | ||
|
|
1d343ac071 | ||
|
|
ca602de0ae | ||
|
|
cdc0293ca8 | ||
|
|
e7f749f082 | ||
|
|
d42e926e5c | ||
|
|
32768ea874 | ||
|
|
b585e18ccf | ||
|
|
655910457f | ||
|
|
d6984f1057 | ||
|
|
a637aebe69 | ||
|
|
a5269d23db | ||
|
|
fc450e5024 | ||
|
|
a99c2b572d | ||
|
|
96289e95f1 | ||
|
|
e316b0b4bb | ||
|
|
732270b571 | ||
|
|
0c6aa15746 | ||
|
|
410413dc57 | ||
|
|
e239be5bbb | ||
|
|
f80782a90a | ||
|
|
f1ba73a386 | ||
|
|
f1963740b4 | ||
|
|
4d6c976ad9 | ||
|
|
8377152d86 | ||
|
|
7a511e3756 | ||
|
|
6d261c44a1 | ||
|
|
103e98b38f | ||
|
|
1c61b47a64 | ||
|
|
626e3740e1 | ||
|
|
310a4acb02 | ||
|
|
899b90202b | ||
|
|
e8d54d52d3 | ||
|
|
00c5b75ffb | ||
|
|
25134b4ba9 | ||
|
|
3d922ec846 | ||
|
|
638820c839 | ||
|
|
7cbf5a1ded | ||
|
|
e89a7eb7e7 | ||
|
|
e18757bab3 | ||
|
|
0e6c678fc3 | ||
|
|
942dabbcac | ||
|
|
b915825165 | ||
|
|
f3fc63942f | ||
|
|
9044b986f3 | ||
|
|
b01076b6b3 | ||
|
|
5121c76e39 | ||
|
|
49ebf6222e | ||
|
|
509b0e71cc | ||
|
|
81f8be1a94 | ||
|
|
655a60ca81 | ||
|
|
52285362ce | ||
|
|
3ea587797f | ||
|
|
325714bb83 | ||
|
|
9aa7080c51 | ||
|
|
97292ecef7 | ||
|
|
837f8525d8 | ||
|
|
70ca7f66cb | ||
|
|
c12b5d6f0a | ||
|
|
139fa38e3f | ||
|
|
0e9f5db40f | ||
|
|
2f535a772f | ||
|
|
b420637957 | ||
|
|
bfd7b815a5 | ||
|
|
cac80f6af0 | ||
|
|
47ce4cafdf | ||
|
|
bfe912e41a | ||
|
|
5ccf79a8f7 | ||
|
|
1d32170c2e | ||
|
|
ca74c155e2 | ||
|
|
1f770a157d | ||
|
|
d64e24d422 | ||
|
|
77d86ba835 | ||
|
|
986a748420 | ||
|
|
50e01d17f8 | ||
|
|
084c51b983 | ||
|
|
efbbbc7ec1 | ||
|
|
8e7f0cb9ad | ||
|
|
3357a06cba | ||
|
|
f58c7c1cf3 | ||
|
|
ea41213123 | ||
|
|
b5801891b8 | ||
|
|
8f77c041f5 | ||
|
|
5a3f3ba030 | ||
|
|
3c25ee6e2c | ||
|
|
b92b0dd508 | ||
|
|
670900bf5a | ||
|
|
219cfb1605 | ||
|
|
bf728a780c | ||
|
|
3e6513c0fb | ||
|
|
c54ee14173 | ||
|
|
cc07d4d3c9 | ||
|
|
9beb9d7fec | ||
|
|
76bb207b2b | ||
|
|
3576521d62 | ||
|
|
2df4ece388 | ||
|
|
cbb4979ed6 | ||
|
|
753158c1c9 | ||
|
|
573f82a7e6 | ||
|
|
1e5afc2bef | ||
|
|
163528bf69 | ||
|
|
e1ad7a6e73 | ||
|
|
411e91bb19 | ||
|
|
05cf163744 | ||
|
|
d46eb5bcc6 | ||
|
|
6359dee406 | ||
|
|
a69c216794 | ||
|
|
2867421550 | ||
|
|
758577fd4b | ||
|
|
3d2506767d | ||
|
|
cdef2163c1 | ||
|
|
9955e1dcb7 | ||
|
|
19a28bf6c5 | ||
|
|
a8ecf3f31d | ||
|
|
d744e634a8 | ||
|
|
a362d7de2a | ||
|
|
3f10a70d4c | ||
|
|
d0dfe78701 | ||
|
|
172addd918 | ||
|
|
ada309cfb9 | ||
|
|
30ee07c2e3 | ||
|
|
d21c63b52c | ||
|
|
5ad67d7ecb | ||
|
|
6cc96c2614 | ||
|
|
38bf96ff20 | ||
|
|
0b14cb1b2c | ||
|
|
4d24e69012 | ||
|
|
90436e428d | ||
|
|
e4bb288904 | ||
|
|
6f8bc7f23b | ||
|
|
baca111d50 | ||
|
|
cc298fe6d8 | ||
|
|
46b8f6b882 | ||
|
|
edfc6e99fe | ||
|
|
d00e28ffda | ||
|
|
162cf4fb84 | ||
|
|
68558243d9 | ||
|
|
8d5ad926f9 | ||
|
|
334f037dce | ||
|
|
f6522eea80 | ||
|
|
7615a08f81 | ||
|
|
28a4fd4824 | ||
|
|
31ea3c68a5 | ||
|
|
54d72ddfd0 | ||
|
|
d4794f78e1 | ||
|
|
aa7c9a9a8d | ||
|
|
ca6ed0998a | ||
|
|
829d4d6d5b | ||
|
|
80c87c8e2c | ||
|
|
427d811954 | ||
|
|
0be7975db5 | ||
|
|
40e4af828a | ||
|
|
0e57826ea5 | ||
|
|
eb2cd861b1 | ||
|
|
701cc647a7 | ||
|
|
be6ccaf044 | ||
|
|
2e6efa00a2 | ||
|
|
788a04f265 | ||
|
|
1040625a88 | ||
|
|
6f4503b521 | ||
|
|
f5b6edc2e7 | ||
|
|
8197f0724c | ||
|
|
d40f7d2676 | ||
|
|
99fbcca7f4 | ||
|
|
04f9880c9a | ||
|
|
b9c858295d | ||
|
|
610f64c762 | ||
|
|
220333b3bb | ||
|
|
e194e95512 | ||
|
|
e796412c2c | ||
|
|
cb541521d2 | ||
|
|
2bc243f93e | ||
|
|
0209008d73 | ||
|
|
5cb54ee03c | ||
|
|
0922845101 | ||
|
|
64dd29d35a | ||
|
|
9bde534860 | ||
|
|
97eb570b28 | ||
|
|
7d0557a099 | ||
|
|
60a03c56a9 | ||
|
|
d3393fb940 | ||
|
|
07c0aba883 | ||
|
|
77418a52ae | ||
|
|
46f6806e14 | ||
|
|
20851ccc2b | ||
|
|
0b17073345 | ||
|
|
17106b1e40 | ||
|
|
7fcca09f24 |
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 0.1.11
|
current_version = 0.2.3
|
||||||
commit = True
|
commit = True
|
||||||
tag = True
|
tag = True
|
||||||
tag_name = v{new_version}
|
tag_name = v{new_version}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Version control
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Desktop-only (not needed in web container)
|
||||||
|
tauri/
|
||||||
|
landing/
|
||||||
|
docs/
|
||||||
|
mlx-test/
|
||||||
|
scripts/
|
||||||
|
|
||||||
|
# Dependencies & build artifacts (rebuilt in Docker)
|
||||||
|
node_modules/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Data (will be bind-mounted)
|
||||||
|
data/
|
||||||
|
backend/data/
|
||||||
|
|
||||||
|
# IDE & OS
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Config files not needed in container
|
||||||
|
biome.json
|
||||||
|
.biomeignore
|
||||||
|
.bumpversion.cfg
|
||||||
|
.npmrc
|
||||||
|
Makefile
|
||||||
|
CHANGELOG.md
|
||||||
|
CONTRIBUTING.md
|
||||||
|
SECURITY.md
|
||||||
|
LICENSE
|
||||||
|
README.md
|
||||||
|
backend/README.md
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
name: Build Windows
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-windows:
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: "pip"
|
||||||
|
|
||||||
|
- name: Install Python dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install pyinstaller
|
||||||
|
pip install -r backend/requirements.txt
|
||||||
|
|
||||||
|
- name: Build Python server
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
python build_binary.py
|
||||||
|
|
||||||
|
PLATFORM=$(rustc --print host-tuple)
|
||||||
|
mkdir -p ../tauri/src-tauri/binaries
|
||||||
|
cp dist/voicebox-server.exe ../tauri/src-tauri/binaries/voicebox-server-${PLATFORM}.exe
|
||||||
|
echo "Built voicebox-server-${PLATFORM}.exe"
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: "./tauri/src-tauri -> target"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install
|
||||||
|
|
||||||
|
- uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
projectPath: tauri
|
||||||
|
tagName: v__VERSION__
|
||||||
|
releaseName: "voicebox v__VERSION__ (test build)"
|
||||||
|
releaseBody: "Test build for audio export fix"
|
||||||
|
releaseDraft: true
|
||||||
|
prerelease: true
|
||||||
|
args: ""
|
||||||
|
includeUpdaterJson: false
|
||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
@@ -14,22 +14,18 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- platform: 'macos-latest'
|
- platform: "macos-latest"
|
||||||
args: '--target aarch64-apple-darwin'
|
args: "--target aarch64-apple-darwin"
|
||||||
python-version: '3.12'
|
python-version: "3.12"
|
||||||
backend: 'mlx'
|
backend: "mlx"
|
||||||
- platform: 'macos-15-intel'
|
- platform: "macos-15-intel"
|
||||||
args: '--target x86_64-apple-darwin'
|
args: "--target x86_64-apple-darwin"
|
||||||
python-version: '3.12'
|
python-version: "3.12"
|
||||||
backend: 'pytorch'
|
backend: "pytorch"
|
||||||
# - platform: 'ubuntu-22.04'
|
- platform: "windows-latest"
|
||||||
# args: ''
|
args: ""
|
||||||
# python-version: '3.12'
|
python-version: "3.12"
|
||||||
# backend: 'pytorch'
|
backend: "pytorch"
|
||||||
- platform: 'windows-latest'
|
|
||||||
args: ''
|
|
||||||
python-version: '3.12'
|
|
||||||
backend: 'pytorch'
|
|
||||||
|
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
|
|
||||||
@@ -37,10 +33,10 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install dependencies (ubuntu only)
|
- name: Install dependencies (ubuntu only)
|
||||||
if: matrix.platform == 'ubuntu-22.04'
|
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf llvm-dev
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf llvm-dev libasound2-dev
|
||||||
|
|
||||||
- name: Install LLVM (macOS)
|
- name: Install LLVM (macOS)
|
||||||
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
|
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
|
||||||
@@ -53,13 +49,19 @@ jobs:
|
|||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
cache: 'pip'
|
cache: "pip"
|
||||||
|
|
||||||
|
- name: Install CPU-only PyTorch (Linux)
|
||||||
|
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
|
||||||
|
run: |
|
||||||
|
pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||||
|
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install pyinstaller
|
pip install pyinstaller
|
||||||
pip install -r backend/requirements.txt
|
pip install -r backend/requirements.txt
|
||||||
|
pip install --no-deps chatterbox-tts
|
||||||
|
|
||||||
- name: Install MLX dependencies (Apple Silicon only)
|
- name: Install MLX dependencies (Apple Silicon only)
|
||||||
if: matrix.backend == 'mlx'
|
if: matrix.backend == 'mlx'
|
||||||
@@ -100,7 +102,7 @@ jobs:
|
|||||||
- name: Rust cache
|
- name: Rust cache
|
||||||
uses: swatinem/rust-cache@v2
|
uses: swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
workspaces: './tauri/src-tauri -> target'
|
workspaces: "./tauri/src-tauri -> target"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
@@ -121,7 +123,7 @@ jobs:
|
|||||||
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
|
||||||
- uses: tauri-apps/tauri-action@v0
|
- uses: tauri-apps/tauri-action@v0.6
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
@@ -136,7 +138,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
projectPath: tauri
|
projectPath: tauri
|
||||||
tagName: v__VERSION__
|
tagName: v__VERSION__
|
||||||
releaseName: 'voicebox v__VERSION__'
|
releaseName: "voicebox v__VERSION__"
|
||||||
releaseBody: |
|
releaseBody: |
|
||||||
## What's Changed
|
## What's Changed
|
||||||
See the assets below to download and install this version.
|
See the assets below to download and install this version.
|
||||||
@@ -145,10 +147,71 @@ jobs:
|
|||||||
- **macOS (Apple Silicon)**: Download the `aarch64.dmg` file - uses MLX for fast native inference
|
- **macOS (Apple Silicon)**: Download the `aarch64.dmg` file - uses MLX for fast native inference
|
||||||
- **macOS (Intel)**: Download the `x64.dmg` file - uses PyTorch
|
- **macOS (Intel)**: Download the `x64.dmg` file - uses PyTorch
|
||||||
- **Windows**: Download the `.msi` installer
|
- **Windows**: Download the `.msi` installer
|
||||||
- **Linux**: Download the `.AppImage` or `.deb` package
|
- **Linux**: Compile from source (see README)
|
||||||
|
|
||||||
The app includes automatic updates - future updates will be installed automatically.
|
The app includes automatic updates - future updates will be installed automatically.
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
args: ${{ matrix.args }}
|
args: ${{ matrix.args }}
|
||||||
includeUpdaterJson: true
|
includeUpdaterJson: true
|
||||||
|
|
||||||
|
build-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
|
||||||
|
pip install --no-deps chatterbox-tts
|
||||||
|
|
||||||
|
- 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
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: voicebox-server-cuda-windows
|
||||||
|
path: backend/dist/voicebox-server-cuda.exe
|
||||||
|
retention-days: 7
|
||||||
|
|||||||
+2
-4
@@ -35,10 +35,7 @@ target/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Data (user-generated)
|
# Data (user-generated)
|
||||||
data/profiles/*
|
data/
|
||||||
data/generations/*
|
|
||||||
data/projects/*
|
|
||||||
data/voicebox.db
|
|
||||||
!data/.gitkeep
|
!data/.gitkeep
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
@@ -52,6 +49,7 @@ logs/
|
|||||||
# Generated files
|
# Generated files
|
||||||
app/openapi.json
|
app/openapi.json
|
||||||
tauri/src-tauri/binaries/*
|
tauri/src-tauri/binaries/*
|
||||||
|
tauri/src-tauri/gen/Assets.car
|
||||||
|
|
||||||
# Temporary
|
# Temporary
|
||||||
tmp/
|
tmp/
|
||||||
|
|||||||
+20
-6
@@ -5,6 +5,14 @@ All notable changes to Voicebox will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Profile Name Validation** - Added proper validation to prevent duplicate profile names ([#134](https://github.com/jamiepine/voicebox/issues/134))
|
||||||
|
- Users now receive clear error messages when attempting to create or update profiles with duplicate names
|
||||||
|
- Improved error handling in create and update profile API endpoints
|
||||||
|
- Added comprehensive test suite for duplicate name validation
|
||||||
|
|
||||||
## [0.1.0] - 2026-01-25
|
## [0.1.0] - 2026-01-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -53,15 +61,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Audio export failing when Tauri save dialog returns object instead of string path
|
||||||
|
- OpenAPI client generator script now documents the local backend port and avoids an unused loop variable warning
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Makefile** - Comprehensive development workflow automation with commands for setup, development, building, testing, and code quality checks
|
- **justfile** - Comprehensive development workflow automation with commands for setup, development, building, testing, and code quality checks
|
||||||
- Includes Python version detection and compatibility warnings
|
- Cross-platform support (macOS, Linux, Windows)
|
||||||
- Self-documenting help system with `make help`
|
- Python version detection and compatibility warnings
|
||||||
- Colored output for better readability
|
- Self-documenting help system with `just --list`
|
||||||
- Supports parallel development server execution
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **README** - Added Makefile reference and updated Quick Start with Makefile-based setup instructions alongside manual setup
|
- **README** - Updated Quick Start with justfile-based setup instructions
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **Makefile** - Replaced by justfile (cross-platform, simpler syntax)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+46
-88
@@ -27,87 +27,47 @@ Thank you for your interest in contributing to Voicebox! This document provides
|
|||||||
```bash
|
```bash
|
||||||
rustc --version # Check if installed
|
rustc --version # Check if installed
|
||||||
```
|
```
|
||||||
|
- **[Tauri Prerequisites](https://v2.tauri.app/start/prerequisites)** - Tauri-specific system dependencies (varies by OS).
|
||||||
|
|
||||||
- **Git** - Version control
|
- **Git** - Version control
|
||||||
|
|
||||||
### Development Setup
|
### Development Setup
|
||||||
|
|
||||||
**Using the Makefile (recommended for macOS/Linux):** Run `make setup` to install all dependencies, then `make dev` to start development servers. See `make help` for all available commands.
|
Install [just](https://github.com/casey/just) (`brew install just`, `cargo install just`, or `winget install Casey.Just`), then:
|
||||||
|
|
||||||
**Manual setup (required for Windows):**
|
```bash
|
||||||
|
git clone https://github.com/YOUR_USERNAME/voicebox.git
|
||||||
|
cd voicebox
|
||||||
|
|
||||||
1. **Fork and clone the repository**
|
just setup # creates venv, installs Python + JS deps
|
||||||
```bash
|
just dev # starts backend + desktop app
|
||||||
git clone https://github.com/YOUR_USERNAME/voicebox.git
|
```
|
||||||
cd voicebox
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Install JavaScript dependencies**
|
`just setup` handles everything automatically, including:
|
||||||
```bash
|
- Creating a Python virtual environment
|
||||||
bun install
|
- Installing Python dependencies (with CUDA PyTorch on Windows if an NVIDIA GPU is detected)
|
||||||
```
|
- Installing MLX dependencies on Apple Silicon
|
||||||
This installs dependencies for:
|
- Installing JavaScript dependencies
|
||||||
- `app/` - Shared React frontend
|
|
||||||
- `tauri/` - Tauri desktop wrapper
|
|
||||||
- `web/` - Web deployment wrapper
|
|
||||||
|
|
||||||
3. **Set up Python backend**
|
`just dev` starts the backend and desktop app together. If a backend is already running (e.g. from `just dev-backend` in another terminal), it detects it and only starts the frontend.
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
|
|
||||||
# Create virtual environment
|
|
||||||
python -m venv venv
|
|
||||||
|
|
||||||
# Activate virtual environment
|
|
||||||
source venv/bin/activate # On macOS/Linux
|
|
||||||
# or
|
|
||||||
venv\Scripts\activate # On Windows
|
|
||||||
|
|
||||||
# Install Python dependencies
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# Install MLX dependencies (Apple Silicon only - for faster inference)
|
|
||||||
# On Apple Silicon, this enables native Metal acceleration
|
|
||||||
if [[ $(uname -m) == "arm64" ]]; then
|
|
||||||
pip install -r requirements-mlx.txt
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install Qwen3-TTS (required for voice synthesis)
|
|
||||||
pip install git+https://github.com/QwenLM/Qwen3-TTS.git
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Start development servers**
|
Other useful commands:
|
||||||
|
|
||||||
Development requires two terminals: one for the Python backend, one for the Tauri app.
|
```bash
|
||||||
|
just dev-web # backend + web app (no Tauri/Rust build)
|
||||||
|
just dev-backend # backend only
|
||||||
|
just dev-frontend # Tauri app only (backend must be running)
|
||||||
|
just kill # stop all dev processes
|
||||||
|
just clean-all # nuke everything and start fresh
|
||||||
|
just --list # see all available commands
|
||||||
|
```
|
||||||
|
|
||||||
**Terminal 1: Backend server** (start this first)
|
> **Note:** In dev mode, the app connects to a manually-started Python server.
|
||||||
```bash
|
> The bundled server binary is only used in production builds.
|
||||||
cd backend
|
|
||||||
source venv/bin/activate # Activate venv if not already active
|
|
||||||
bun run dev:server
|
|
||||||
# Or manually: uvicorn main:app --reload --port 17493
|
|
||||||
```
|
|
||||||
Backend will be available at `http://localhost:17493`
|
|
||||||
|
|
||||||
**Terminal 2: Desktop app**
|
#### Windows Notes
|
||||||
```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 justfile works natively on Windows via PowerShell. No WSL or Git Bash required. On Windows with an NVIDIA GPU, `just setup` automatically installs CUDA-enabled PyTorch for GPU acceleration.
|
||||||
> The bundled server binary is only used in production builds.
|
|
||||||
|
|
||||||
**Optional: Web app**
|
|
||||||
```bash
|
|
||||||
bun run dev:web
|
|
||||||
```
|
|
||||||
Web app will be available at `http://localhost:5174`
|
|
||||||
|
|
||||||
### Model Downloads
|
### Model Downloads
|
||||||
|
|
||||||
@@ -119,25 +79,30 @@ First-time usage will be slower due to model downloads, but subsequent runs will
|
|||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
||||||
**Build everything (recommended):**
|
**Build production app:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run build
|
just build # Build CPU server binary + Tauri installer
|
||||||
```
|
```
|
||||||
This automatically:
|
|
||||||
1. Builds the Python server binary (`./scripts/build-server.sh`)
|
On Windows, to build with CUDA support for local testing:
|
||||||
2. Builds the Tauri desktop app (`cd tauri && bun run tauri build`)
|
|
||||||
|
```bash
|
||||||
|
just build-local # Build CPU + CUDA server binaries + Tauri installer
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds the CPU sidecar (bundled with the app), the CUDA binary (placed in `%APPDATA%/com.voicebox.app/backends/` for runtime GPU switching), and the installable Tauri app.
|
||||||
|
|
||||||
Creates platform-specific installers (`.dmg`, `.msi`, `.AppImage`) in `tauri/src-tauri/target/release/bundle/`.
|
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).
|
**Individual build targets:**
|
||||||
|
|
||||||
**Build server binary only:**
|
|
||||||
```bash
|
```bash
|
||||||
bun run build:server
|
just build-server # CPU server binary only
|
||||||
# or
|
just build-server-cuda # CUDA server binary only (Windows)
|
||||||
./scripts/build-server.sh
|
just build-tauri # Tauri desktop app only
|
||||||
|
just build-web # Web app only
|
||||||
```
|
```
|
||||||
Creates platform-specific binary in `tauri/src-tauri/binaries/`
|
|
||||||
|
|
||||||
**Building with local Qwen3-TTS development version:**
|
**Building with local Qwen3-TTS development version:**
|
||||||
|
|
||||||
@@ -145,17 +110,10 @@ If you're actively developing or modifying the Qwen3-TTS library, set the `QWEN_
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
export QWEN_TTS_PATH=~/path/to/your/Qwen3-TTS
|
export QWEN_TTS_PATH=~/path/to/your/Qwen3-TTS
|
||||||
bun run build:server
|
just build-server
|
||||||
```
|
```
|
||||||
|
|
||||||
This makes PyInstaller use your local qwen-tts version instead of the pip-installed package. Useful when testing changes to the TTS library before they're published to PyPI or when using an editable install (`pip install -e`).
|
This makes PyInstaller use your local qwen-tts version instead of the pip-installed package.
|
||||||
|
|
||||||
**Build web app:**
|
|
||||||
```bash
|
|
||||||
cd web
|
|
||||||
bun run build
|
|
||||||
```
|
|
||||||
Output in `web/dist/`
|
|
||||||
|
|
||||||
### Generate OpenAPI Client
|
### Generate OpenAPI Client
|
||||||
|
|
||||||
@@ -407,7 +365,7 @@ See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues and sol
|
|||||||
|
|
||||||
- **Backend won't start:** Check Python version (3.11+), ensure venv is activated, install dependencies
|
- **Backend won't start:** Check Python version (3.11+), ensure venv is activated, install dependencies
|
||||||
- **Tauri build fails:** Ensure Rust is installed, clean build with `cd tauri/src-tauri && cargo clean`
|
- **Tauri build fails:** Ensure Rust is installed, clean build with `cd tauri/src-tauri && cargo clean`
|
||||||
- **OpenAPI client generation fails:** Ensure backend is running, check `curl http://localhost:8000/openapi.json`
|
- **OpenAPI client generation fails:** Ensure backend is running, check `curl http://localhost:17493/openapi.json`
|
||||||
|
|
||||||
## Questions?
|
## Questions?
|
||||||
|
|
||||||
|
|||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Voicebox — Local TTS Server with Web UI (CPU)
|
||||||
|
# 3-stage build: Frontend → Python deps → Runtime
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# === Stage 1: Build frontend ===
|
||||||
|
FROM oven/bun:1 AS frontend
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copy workspace config and frontend source
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
COPY app/ ./app/
|
||||||
|
COPY web/ ./web/
|
||||||
|
|
||||||
|
# Strip workspaces not needed for web build, and fix trailing comma
|
||||||
|
RUN sed -i '/"tauri"/d; /"landing"/d' package.json && \
|
||||||
|
sed -i -z 's/,\n ]/\n ]/' package.json
|
||||||
|
RUN bun install --no-save
|
||||||
|
# Build frontend (skip tsc — upstream has pre-existing type errors)
|
||||||
|
RUN cd web && bunx --bun vite build
|
||||||
|
|
||||||
|
|
||||||
|
# === Stage 2: Build Python dependencies ===
|
||||||
|
FROM python:3.11-slim AS backend-builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||||
|
RUN pip install --no-cache-dir --prefix=/install \
|
||||||
|
git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||||
|
|
||||||
|
|
||||||
|
# === Stage 3: Runtime ===
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Create non-root user for security
|
||||||
|
RUN groupadd -r voicebox && \
|
||||||
|
useradd -r -g voicebox -m -s /bin/bash voicebox
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install only runtime system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ffmpeg \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy installed Python packages from builder stage
|
||||||
|
COPY --from=backend-builder /install /usr/local
|
||||||
|
|
||||||
|
# Copy backend application code
|
||||||
|
COPY --chown=voicebox:voicebox backend/ /app/backend/
|
||||||
|
|
||||||
|
# Copy built frontend from frontend stage
|
||||||
|
COPY --from=frontend --chown=voicebox:voicebox /build/web/dist /app/frontend/
|
||||||
|
|
||||||
|
# Create data directories owned by non-root user
|
||||||
|
RUN mkdir -p /app/data/generations /app/data/profiles /app/data/cache \
|
||||||
|
&& chown -R voicebox:voicebox /app/data
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER voicebox
|
||||||
|
|
||||||
|
# Expose the API port
|
||||||
|
EXPOSE 17493
|
||||||
|
|
||||||
|
# Health check — auto-restart if the server hangs
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s \
|
||||||
|
CMD curl -f http://localhost:17493/health || exit 1
|
||||||
|
|
||||||
|
# Start the FastAPI server
|
||||||
|
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "17493"]
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
# 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
|
|
||||||
@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 && $(MAKE) dev-frontend & \
|
|
||||||
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: `just build`
|
||||||
|
2. Disconnect from internet
|
||||||
|
3. Try generating speech
|
||||||
|
4. Should work without network requests
|
||||||
|
|
||||||
|
## Build Instructions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
just setup
|
||||||
|
|
||||||
|
# Build the app
|
||||||
|
just build
|
||||||
|
|
||||||
|
# Or build just the server
|
||||||
|
just 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*
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>The open-source voice synthesis studio.</strong><br/>
|
<strong>The open-source voice synthesis studio.</strong><br/>
|
||||||
Clone voices. Generate speech. Build voice-powered apps.<br/>
|
Clone voices. Generate speech. Apply effects. Build voice-powered apps.<br/>
|
||||||
All running locally on your machine.
|
All running locally on your machine.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -59,118 +59,166 @@
|
|||||||
|
|
||||||
## What is Voicebox?
|
## What is Voicebox?
|
||||||
|
|
||||||
Voicebox is a **local-first voice cloning studio** with DAW-like features for professional voice synthesis. Think of it as the **Ollama for voice** — download models, clone voices, and generate speech entirely on your machine.
|
Voicebox is a **local-first voice cloning studio** — a free and open-source alternative to ElevenLabs. Clone voices from a few seconds of audio, generate speech in 23 languages across 4 TTS engines, apply post-processing effects, and compose multi-voice projects with a timeline editor.
|
||||||
|
|
||||||
Unlike cloud services that lock your voice data behind subscriptions, Voicebox gives you:
|
|
||||||
|
|
||||||
- **Complete privacy** — models and voice data stay on your machine
|
- **Complete privacy** — models and voice data stay on your machine
|
||||||
- **Professional tools** — multi-track timeline editor, audio trimming, conversation mixing
|
- **4 TTS engines** — Qwen3-TTS, LuxTTS, Chatterbox Multilingual, and Chatterbox Turbo
|
||||||
- **Model flexibility** — currently powered by Qwen3-TTS, with support for XTTS, Bark, and other models coming soon
|
- **23 languages** — from English to Arabic, Japanese, Hindi, Swahili, and more
|
||||||
- **API-first** — use the desktop app or integrate voice synthesis into your own projects
|
- **Post-processing effects** — pitch shift, reverb, delay, chorus, compression, and filters
|
||||||
|
- **Expressive speech** — paralinguistic tags like `[laugh]`, `[sigh]`, `[gasp]` via Chatterbox Turbo
|
||||||
|
- **Unlimited length** — auto-chunking with crossfade for scripts, articles, and chapters
|
||||||
|
- **Stories editor** — multi-track timeline for conversations, podcasts, and narratives
|
||||||
|
- **API-first** — REST API for integrating voice synthesis into your own projects
|
||||||
- **Native performance** — built with Tauri (Rust), not Electron
|
- **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
|
- **Runs everywhere** — macOS (MLX/Metal), Windows (CUDA), Linux, AMD ROCm, Intel Arc, Docker
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
Voicebox is available now for macOS and Windows.
|
|
||||||
|
|
||||||
| Platform | Download |
|
| Platform | Download |
|
||||||
|----------|----------|
|
|----------|----------|
|
||||||
| macOS (Apple Silicon) | [voicebox_aarch64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_aarch64.app.tar.gz) |
|
| macOS (Apple Silicon) | [Download DMG](https://voicebox.sh/download/mac-arm) |
|
||||||
| macOS (Intel) | [voicebox_x64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/download/v0.1.0/voicebox_x64.app.tar.gz) |
|
| macOS (Intel) | [Download DMG](https://voicebox.sh/download/mac-intel) |
|
||||||
| 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 | [Download MSI](https://voicebox.sh/download/windows) |
|
||||||
| 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) |
|
| Docker | `docker compose up` |
|
||||||
|
|
||||||
> **Linux builds coming soon** — Currently blocked by GitHub runner disk space limitations.
|
> **[View all binaries →](https://github.com/jamiepine/voicebox/releases/latest)**
|
||||||
|
|
||||||
|
> **Linux** — Pre-built binaries are not yet available. See [voicebox.sh/linux-install](https://voicebox.sh/linux-install) for build-from-source instructions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Voice Cloning with Qwen3-TTS
|
### Multi-Engine Voice Cloning
|
||||||
|
|
||||||
Powered by Alibaba's **Qwen3-TTS** — a breakthrough model that achieves near-perfect voice cloning from just a few seconds of audio.
|
Four TTS engines with different strengths, switchable per-generation:
|
||||||
|
|
||||||
- **Instant cloning** — Upload a sample, get a voice profile
|
| Engine | Languages | Strengths |
|
||||||
- **High fidelity** — Natural prosody, emotion, and cadence
|
|--------|-----------|-----------|
|
||||||
- **Multi-language** — English, Chinese, and more coming
|
| **Qwen3-TTS** (0.6B / 1.7B) | 10 | High-quality multilingual cloning, delivery instructions ("speak slowly", "whisper") |
|
||||||
- **Lightning fast on Mac** — MLX backend leverages Apple Silicon's Neural Engine for super fast generation
|
| **LuxTTS** | English | Lightweight (~1GB VRAM), 48kHz output, 150x realtime on CPU |
|
||||||
|
| **Chatterbox Multilingual** | 23 | Broadest language coverage — Arabic, Danish, Finnish, Greek, Hebrew, Hindi, Malay, Norwegian, Polish, Swahili, Swedish, Turkish and more |
|
||||||
|
| **Chatterbox Turbo** | English | Fast 350M model with paralinguistic emotion/sound tags |
|
||||||
|
|
||||||
|
### Emotions & Paralinguistic Tags
|
||||||
|
|
||||||
|
Type `/` in the text input to insert expressive tags that the model synthesizes inline with speech (Chatterbox Turbo):
|
||||||
|
|
||||||
|
`[laugh]` `[chuckle]` `[gasp]` `[cough]` `[sigh]` `[groan]` `[sniff]` `[shush]` `[clear throat]`
|
||||||
|
|
||||||
|
### Post-Processing Effects
|
||||||
|
|
||||||
|
8 audio effects powered by Spotify's `pedalboard` library. Apply after generation, preview in real time, build reusable presets.
|
||||||
|
|
||||||
|
| Effect | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| Pitch Shift | Up or down by up to 12 semitones |
|
||||||
|
| Reverb | Configurable room size, damping, wet/dry mix |
|
||||||
|
| Delay | Echo with adjustable time, feedback, and mix |
|
||||||
|
| Chorus / Flanger | Modulated delay for metallic or lush textures |
|
||||||
|
| Compressor | Dynamic range compression |
|
||||||
|
| Gain | Volume adjustment (-40 to +40 dB) |
|
||||||
|
| High-Pass Filter | Remove low frequencies |
|
||||||
|
| Low-Pass Filter | Remove high frequencies |
|
||||||
|
|
||||||
|
Ships with 4 built-in presets (Robotic, Radio, Echo Chamber, Deep Voice) and supports custom presets. Effects can be assigned per-profile as defaults.
|
||||||
|
|
||||||
|
### Unlimited Generation Length
|
||||||
|
|
||||||
|
Text is automatically split at sentence boundaries and each chunk is generated independently, then crossfaded together. Works with all engines.
|
||||||
|
|
||||||
|
- Configurable auto-chunking limit (100–5,000 chars)
|
||||||
|
- Crossfade slider (0–200ms) for smooth transitions
|
||||||
|
- Max text length: 50,000 characters
|
||||||
|
- Smart splitting respects abbreviations, CJK punctuation, and `[tags]`
|
||||||
|
|
||||||
|
### Generation Versions
|
||||||
|
|
||||||
|
Every generation supports multiple versions with provenance tracking:
|
||||||
|
|
||||||
|
- **Original** — clean TTS output, always preserved
|
||||||
|
- **Effects versions** — apply different effects chains from any source version
|
||||||
|
- **Takes** — regenerate with a new seed for variation
|
||||||
|
- **Source tracking** — each version records its lineage
|
||||||
|
- **Favorites** — star generations for quick access
|
||||||
|
|
||||||
|
### Async Generation Queue
|
||||||
|
|
||||||
|
Generation is non-blocking. Submit and immediately start typing the next one.
|
||||||
|
|
||||||
|
- Serial execution queue prevents GPU contention
|
||||||
|
- Real-time SSE status streaming
|
||||||
|
- Failed generations can be retried
|
||||||
|
- Stale generations from crashes auto-recover on startup
|
||||||
|
|
||||||
### Voice Profile Management
|
### Voice Profile Management
|
||||||
|
|
||||||
- **Create profiles** from audio files or record directly in-app
|
- Create profiles from audio files or record directly in-app
|
||||||
- **Import/Export** profiles to share or backup
|
- Import/export profiles to share or back up
|
||||||
- **Multi-sample support** — combine multiple samples for higher quality cloning
|
- Multi-sample support for higher quality cloning
|
||||||
- **Organize** with descriptions and language tags
|
- Per-profile default effects chains
|
||||||
|
- Organize with descriptions and language tags
|
||||||
### Speech Generation
|
|
||||||
|
|
||||||
- **Text-to-speech** with any cloned voice
|
|
||||||
- **Batch generation** for long-form content
|
|
||||||
- **Smart caching** — regenerate instantly with voice prompt caching
|
|
||||||
|
|
||||||
### Stories Editor
|
### Stories Editor
|
||||||
|
|
||||||
Create multi-voice narratives, podcasts, and conversations with a timeline-based editor.
|
Multi-voice timeline editor for conversations, podcasts, and narratives.
|
||||||
|
|
||||||
- **Multi-track composition** — arrange multiple voice tracks in a single project
|
- Multi-track composition with drag-and-drop
|
||||||
- **Inline audio editing** — trim and split clips directly in the timeline
|
- Inline audio trimming and splitting
|
||||||
- **Auto-playback** — preview stories with synchronized playhead
|
- Auto-playback with synchronized playhead
|
||||||
- **Voice mixing** — build conversations with multiple participants
|
- Version pinning per track clip
|
||||||
|
|
||||||
### Recording & Transcription
|
### Recording & Transcription
|
||||||
|
|
||||||
- **In-app recording** with waveform visualization
|
- In-app recording with waveform visualization
|
||||||
- **System audio capture** — record desktop audio on macOS and Windows
|
- System audio capture (macOS and Windows)
|
||||||
- **Automatic transcription** powered by Whisper
|
- Automatic transcription powered by Whisper (including Whisper Turbo)
|
||||||
- **Export recordings** in multiple formats
|
- Export recordings in multiple formats
|
||||||
|
|
||||||
### Generation History
|
### Model Management
|
||||||
|
|
||||||
- **Full history** of all generated audio
|
- Per-model unload to free GPU memory without deleting downloads
|
||||||
- **Search & filter** by voice, text, or date
|
- Custom models directory via `VOICEBOX_MODELS_DIR`
|
||||||
- **Re-generate** any past generation with one click
|
- Model folder migration with progress tracking
|
||||||
|
- Download cancel/clear UI
|
||||||
|
|
||||||
### Flexible Deployment
|
### GPU Support
|
||||||
|
|
||||||
- **Local mode** — Everything runs on your machine
|
| Platform | Backend | Notes |
|
||||||
- **Remote mode** — Connect to a GPU server on your network
|
|----------|---------|-------|
|
||||||
- **One-click server** — Turn any machine into a Voicebox server
|
| macOS (Apple Silicon) | MLX (Metal) | 4-5x faster via Neural Engine |
|
||||||
|
| Windows / Linux (NVIDIA) | PyTorch (CUDA) | Auto-downloads CUDA binary from within the app |
|
||||||
|
| Linux (AMD) | PyTorch (ROCm) | Auto-configures HSA_OVERRIDE_GFX_VERSION |
|
||||||
|
| Windows (any GPU) | DirectML | Universal Windows GPU support |
|
||||||
|
| Intel Arc | IPEX/XPU | Intel discrete GPU acceleration |
|
||||||
|
| Any | CPU | Works everywhere, just slower |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
Voicebox exposes a full REST API, so you can integrate voice synthesis into your own apps.
|
Voicebox exposes a full REST API for integrating voice synthesis into your own apps.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate speech
|
# Generate speech
|
||||||
curl -X POST http://localhost:8000/generate \
|
curl -X POST http://localhost:17493/generate \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"text": "Hello world", "profile_id": "abc123", "language": "en"}'
|
-d '{"text": "Hello world", "profile_id": "abc123", "language": "en"}'
|
||||||
|
|
||||||
# List voice profiles
|
# List voice profiles
|
||||||
curl http://localhost:8000/profiles
|
curl http://localhost:17493/profiles
|
||||||
|
|
||||||
# Create a profile
|
# Create a profile
|
||||||
curl -X POST http://localhost:8000/profiles \
|
curl -X POST http://localhost:17493/profiles \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"name": "My Voice", "language": "en"}'
|
-d '{"name": "My Voice", "language": "en"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
**Use cases:**
|
**Use cases:** game dialogue, podcast production, accessibility tools, voice assistants, content automation.
|
||||||
|
|
||||||
- Game dialogue systems
|
Full API documentation available at `http://localhost:17493/docs`.
|
||||||
- Podcast/video production pipelines
|
|
||||||
- Accessibility tools
|
|
||||||
- Voice assistants
|
|
||||||
- Content creation automation
|
|
||||||
|
|
||||||
Full API documentation available at `http://localhost:8000/docs` when running.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -182,42 +230,24 @@ Full API documentation available at `http://localhost:8000/docs` when running.
|
|||||||
| Frontend | React, TypeScript, Tailwind CSS |
|
| Frontend | React, TypeScript, Tailwind CSS |
|
||||||
| State | Zustand, React Query |
|
| State | Zustand, React Query |
|
||||||
| Backend | FastAPI (Python) |
|
| Backend | FastAPI (Python) |
|
||||||
| Voice Model | Qwen3-TTS (PyTorch or MLX) |
|
| TTS Engines | Qwen3-TTS, LuxTTS, Chatterbox, Chatterbox Turbo |
|
||||||
| Transcription | Whisper (PyTorch or MLX) |
|
| Effects | Pedalboard (Spotify) |
|
||||||
| Inference Engine | MLX (Apple Silicon) / PyTorch (Windows/Linux/Intel) |
|
| Transcription | Whisper / Whisper Turbo (PyTorch or MLX) |
|
||||||
|
| Inference | MLX (Apple Silicon) / PyTorch (CUDA/ROCm/XPU/CPU) |
|
||||||
| Database | SQLite |
|
| Database | SQLite |
|
||||||
| Audio | WaveSurfer.js, librosa |
|
| Audio | WaveSurfer.js, librosa |
|
||||||
|
|
||||||
**Why this stack?**
|
|
||||||
|
|
||||||
- **Tauri over Electron** — 10x smaller bundle, native performance, lower memory
|
|
||||||
- **FastAPI** — Async Python with automatic OpenAPI schema generation
|
|
||||||
- **Type-safe end-to-end** — Generated TypeScript client from OpenAPI spec
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
Voicebox is the beginning of something bigger. Here's what's coming:
|
|
||||||
|
|
||||||
### Coming Soon
|
|
||||||
|
|
||||||
| Feature | Description |
|
| Feature | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| **Real-time Synthesis** | Stream audio as it generates, word by word |
|
| **Real-time Streaming** | Stream audio as it generates, word by word |
|
||||||
| **Conversation Mode** | Multi-speaker dialogues with automatic turn-taking |
|
| **Voice Design** | Create new voices from text descriptions |
|
||||||
| **Voice Effects** | Pitch shift, reverb, M3GAN-style effects |
|
|
||||||
| **Timeline Editor** | Audio studio with word-level precision editing |
|
|
||||||
| **More Models** | XTTS, Bark, and other open-source voice models |
|
| **More Models** | XTTS, Bark, and other open-source voice models |
|
||||||
|
| **Plugin Architecture** | Extend with custom models and effects |
|
||||||
### Future Vision
|
| **Mobile Companion** | Control Voicebox from your phone |
|
||||||
|
|
||||||
- **Voice Design** — Create new voices from text descriptions
|
|
||||||
- **Project System** — Save and load complex multi-voice sessions
|
|
||||||
- **Plugin Architecture** — Extend with custom models and effects
|
|
||||||
- **Mobile Companion** — Control Voicebox from your phone
|
|
||||||
|
|
||||||
Voicebox aims to be the **one-stop shop for everything voice** — cloning, synthesis, editing, effects, and beyond.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -225,47 +255,27 @@ Voicebox aims to be the **one-stop shop for everything voice** — cloning, synt
|
|||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed setup and contribution guidelines.
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed setup and contribution guidelines.
|
||||||
|
|
||||||
**Using the Makefile (recommended):** Run `make help` to see all available commands for setup, development, building, and testing.
|
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
**With Makefile (Unix/macOS/Linux):**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repo
|
git clone https://github.com/jamiepine/voicebox.git
|
||||||
git clone https://github.com/voicebox-sh/voicebox.git
|
|
||||||
cd voicebox
|
cd voicebox
|
||||||
|
|
||||||
# Setup everything
|
just setup # creates Python venv, installs all deps
|
||||||
make setup
|
just dev # starts backend + desktop app
|
||||||
|
|
||||||
# Start development
|
|
||||||
make dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Manual setup (all platforms):**
|
Install [just](https://github.com/casey/just): `brew install just` or `cargo install just`. Run `just --list` to see all commands.
|
||||||
|
|
||||||
|
**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org), [Tauri Prerequisites](https://v2.tauri.app/start/prerequisites/), and [Xcode](https://developer.apple.com/xcode/) on macOS.
|
||||||
|
|
||||||
|
### Building Locally
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repo
|
just build # Build CPU server binary + Tauri app
|
||||||
git clone https://github.com/voicebox-sh/voicebox.git
|
just build-local # (Windows) Build CPU + CUDA server binaries + Tauri app
|
||||||
cd voicebox
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
bun install
|
|
||||||
|
|
||||||
# Install Python dependencies
|
|
||||||
cd backend && pip install -r requirements.txt && cd ..
|
|
||||||
|
|
||||||
# Start development
|
|
||||||
bun run dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org).
|
|
||||||
|
|
||||||
**Performance:**
|
|
||||||
- **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
|
### Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/app",
|
"name": "@voicebox/app",
|
||||||
"version": "0.1.11",
|
"version": "0.2.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+19
-7
@@ -1,13 +1,14 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { RouterProvider } from '@tanstack/react-router';
|
import { RouterProvider } from '@tanstack/react-router';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||||
import ShinyText from '@/components/ShinyText';
|
import ShinyText from '@/components/ShinyText';
|
||||||
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||||
|
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||||
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
import { router } from '@/router';
|
import { router } from '@/router';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
|
||||||
|
|
||||||
const LOADING_MESSAGES = [
|
const LOADING_MESSAGES = [
|
||||||
'Warming up tensors...',
|
'Warming up tensors...',
|
||||||
@@ -38,6 +39,9 @@ function App() {
|
|||||||
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
||||||
const serverStartingRef = useRef(false);
|
const serverStartingRef = useRef(false);
|
||||||
|
|
||||||
|
// Automatically check for app updates on startup and show toast notifications
|
||||||
|
useAutoUpdater({ checkOnMount: true, showToast: true });
|
||||||
|
|
||||||
// Sync stored setting to Rust on startup
|
// Sync stored setting to Rust on startup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (platform.metadata.isTauri) {
|
if (platform.metadata.isTauri) {
|
||||||
@@ -46,14 +50,18 @@ function App() {
|
|||||||
console.error('Failed to sync initial setting to Rust:', error);
|
console.error('Failed to sync initial setting to Rust:', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [platform]);
|
// Empty dependency array - platform is stable from context, only run once
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.metadata.isTauri, platform.lifecycle]);
|
||||||
|
|
||||||
// Setup lifecycle callbacks
|
// Setup lifecycle callbacks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
platform.lifecycle.onServerReady = () => {
|
platform.lifecycle.onServerReady = () => {
|
||||||
setServerReady(true);
|
setServerReady(true);
|
||||||
};
|
};
|
||||||
}, [platform]);
|
// Empty dependency array - platform is stable from context, only run once
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.lifecycle]);
|
||||||
|
|
||||||
// Setup window close handler and auto-start server when running in Tauri (production only)
|
// Setup window close handler and auto-start server when running in Tauri (production only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -85,10 +93,12 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serverStartingRef.current = true;
|
serverStartingRef.current = true;
|
||||||
console.log('Production mode: Starting bundled server...');
|
const isRemote = useServerStore.getState().mode === 'remote';
|
||||||
|
const customModelsDir = useServerStore.getState().customModelsDir;
|
||||||
|
console.log(`Production mode: Starting bundled server... (remote: ${isRemote})`);
|
||||||
|
|
||||||
platform.lifecycle
|
platform.lifecycle
|
||||||
.startServer(false)
|
.startServer(isRemote, customModelsDir)
|
||||||
.then((serverUrl) => {
|
.then((serverUrl) => {
|
||||||
console.log('Server is ready at:', serverUrl);
|
console.log('Server is ready at:', serverUrl);
|
||||||
// Update the server URL in the store with the dynamically assigned port
|
// Update the server URL in the store with the dynamically assigned port
|
||||||
@@ -111,7 +121,9 @@ function App() {
|
|||||||
// Window close event handles server shutdown based on setting
|
// Window close event handles server shutdown based on setting
|
||||||
serverStartingRef.current = false;
|
serverStartingRef.current = false;
|
||||||
};
|
};
|
||||||
}, [platform]);
|
// Empty dependency array - platform is stable from context, only run once
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.metadata.isTauri, platform.lifecycle]);
|
||||||
|
|
||||||
// Cycle through loading messages every 3 seconds
|
// Cycle through loading messages every 3 seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react';
|
import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||||
import WaveSurfer from 'wavesurfer.js';
|
import WaveSurfer from 'wavesurfer.js';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
import { debug } from '@/lib/utils/debug';
|
import { debug } from '@/lib/utils/debug';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
export function AudioPlayer() {
|
export function AudioPlayer() {
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
|
const volumeLabelId = useId();
|
||||||
const {
|
const {
|
||||||
audioUrl,
|
audioUrl,
|
||||||
audioId,
|
audioId,
|
||||||
@@ -138,7 +139,11 @@ export function AudioPlayer() {
|
|||||||
barRadius: 2,
|
barRadius: 2,
|
||||||
height: 80,
|
height: 80,
|
||||||
normalize: true,
|
normalize: true,
|
||||||
backend: 'WebAudio',
|
// Use MediaElement backend (default). Unlike the WebAudio backend,
|
||||||
|
// MediaElement uses a standard <audio> element for playback which
|
||||||
|
// benefits from the browser/webview's built-in audio session recovery.
|
||||||
|
// This prevents audio loss when another app steals audio output or
|
||||||
|
// the system audio session is interrupted.
|
||||||
interact: true, // Enable interaction (click to seek)
|
interact: true, // Enable interaction (click to seek)
|
||||||
mediaControls: false, // Don't show native controls
|
mediaControls: false, // Don't show native controls
|
||||||
});
|
});
|
||||||
@@ -156,8 +161,21 @@ export function AudioPlayer() {
|
|||||||
const wavesurfer = wavesurferRef.current;
|
const wavesurfer = wavesurferRef.current;
|
||||||
if (!wavesurfer) return;
|
if (!wavesurfer) return;
|
||||||
|
|
||||||
// Update store when time changes
|
// Update store when time changes, stop if past duration
|
||||||
wavesurfer.on('timeupdate', (time) => {
|
wavesurfer.on('timeupdate', (time) => {
|
||||||
|
const dur = usePlayerStore.getState().duration;
|
||||||
|
if (dur > 0 && time >= dur) {
|
||||||
|
setCurrentTime(dur);
|
||||||
|
const loop = usePlayerStore.getState().isLooping;
|
||||||
|
if (loop) {
|
||||||
|
wavesurfer.seekTo(0);
|
||||||
|
wavesurfer.play();
|
||||||
|
} else {
|
||||||
|
wavesurfer.pause();
|
||||||
|
setIsPlaying(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCurrentTime(time);
|
setCurrentTime(time);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -175,15 +193,6 @@ export function AudioPlayer() {
|
|||||||
const currentVolume = usePlayerStore.getState().volume;
|
const currentVolume = usePlayerStore.getState().volume;
|
||||||
wavesurfer.setVolume(currentVolume);
|
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 && !isUsingNativePlaybackRef.current) {
|
|
||||||
mediaElement.volume = currentVolume;
|
|
||||||
mediaElement.muted = false;
|
|
||||||
debug.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-play when ready - check if we should use native playback
|
// 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)
|
// Get current values from the store and queries at runtime (not captured closure values)
|
||||||
const currentAudioUrl = usePlayerStore.getState().audioUrl;
|
const currentAudioUrl = usePlayerStore.getState().audioUrl;
|
||||||
@@ -250,21 +259,8 @@ export function AudioPlayer() {
|
|||||||
debug.log('Should use native playback:', shouldUseNative);
|
debug.log('Should use native playback:', shouldUseNative);
|
||||||
|
|
||||||
if (!shouldUseNative) {
|
if (!shouldUseNative) {
|
||||||
debug.log('No custom devices assigned, falling back to WaveSurfer');
|
debug.log('No custom devices assigned, using standard playback');
|
||||||
// Reset native playback flag and unmute WaveSurfer
|
|
||||||
isUsingNativePlaybackRef.current = false;
|
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 {
|
} else {
|
||||||
const deviceIds = assignedChannels.flatMap((ch: any) => ch.device_ids);
|
const deviceIds = assignedChannels.flatMap((ch: any) => ch.device_ids);
|
||||||
debug.log('Device IDs to play to:', deviceIds);
|
debug.log('Device IDs to play to:', deviceIds);
|
||||||
@@ -285,19 +281,10 @@ export function AudioPlayer() {
|
|||||||
// Mark that we're using native playback
|
// Mark that we're using native playback
|
||||||
isUsingNativePlaybackRef.current = true;
|
isUsingNativePlaybackRef.current = true;
|
||||||
|
|
||||||
// Mute WaveSurfer's audio element to prevent UI audio output
|
// Mute WaveSurfer's audio output — native handles the actual sound
|
||||||
// Keep WaveSurfer running for visualization
|
// Keep WaveSurfer running for waveform visualization
|
||||||
const mediaElement = wavesurfer.getMediaElement();
|
wavesurfer.setVolume(0);
|
||||||
if (mediaElement) {
|
wavesurfer.setMuted(true);
|
||||||
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)
|
// Start WaveSurfer playback for visualization (muted)
|
||||||
wavesurfer.play().catch((error) => {
|
wavesurfer.play().catch((error) => {
|
||||||
@@ -320,38 +307,15 @@ export function AudioPlayer() {
|
|||||||
'Native playback failed during auto-play, falling back to WaveSurfer:',
|
'Native playback failed during auto-play, falling back to WaveSurfer:',
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
// Reset native playback flag and unmute WaveSurfer
|
|
||||||
isUsingNativePlaybackRef.current = false;
|
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
|
// Fall through to WaveSurfer playback
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
debug.log('Not using native playback, using WaveSurfer');
|
|
||||||
// Reset native playback flag and unmute WaveSurfer
|
// Standard playback path — ensure WaveSurfer is unmuted
|
||||||
isUsingNativePlaybackRef.current = false;
|
if (!isUsingNativePlaybackRef.current) {
|
||||||
const mediaElement = wavesurfer.getMediaElement();
|
wavesurfer.setMuted(false);
|
||||||
if (mediaElement) {
|
wavesurfer.setVolume(usePlayerStore.getState().volume);
|
||||||
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)
|
// Only auto-play if shouldAutoPlay flag is set (user explicitly clicked to play)
|
||||||
@@ -359,7 +323,7 @@ export function AudioPlayer() {
|
|||||||
if (shouldAutoPlayNow) {
|
if (shouldAutoPlayNow) {
|
||||||
// Clear the flag first
|
// Clear the flag first
|
||||||
usePlayerStore.getState().clearAutoPlayFlag();
|
usePlayerStore.getState().clearAutoPlayFlag();
|
||||||
|
|
||||||
// Use a small delay to ensure audio element is fully ready
|
// Use a small delay to ensure audio element is fully ready
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
wavesurfer.play().catch((error) => {
|
wavesurfer.play().catch((error) => {
|
||||||
@@ -375,28 +339,6 @@ export function AudioPlayer() {
|
|||||||
// Handle play/pause
|
// Handle play/pause
|
||||||
wavesurfer.on('play', () => {
|
wavesurfer.on('play', () => {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
// Ensure audio element volume is set correctly
|
|
||||||
const mediaElement = wavesurfer.getMediaElement();
|
|
||||||
if (mediaElement) {
|
|
||||||
// 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));
|
wavesurfer.on('pause', () => setIsPlaying(false));
|
||||||
wavesurfer.on('finish', () => {
|
wavesurfer.on('finish', () => {
|
||||||
@@ -478,11 +420,6 @@ export function AudioPlayer() {
|
|||||||
if (wavesurferRef.current) {
|
if (wavesurferRef.current) {
|
||||||
debug.log('Destroying WaveSurfer instance');
|
debug.log('Destroying WaveSurfer instance');
|
||||||
try {
|
try {
|
||||||
const mediaElement = wavesurferRef.current.getMediaElement();
|
|
||||||
if (mediaElement) {
|
|
||||||
mediaElement.pause();
|
|
||||||
mediaElement.src = '';
|
|
||||||
}
|
|
||||||
wavesurferRef.current.destroy();
|
wavesurferRef.current.destroy();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Error destroying WaveSurfer:', error);
|
debug.error('Error destroying WaveSurfer:', error);
|
||||||
@@ -523,13 +460,10 @@ export function AudioPlayer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reset native playback flag when loading new audio
|
// Reset native playback flag when loading new audio
|
||||||
// Also unmute WaveSurfer if it was muted
|
// Unmute WaveSurfer if it was muted for native playback
|
||||||
if (isUsingNativePlaybackRef.current) {
|
if (isUsingNativePlaybackRef.current) {
|
||||||
const mediaElement = wavesurfer.getMediaElement();
|
wavesurfer.setMuted(false);
|
||||||
if (mediaElement) {
|
wavesurfer.setVolume(usePlayerStore.getState().volume);
|
||||||
mediaElement.muted = false;
|
|
||||||
mediaElement.volume = usePlayerStore.getState().volume;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
isUsingNativePlaybackRef.current = false;
|
isUsingNativePlaybackRef.current = false;
|
||||||
|
|
||||||
@@ -545,16 +479,7 @@ export function AudioPlayer() {
|
|||||||
wavesurfer.pause();
|
wavesurfer.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop the media element explicitly
|
// Use empty() to completely destroy the waveform and reset media
|
||||||
const mediaElement = wavesurfer.getMediaElement();
|
|
||||||
if (mediaElement) {
|
|
||||||
debug.log('Stopping media element');
|
|
||||||
mediaElement.pause();
|
|
||||||
mediaElement.currentTime = 0;
|
|
||||||
mediaElement.src = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use empty() to completely destroy the waveform and media element
|
|
||||||
debug.log('Calling wavesurfer.empty() to destroy audio');
|
debug.log('Calling wavesurfer.empty() to destroy audio');
|
||||||
wavesurfer.empty();
|
wavesurfer.empty();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -609,20 +534,13 @@ export function AudioPlayer() {
|
|||||||
// Sync volume
|
// Sync volume
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (wavesurferRef.current) {
|
if (wavesurferRef.current) {
|
||||||
wavesurferRef.current.setVolume(volume);
|
// If using native playback, keep WaveSurfer muted regardless of volume setting
|
||||||
// Also ensure the underlying audio element volume is set
|
if (isUsingNativePlaybackRef.current) {
|
||||||
const mediaElement = wavesurferRef.current.getMediaElement();
|
wavesurferRef.current.setVolume(0);
|
||||||
if (mediaElement) {
|
debug.log('Volume sync: Using native playback, keeping WaveSurfer muted');
|
||||||
// If using native playback, keep WaveSurfer muted regardless of volume setting
|
} else {
|
||||||
if (isUsingNativePlaybackRef.current) {
|
wavesurferRef.current.setVolume(volume);
|
||||||
mediaElement.volume = 0;
|
debug.log('Volume synced:', volume);
|
||||||
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]);
|
}, [volume]);
|
||||||
@@ -664,7 +582,7 @@ export function AudioPlayer() {
|
|||||||
// Handle shouldAutoPlay flag - for story mode auto-advance
|
// Handle shouldAutoPlay flag - for story mode auto-advance
|
||||||
const shouldAutoPlay = usePlayerStore((state) => state.shouldAutoPlay);
|
const shouldAutoPlay = usePlayerStore((state) => state.shouldAutoPlay);
|
||||||
const clearAutoPlayFlag = usePlayerStore((state) => state.clearAutoPlayFlag);
|
const clearAutoPlayFlag = usePlayerStore((state) => state.clearAutoPlayFlag);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wavesurfer = wavesurferRef.current;
|
const wavesurfer = wavesurferRef.current;
|
||||||
if (!wavesurfer || !shouldAutoPlay || duration === 0) {
|
if (!wavesurfer || !shouldAutoPlay || duration === 0) {
|
||||||
@@ -743,11 +661,8 @@ export function AudioPlayer() {
|
|||||||
isUsingNativePlaybackRef.current = true;
|
isUsingNativePlaybackRef.current = true;
|
||||||
|
|
||||||
// Mute WaveSurfer and start it for visualization
|
// Mute WaveSurfer and start it for visualization
|
||||||
const mediaElement = wavesurferRef.current.getMediaElement();
|
wavesurferRef.current.setVolume(0);
|
||||||
if (mediaElement) {
|
wavesurferRef.current.setMuted(true);
|
||||||
mediaElement.volume = 0;
|
|
||||||
mediaElement.muted = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start WaveSurfer for visualization (muted)
|
// Start WaveSurfer for visualization (muted)
|
||||||
wavesurferRef.current.play().catch((error) => {
|
wavesurferRef.current.play().catch((error) => {
|
||||||
@@ -771,11 +686,8 @@ export function AudioPlayer() {
|
|||||||
} else {
|
} else {
|
||||||
// Ensure WaveSurfer is not muted if not using native playback
|
// Ensure WaveSurfer is not muted if not using native playback
|
||||||
if (!isUsingNativePlaybackRef.current) {
|
if (!isUsingNativePlaybackRef.current) {
|
||||||
const mediaElement = wavesurferRef.current.getMediaElement();
|
wavesurferRef.current.setMuted(false);
|
||||||
if (mediaElement) {
|
wavesurferRef.current.setVolume(volume);
|
||||||
mediaElement.muted = false;
|
|
||||||
mediaElement.volume = volume;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wavesurferRef.current.play().catch((error) => {
|
wavesurferRef.current.play().catch((error) => {
|
||||||
@@ -831,6 +743,9 @@ export function AudioPlayer() {
|
|||||||
disabled={isLoading || duration === 0}
|
disabled={isLoading || duration === 0}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
|
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
|
||||||
|
aria-label={
|
||||||
|
duration === 0 && !isLoading ? 'Audio not loaded' : isPlaying ? 'Pause' : 'Play'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -845,6 +760,8 @@ export function AudioPlayer() {
|
|||||||
max={100}
|
max={100}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
aria-label="Playback position"
|
||||||
|
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
@@ -862,7 +779,9 @@ export function AudioPlayer() {
|
|||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
{title && (
|
{title && (
|
||||||
<div className="text-sm font-medium truncate max-w-[200px] shrink-0">{title}</div>
|
<div className="text-sm font-medium truncate max-w-[200px] shrink-0 hidden lg:block">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loop Button */}
|
{/* Loop Button */}
|
||||||
@@ -872,26 +791,37 @@ export function AudioPlayer() {
|
|||||||
onClick={toggleLoop}
|
onClick={toggleLoop}
|
||||||
className={isLooping ? 'text-primary' : ''}
|
className={isLooping ? 'text-primary' : ''}
|
||||||
title="Toggle loop"
|
title="Toggle loop"
|
||||||
|
aria-label={isLooping ? 'Stop looping' : 'Loop'}
|
||||||
>
|
>
|
||||||
<Repeat className="h-4 w-4" />
|
<Repeat className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Volume Control */}
|
{/* Volume Control */}
|
||||||
<div className="flex items-center gap-2 shrink-0 w-[120px]">
|
<div
|
||||||
|
className="flex items-center gap-2 shrink-0 w-[120px]"
|
||||||
|
role="group"
|
||||||
|
aria-label="Volume"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setVolume(volume > 0 ? 0 : 1)}
|
onClick={() => setVolume(volume > 0 ? 0 : 1)}
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
|
aria-label={volume > 0 ? 'Mute' : 'Unmute'}
|
||||||
>
|
>
|
||||||
{volume > 0 ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
|
{volume > 0 ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
<span id={volumeLabelId} className="sr-only">
|
||||||
|
Volume level, {Math.round(volume * 100)}%
|
||||||
|
</span>
|
||||||
<Slider
|
<Slider
|
||||||
value={[volume * 100]}
|
value={[volume * 100]}
|
||||||
onValueChange={handleVolumeChange}
|
onValueChange={handleVolumeChange}
|
||||||
max={100}
|
max={100}
|
||||||
step={1}
|
step={1}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
|
aria-labelledby={volumeLabelId}
|
||||||
|
aria-valuetext={`${Math.round(volume * 100)}%`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -902,6 +832,7 @@ export function AudioPlayer() {
|
|||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
title="Close player"
|
title="Close player"
|
||||||
|
aria-label="Close player"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ import {
|
|||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
interface AudioDevice {
|
interface AudioDevice {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -124,6 +124,13 @@ export function AudioTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleChannelDelete = async (e, channelId) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (await confirm('Delete this channel?')) {
|
||||||
|
deleteChannel.mutate(channelId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const allChannels = channels || [];
|
const allChannels = channels || [];
|
||||||
const allDevices = devices || [];
|
const allDevices = devices || [];
|
||||||
const selectedChannel = selectedChannelId
|
const selectedChannel = selectedChannelId
|
||||||
@@ -161,7 +168,7 @@ export function AudioTab() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3 p-2">
|
<div className="space-y-3">
|
||||||
{allChannels.map((channel) => {
|
{allChannels.map((channel) => {
|
||||||
const isSelected = selectedChannelId === channel.id;
|
const isSelected = selectedChannelId === channel.id;
|
||||||
return (
|
return (
|
||||||
@@ -241,12 +248,7 @@ export function AudioTab() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
onClick={(e) => {
|
onClick={(e) => handleChannelDelete(e, channel.id)}
|
||||||
e.stopPropagation();
|
|
||||||
if (confirm('Delete this channel?')) {
|
|
||||||
deleteChannel.mutate(channel.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -341,7 +343,9 @@ export function AudioTab() {
|
|||||||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
||||||
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
|
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
<p className="text-muted-foreground text-center">
|
<p className="text-muted-foreground text-center">
|
||||||
{platform.metadata.isTauri ? 'No audio devices found' : 'Audio device selection requires Tauri'}
|
{platform.metadata.isTauri
|
||||||
|
? 'No audio devices found'
|
||||||
|
: 'Audio device selection requires Tauri'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
type DragEndEvent,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { ChevronDown, ChevronRight, GripVertical, Plus, Power, Trash2 } from 'lucide-react';
|
||||||
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { AvailableEffect, EffectConfig, EffectPresetResponse } from '@/lib/api/types';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
// Each effect in the chain gets a stable ID for dnd-kit
|
||||||
|
interface EffectWithId extends EffectConfig {
|
||||||
|
_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
function makeId() {
|
||||||
|
return `fx-${++nextId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EffectsChainEditorProps {
|
||||||
|
value: EffectConfig[];
|
||||||
|
onChange: (chain: EffectConfig[]) => void;
|
||||||
|
compact?: boolean;
|
||||||
|
showPresets?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EffectsChainEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
compact = false,
|
||||||
|
showPresets = true,
|
||||||
|
}: EffectsChainEditorProps) {
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Maintain stable IDs for each effect across renders.
|
||||||
|
// We use a ref to map value items to IDs, rebuilding when length changes.
|
||||||
|
const idsRef = useRef<string[]>([]);
|
||||||
|
const items: EffectWithId[] = useMemo(() => {
|
||||||
|
// Grow ID array if effects were added
|
||||||
|
while (idsRef.current.length < value.length) {
|
||||||
|
idsRef.current.push(makeId());
|
||||||
|
}
|
||||||
|
// Shrink if effects were removed
|
||||||
|
if (idsRef.current.length > value.length) {
|
||||||
|
idsRef.current = idsRef.current.slice(0, value.length);
|
||||||
|
}
|
||||||
|
return value.map((e, i) => ({ ...e, _id: idsRef.current[i] }));
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: availableEffects } = useQuery({
|
||||||
|
queryKey: ['available-effects'],
|
||||||
|
queryFn: () => apiClient.getAvailableEffects(),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: presets } = useQuery({
|
||||||
|
queryKey: ['effect-presets'],
|
||||||
|
queryFn: () => apiClient.listEffectPresets(),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const effectsMap = useMemo(() => {
|
||||||
|
const m = new Map<string, AvailableEffect>();
|
||||||
|
if (availableEffects) {
|
||||||
|
for (const e of availableEffects.effects) {
|
||||||
|
m.set(e.type, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [availableEffects]);
|
||||||
|
|
||||||
|
function addEffect(type: string) {
|
||||||
|
const def = effectsMap.get(type);
|
||||||
|
if (!def) return;
|
||||||
|
const params: Record<string, number> = {};
|
||||||
|
for (const [key, p] of Object.entries(def.params)) {
|
||||||
|
params[key] = p.default;
|
||||||
|
}
|
||||||
|
const newEffect: EffectConfig = { type, enabled: true, params };
|
||||||
|
const newId = makeId();
|
||||||
|
idsRef.current = [...idsRef.current, newId];
|
||||||
|
onChange([...value, newEffect]);
|
||||||
|
setExpandedId(newId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeEffect = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const removedId = idsRef.current[index];
|
||||||
|
idsRef.current = idsRef.current.filter((_, i) => i !== index);
|
||||||
|
onChange(value.filter((_, i) => i !== index));
|
||||||
|
if (expandedId === removedId) setExpandedId(null);
|
||||||
|
},
|
||||||
|
[value, onChange, expandedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleEnabled = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
onChange(value.map((e, i) => (i === index ? { ...e, enabled: !e.enabled } : e)));
|
||||||
|
},
|
||||||
|
[value, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateParam = useCallback(
|
||||||
|
(index: number, paramName: string, paramValue: number) => {
|
||||||
|
onChange(
|
||||||
|
value.map((e, i) =>
|
||||||
|
i === index ? { ...e, params: { ...e.params, [paramName]: paramValue } } : e,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[value, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
function loadPreset(preset: EffectPresetResponse) {
|
||||||
|
idsRef.current = preset.effects_chain.map(() => makeId());
|
||||||
|
onChange(preset.effects_chain);
|
||||||
|
setExpandedId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
idsRef.current = [];
|
||||||
|
onChange([]);
|
||||||
|
setExpandedId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
const oldIndex = idsRef.current.indexOf(active.id as string);
|
||||||
|
const newIndex = idsRef.current.indexOf(over.id as string);
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return;
|
||||||
|
|
||||||
|
idsRef.current = arrayMove(idsRef.current, oldIndex, newIndex);
|
||||||
|
onChange(arrayMove([...value], oldIndex, newIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-2', compact && 'text-sm')}>
|
||||||
|
{/* Preset selector row */}
|
||||||
|
{showPresets && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={(id) => {
|
||||||
|
const preset = presets?.find((p) => p.id === id);
|
||||||
|
if (preset) loadPreset(preset);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 flex-1 text-xs focus:ring-0 focus:ring-offset-0">
|
||||||
|
<SelectValue placeholder="Load preset..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{presets?.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
{p.description && (
|
||||||
|
<span className="ml-1 text-muted-foreground">- {p.description}</span>
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{value.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2 text-xs text-muted-foreground"
|
||||||
|
onClick={clearAll}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sortable effects chain */}
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<SortableContext items={items.map((i) => i._id)} strategy={verticalListSortingStrategy}>
|
||||||
|
{items.map((effect, index) => (
|
||||||
|
<SortableEffectItem
|
||||||
|
key={effect._id}
|
||||||
|
id={effect._id}
|
||||||
|
effect={effect}
|
||||||
|
index={index}
|
||||||
|
effectDef={effectsMap.get(effect.type)}
|
||||||
|
isExpanded={expandedId === effect._id}
|
||||||
|
onToggleExpand={() => setExpandedId(expandedId === effect._id ? null : effect._id)}
|
||||||
|
onRemove={() => removeEffect(index)}
|
||||||
|
onToggleEnabled={() => toggleEnabled(index)}
|
||||||
|
onUpdateParam={(paramName, paramValue) => updateParam(index, paramName, paramValue)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
{/* Add effect */}
|
||||||
|
{availableEffects && (
|
||||||
|
<Select onValueChange={addEffect}>
|
||||||
|
<SelectTrigger className="h-8 border-dashed text-xs text-muted-foreground focus:ring-0 focus:ring-offset-0">
|
||||||
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||||
|
<SelectValue placeholder="Add effect..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableEffects.effects.map((e) => (
|
||||||
|
<SelectItem key={e.type} value={e.type}>
|
||||||
|
{e.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sortable effect item
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface SortableEffectItemProps {
|
||||||
|
id: string;
|
||||||
|
effect: EffectConfig;
|
||||||
|
index: number;
|
||||||
|
effectDef?: AvailableEffect;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggleExpand: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onToggleEnabled: () => void;
|
||||||
|
onUpdateParam: (paramName: string, paramValue: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableEffectItem({
|
||||||
|
id,
|
||||||
|
effect,
|
||||||
|
effectDef,
|
||||||
|
isExpanded,
|
||||||
|
onToggleExpand,
|
||||||
|
onRemove,
|
||||||
|
onToggleEnabled,
|
||||||
|
onUpdateParam,
|
||||||
|
}: SortableEffectItemProps) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
zIndex: isDragging ? 10 : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = effectDef?.label ?? effect.type;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border',
|
||||||
|
effect.enabled ? 'border-border bg-card' : 'border-border/50 bg-muted/30',
|
||||||
|
isDragging && 'opacity-80 shadow-lg',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-1 px-2 py-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-0.5 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={onToggleExpand}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-0.5 text-muted-foreground/50 hover:text-muted-foreground cursor-grab active:cursor-grabbing touch-none"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={cn('flex-1 text-xs font-medium', !effect.enabled && 'text-muted-foreground')}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'p-0.5 transition-colors',
|
||||||
|
effect.enabled ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
onClick={onToggleEnabled}
|
||||||
|
title={effect.enabled ? 'Disable' : 'Enable'}
|
||||||
|
>
|
||||||
|
<Power className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-0.5 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={onRemove}
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Params */}
|
||||||
|
{isExpanded && effectDef && (
|
||||||
|
<div className="space-y-3 border-t px-3 py-2.5">
|
||||||
|
{Object.entries(effectDef.params).map(([paramName, paramDef]) => {
|
||||||
|
const currentValue = effect.params[paramName] ?? paramDef.default;
|
||||||
|
return (
|
||||||
|
<div key={paramName} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-[11px] text-muted-foreground">
|
||||||
|
{paramDef.description}
|
||||||
|
</Label>
|
||||||
|
<span className="text-[11px] font-mono tabular-nums text-foreground">
|
||||||
|
{currentValue.toFixed(
|
||||||
|
paramDef.step < 1 ? Math.max(1, -Math.floor(Math.log10(paramDef.step))) : 0,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
min={paramDef.min}
|
||||||
|
max={paramDef.max}
|
||||||
|
step={paramDef.step}
|
||||||
|
value={[currentValue]}
|
||||||
|
onValueChange={([v]) => onUpdateParam(paramName, v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { ChevronDown, Search } from 'lucide-react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import type { HistoryResponse } from '@/lib/api/types';
|
||||||
|
import { useHistory } from '@/lib/hooks/useHistory';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
interface GenerationPickerProps {
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (generation: HistoryResponse) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GenerationPicker({ selectedId, onSelect, className }: GenerationPickerProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
const { data: historyData } = useHistory({ limit: 50 });
|
||||||
|
|
||||||
|
const completedGenerations = useMemo(() => {
|
||||||
|
if (!historyData?.items) return [];
|
||||||
|
return historyData.items.filter((gen) => gen.status === 'completed');
|
||||||
|
}, [historyData]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!searchQuery) return completedGenerations;
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return completedGenerations.filter(
|
||||||
|
(gen) => gen.text.toLowerCase().includes(q) || gen.profile_name.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [completedGenerations, searchQuery]);
|
||||||
|
|
||||||
|
const selectedGeneration = completedGenerations.find((g) => g.id === selectedId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={cn('h-8 justify-between gap-2 text-xs font-normal', className)}
|
||||||
|
>
|
||||||
|
{selectedGeneration ? (
|
||||||
|
<span className="truncate">
|
||||||
|
<span className="font-medium">{selectedGeneration.profile_name}</span>
|
||||||
|
<span className="text-muted-foreground ml-1.5">
|
||||||
|
{selectedGeneration.text.length > 30
|
||||||
|
? `${selectedGeneration.text.substring(0, 30)}...`
|
||||||
|
: selectedGeneration.text}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Select a generation...</span>
|
||||||
|
)}
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80 p-0" align="start">
|
||||||
|
<div className="p-2 border-b">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by voice or text..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="h-8 pl-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-60 overflow-y-auto">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-xs text-muted-foreground">
|
||||||
|
No generations found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((gen) => (
|
||||||
|
<button
|
||||||
|
key={gen.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-3 py-2 hover:bg-muted/50 transition-colors border-b border-border/30 last:border-0',
|
||||||
|
gen.id === selectedId && 'bg-accent/10',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(gen);
|
||||||
|
setOpen(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm">{gen.profile_name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{gen.text.length > 60 ? `${gen.text.substring(0, 60)}...` : gen.text}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Loader2, Play, Save, Trash2, Wand2 } from 'lucide-react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||||
|
import { GenerationPicker } from '@/components/Effects/GenerationPicker';
|
||||||
|
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 { Separator } from '@/components/ui/separator';
|
||||||
|
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 { useHistory } from '@/lib/hooks/useHistory';
|
||||||
|
import { useEffectsStore } from '@/stores/effectsStore';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
|
export function EffectsDetail() {
|
||||||
|
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
|
||||||
|
const isCreatingNew = useEffectsStore((s) => s.isCreatingNew);
|
||||||
|
const workingChain = useEffectsStore((s) => s.workingChain);
|
||||||
|
const setWorkingChain = useEffectsStore((s) => s.setWorkingChain);
|
||||||
|
const setSelectedPresetId = useEffectsStore((s) => s.setSelectedPresetId);
|
||||||
|
const setIsCreatingNew = useEffectsStore((s) => s.setIsCreatingNew);
|
||||||
|
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
// "Save as Custom" dialog state
|
||||||
|
const [saveAsDialogOpen, setSaveAsDialogOpen] = useState(false);
|
||||||
|
const [saveAsName, setSaveAsName] = useState('');
|
||||||
|
const [saveAsDescription, setSaveAsDescription] = useState('');
|
||||||
|
|
||||||
|
// Preview state
|
||||||
|
const [previewGenId, setPreviewGenId] = useState<string | null>(null);
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
const blobUrlRef = useRef<string | null>(null);
|
||||||
|
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Auto-select the most recent generation as preview source
|
||||||
|
const { data: historyData } = useHistory({ limit: 1 });
|
||||||
|
useEffect(() => {
|
||||||
|
if (!previewGenId && historyData?.items?.length) {
|
||||||
|
const first = historyData.items.find((g) => g.status === 'completed');
|
||||||
|
if (first) setPreviewGenId(first.id);
|
||||||
|
}
|
||||||
|
}, [historyData, previewGenId]);
|
||||||
|
|
||||||
|
const { data: preset } = useQuery({
|
||||||
|
queryKey: ['effect-preset', selectedPresetId],
|
||||||
|
queryFn: () =>
|
||||||
|
selectedPresetId
|
||||||
|
? apiClient
|
||||||
|
.listEffectPresets()
|
||||||
|
.then((all) => all.find((p) => p.id === selectedPresetId) ?? null)
|
||||||
|
: null,
|
||||||
|
enabled: !!selectedPresetId,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync name/description when selecting a preset
|
||||||
|
useEffect(() => {
|
||||||
|
if (preset) {
|
||||||
|
setName(preset.name);
|
||||||
|
setDescription(preset.description ?? '');
|
||||||
|
} else if (isCreatingNew) {
|
||||||
|
setName('');
|
||||||
|
setDescription('');
|
||||||
|
}
|
||||||
|
}, [preset, isCreatingNew]);
|
||||||
|
|
||||||
|
// Cleanup blob URL on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (blobUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(blobUrlRef.current);
|
||||||
|
blobUrlRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isEditing = !!selectedPresetId || isCreatingNew;
|
||||||
|
const isBuiltIn = preset?.is_builtin ?? false;
|
||||||
|
|
||||||
|
async function handlePreview() {
|
||||||
|
if (!previewGenId || workingChain.length === 0) return;
|
||||||
|
|
||||||
|
setPreviewLoading(true);
|
||||||
|
try {
|
||||||
|
const blob = await apiClient.previewEffects(previewGenId, workingChain);
|
||||||
|
|
||||||
|
// Revoke old blob URL
|
||||||
|
if (blobUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(blobUrlRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
blobUrlRef.current = url;
|
||||||
|
|
||||||
|
// Play through the main audio player
|
||||||
|
setAudioWithAutoPlay(url, `preview-${Date.now()}`, null, 'Effects Preview');
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Preview failed',
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectGeneration(gen: HistoryResponse) {
|
||||||
|
setPreviewGenId(gen.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveNew() {
|
||||||
|
if (!name.trim()) {
|
||||||
|
toast({ title: 'Name required', variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const created = await apiClient.createEffectPreset({
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
effects_chain: workingChain,
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||||
|
setIsCreatingNew(false);
|
||||||
|
setSelectedPresetId(created.id);
|
||||||
|
toast({ title: 'Preset saved', description: `"${created.name}" has been created.` });
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to save',
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveExisting() {
|
||||||
|
if (!selectedPresetId || !name.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await apiClient.updateEffectPreset(selectedPresetId, {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
effects_chain: workingChain,
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['effect-preset', selectedPresetId] });
|
||||||
|
toast({ title: 'Preset updated' });
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to save',
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveAsNew() {
|
||||||
|
// Open the dialog with a suggested name based on the current preset
|
||||||
|
setSaveAsName(`${name} (Copy)`);
|
||||||
|
setSaveAsDescription(description);
|
||||||
|
setSaveAsDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveAsConfirm() {
|
||||||
|
if (!saveAsName.trim()) {
|
||||||
|
toast({ title: 'Name required', variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const created = await apiClient.createEffectPreset({
|
||||||
|
name: saveAsName.trim(),
|
||||||
|
description: saveAsDescription.trim() || undefined,
|
||||||
|
effects_chain: workingChain,
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||||
|
setSaveAsDialogOpen(false);
|
||||||
|
setSelectedPresetId(created.id);
|
||||||
|
toast({ title: 'Preset saved', description: `"${created.name}" has been created.` });
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to save',
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!selectedPresetId) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await apiClient.deleteEffectPreset(selectedPresetId);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||||
|
setSelectedPresetId(null);
|
||||||
|
setWorkingChain([]);
|
||||||
|
toast({ title: 'Preset deleted' });
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to delete',
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEditing) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<Wand2 className="h-10 w-10 mx-auto opacity-30" />
|
||||||
|
<p className="text-sm">Select a preset or create a new one</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
{isCreatingNew ? 'New Preset' : isBuiltIn ? preset?.name : 'Edit Preset'}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!isBuiltIn && !isCreatingNew && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-destructive hover:text-destructive gap-1.5"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5"
|
||||||
|
onClick={handleSaveExisting}
|
||||||
|
disabled={saving || workingChain.length === 0}
|
||||||
|
>
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isCreatingNew && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5"
|
||||||
|
onClick={handleSaveNew}
|
||||||
|
disabled={saving || workingChain.length === 0}
|
||||||
|
>
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
{saving ? 'Saving...' : 'Save Preset'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isBuiltIn && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 gap-1.5"
|
||||||
|
onClick={handleSaveAsNew}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
{saving ? 'Saving...' : 'Save as Custom'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable content */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto space-y-5 pr-1">
|
||||||
|
{/* Name & description */}
|
||||||
|
{(isCreatingNew || !isBuiltIn) && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Name</Label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="My preset..."
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Describe what this preset does..."
|
||||||
|
className="min-h-[60px] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Built-in description (read-only) */}
|
||||||
|
{isBuiltIn && preset?.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{preset.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Effects chain editor */}
|
||||||
|
<EffectsChainEditor value={workingChain} onChange={setWorkingChain} showPresets={false} />
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Preview section */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs">Preview</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GenerationPicker
|
||||||
|
selectedId={previewGenId}
|
||||||
|
onSelect={handleSelectGeneration}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 shrink-0"
|
||||||
|
onClick={handlePreview}
|
||||||
|
disabled={!previewGenId || workingChain.length === 0 || previewLoading}
|
||||||
|
>
|
||||||
|
{previewLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play className="h-3.5 w-3.5" />
|
||||||
|
Preview
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Preview applies effects to the clean version without saving.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save as Custom dialog */}
|
||||||
|
<Dialog open={saveAsDialogOpen} onOpenChange={setSaveAsDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Save as Custom Preset</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new custom preset based on the current effects chain.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Name</Label>
|
||||||
|
<Input
|
||||||
|
value={saveAsName}
|
||||||
|
onChange={(e) => setSaveAsName(e.target.value)}
|
||||||
|
placeholder="My preset..."
|
||||||
|
className="h-9"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && saveAsName.trim()) {
|
||||||
|
handleSaveAsConfirm();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
value={saveAsDescription}
|
||||||
|
onChange={(e) => setSaveAsDescription(e.target.value)}
|
||||||
|
placeholder="Describe what this preset does..."
|
||||||
|
className="min-h-[60px] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setSaveAsDialogOpen(false)} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSaveAsConfirm} disabled={saving || !saveAsName.trim()}>
|
||||||
|
<Save className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Loader2, Plus, Sparkles, Wand2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { EffectPresetResponse } from '@/lib/api/types';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { useEffectsStore } from '@/stores/effectsStore';
|
||||||
|
|
||||||
|
export function EffectsList() {
|
||||||
|
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
|
||||||
|
const setSelectedPresetId = useEffectsStore((s) => s.setSelectedPresetId);
|
||||||
|
const setWorkingChain = useEffectsStore((s) => s.setWorkingChain);
|
||||||
|
const setIsCreatingNew = useEffectsStore((s) => s.setIsCreatingNew);
|
||||||
|
const isCreatingNew = useEffectsStore((s) => s.isCreatingNew);
|
||||||
|
|
||||||
|
const { data: presets, isLoading } = useQuery({
|
||||||
|
queryKey: ['effect-presets'],
|
||||||
|
queryFn: () => apiClient.listEffectPresets(),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const builtIn = presets?.filter((p) => p.is_builtin) ?? [];
|
||||||
|
const userPresets = presets?.filter((p) => !p.is_builtin) ?? [];
|
||||||
|
|
||||||
|
function handleSelect(preset: EffectPresetResponse) {
|
||||||
|
setSelectedPresetId(preset.id);
|
||||||
|
setWorkingChain(preset.effects_chain);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateNew() {
|
||||||
|
setIsCreatingNew(true);
|
||||||
|
setWorkingChain([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">Effects</h2>
|
||||||
|
<Button variant="outline" size="sm" className="h-8 gap-1.5" onClick={handleCreateNew}>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
New Preset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable list */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto space-y-4">
|
||||||
|
{/* Built-in presets */}
|
||||||
|
{builtIn.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||||
|
Built-in
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{builtIn.map((preset) => (
|
||||||
|
<PresetCard
|
||||||
|
key={preset.id}
|
||||||
|
preset={preset}
|
||||||
|
isSelected={selectedPresetId === preset.id && !isCreatingNew}
|
||||||
|
onSelect={() => handleSelect(preset)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User presets */}
|
||||||
|
{userPresets.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||||
|
Custom
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{userPresets.map((preset) => (
|
||||||
|
<PresetCard
|
||||||
|
key={preset.id}
|
||||||
|
preset={preset}
|
||||||
|
isSelected={selectedPresetId === preset.id && !isCreatingNew}
|
||||||
|
onSelect={() => handleSelect(preset)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New preset placeholder */}
|
||||||
|
{isCreatingNew && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||||
|
New
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border-2 border-accent/40 bg-accent/5 p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-accent" />
|
||||||
|
<span className="text-sm font-medium">Unsaved Preset</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Configure effects in the panel on the right.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PresetCard({
|
||||||
|
preset,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
preset: EffectPresetResponse;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
const effectCount = preset.effects_chain.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left rounded-xl border p-3 h-[88px] transition-all duration-150',
|
||||||
|
isSelected
|
||||||
|
? 'border-accent/50 bg-accent/10'
|
||||||
|
: 'border-border bg-card hover:bg-muted/50 hover:border-border',
|
||||||
|
)}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wand2
|
||||||
|
className={cn('h-4 w-4 shrink-0', isSelected ? 'text-accent' : 'text-muted-foreground')}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium truncate">{preset.name}</span>
|
||||||
|
{preset.is_builtin && (
|
||||||
|
<span className="text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded-full shrink-0">
|
||||||
|
built-in
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-1 pl-6">
|
||||||
|
{preset.description || 'No description'}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1.5 pl-6">
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{effectCount} effect{effectCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground/50">
|
||||||
|
{preset.effects_chain
|
||||||
|
.filter((e) => e.enabled)
|
||||||
|
.map((e) => e.type)
|
||||||
|
.join(' → ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import {EffectsDetail} from "./EffectsDetail";
|
||||||
|
import {EffectsList} from "./EffectsList";
|
||||||
|
|
||||||
|
export function EffectsTab() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||||
|
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden">
|
||||||
|
{/* Left - Presets list */}
|
||||||
|
<div className="w-full max-w-[360px] shrink-0 flex flex-col min-h-0">
|
||||||
|
<EffectsList />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right - Detail / editor */}
|
||||||
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
|
<EffectsDetail />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { UseFormReturn } from 'react-hook-form';
|
||||||
|
import { FormControl } from '@/components/ui/form';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { getLanguageOptionsForEngine } from '@/lib/constants/languages';
|
||||||
|
import type { GenerationFormValues } from '@/lib/hooks/useGenerationForm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Engine/model options and their display metadata.
|
||||||
|
* Adding a new engine means adding one entry here.
|
||||||
|
*/
|
||||||
|
const ENGINE_OPTIONS = [
|
||||||
|
{ value: 'qwen:1.7B', label: 'Qwen3-TTS 1.7B' },
|
||||||
|
{ value: 'qwen:0.6B', label: 'Qwen3-TTS 0.6B' },
|
||||||
|
{ value: 'luxtts', label: 'LuxTTS' },
|
||||||
|
{ value: 'chatterbox', label: 'Chatterbox' },
|
||||||
|
{ value: 'chatterbox_turbo', label: 'Chatterbox Turbo' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ENGINE_DESCRIPTIONS: Record<string, string> = {
|
||||||
|
qwen: 'Multi-language, two sizes',
|
||||||
|
luxtts: 'Fast, English-focused',
|
||||||
|
chatterbox: '23 languages, incl. Hebrew',
|
||||||
|
chatterbox_turbo: 'English, [laugh] [cough] tags',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Engines that only support English and should force language to 'en' on select. */
|
||||||
|
const ENGLISH_ONLY_ENGINES = new Set(['luxtts', 'chatterbox_turbo']);
|
||||||
|
|
||||||
|
function getSelectValue(engine: string, modelSize?: string): string {
|
||||||
|
if (engine === 'qwen') return `qwen:${modelSize || '1.7B'}`;
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEngineChange(form: UseFormReturn<GenerationFormValues>, value: string) {
|
||||||
|
if (value.startsWith('qwen:')) {
|
||||||
|
const [, modelSize] = value.split(':');
|
||||||
|
form.setValue('engine', 'qwen');
|
||||||
|
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
|
||||||
|
// Validate language is supported by Qwen
|
||||||
|
const currentLang = form.getValues('language');
|
||||||
|
const available = getLanguageOptionsForEngine('qwen');
|
||||||
|
if (!available.some((l) => l.value === currentLang)) {
|
||||||
|
form.setValue('language', available[0]?.value ?? 'en');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
form.setValue('engine', value as GenerationFormValues['engine']);
|
||||||
|
form.setValue('modelSize', undefined as unknown as '1.7B' | '0.6B');
|
||||||
|
if (ENGLISH_ONLY_ENGINES.has(value)) {
|
||||||
|
form.setValue('language', 'en');
|
||||||
|
} else {
|
||||||
|
// If current language isn't supported by the new engine, reset to first available
|
||||||
|
const currentLang = form.getValues('language');
|
||||||
|
const available = getLanguageOptionsForEngine(value);
|
||||||
|
if (!available.some((l) => l.value === currentLang)) {
|
||||||
|
form.setValue('language', available[0]?.value ?? 'en');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EngineModelSelectorProps {
|
||||||
|
form: UseFormReturn<GenerationFormValues>;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EngineModelSelector({ form, compact }: EngineModelSelectorProps) {
|
||||||
|
const engine = form.watch('engine') || 'qwen';
|
||||||
|
const modelSize = form.watch('modelSize');
|
||||||
|
const selectValue = getSelectValue(engine, modelSize);
|
||||||
|
|
||||||
|
const itemClass = compact ? 'text-xs text-muted-foreground' : undefined;
|
||||||
|
const triggerClass = compact
|
||||||
|
? 'h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all'
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select value={selectValue} onValueChange={(v) => handleEngineChange(form, v)}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className={triggerClass}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{ENGINE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value} className={itemClass}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a human-readable description for the currently selected engine. */
|
||||||
|
export function getEngineDescription(engine: string): string {
|
||||||
|
return ENGINE_DESCRIPTIONS[engine] ?? '';
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useMatchRoute } from '@tanstack/react-router';
|
import { useMatchRoute } from '@tanstack/react-router';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Loader2, MessageSquare, Sparkles } from 'lucide-react';
|
import { Loader2, Sparkles } from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||||
@@ -12,14 +13,17 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { LANGUAGE_OPTIONS } from '@/lib/constants/languages';
|
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
|
||||||
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
||||||
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
|
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
|
||||||
import { useAddStoryItem, useStory } from '@/lib/hooks/useStories';
|
import { useStory } from '@/lib/hooks/useStories';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
import { EngineModelSelector } from './EngineModelSelector';
|
||||||
|
import { ParalinguisticInput } from './ParalinguisticInput';
|
||||||
|
|
||||||
interface FloatingGenerateBoxProps {
|
interface FloatingGenerateBoxProps {
|
||||||
isPlayerOpen?: boolean;
|
isPlayerOpen?: boolean;
|
||||||
@@ -35,7 +39,7 @@ export function FloatingGenerateBox({
|
|||||||
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
||||||
const { data: profiles } = useProfiles();
|
const { data: profiles } = useProfiles();
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [isInstructMode, setIsInstructMode] = useState(false);
|
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const matchRoute = useMatchRoute();
|
const matchRoute = useMatchRoute();
|
||||||
@@ -43,8 +47,13 @@ export function FloatingGenerateBox({
|
|||||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||||
const { data: currentStory } = useStory(selectedStoryId);
|
const { data: currentStory } = useStory(selectedStoryId);
|
||||||
const addStoryItem = useAddStoryItem();
|
const addPendingStoryAdd = useGenerationStore((s) => s.addPendingStoryAdd);
|
||||||
const { toast } = useToast();
|
|
||||||
|
// Fetch effect presets for the dropdown
|
||||||
|
const { data: effectPresets } = useQuery({
|
||||||
|
queryKey: ['effectPresets'],
|
||||||
|
queryFn: () => apiClient.listEffectPresets(),
|
||||||
|
});
|
||||||
|
|
||||||
// Calculate if track editor is visible (on stories route with items)
|
// Calculate if track editor is visible (on stories route with items)
|
||||||
const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0;
|
const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0;
|
||||||
@@ -52,27 +61,16 @@ export function FloatingGenerateBox({
|
|||||||
const { form, handleSubmit, isPending } = useGenerationForm({
|
const { form, handleSubmit, isPending } = useGenerationForm({
|
||||||
onSuccess: async (generationId) => {
|
onSuccess: async (generationId) => {
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
// If on stories route and a story is selected, add generation to story
|
// Defer the story add until TTS completes -- useGenerationProgress handles it
|
||||||
if (isStoriesRoute && selectedStoryId && generationId) {
|
if (isStoriesRoute && selectedStoryId && generationId) {
|
||||||
try {
|
addPendingStoryAdd(generationId, selectedStoryId);
|
||||||
await addStoryItem.mutateAsync({
|
|
||||||
storyId: selectedStoryId,
|
|
||||||
data: { generation_id: generationId },
|
|
||||||
});
|
|
||||||
toast({
|
|
||||||
title: 'Added to story',
|
|
||||||
description: `Generation added to "${currentStory?.name || 'story'}"`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Failed to add to story',
|
|
||||||
description:
|
|
||||||
error instanceof Error ? error.message : 'Could not add generation to story',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
getEffectsChain: () => {
|
||||||
|
if (!selectedPresetId || !effectPresets) return undefined;
|
||||||
|
const preset = effectPresets.find((p) => p.id === selectedPresetId);
|
||||||
|
return preset?.effects_chain;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click away handler to collapse the box
|
// Click away handler to collapse the box
|
||||||
@@ -112,6 +110,13 @@ export function FloatingGenerateBox({
|
|||||||
}
|
}
|
||||||
}, [selectedProfileId, profiles, setSelectedProfileId]);
|
}, [selectedProfileId, profiles, setSelectedProfileId]);
|
||||||
|
|
||||||
|
// Sync generation form language with selected profile's language
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedProfile?.language) {
|
||||||
|
form.setValue('language', selectedProfile.language as LanguageCode);
|
||||||
|
}
|
||||||
|
}, [selectedProfile, form]);
|
||||||
|
|
||||||
// Auto-resize textarea based on content (only when expanded)
|
// Auto-resize textarea based on content (only when expanded)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExpanded) {
|
if (!isExpanded) {
|
||||||
@@ -174,7 +179,7 @@ export function FloatingGenerateBox({
|
|||||||
isStoriesRoute
|
isStoriesRoute
|
||||||
? // Position aligned with story list: after sidebar + padding, width 360px
|
? // Position aligned with story list: after sidebar + padding, width 360px
|
||||||
'left-[calc(5rem+2rem)] w-[360px]'
|
'left-[calc(5rem+2rem)] w-[360px]'
|
||||||
: 'left-[calc(5rem+2rem)] w-[calc((100%-5rem-4rem)/2-1rem)]',
|
: 'left-[calc(5rem+2rem)] right-8 lg:right-auto lg:w-[calc((100%-5rem-4rem)/2-1rem)]',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
// On stories route: offset by track editor height when visible
|
// On stories route: offset by track editor height when visible
|
||||||
@@ -187,39 +192,52 @@ export function FloatingGenerateBox({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[2rem] shadow-2xl hover:bg-background/40 hover:border-accent/20 transition-all duration-300 overflow-hidden p-3"
|
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[2rem] shadow-2xl hover:bg-background/40 hover:border-accent/20 transition-all duration-300 p-3"
|
||||||
transition={{ duration: 0.6, ease: 'easeInOut' }}
|
transition={{ duration: 0.6, ease: 'easeInOut' }}
|
||||||
>
|
>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<motion.div
|
<motion.div className="flex-1" transition={{ duration: 0.3, ease: 'easeOut' }}>
|
||||||
className={cn('flex-1', isExpanded && 'mr-12')}
|
<FormField
|
||||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
control={form.control}
|
||||||
>
|
name="text"
|
||||||
{/* Text field - hidden when in instruct mode */}
|
render={({ field }) => (
|
||||||
<div style={{ display: isInstructMode ? 'none' : 'block' }}>
|
<FormItem>
|
||||||
<FormField
|
<FormControl>
|
||||||
control={form.control}
|
<motion.div
|
||||||
name="text"
|
animate={{
|
||||||
render={({ field }) => (
|
height: isExpanded ? 'auto' : '32px',
|
||||||
<FormItem>
|
}}
|
||||||
<FormControl>
|
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||||
<motion.div
|
style={{ overflow: 'hidden' }}
|
||||||
animate={{
|
>
|
||||||
height: isExpanded ? 'auto' : '32px',
|
{form.watch('engine') === 'chatterbox_turbo' ? (
|
||||||
}}
|
<ParalinguisticInput
|
||||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
value={field.value}
|
||||||
style={{ overflow: 'hidden' }}
|
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
|
<Textarea
|
||||||
{...field}
|
{...field}
|
||||||
ref={(node: HTMLTextAreaElement | null) => {
|
ref={(node: HTMLTextAreaElement | null) => {
|
||||||
// Store ref for auto-resize (only for active field)
|
textareaRef.current = node;
|
||||||
if (!isInstructMode) {
|
|
||||||
textareaRef.current = node;
|
|
||||||
}
|
|
||||||
// Forward ref to react-hook-form
|
|
||||||
if (typeof field.ref === 'function') {
|
if (typeof field.ref === 'function') {
|
||||||
field.ref(node);
|
field.ref(node);
|
||||||
}
|
}
|
||||||
@@ -240,98 +258,44 @@ export function FloatingGenerateBox({
|
|||||||
onClick={() => setIsExpanded(true)}
|
onClick={() => setIsExpanded(true)}
|
||||||
onFocus={() => setIsExpanded(true)}
|
onFocus={() => setIsExpanded(true)}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
)}
|
||||||
</FormControl>
|
</motion.div>
|
||||||
<FormMessage className="text-xs" />
|
</FormControl>
|
||||||
</FormItem>
|
<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="Add delivery instructions..."
|
|
||||||
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>
|
</motion.div>
|
||||||
|
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<Button
|
<div className="group relative">
|
||||||
type="submit"
|
<Button
|
||||||
disabled={isPending || !selectedProfileId}
|
type="submit"
|
||||||
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200"
|
disabled={isPending || !selectedProfileId}
|
||||||
size="icon"
|
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200"
|
||||||
>
|
size="icon"
|
||||||
{isPending ? (
|
aria-label={
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
isPending
|
||||||
) : (
|
? 'Generating...'
|
||||||
<Sparkles className="h-4 w-4" />
|
: !selectedProfileId
|
||||||
)}
|
? 'Select a voice profile first'
|
||||||
</Button>
|
: 'Generate speech'
|
||||||
<AnimatePresence>
|
}
|
||||||
{isExpanded && (
|
>
|
||||||
<motion.div
|
{isPending ? (
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
) : (
|
||||||
exit={{ opacity: 0, scale: 0.8 }}
|
<Sparkles className="h-4 w-4" />
|
||||||
transition={{ duration: 0.2 }}
|
)}
|
||||||
className="absolute top-0 right-[calc(100%+0.5rem)]"
|
</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]">
|
||||||
<Button
|
{isPending
|
||||||
type="button"
|
? 'Generating...'
|
||||||
variant="ghost"
|
: !selectedProfileId
|
||||||
size="icon"
|
? 'Select a voice profile first'
|
||||||
onClick={() => setIsInstructMode(!isInstructMode)}
|
: 'Generate speech'}
|
||||||
className={cn(
|
</span>
|
||||||
'h-10 w-10 rounded-full transition-all duration-200',
|
</div>
|
||||||
isInstructMode
|
|
||||||
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
|
|
||||||
: 'bg-card border border-border hover:bg-background/50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<MessageSquare className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -367,51 +331,58 @@ export function FloatingGenerateBox({
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="language"
|
name="language"
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
<FormItem className="flex-1 space-y-0">
|
const engineLangs = getLanguageOptionsForEngine(
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
form.watch('engine') || 'qwen',
|
||||||
<FormControl>
|
);
|
||||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
return (
|
||||||
<SelectValue />
|
<FormItem className="flex-1 space-y-0">
|
||||||
</SelectTrigger>
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
</FormControl>
|
<FormControl>
|
||||||
<SelectContent>
|
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
||||||
{LANGUAGE_OPTIONS.map((lang) => (
|
<SelectValue />
|
||||||
<SelectItem key={lang.value} value={lang.value} className="text-xs">
|
</SelectTrigger>
|
||||||
{lang.label}
|
</FormControl>
|
||||||
</SelectItem>
|
<SelectContent>
|
||||||
))}
|
{engineLangs.map((lang) => (
|
||||||
</SelectContent>
|
<SelectItem key={lang.value} value={lang.value} className="text-xs">
|
||||||
</Select>
|
{lang.label}
|
||||||
<FormMessage className="text-xs" />
|
</SelectItem>
|
||||||
</FormItem>
|
))}
|
||||||
)}
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage className="text-xs" />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormItem className="flex-1 space-y-0">
|
||||||
control={form.control}
|
<EngineModelSelector form={form} compact />
|
||||||
name="modelSize"
|
</FormItem>
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1 space-y-0">
|
<FormItem className="flex-1 space-y-0">
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<Select
|
||||||
<FormControl>
|
value={selectedPresetId || 'none'}
|
||||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
onValueChange={(value) =>
|
||||||
<SelectValue />
|
setSelectedPresetId(value === 'none' ? null : value)
|
||||||
</SelectTrigger>
|
}
|
||||||
</FormControl>
|
>
|
||||||
<SelectContent>
|
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
||||||
<SelectItem value="1.7B" className="text-xs text-muted-foreground">
|
<SelectValue placeholder="No effects" />
|
||||||
Qwen3-TTS 1.7B
|
</SelectTrigger>
|
||||||
</SelectItem>
|
<SelectContent>
|
||||||
<SelectItem value="0.6B" className="text-xs text-muted-foreground">
|
<SelectItem value="none" className="text-xs">
|
||||||
Qwen3-TTS 0.6B
|
No effects
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
{effectPresets?.map((preset) => (
|
||||||
</Select>
|
<SelectItem key={preset.id} value={preset.id} className="text-xs">
|
||||||
<FormMessage className="text-xs" />
|
{preset.name}
|
||||||
</FormItem>
|
</SelectItem>
|
||||||
)}
|
))}
|
||||||
/>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormItem>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { LANGUAGE_OPTIONS } from '@/lib/constants/languages';
|
import { getLanguageOptionsForEngine } from '@/lib/constants/languages';
|
||||||
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
||||||
import { useProfile } from '@/lib/hooks/useProfiles';
|
import { useProfile } from '@/lib/hooks/useProfiles';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
import { EngineModelSelector, getEngineDescription } from './EngineModelSelector';
|
||||||
|
import { ParalinguisticInput } from './ParalinguisticInput';
|
||||||
|
|
||||||
export function GenerationForm() {
|
export function GenerationForm() {
|
||||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||||
@@ -64,87 +66,90 @@ export function GenerationForm() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Text to Speak</FormLabel>
|
<FormLabel>Text to Speak</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
{form.watch('engine') === 'chatterbox_turbo' ? (
|
||||||
placeholder="Enter the text you want to generate..."
|
<ParalinguisticInput
|
||||||
className="min-h-[150px]"
|
value={field.value}
|
||||||
{...field}
|
onChange={field.onChange}
|
||||||
/>
|
placeholder="Enter text... type / for effects like [laugh], [sigh]"
|
||||||
</FormControl>
|
className="min-h-[150px] rounded-md border border-input bg-background px-3 py-2"
|
||||||
<FormDescription>Max 5000 characters</FormDescription>
|
/>
|
||||||
<FormMessage />
|
) : (
|
||||||
</FormItem>
|
<Textarea
|
||||||
)}
|
placeholder="Enter the text you want to generate..."
|
||||||
/>
|
className="min-h-[150px]"
|
||||||
|
{...field}
|
||||||
<FormField
|
/>
|
||||||
control={form.control}
|
)}
|
||||||
name="instruct"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Delivery Instructions (optional)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
placeholder="e.g. Speak slowly with emphasis, Warm and friendly tone, Professional and authoritative..."
|
|
||||||
className="min-h-[80px]"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Natural language instructions to control speech delivery (tone, emotion, pace).
|
{form.watch('engine') === 'chatterbox_turbo'
|
||||||
Max 500 characters
|
? 'Max 5000 characters. Type / to insert sound effects.'
|
||||||
|
: 'Max 5000 characters'}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
{form.watch('engine') === 'qwen' && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="language"
|
name="instruct"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Language</FormLabel>
|
<FormLabel>Delivery Instructions (optional)</FormLabel>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<FormControl>
|
||||||
<FormControl>
|
<Textarea
|
||||||
<SelectTrigger>
|
placeholder="e.g. Speak slowly with emphasis, Warm and friendly tone, Professional and authoritative..."
|
||||||
<SelectValue />
|
className="min-h-[80px]"
|
||||||
</SelectTrigger>
|
{...field}
|
||||||
</FormControl>
|
/>
|
||||||
<SelectContent>
|
</FormControl>
|
||||||
{LANGUAGE_OPTIONS.map((lang) => (
|
<FormDescription>
|
||||||
<SelectItem key={lang.value} value={lang.value}>
|
Natural language instructions to control speech delivery (tone, emotion,
|
||||||
{lang.label}
|
pace). Max 500 characters
|
||||||
</SelectItem>
|
</FormDescription>
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Model</FormLabel>
|
||||||
|
<EngineModelSelector form={form} />
|
||||||
|
<FormDescription>
|
||||||
|
{getEngineDescription(form.watch('engine') || 'qwen')}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="modelSize"
|
name="language"
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
<FormItem>
|
const engineLangs = getLanguageOptionsForEngine(form.watch('engine') || 'qwen');
|
||||||
<FormLabel>Model Size</FormLabel>
|
return (
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>Language</FormLabel>
|
||||||
<SelectTrigger>
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
<SelectValue />
|
<FormControl>
|
||||||
</SelectTrigger>
|
<SelectTrigger>
|
||||||
</FormControl>
|
<SelectValue />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
<SelectItem value="1.7B">Qwen TTS 1.7B (Higher Quality)</SelectItem>
|
</FormControl>
|
||||||
<SelectItem value="0.6B">Qwen TTS 0.6B (Faster)</SelectItem>
|
<SelectContent>
|
||||||
</SelectContent>
|
{engineLangs.map((lang) => (
|
||||||
</Select>
|
<SelectItem key={lang.value} value={lang.value}>
|
||||||
<FormDescription>Larger models produce better quality</FormDescription>
|
{lang.label}
|
||||||
<FormMessage />
|
</SelectItem>
|
||||||
</FormItem>
|
))}
|
||||||
)}
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@@ -170,11 +175,7 @@ export function GenerationForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button type="submit" className="w-full" disabled={isPending || !selectedProfileId}>
|
||||||
type="submit"
|
|
||||||
className="w-full"
|
|
||||||
disabled={isPending || !selectedProfileId}
|
|
||||||
>
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
/**
|
||||||
|
* ParalinguisticInput — a contentEditable rich text input that renders
|
||||||
|
* Chatterbox Turbo paralinguistic tags (e.g. [laugh]) as inline badges.
|
||||||
|
*
|
||||||
|
* Trigger: typing "/" opens an autocomplete dropdown.
|
||||||
|
* Paste: pasting text with [tag] patterns auto-converts to badges.
|
||||||
|
* Output: serializes badges back to plain [tag] text for the API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
// ── Tag definitions ─────────────────────────────────────────────────
|
||||||
|
const PARALINGUISTIC_TAGS = [
|
||||||
|
{ tag: '[laugh]', label: 'laugh', emoji: '\u{1F602}' },
|
||||||
|
{ tag: '[chuckle]', label: 'chuckle', emoji: '\u{1F60F}' },
|
||||||
|
{ tag: '[gasp]', label: 'gasp', emoji: '\u{1F62E}' },
|
||||||
|
{ tag: '[cough]', label: 'cough', emoji: '\u{1F637}' },
|
||||||
|
{ tag: '[sigh]', label: 'sigh', emoji: '\u{1F614}' },
|
||||||
|
{ tag: '[groan]', label: 'groan', emoji: '\u{1F629}' },
|
||||||
|
{ tag: '[sniff]', label: 'sniff', emoji: '\u{1F443}' },
|
||||||
|
{ tag: '[shush]', label: 'shush', emoji: '\u{1F92B}' },
|
||||||
|
{ tag: '[clear throat]', label: 'clear throat', emoji: '\u{1F64A}' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TAG_REGEX = /\[(laugh|chuckle|gasp|cough|sigh|groan|sniff|shush|clear throat)\]/gi;
|
||||||
|
|
||||||
|
// Data attribute used to identify badge spans in the DOM
|
||||||
|
const BADGE_ATTR = 'data-ptag';
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Build an inline badge <span> for a tag. */
|
||||||
|
function makeBadgeHTML(tag: string): string {
|
||||||
|
const entry = PARALINGUISTIC_TAGS.find((t) => t.tag.toLowerCase() === tag.toLowerCase());
|
||||||
|
const label = entry?.label ?? tag.replace(/[[\]]/g, '');
|
||||||
|
const emoji = entry?.emoji ?? '';
|
||||||
|
// Non-editable inline badge. Zero-width spaces around it let the
|
||||||
|
// caret sit on either side so the user can type before/after.
|
||||||
|
return `\u200B<span ${BADGE_ATTR}="${tag}" contenteditable="false" class="ptag-badge">${emoji ? `${emoji}\u00A0` : ''}${label}</span>\u200B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert plain text with [tag] patterns into HTML with badge spans. */
|
||||||
|
function textToHTML(text: string): string {
|
||||||
|
// Escape HTML entities first
|
||||||
|
const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
// Replace tag patterns with badge HTML
|
||||||
|
return escaped.replace(TAG_REGEX, (match) => makeBadgeHTML(match));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialize the contentEditable innerHTML back to plain text with [tag] syntax. */
|
||||||
|
function htmlToText(container: HTMLElement): string {
|
||||||
|
let result = '';
|
||||||
|
for (const node of container.childNodes) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
// Strip zero-width spaces we added around badges
|
||||||
|
result += (node.textContent ?? '').replace(/\u200B/g, '');
|
||||||
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
if (el.hasAttribute(BADGE_ATTR)) {
|
||||||
|
result += el.getAttribute(BADGE_ATTR) ?? '';
|
||||||
|
} else if (el.tagName === 'BR') {
|
||||||
|
result += '\n';
|
||||||
|
} else {
|
||||||
|
// Recurse for nested elements (e.g. spans from paste)
|
||||||
|
result += htmlToText(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the text content from the current caret position back to the last
|
||||||
|
* whitespace or start of container, to detect the "/" trigger. */
|
||||||
|
function getWordBeforeCaret(_container: HTMLElement): { word: string; range: Range | null } {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel || sel.rangeCount === 0) return { word: '', range: null };
|
||||||
|
const range = sel.getRangeAt(0).cloneRange();
|
||||||
|
range.collapse(true);
|
||||||
|
|
||||||
|
// Walk backwards from caret through the text node
|
||||||
|
const textNode = range.startContainer;
|
||||||
|
if (textNode.nodeType !== Node.TEXT_NODE) return { word: '', range: null };
|
||||||
|
const text = textNode.textContent ?? '';
|
||||||
|
const offset = range.startOffset;
|
||||||
|
|
||||||
|
let start = offset;
|
||||||
|
while (
|
||||||
|
start > 0 &&
|
||||||
|
text[start - 1] !== ' ' &&
|
||||||
|
text[start - 1] !== '\n' &&
|
||||||
|
text[start - 1] !== '\u00A0'
|
||||||
|
) {
|
||||||
|
start--;
|
||||||
|
}
|
||||||
|
|
||||||
|
const word = text.slice(start, offset);
|
||||||
|
const wordRange = document.createRange();
|
||||||
|
wordRange.setStart(textNode, start);
|
||||||
|
wordRange.setEnd(textNode, offset);
|
||||||
|
|
||||||
|
return { word, range: wordRange };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ParalinguisticInputProps {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
onClick?: () => void;
|
||||||
|
onFocus?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParalinguisticInputRef {
|
||||||
|
focus: () => void;
|
||||||
|
element: HTMLDivElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParalinguisticInput = forwardRef<ParalinguisticInputRef, ParalinguisticInputProps>(
|
||||||
|
function ParalinguisticInput(
|
||||||
|
{ value, onChange, placeholder, disabled, className, style, onClick, onFocus },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
const [menuFilter, setMenuFilter] = useState('');
|
||||||
|
const [menuIndex, setMenuIndex] = useState(0);
|
||||||
|
const [menuPosition, setMenuPosition] = useState<{ bottom: number; left: number }>({
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
});
|
||||||
|
const triggerRangeRef = useRef<Range | null>(null);
|
||||||
|
const lastSerializedRef = useRef<string>('');
|
||||||
|
const isComposingRef = useRef(false);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
focus: () => editorRef.current?.focus(),
|
||||||
|
element: editorRef.current,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Filtered tag list for the autocomplete menu
|
||||||
|
const filteredTags = PARALINGUISTIC_TAGS.filter((t) =>
|
||||||
|
t.label.toLowerCase().includes(menuFilter.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Sync external value → editor ──────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const el = editorRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
// Only update DOM if the external value differs from what we last emitted
|
||||||
|
if (value !== undefined && value !== lastSerializedRef.current) {
|
||||||
|
lastSerializedRef.current = value;
|
||||||
|
el.innerHTML = value ? textToHTML(value) : '';
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// ── Emit plain-text value on input ────────────────────────────
|
||||||
|
const emitChange = useCallback(() => {
|
||||||
|
const el = editorRef.current;
|
||||||
|
if (!el || !onChange) return;
|
||||||
|
const text = htmlToText(el);
|
||||||
|
lastSerializedRef.current = text;
|
||||||
|
onChange(text);
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
// ── Insert a tag badge at the caret ───────────────────────────
|
||||||
|
const insertTag = useCallback(
|
||||||
|
(tag: string) => {
|
||||||
|
const el = editorRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
// Delete the /filter text
|
||||||
|
const wordRange = triggerRangeRef.current;
|
||||||
|
if (wordRange) {
|
||||||
|
wordRange.deleteContents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert badge HTML
|
||||||
|
const temp = document.createElement('span');
|
||||||
|
temp.innerHTML = makeBadgeHTML(tag);
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
let lastNode: Node | null = null;
|
||||||
|
while (temp.firstChild) {
|
||||||
|
lastNode = frag.appendChild(temp.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel && sel.rangeCount > 0) {
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(frag);
|
||||||
|
|
||||||
|
// Move caret after the badge
|
||||||
|
if (lastNode) {
|
||||||
|
const newRange = document.createRange();
|
||||||
|
newRange.setStartAfter(lastNode);
|
||||||
|
newRange.collapse(true);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(newRange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowMenu(false);
|
||||||
|
setMenuFilter('');
|
||||||
|
emitChange();
|
||||||
|
el.focus();
|
||||||
|
},
|
||||||
|
[emitChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Handle keydown for autocomplete navigation ────────────────
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (showMenu) {
|
||||||
|
if (filteredTags.length === 0) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowMenu(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setMenuIndex((i) => (i + 1) % filteredTags.length);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setMenuIndex((i) => (i - 1 + filteredTags.length) % filteredTags.length);
|
||||||
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (filteredTags[menuIndex]) {
|
||||||
|
insertTag(filteredTags[menuIndex].tag);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowMenu(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Prevent Enter from creating <div> blocks in contentEditable
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
// Let the form handle submit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showMenu, filteredTags, menuIndex, insertTag],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Handle input (check for / trigger) ────────────────────────
|
||||||
|
const handleInput = useCallback(() => {
|
||||||
|
if (isComposingRef.current) return;
|
||||||
|
const el = editorRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const { word, range } = getWordBeforeCaret(el);
|
||||||
|
|
||||||
|
if (word.startsWith('/')) {
|
||||||
|
const filter = word.slice(1); // strip the /
|
||||||
|
setMenuFilter(filter);
|
||||||
|
setMenuIndex(0);
|
||||||
|
triggerRangeRef.current = range;
|
||||||
|
|
||||||
|
// Position the menu above the caret using viewport coords (portalled)
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel && sel.rangeCount > 0) {
|
||||||
|
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
||||||
|
setMenuPosition({
|
||||||
|
bottom: window.innerHeight - rect.top + 4,
|
||||||
|
left: rect.left,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowMenu(true);
|
||||||
|
} else {
|
||||||
|
setShowMenu(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
emitChange();
|
||||||
|
}, [emitChange]);
|
||||||
|
|
||||||
|
// ── Handle paste — convert [tag] patterns to badges ───────────
|
||||||
|
const handlePaste = useCallback(
|
||||||
|
(e: React.ClipboardEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const text = e.clipboardData.getData('text/plain');
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
const el = editorRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const html = textToHTML(text);
|
||||||
|
|
||||||
|
// Insert at caret
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel && sel.rangeCount > 0) {
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
range.deleteContents();
|
||||||
|
const temp = document.createElement('div');
|
||||||
|
temp.innerHTML = html;
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
let lastNode: Node | null = null;
|
||||||
|
while (temp.firstChild) {
|
||||||
|
lastNode = frag.appendChild(temp.firstChild);
|
||||||
|
}
|
||||||
|
range.insertNode(frag);
|
||||||
|
if (lastNode) {
|
||||||
|
const newRange = document.createRange();
|
||||||
|
newRange.setStartAfter(lastNode);
|
||||||
|
newRange.collapse(true);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(newRange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitChange();
|
||||||
|
},
|
||||||
|
[emitChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Show placeholder ──────────────────────────────────────────
|
||||||
|
const isEmpty = !value || value.trim() === '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Placeholder */}
|
||||||
|
{isEmpty && placeholder && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 text-sm text-muted-foreground/60 px-3 py-2 select-none"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Editable area */}
|
||||||
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
contentEditable={!disabled}
|
||||||
|
suppressContentEditableWarning
|
||||||
|
role={disabled ? undefined : 'textbox'}
|
||||||
|
aria-multiline={disabled ? undefined : true}
|
||||||
|
aria-placeholder={placeholder}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
className={cn(
|
||||||
|
'min-h-[32px] text-sm whitespace-pre-wrap break-words outline-none',
|
||||||
|
'[&_.ptag-badge]:inline-flex [&_.ptag-badge]:items-center [&_.ptag-badge]:rounded-full',
|
||||||
|
'[&_.ptag-badge]:bg-accent/20 [&_.ptag-badge]:text-accent [&_.ptag-badge]:border [&_.ptag-badge]:border-accent/30',
|
||||||
|
'[&_.ptag-badge]:px-2 [&_.ptag-badge]:py-0 [&_.ptag-badge]:text-xs [&_.ptag-badge]:font-medium',
|
||||||
|
'[&_.ptag-badge]:mx-0.5 [&_.ptag-badge]:select-none [&_.ptag-badge]:cursor-default',
|
||||||
|
'[&_.ptag-badge]:align-baseline',
|
||||||
|
disabled && 'opacity-50 cursor-not-allowed',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
onInput={!disabled ? handleInput : undefined}
|
||||||
|
onKeyDown={!disabled ? handleKeyDown : undefined}
|
||||||
|
onPaste={!disabled ? handlePaste : undefined}
|
||||||
|
onClick={!disabled ? onClick : undefined}
|
||||||
|
onFocus={!disabled ? onFocus : undefined}
|
||||||
|
onBlur={() => {
|
||||||
|
setShowMenu(false);
|
||||||
|
triggerRangeRef.current = null;
|
||||||
|
}}
|
||||||
|
onCompositionStart={() => {
|
||||||
|
isComposingRef.current = true;
|
||||||
|
}}
|
||||||
|
onCompositionEnd={() => {
|
||||||
|
isComposingRef.current = false;
|
||||||
|
handleInput();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Autocomplete dropdown — portalled to body, positioned above the caret */}
|
||||||
|
{showMenu &&
|
||||||
|
filteredTags.length > 0 &&
|
||||||
|
createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 4 }}
|
||||||
|
transition={{ duration: 0.12 }}
|
||||||
|
className="fixed z-[9999] min-w-[200px] max-h-[280px] overflow-y-auto rounded-lg border border-border bg-popover shadow-lg"
|
||||||
|
style={{
|
||||||
|
bottom: menuPosition.bottom,
|
||||||
|
left: menuPosition.left,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredTags.map((t, i) => (
|
||||||
|
<button
|
||||||
|
key={t.tag}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left transition-colors',
|
||||||
|
i === menuIndex
|
||||||
|
? 'bg-accent/20 text-accent-foreground'
|
||||||
|
: 'text-popover-foreground hover:bg-muted/50',
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault(); // Keep focus in editor
|
||||||
|
insertTag(t.tag);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setMenuIndex(i)}
|
||||||
|
>
|
||||||
|
<span className="text-base leading-none">{t.emoji}</span>
|
||||||
|
<span>{t.label}</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground font-mono">{t.tag}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,6 +1,22 @@
|
|||||||
import { AudioWaveform, Download, FileArchive, Loader2, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
AlignCenter,
|
||||||
|
AudioLines,
|
||||||
|
AudioWaveform,
|
||||||
|
Download,
|
||||||
|
FileArchive,
|
||||||
|
Loader2,
|
||||||
|
MoreHorizontal,
|
||||||
|
Play,
|
||||||
|
RotateCcw,
|
||||||
|
Star,
|
||||||
|
Trash2,
|
||||||
|
Wand2,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import type { HistoryResponse } from '@/lib/api/types';
|
import Loader from 'react-loaders';
|
||||||
|
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -16,9 +32,17 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { EffectConfig, GenerationVersionResponse, HistoryResponse } from '@/lib/api/types';
|
||||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import {
|
import {
|
||||||
useDeleteGeneration,
|
useDeleteGeneration,
|
||||||
@@ -28,7 +52,8 @@ import {
|
|||||||
useImportGeneration,
|
useImportGeneration,
|
||||||
} from '@/lib/hooks/useHistory';
|
} from '@/lib/hooks/useHistory';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { formatDate, formatDuration } from '@/lib/utils/format';
|
import { formatDate, formatDuration, formatEngineName } from '@/lib/utils/format';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
|
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
|
||||||
@@ -46,11 +71,27 @@ export function HistoryTable() {
|
|||||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(null);
|
const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [effectsDialogOpen, setEffectsDialogOpen] = useState(false);
|
||||||
|
const [effectsTargetId, setEffectsTargetId] = useState<string | null>(null);
|
||||||
|
const [effectsTargetVersions, setEffectsTargetVersions] = useState<GenerationVersionResponse[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [effectsSourceVersionId, setEffectsSourceVersionId] = useState<string | null>(null);
|
||||||
|
const [effectsChain, setEffectsChain] = useState<EffectConfig[]>([]);
|
||||||
|
const [applyingEffects, setApplyingEffects] = useState(false);
|
||||||
|
const [expandedVersionsId, setExpandedVersionsId] = useState<string | null>(null);
|
||||||
const limit = 20;
|
const limit = 20;
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: historyData, isLoading, isFetching } = useHistory({
|
const {
|
||||||
|
data: historyData,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
} = useHistory({
|
||||||
limit,
|
limit,
|
||||||
offset: page * limit,
|
offset: page * limit,
|
||||||
});
|
});
|
||||||
@@ -59,6 +100,7 @@ export function HistoryTable() {
|
|||||||
const exportGeneration = useExportGeneration();
|
const exportGeneration = useExportGeneration();
|
||||||
const exportGenerationAudio = useExportGenerationAudio();
|
const exportGenerationAudio = useExportGenerationAudio();
|
||||||
const importGeneration = useImportGeneration();
|
const importGeneration = useImportGeneration();
|
||||||
|
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
|
||||||
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
||||||
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
|
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
|
||||||
const currentAudioId = usePlayerStore((state) => state.audioId);
|
const currentAudioId = usePlayerStore((state) => state.audioId);
|
||||||
@@ -182,6 +224,120 @@ export function HistoryTable() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRetry = async (generationId: string) => {
|
||||||
|
try {
|
||||||
|
const result = await apiClient.retryGeneration(generationId);
|
||||||
|
addPendingGeneration(result.id);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Retry failed',
|
||||||
|
description: error instanceof Error ? error.message : 'Could not retry generation',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegenerate = async (generationId: string) => {
|
||||||
|
try {
|
||||||
|
await apiClient.regenerateGeneration(generationId);
|
||||||
|
addPendingGeneration(generationId);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Regenerate failed',
|
||||||
|
description: error instanceof Error ? error.message : 'Could not regenerate',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleFavorite = async (generationId: string) => {
|
||||||
|
try {
|
||||||
|
await apiClient.toggleFavorite(generationId);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to update favorite',
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyEffects = (generationId: string) => {
|
||||||
|
const gen = allHistory.find((g) => g.id === generationId);
|
||||||
|
const versions = gen?.versions ?? [];
|
||||||
|
setEffectsTargetId(generationId);
|
||||||
|
setEffectsTargetVersions(versions);
|
||||||
|
// Default to clean/original version (no effects chain)
|
||||||
|
const cleanVersion = versions.find((v) => !v.effects_chain || v.effects_chain.length === 0);
|
||||||
|
setEffectsSourceVersionId(cleanVersion?.id ?? null);
|
||||||
|
setEffectsChain([]);
|
||||||
|
setEffectsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyEffectsConfirm = async () => {
|
||||||
|
if (!effectsTargetId || effectsChain.length === 0) return;
|
||||||
|
setApplyingEffects(true);
|
||||||
|
try {
|
||||||
|
const newVersion = await apiClient.applyEffectsToGeneration(effectsTargetId, {
|
||||||
|
effects_chain: effectsChain,
|
||||||
|
source_version_id: effectsSourceVersionId ?? undefined,
|
||||||
|
set_as_default: true,
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
|
||||||
|
// If the player is currently on this generation, reload with the new version audio
|
||||||
|
if (currentAudioId === effectsTargetId) {
|
||||||
|
const gen = allHistory.find((g) => g.id === effectsTargetId);
|
||||||
|
if (gen) {
|
||||||
|
const versionUrl = apiClient.getVersionAudioUrl(newVersion.id);
|
||||||
|
setAudioWithAutoPlay(
|
||||||
|
versionUrl,
|
||||||
|
effectsTargetId,
|
||||||
|
gen.profile_id,
|
||||||
|
gen.text.substring(0, 50),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setEffectsDialogOpen(false);
|
||||||
|
toast({ title: 'Effects applied', description: 'A new version has been created.' });
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to apply effects',
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setApplyingEffects(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSwitchVersion = async (generationId: string, versionId: string) => {
|
||||||
|
try {
|
||||||
|
await apiClient.setDefaultVersion(generationId, versionId);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to switch version',
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlayVersion = (
|
||||||
|
generationId: string,
|
||||||
|
versionId: string,
|
||||||
|
text: string,
|
||||||
|
profileId: string,
|
||||||
|
) => {
|
||||||
|
const audioUrl = apiClient.getVersionAudioUrl(versionId);
|
||||||
|
setAudioWithAutoPlay(audioUrl, generationId, profileId, text.substring(0, 50));
|
||||||
|
};
|
||||||
|
|
||||||
const handleImportConfirm = () => {
|
const handleImportConfirm = () => {
|
||||||
if (selectedFile) {
|
if (selectedFile) {
|
||||||
importGeneration.mutate(selectedFile, {
|
importGeneration.mutate(selectedFile, {
|
||||||
@@ -238,100 +394,269 @@ export function HistoryTable() {
|
|||||||
>
|
>
|
||||||
{history.map((gen) => {
|
{history.map((gen) => {
|
||||||
const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying;
|
const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying;
|
||||||
|
const isInProgress = gen.status === 'loading_model' || gen.status === 'generating';
|
||||||
|
const isGenerating = isInProgress;
|
||||||
|
const isFailed = gen.status === 'failed';
|
||||||
|
const isPlayable = !isGenerating && !isFailed;
|
||||||
|
const hasVersions = gen.versions && gen.versions.length > 1;
|
||||||
|
const isVersionsExpanded = expandedVersionsId === gen.id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={gen.id}
|
key={gen.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-stretch gap-4 h-26 border rounded-md p-3 bg-card hover:bg-muted/70 transition-colors text-left w-full',
|
'border rounded-md bg-card transition-colors text-left w-full',
|
||||||
isCurrentlyPlaying && 'bg-muted/70',
|
isCurrentlyPlaying && 'bg-muted/70',
|
||||||
)}
|
)}
|
||||||
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, gen.profile_id);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Waveform icon */}
|
{/* Main row */}
|
||||||
<div className="flex items-center shrink-0">
|
|
||||||
<AudioWaveform className="h-5 w-5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Left side - Meta information */}
|
|
||||||
<div className="flex flex-col gap-1.5 w-48 shrink-0 justify-center">
|
|
||||||
<div className="font-medium text-sm truncate" title={gen.profile_name}>
|
|
||||||
{gen.profile_name}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground">{gen.language}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatDuration(gen.duration)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{formatDate(gen.created_at)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right side - Transcript textarea */}
|
|
||||||
<div className="flex-1 min-w-0 flex">
|
|
||||||
<Textarea
|
|
||||||
value={gen.text}
|
|
||||||
className="flex-1 resize-none text-sm text-muted-foreground select-text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Far right - Ellipsis actions */}
|
|
||||||
<div
|
<div
|
||||||
className="w-10 shrink-0 flex justify-end"
|
role={isPlayable ? 'button' : undefined}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
tabIndex={isPlayable ? 0 : undefined}
|
||||||
onClick={(e) => e.stopPropagation()}
|
className={cn(
|
||||||
|
'flex items-stretch gap-4 h-26 p-3',
|
||||||
|
isPlayable && 'hover:bg-muted/70 cursor-pointer rounded-md',
|
||||||
|
isVersionsExpanded && 'rounded-b-none',
|
||||||
|
)}
|
||||||
|
aria-label={
|
||||||
|
isGenerating
|
||||||
|
? `Generating speech for ${gen.profile_name}...`
|
||||||
|
: isFailed
|
||||||
|
? `Generation failed for ${gen.profile_name}`
|
||||||
|
: isCurrentlyPlaying
|
||||||
|
? `Sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}, ${formatDate(gen.created_at)}. Playing. Press Enter to restart.`
|
||||||
|
: `Sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}, ${formatDate(gen.created_at)}. Press Enter to play.`
|
||||||
|
}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (!isPlayable) return;
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('textarea') || window.getSelection()?.toString()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlePlay(gen.id, gen.text, gen.profile_id);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (!isPlayable) return;
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('textarea') || target.closest('button')) return;
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handlePlay(gen.id, gen.text, gen.profile_id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu>
|
{/* Status icon */}
|
||||||
<DropdownMenuTrigger asChild>
|
<div className="flex items-center shrink-0 w-10 justify-center overflow-hidden">
|
||||||
|
<div className="scale-50">
|
||||||
|
<Loader
|
||||||
|
type={isGenerating ? 'line-scale' : 'line-scale-pulse-out-rapid'}
|
||||||
|
active={isGenerating || isCurrentlyPlaying}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Left side - Meta information */}
|
||||||
|
<div className="flex flex-col gap-1.5 w-48 shrink-0 justify-center">
|
||||||
|
<div className="font-medium text-sm truncate" title={gen.profile_name}>
|
||||||
|
{gen.profile_name}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">{gen.language}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatEngineName(gen.engine, gen.model_size)}
|
||||||
|
</span>
|
||||||
|
{isFailed ? (
|
||||||
|
<span className="text-xs text-destructive">Failed</span>
|
||||||
|
) : !isGenerating ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDuration(gen.duration ?? 0)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{isInProgress ? (
|
||||||
|
<span className="text-accent">
|
||||||
|
{gen.status === 'loading_model' ? 'Loading model...' : 'Generating...'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
formatDate(gen.created_at)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Transcript textarea */}
|
||||||
|
<div className="flex-1 min-w-0 flex">
|
||||||
|
<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 ?? 0)}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Far right - Actions */}
|
||||||
|
<div
|
||||||
|
className="shrink-0 flex flex-col justify-center items-center gap-0.5"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
'h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground',
|
||||||
|
gen.is_favorited && 'text-accent hover:text-accent',
|
||||||
|
)}
|
||||||
|
aria-label={gen.is_favorited ? 'Unfavorite' : 'Favorite'}
|
||||||
|
onClick={() => handleToggleFavorite(gen.id)}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className="h-2 w-2"
|
||||||
|
fill={gen.is_favorited ? 'currentColor' : 'none'}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
{hasVersions && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className={cn(
|
||||||
aria-label="Actions"
|
'h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground',
|
||||||
|
isVersionsExpanded && 'text-accent hover:text-accent',
|
||||||
|
)}
|
||||||
|
aria-label="Toggle versions"
|
||||||
|
onClick={() => setExpandedVersionsId(isVersionsExpanded ? null : gen.id)}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<AudioLines className="h-2 w-2" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
)}
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
{isFailed ? (
|
||||||
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
|
||||||
|
aria-label="Retry generation"
|
||||||
|
onClick={() => handleRetry(gen.id)}
|
||||||
>
|
>
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<RotateCcw className="h-2 w-2" />
|
||||||
Play
|
</Button>
|
||||||
</DropdownMenuItem>
|
) : (
|
||||||
<DropdownMenuItem
|
<>
|
||||||
onClick={() => handleDownloadAudio(gen.id, gen.text)}
|
<DropdownMenu>
|
||||||
disabled={exportGenerationAudio.isPending}
|
<DropdownMenuTrigger asChild>
|
||||||
>
|
<Button
|
||||||
<Download className="mr-2 h-4 w-4" />
|
variant="ghost"
|
||||||
Export Audio
|
size="icon"
|
||||||
</DropdownMenuItem>
|
className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
|
||||||
<DropdownMenuItem
|
aria-label="Actions"
|
||||||
onClick={() => handleExportPackage(gen.id, gen.text)}
|
disabled={isGenerating}
|
||||||
disabled={exportGeneration.isPending}
|
>
|
||||||
>
|
<MoreHorizontal className="h-2 w-2" />
|
||||||
<FileArchive className="mr-2 h-4 w-4" />
|
</Button>
|
||||||
Export Package
|
</DropdownMenuTrigger>
|
||||||
</DropdownMenuItem>
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
|
||||||
disabled={deleteGeneration.isPending}
|
>
|
||||||
className="text-destructive focus:text-destructive"
|
<Play className="mr-2 h-4 w-4" />
|
||||||
>
|
Play
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
</DropdownMenuItem>
|
||||||
Delete
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={() => handleDownloadAudio(gen.id, gen.text)}
|
||||||
</DropdownMenuContent>
|
disabled={exportGenerationAudio.isPending}
|
||||||
</DropdownMenu>
|
>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Export Audio
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleExportPackage(gen.id, gen.text)}
|
||||||
|
disabled={exportGeneration.isPending}
|
||||||
|
>
|
||||||
|
<FileArchive className="mr-2 h-4 w-4" />
|
||||||
|
Export Package
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleApplyEffects(gen.id)}>
|
||||||
|
<Wand2 className="mr-2 h-4 w-4" />
|
||||||
|
Apply Effects
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleRegenerate(gen.id)}>
|
||||||
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
|
Regenerate
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
||||||
|
disabled={deleteGeneration.isPending}
|
||||||
|
// className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Expandable versions panel */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isVersionsExpanded && gen.versions && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="border-t border-border/50">
|
||||||
|
<div className="divide-y divide-border/40">
|
||||||
|
{gen.versions.map((v) => {
|
||||||
|
// Show source provenance when effects were applied to a non-clean version
|
||||||
|
const sourceVersion = v.source_version_id
|
||||||
|
? gen.versions?.find((sv) => sv.id === v.source_version_id)
|
||||||
|
: null;
|
||||||
|
const showSource =
|
||||||
|
sourceVersion &&
|
||||||
|
sourceVersion.effects_chain &&
|
||||||
|
sourceVersion.effects_chain.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={v.id}
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 w-full h-9 px-3 text-left hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
handlePlayVersion(gen.id, v.id, gen.text, gen.profile_id);
|
||||||
|
if (!v.is_default) {
|
||||||
|
handleSwitchVersion(gen.id, v.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AudioLines className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate text-xs font-medium">{v.label}</span>
|
||||||
|
{v.effects_chain && v.effects_chain.length > 0 && (
|
||||||
|
<span className="text-[10px] text-muted-foreground truncate">
|
||||||
|
{v.effects_chain.map((e) => e.type).join(' → ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{showSource && (
|
||||||
|
<span className="text-[10px] text-muted-foreground/60 truncate">
|
||||||
|
from {sourceVersion.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex-1" />
|
||||||
|
{v.is_default && (
|
||||||
|
<span className="text-[10px] bg-accent/15 text-accent px-1.5 py-0.5 rounded-full">
|
||||||
|
active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -358,7 +683,8 @@ export function HistoryTable() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete Generation</DialogTitle>
|
<DialogTitle>Delete Generation</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to delete this generation from "{generationToDelete?.name}"? This action cannot be undone.
|
Are you sure you want to delete this generation from "{generationToDelete?.name}"?
|
||||||
|
This action cannot be undone.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -412,6 +738,57 @@ export function HistoryTable() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={effectsDialogOpen} onOpenChange={setEffectsDialogOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Apply Effects</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure post-processing effects to apply to this generation. A new version will be
|
||||||
|
created.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{effectsTargetVersions.length > 1 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">Source</label>
|
||||||
|
<Select
|
||||||
|
value={effectsSourceVersionId ?? ''}
|
||||||
|
onValueChange={(val) => setEffectsSourceVersionId(val || null)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="Select source version" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{effectsTargetVersions.map((v) => (
|
||||||
|
<SelectItem key={v.id} value={v.id} className="text-xs">
|
||||||
|
{v.label}
|
||||||
|
{v.effects_chain && v.effects_chain.length > 0 && (
|
||||||
|
<span className="text-muted-foreground ml-1.5">
|
||||||
|
({v.effects_chain.map((e) => e.type).join(' + ')})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="py-2 max-h-80 overflow-y-auto">
|
||||||
|
<EffectsChainEditor value={effectsChain} onChange={setEffectsChain} />
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEffectsDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleApplyEffectsConfirm}
|
||||||
|
disabled={applyingEffects || effectsChain.length === 0}
|
||||||
|
>
|
||||||
|
{applyingEffects ? 'Applying...' : 'Apply'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
|
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
|
||||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
|
||||||
import { useImportProfile } from '@/lib/hooks/useProfiles';
|
import { useImportProfile } from '@/lib/hooks/useProfiles';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
@@ -77,9 +77,9 @@ export function MainEditor() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
// Main view: Profiles top left, Generator bottom left, History right
|
// Main view: Profiles top left, Generator bottom left, History right
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0 overflow-hidden relative">
|
<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-6 h-full min-h-0 overflow-hidden relative">
|
||||||
{/* Left Column */}
|
{/* Left Column */}
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden relative">
|
<div className="flex flex-col min-h-0 overflow-hidden relative lg:overflow-hidden">
|
||||||
{/* Scroll Mask - Always visible, behind content */}
|
{/* Scroll Mask - Always visible, behind content */}
|
||||||
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />
|
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />
|
||||||
|
|
||||||
@@ -110,10 +110,7 @@ export function MainEditor() {
|
|||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className={cn(
|
className={cn('flex-1 min-h-0 overflow-y-auto pt-14 pb-4', isPlayerVisible && 'lg:pb-32')}
|
||||||
'flex-1 min-h-0 overflow-y-auto pt-14',
|
|
||||||
isPlayerVisible ? BOTTOM_SAFE_AREA_PADDING : 'pb-4',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="shrink-0 flex flex-col">
|
<div className="shrink-0 flex flex-col">
|
||||||
@@ -123,6 +120,9 @@ export function MainEditor() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Divider - single column only */}
|
||||||
|
{/* <div className="border-t border-border -my-3 lg:hidden" /> */}
|
||||||
|
|
||||||
{/* Right Column - History */}
|
{/* Right Column - History */}
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||||
<HistoryTable />
|
<HistoryTable />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
|
|||||||
|
|
||||||
export function ModelsTab() {
|
export function ModelsTab() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 overflow-y-auto flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<ModelManagement />
|
<ModelManagement />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Loader2, XCircle } from 'lucide-react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -14,10 +17,10 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
const connectionSchema = z.object({
|
const connectionSchema = z.object({
|
||||||
serverUrl: z.string().url('Please enter a valid URL'),
|
serverUrl: z.string().url('Please enter a valid URL'),
|
||||||
@@ -31,7 +34,10 @@ export function ConnectionForm() {
|
|||||||
const setServerUrl = useServerStore((state) => state.setServerUrl);
|
const setServerUrl = useServerStore((state) => state.setServerUrl);
|
||||||
const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose);
|
const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose);
|
||||||
const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose);
|
const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose);
|
||||||
|
const mode = useServerStore((state) => state.mode);
|
||||||
|
const setMode = useServerStore((state) => state.setMode);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { data: health, isLoading, error: healthError } = useServerHealth();
|
||||||
|
|
||||||
const form = useForm<ConnectionFormValues>({
|
const form = useForm<ConnectionFormValues>({
|
||||||
resolver: zodResolver(connectionSchema),
|
resolver: zodResolver(connectionSchema),
|
||||||
@@ -49,7 +55,7 @@ export function ConnectionForm() {
|
|||||||
|
|
||||||
function onSubmit(data: ConnectionFormValues) {
|
function onSubmit(data: ConnectionFormValues) {
|
||||||
setServerUrl(data.serverUrl);
|
setServerUrl(data.serverUrl);
|
||||||
form.reset(data); // Reset form state after successful submission
|
form.reset(data);
|
||||||
toast({
|
toast({
|
||||||
title: 'Server URL updated',
|
title: 'Server URL updated',
|
||||||
description: `Connected to ${data.serverUrl}`,
|
description: `Connected to ${data.serverUrl}`,
|
||||||
@@ -57,7 +63,7 @@ export function ConnectionForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card role="region" aria-label="Server Connection" tabIndex={0}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Server Connection</CardTitle>
|
<CardTitle>Server Connection</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -83,10 +89,42 @@ export function ConnectionForm() {
|
|||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
|
{/* Connection status */}
|
||||||
|
<div className="mt-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-sm text-muted-foreground">Checking connection...</span>
|
||||||
|
</div>
|
||||||
|
) : healthError ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<XCircle className="h-4 w-4 text-destructive" />
|
||||||
|
<span className="text-sm text-destructive">
|
||||||
|
Connection failed: {healthError.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : health ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={health.model_loaded || health.model_downloaded ? 'default' : 'secondary'}
|
||||||
|
>
|
||||||
|
{health.model_loaded || health.model_downloaded ? 'Model Ready' : 'No Model'}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={health.gpu_available ? 'default' : 'secondary'}>
|
||||||
|
GPU: {health.gpu_available ? 'Available' : 'Not Available'}
|
||||||
|
</Badge>
|
||||||
|
{health.vram_used_mb != null && health.vram_used_mb > 0 && (
|
||||||
|
<Badge variant="outline">VRAM: {health.vram_used_mb.toFixed(0)} MB</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t">
|
<div className="mt-6 pt-6 border-t">
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="keepServerRunning"
|
id="keepServerRunning"
|
||||||
|
className="mt-[6px]"
|
||||||
checked={keepServerRunningOnClose}
|
checked={keepServerRunningOnClose}
|
||||||
onCheckedChange={(checked: boolean) => {
|
onCheckedChange={(checked: boolean) => {
|
||||||
setKeepServerRunningOnClose(checked);
|
setKeepServerRunningOnClose(checked);
|
||||||
@@ -115,6 +153,39 @@ export function ConnectionForm() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{platform.metadata.isTauri && (
|
||||||
|
<div className="mt-6 pt-6 border-t">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
id="allowNetworkAccess"
|
||||||
|
className="mt-[6px]"
|
||||||
|
checked={mode === 'remote'}
|
||||||
|
onCheckedChange={(checked: boolean) => {
|
||||||
|
setMode(checked ? 'remote' : 'local');
|
||||||
|
toast({
|
||||||
|
title: 'Setting updated',
|
||||||
|
description: checked
|
||||||
|
? 'Network access enabled. Restart the app to apply.'
|
||||||
|
: 'Network access disabled. Restart the app to apply.',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label
|
||||||
|
htmlFor="allowNetworkAccess"
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||||
|
>
|
||||||
|
Allow network access
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Makes the server accessible from other devices on your network. Restart the app
|
||||||
|
after changing this setting.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
|
export function GenerationSettings() {
|
||||||
|
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
|
||||||
|
const setMaxChunkChars = useServerStore((state) => state.setMaxChunkChars);
|
||||||
|
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
|
||||||
|
const setCrossfadeMs = useServerStore((state) => state.setCrossfadeMs);
|
||||||
|
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
|
||||||
|
const setNormalizeAudio = useServerStore((state) => state.setNormalizeAudio);
|
||||||
|
const autoplayOnGenerate = useServerStore((state) => state.autoplayOnGenerate);
|
||||||
|
const setAutoplayOnGenerate = useServerStore((state) => state.setAutoplayOnGenerate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card role="region" aria-label="Generation Settings" tabIndex={0}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Generation Settings</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Controls for long text generation. These settings apply to all engines.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label htmlFor="maxChunkChars" className="text-sm font-medium leading-none">
|
||||||
|
Auto-chunking limit
|
||||||
|
</label>
|
||||||
|
<span className="text-sm tabular-nums text-muted-foreground">
|
||||||
|
{maxChunkChars} chars
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
id="maxChunkChars"
|
||||||
|
value={[maxChunkChars]}
|
||||||
|
onValueChange={([value]) => setMaxChunkChars(value)}
|
||||||
|
min={100}
|
||||||
|
max={5000}
|
||||||
|
step={50}
|
||||||
|
aria-label="Auto-chunking character limit"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Long text is split into chunks at sentence boundaries before generating. Lower values
|
||||||
|
can improve quality for long outputs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label htmlFor="crossfadeMs" className="text-sm font-medium leading-none">
|
||||||
|
Chunk crossfade
|
||||||
|
</label>
|
||||||
|
<span className="text-sm tabular-nums text-muted-foreground">
|
||||||
|
{crossfadeMs === 0 ? 'Cut' : `${crossfadeMs}ms`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
id="crossfadeMs"
|
||||||
|
value={[crossfadeMs]}
|
||||||
|
onValueChange={([value]) => setCrossfadeMs(value)}
|
||||||
|
min={0}
|
||||||
|
max={200}
|
||||||
|
step={10}
|
||||||
|
aria-label="Chunk crossfade duration"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Blends audio between chunks to smooth transitions. Set to 0 for a hard cut.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
id="normalizeAudio"
|
||||||
|
checked={normalizeAudio}
|
||||||
|
onCheckedChange={setNormalizeAudio}
|
||||||
|
className="mt-[6px]"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label
|
||||||
|
htmlFor="normalizeAudio"
|
||||||
|
className="text-sm font-medium leading-none cursor-pointer"
|
||||||
|
>
|
||||||
|
Normalize audio
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Adjusts output volume to a consistent level across generations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
id="autoplayOnGenerate"
|
||||||
|
checked={autoplayOnGenerate}
|
||||||
|
onCheckedChange={setAutoplayOnGenerate}
|
||||||
|
className="mt-[6px]"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label
|
||||||
|
htmlFor="autoplayOnGenerate"
|
||||||
|
className="text-sm font-medium leading-none cursor-pointer"
|
||||||
|
>
|
||||||
|
Autoplay on generate
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Automatically play audio when a generation completes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
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">
|
||||||
|
{/* GPU status */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{health.gpu_available && health.gpu_type ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{health.gpu_type.replace(/^(CUDA|ROCm|MPS|Metal|XPU|DirectML)\s*\((.+)\)$/, '$2') ||
|
||||||
|
health.gpu_type}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{health.gpu_type.replace(/\s*\(.+\)$/, '')}
|
||||||
|
{health.vram_used_mb != null && health.vram_used_mb > 0
|
||||||
|
? ` \u00b7 ${health.vram_used_mb.toFixed(0)} MB VRAM used`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-sm font-medium">CPU</div>
|
||||||
|
<div className="text-sm text-muted-foreground">No GPU acceleration available</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Native GPU detected - no CUDA download needed */}
|
||||||
|
|
||||||
|
{/* CUDA download section - only show when no GPU is active (native or CUDA) */}
|
||||||
|
{!hasNativeGpu && !isCurrentlyCuda && (
|
||||||
|
<>
|
||||||
|
{/* Download progress (manual download or auto-update) */}
|
||||||
|
{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 ||
|
||||||
|
(cudaAvailable
|
||||||
|
? 'Updating CUDA backend...'
|
||||||
|
: 'Downloading CUDA backend...')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{downloadProgress.total > 0 && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{downloadProgress.progress.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{downloadProgress.total > 0 && (
|
||||||
|
<>
|
||||||
|
<Progress value={downloadProgress.progress} className="h-2" />
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{formatBytes(downloadProgress.current)} /{' '}
|
||||||
|
{formatBytes(downloadProgress.total)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Restart in progress */}
|
||||||
|
{restartPhase !== 'idle' && (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-primary/5 border">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-sm">
|
||||||
|
{restartPhase === 'stopping' && 'Stopping server...'}
|
||||||
|
{restartPhase === 'waiting' && 'Restarting server...'}
|
||||||
|
{restartPhase === 'ready' && 'Server restarted successfully!'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{restartPhase === 'idle' && !cudaDownloading && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Not downloaded yet - show download button */}
|
||||||
|
{!cudaAvailable && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Download the CUDA backend (~2.4 GB) for NVIDIA GPU acceleration. Requires an
|
||||||
|
NVIDIA GPU with CUDA support.
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleDownload} className="w-full" size="sm">
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Download CUDA Backend
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Downloaded but not active - show switch button */}
|
||||||
|
{cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
CUDA backend is downloaded and ready. Restart the server to enable GPU
|
||||||
|
acceleration.
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleRestart} className="w-full" size="sm">
|
||||||
|
<RotateCw className="h-4 w-4 mr-2" />
|
||||||
|
Switch to CUDA Backend
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Currently active - show switch back to CPU */}
|
||||||
|
{isCurrentlyCuda && platform.metadata.isTauri && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Running with CUDA GPU acceleration. Switch back to CPU if needed (you can
|
||||||
|
re-download later).
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={handleSwitchToCpu}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<RotateCw className="h-4 w-4 mr-2" />
|
||||||
|
Switch to CPU Backend
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete option when downloaded (and not active) */}
|
||||||
|
{cudaAvailable && !isCurrentlyCuda && (
|
||||||
|
<Button
|
||||||
|
onClick={handleDelete}
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full text-muted-foreground hover:text-destructive"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Remove CUDA Backend
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -8,14 +8,23 @@ import { useServerStore } from '@/stores/serverStore';
|
|||||||
interface ModelProgressProps {
|
interface ModelProgressProps {
|
||||||
modelName: string;
|
modelName: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
/** Only connect to SSE when actively downloading - prevents connection exhaustion */
|
||||||
|
isDownloading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
export function ModelProgress({ modelName, displayName, isDownloading = false }: ModelProgressProps) {
|
||||||
const [progress, setProgress] = useState<ModelProgressType | null>(null);
|
const [progress, setProgress] = useState<ModelProgressType | null>(null);
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverUrl) return;
|
// IMPORTANT: Only connect to SSE when this specific model is downloading
|
||||||
|
// Opening SSE connections for all models exhausts HTTP/1.1 connection limits (6 per origin)
|
||||||
|
// which causes other fetches (like the download trigger) to be queued/blocked
|
||||||
|
if (!serverUrl || !isDownloading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[ModelProgress] Connecting SSE for ${modelName}`);
|
||||||
|
|
||||||
// Subscribe to progress updates via Server-Sent Events
|
// Subscribe to progress updates via Server-Sent Events
|
||||||
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
||||||
@@ -27,6 +36,7 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
|||||||
|
|
||||||
// Close connection if complete or error
|
// Close connection if complete or error
|
||||||
if (data.status === 'complete' || data.status === 'error') {
|
if (data.status === 'complete' || data.status === 'error') {
|
||||||
|
console.log(`[ModelProgress] Download ${data.status} for ${modelName}, closing SSE`);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -35,14 +45,15 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onerror = (error) => {
|
eventSource.onerror = (error) => {
|
||||||
console.error('SSE error:', error);
|
console.error(`[ModelProgress] SSE error for ${modelName}:`, error);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
console.log(`[ModelProgress] Cleanup - closing SSE for ${modelName}`);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
};
|
};
|
||||||
}, [serverUrl, modelName]);
|
}, [serverUrl, modelName, isDownloading]);
|
||||||
|
|
||||||
// Don't render if no progress or if complete/error and some time has passed
|
// Don't render if no progress or if complete/error and some time has passed
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
import { ModelProgress } from './ModelProgress';
|
|
||||||
|
|
||||||
export function ServerStatus() {
|
export function ServerStatus() {
|
||||||
const { data: health, isLoading, error } = useServerHealth();
|
const { data: health, isLoading, error } = useServerHealth();
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card role="region" aria-label="Server Status" tabIndex={0}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Server Status</CardTitle>
|
<CardTitle>Server Status</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -20,16 +19,6 @@ export function ServerStatus() {
|
|||||||
<div className="font-mono text-sm">{serverUrl}</div>
|
<div className="font-mono text-sm">{serverUrl}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model download progress */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<ModelProgress modelName="qwen-tts-1.7B" displayName="Qwen TTS 1.7B" />
|
|
||||||
<ModelProgress modelName="qwen-tts-0.6B" displayName="Qwen TTS 0.6B" />
|
|
||||||
<ModelProgress modelName="whisper-base" displayName="Whisper Base" />
|
|
||||||
<ModelProgress modelName="whisper-small" displayName="Whisper Small" />
|
|
||||||
<ModelProgress modelName="whisper-medium" displayName="Whisper Medium" />
|
|
||||||
<ModelProgress modelName="whisper-large" displayName="Whisper Large" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
|||||||
@@ -11,15 +11,17 @@ export function UpdateStatus() {
|
|||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
|
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
|
||||||
const [currentVersion, setCurrentVersion] = useState<string>('');
|
const [currentVersion, setCurrentVersion] = useState<string>('');
|
||||||
|
const isDev = !import.meta.env?.PROD;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
platform.metadata.getVersion()
|
platform.metadata
|
||||||
|
.getVersion()
|
||||||
.then(setCurrentVersion)
|
.then(setCurrentVersion)
|
||||||
.catch(() => setCurrentVersion('0.1.0'));
|
.catch(() => setCurrentVersion('Unknown'));
|
||||||
}, [platform]);
|
}, [platform]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card role="region" aria-label="App Updates" tabIndex={0}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>App Updates</CardTitle>
|
<CardTitle>App Updates</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -27,97 +29,110 @@ export function UpdateStatus() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm font-medium">Current Version</div>
|
<div className="text-sm font-medium">Current Version</div>
|
||||||
<div className="text-sm text-muted-foreground">v{currentVersion}</div>
|
<div className="text-sm text-muted-foreground">
|
||||||
|
v{currentVersion}
|
||||||
|
{isDev ? ' (dev)' : ''}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{!isDev && (
|
||||||
onClick={checkForUpdates}
|
<Button
|
||||||
disabled={status.checking || status.downloading || status.readyToInstall}
|
onClick={checkForUpdates}
|
||||||
variant="outline"
|
disabled={status.checking || status.downloading || status.readyToInstall}
|
||||||
size="sm"
|
variant="outline"
|
||||||
>
|
size="sm"
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${status.checking ? 'animate-spin' : ''}`} />
|
>
|
||||||
Check for Updates
|
<RefreshCw className={`h-4 w-4 mr-2 ${status.checking ? 'animate-spin' : ''}`} />
|
||||||
</Button>
|
Check for Updates
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status.checking && (
|
{isDev ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
Auto-updates are disabled in development mode.
|
||||||
Checking for updates...
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
|
<>
|
||||||
{status.error && (
|
{status.checking && (
|
||||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
{status.error}
|
Checking for updates...
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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>
|
|
||||||
<div className="font-semibold">Update Available</div>
|
|
||||||
<div className="text-sm text-muted-foreground">Version {status.version}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Badge>New</Badge>
|
)}
|
||||||
</div>
|
|
||||||
<Button onClick={downloadAndInstall} className="w-full" size="sm">
|
|
||||||
<Download className="h-4 w-4 mr-2" />
|
|
||||||
Download Update
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status.downloading && (
|
{status.error && (
|
||||||
<div className="space-y-2">
|
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<AlertCircle className="h-4 w-4" />
|
||||||
<div className="flex items-center gap-2">
|
{status.error}
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
Downloading update...
|
|
||||||
</div>
|
</div>
|
||||||
{status.downloadProgress !== undefined && (
|
)}
|
||||||
<span className="text-muted-foreground">{status.downloadProgress}%</span>
|
|
||||||
)}
|
{status.available && !status.downloading && !status.readyToInstall && (
|
||||||
</div>
|
<div className="space-y-3 p-4 border rounded-lg bg-primary/5">
|
||||||
<Progress value={status.downloadProgress} />
|
<div className="flex items-center justify-between">
|
||||||
{status.downloadedBytes !== undefined &&
|
<div>
|
||||||
status.totalBytes !== undefined &&
|
<div className="font-semibold">Update Available</div>
|
||||||
status.totalBytes > 0 && (
|
<div className="text-sm text-muted-foreground">Version {status.version}</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
</div>
|
||||||
{(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB /{' '}
|
<Badge>New</Badge>
|
||||||
{(status.totalBytes / 1024 / 1024).toFixed(1)} MB
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<Button onClick={downloadAndInstall} className="w-full" size="sm">
|
||||||
</div>
|
<Download className="h-4 w-4 mr-2" />
|
||||||
)}
|
Download Update
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{status.readyToInstall && (
|
{status.downloading && (
|
||||||
<div className="space-y-3 p-4 border rounded-lg bg-accent/30 border-accent/50">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="font-semibold">Update Ready to Install</div>
|
<Download className="h-4 w-4" />
|
||||||
|
Downloading update...
|
||||||
|
</div>
|
||||||
|
{status.downloadProgress !== undefined && (
|
||||||
|
<span className="text-muted-foreground">{status.downloadProgress}%</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<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.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-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Version {status.version} has been downloaded
|
The app needs to restart to complete the installation. You can do this now or
|
||||||
|
later at your convenience.
|
||||||
</div>
|
</div>
|
||||||
|
<Button onClick={restartAndInstall} className="w-full" size="sm">
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Restart Now
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{!status.available && !status.checking && !status.error && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
You're up to date
|
You're up to date
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
||||||
import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
|
import { GenerationSettings } from '@/components/ServerSettings/GenerationSettings';
|
||||||
|
import { GpuAcceleration } from '@/components/ServerSettings/GpuAcceleration';
|
||||||
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
||||||
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
export function ServerTab() {
|
export function ServerTab() {
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
|
const isPlayerVisible = !!usePlayerStore((state) => state.audioUrl);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 overflow-y-auto flex flex-col">
|
<div
|
||||||
|
className={cn('overflow-y-auto flex flex-col', isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}
|
||||||
|
>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<ConnectionForm />
|
<ConnectionForm />
|
||||||
<ServerStatus />
|
<GenerationSettings />
|
||||||
|
{platform.metadata.isTauri && <GpuAcceleration />}
|
||||||
|
{platform.metadata.isTauri && <UpdateStatus />}
|
||||||
</div>
|
</div>
|
||||||
{platform.metadata.isTauri && <UpdateStatus />}
|
|
||||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
Created by{' '}
|
Created by{' '}
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Link, useMatchRoute } from '@tanstack/react-router';
|
import { Link, useMatchRoute } from '@tanstack/react-router';
|
||||||
import { Box, BookOpen, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react';
|
import { AudioLines, Box, Mic, Server, Speaker, Volume2, Wand2 } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import type { UpdateStatus } from '@/platform/types';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import { version } from '../../package.json';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
isMacOS?: boolean;
|
isMacOS?: boolean;
|
||||||
@@ -11,18 +14,21 @@ interface SidebarProps {
|
|||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'main', path: '/', icon: Volume2, label: 'Generate' },
|
{ id: 'main', path: '/', icon: Volume2, label: 'Generate' },
|
||||||
{ id: 'stories', path: '/stories', icon: BookOpen, label: 'Stories' },
|
{ id: 'stories', path: '/stories', icon: AudioLines, label: 'Stories' },
|
||||||
{ id: 'voices', path: '/voices', icon: Mic, label: 'Voices' },
|
{ id: 'voices', path: '/voices', icon: Mic, label: 'Voices' },
|
||||||
|
{ id: 'effects', path: '/effects', icon: Wand2, label: 'Effects' },
|
||||||
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
|
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
|
||||||
{ id: 'models', path: '/models', icon: Box, label: 'Models' },
|
{ id: 'models', path: '/models', icon: Box, label: 'Models' },
|
||||||
{ id: 'server', path: '/server', icon: Server, label: 'Server' },
|
{ id: 'server', path: '/server', icon: Server, label: 'Server' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar({ isMacOS }: SidebarProps) {
|
export function Sidebar({ isMacOS }: SidebarProps) {
|
||||||
const isGenerating = useGenerationStore((state) => state.isGenerating);
|
|
||||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
|
||||||
const isPlayerVisible = !!audioUrl;
|
|
||||||
const matchRoute = useMatchRoute();
|
const matchRoute = useMatchRoute();
|
||||||
|
const isPlayerOpen = !!usePlayerStore((s) => s.audioUrl);
|
||||||
|
const platform = usePlatform();
|
||||||
|
|
||||||
|
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>(platform.updater.getStatus());
|
||||||
|
useEffect(() => platform.updater.subscribe(setUpdateStatus), [platform.updater]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -33,51 +39,72 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
|||||||
>
|
>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<img src={voiceboxLogo} alt="Voicebox" className="w-12 h-12 object-contain" />
|
<img
|
||||||
|
src={voiceboxLogo}
|
||||||
|
alt="Voicebox"
|
||||||
|
className="w-12 h-12 object-contain"
|
||||||
|
style={{
|
||||||
|
filter:
|
||||||
|
'drop-shadow(0 0 6px hsl(var(--accent) / 0.5)) drop-shadow(0 0 14px hsl(var(--accent) / 0.35)) drop-shadow(0 0 28px hsl(var(--accent) / 0.2))',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation Buttons */}
|
{/* Navigation Buttons */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab, index) => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
// For index route, use exact match; for others, use default matching
|
// For index route, use exact match; for others, use default matching
|
||||||
const isActive =
|
const isActive =
|
||||||
tab.path === '/'
|
tab.path === '/' ? matchRoute({ to: '/', exact: true }) : matchRoute({ to: tab.path });
|
||||||
? matchRoute({ to: '/', exact: true })
|
|
||||||
: matchRoute({ to: tab.path });
|
// Accent fades as buttons get further from the logo
|
||||||
|
const accentOpacity = Math.max(0.08, 0.5 - index * 0.07);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
to={tab.path}
|
to={tab.path}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200',
|
'relative w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200 overflow-hidden',
|
||||||
'hover:bg-muted/50',
|
isActive
|
||||||
isActive ? 'bg-muted/50 text-foreground shadow-lg' : 'text-muted-foreground',
|
? 'bg-white/[0.07] text-foreground shadow-lg backdrop-blur-sm border border-white/[0.08]'
|
||||||
|
: 'text-muted-foreground hover:bg-muted/50',
|
||||||
)}
|
)}
|
||||||
title={tab.label}
|
title={tab.label}
|
||||||
aria-label={tab.label}
|
aria-label={tab.label}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5" />
|
{isActive && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 rounded-full pointer-events-none"
|
||||||
|
style={{
|
||||||
|
maskImage: 'linear-gradient(to bottom, black, transparent 60%)',
|
||||||
|
WebkitMaskImage: 'linear-gradient(to bottom, black, transparent 60%)',
|
||||||
|
border: `1px solid hsl(var(--accent) / ${accentOpacity})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Icon className="h-5 w-5 relative z-10" />
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Spacer to push loader to bottom */}
|
{/* Version */}
|
||||||
<div className="flex-1" />
|
<div
|
||||||
|
className="mt-auto flex flex-col items-center gap-1.5 transition-all duration-300"
|
||||||
{/* Generation Loader */}
|
style={{ paddingBottom: isPlayerOpen ? '7rem' : undefined }}
|
||||||
{isGenerating && (
|
>
|
||||||
<div
|
<span className="text-[10px] text-muted-foreground/50">v{version}</span>
|
||||||
className={cn(
|
{updateStatus.available && (
|
||||||
'w-full flex items-center justify-center transition-all duration-200',
|
<Link
|
||||||
isPlayerVisible ? 'mb-[120px]' : 'mb-0',
|
to="/server"
|
||||||
)}
|
className="text-[9px] font-semibold tracking-wide uppercase px-2 py-0.5 rounded-full bg-accent/15 text-accent hover:bg-accent/25 transition-colors"
|
||||||
>
|
>
|
||||||
<Loader2 className="h-6 w-6 text-accent animate-spin" />
|
Update
|
||||||
</div>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
import { StoryContent } from './StoryContent';
|
import { StoryContent } from './StoryContent';
|
||||||
import { StoryList } from './StoryList';
|
import { StoryList } from './StoryList';
|
||||||
|
|
||||||
export function StoriesTab() {
|
export function StoriesTab() {
|
||||||
|
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
@@ -18,7 +21,7 @@ export function StoriesTab() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating Generate Box - position is managed via storyStore.trackEditorHeight */}
|
{/* Floating Generate Box - position is managed via storyStore.trackEditorHeight */}
|
||||||
<FloatingGenerateBox showVoiceSelector />
|
<FloatingGenerateBox showVoiceSelector isPlayerOpen={!!audioUrl} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ import {
|
|||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Download, Plus } from 'lucide-react';
|
import { Download, Plus } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import Loader from 'react-loaders';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
@@ -28,6 +31,7 @@ import {
|
|||||||
useStory,
|
useStory,
|
||||||
} from '@/lib/hooks/useStories';
|
} from '@/lib/hooks/useStories';
|
||||||
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
|
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
import { SortableStoryChatItem } from './StoryChatItem';
|
import { SortableStoryChatItem } from './StoryChatItem';
|
||||||
|
|
||||||
@@ -40,6 +44,7 @@ export function StoryContent() {
|
|||||||
const addStoryItem = useAddStoryItem();
|
const addStoryItem = useAddStoryItem();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
|
||||||
|
|
||||||
// Add generation popover state
|
// Add generation popover state
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -53,9 +58,9 @@ export function StoryContent() {
|
|||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
return historyData.items.filter(
|
return historyData.items.filter(
|
||||||
(gen) =>
|
(gen) =>
|
||||||
|
gen.status === 'completed' &&
|
||||||
!storyGenerationIds.has(gen.id) &&
|
!storyGenerationIds.has(gen.id) &&
|
||||||
(gen.text.toLowerCase().includes(query) ||
|
(gen.text.toLowerCase().includes(query) || gen.profile_name.toLowerCase().includes(query)),
|
||||||
gen.profile_name.toLowerCase().includes(query)),
|
|
||||||
);
|
);
|
||||||
}, [historyData, story, searchQuery]);
|
}, [historyData, story, searchQuery]);
|
||||||
|
|
||||||
@@ -267,7 +272,31 @@ export function StoryContent() {
|
|||||||
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
|
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 items-center">
|
||||||
|
<AnimatePresence>
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, width: 0 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, width: 'auto' }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, width: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="flex items-center gap-2 h-8 pl-1.5 pr-3 rounded-full bg-card border border-border hover:bg-muted/50 transition-all duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="shrink-0 w-10 h-5 overflow-hidden flex items-center justify-center">
|
||||||
|
<div className="scale-[0.45]">
|
||||||
|
<Loader type="line-scale" active />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
Generating {pendingCount} {pendingCount === 1 ? 'audio' : 'audios'}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
|
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
@@ -287,9 +316,7 @@ export function StoryContent() {
|
|||||||
<div className="max-h-60 overflow-y-auto">
|
<div className="max-h-60 overflow-y-auto">
|
||||||
{availableGenerations.length === 0 ? (
|
{availableGenerations.length === 0 ? (
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
{searchQuery
|
{searchQuery ? 'No matching generations found' : 'No available generations'}
|
||||||
? 'No matching generations found'
|
|
||||||
: 'No available generations'}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
availableGenerations.map((gen) => (
|
availableGenerations.map((gen) => (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Plus, BookOpen, MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
|
import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -29,7 +29,13 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { useStories, useCreateStory, useUpdateStory, useDeleteStory } from '@/lib/hooks/useStories';
|
import {
|
||||||
|
useCreateStory,
|
||||||
|
useDeleteStory,
|
||||||
|
useStories,
|
||||||
|
useStory,
|
||||||
|
useUpdateStory,
|
||||||
|
} from '@/lib/hooks/useStories';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { formatDate } from '@/lib/utils/format';
|
import { formatDate } from '@/lib/utils/format';
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
@@ -38,6 +44,8 @@ export function StoryList() {
|
|||||||
const { data: stories, isLoading } = useStories();
|
const { data: stories, isLoading } = useStories();
|
||||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
|
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
|
||||||
|
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||||
|
const { data: selectedStory } = useStory(selectedStoryId);
|
||||||
const createStory = useCreateStory();
|
const createStory = useCreateStory();
|
||||||
const updateStory = useUpdateStory();
|
const updateStory = useUpdateStory();
|
||||||
const deleteStory = useDeleteStory();
|
const deleteStory = useDeleteStory();
|
||||||
@@ -54,6 +62,13 @@ export function StoryList() {
|
|||||||
const [newStoryDescription, setNewStoryDescription] = useState('');
|
const [newStoryDescription, setNewStoryDescription] = useState('');
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Auto-select the first story when the list loads with no selection
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedStoryId && stories && stories.length > 0) {
|
||||||
|
setSelectedStoryId(stories[0].id);
|
||||||
|
}
|
||||||
|
}, [selectedStoryId, stories, setSelectedStoryId]);
|
||||||
|
|
||||||
const handleCreateStory = () => {
|
const handleCreateStory = () => {
|
||||||
if (!newStoryName.trim()) {
|
if (!newStoryName.trim()) {
|
||||||
toast({
|
toast({
|
||||||
@@ -170,20 +185,29 @@ export function StoryList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storyList = stories || [];
|
const storyList = stories || [];
|
||||||
|
const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="h-full flex flex-col relative overflow-hidden">
|
||||||
{/* Header */}
|
{/* Scroll Mask */}
|
||||||
<div className="flex items-center justify-between mb-4 px-1">
|
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||||
<h2 className="text-2xl font-bold">Stories</h2>
|
|
||||||
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
{/* Fixed Header */}
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<div className="absolute top-0 left-0 right-0 z-20">
|
||||||
New Story
|
<div className="flex items-center justify-between mb-4 px-1">
|
||||||
</Button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Story List */}
|
{/* Scrollable Story List */}
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
|
<div
|
||||||
|
className="flex-1 overflow-y-auto pt-14 relative z-0"
|
||||||
|
style={{ paddingBottom: hasTrackEditor ? `${trackEditorHeight + 140}px` : '170px' }}
|
||||||
|
>
|
||||||
{storyList.length === 0 ? (
|
{storyList.length === 0 ? (
|
||||||
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
||||||
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||||
@@ -191,62 +215,68 @@ export function StoryList() {
|
|||||||
<p className="text-xs mt-2">Create your first story to get started</p>
|
<p className="text-xs mt-2">Create your first story to get started</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
storyList.map((story) => (
|
<div className="space-y-0.5">
|
||||||
<div
|
{storyList.map((story) => (
|
||||||
key={story.id}
|
<div
|
||||||
className={cn(
|
key={story.id}
|
||||||
'h-24 p-4 border rounded-2xl transition-colors group flex items-center',
|
role="button"
|
||||||
selectedStoryId === story.id && 'bg-muted border-primary',
|
tabIndex={0}
|
||||||
)}
|
className={cn(
|
||||||
>
|
'px-5 py-3 rounded-lg transition-colors group flex items-center cursor-pointer',
|
||||||
<div className="flex items-start justify-between gap-2 w-full min-w-0">
|
selectedStoryId === story.id ? 'bg-muted' : 'hover:bg-muted/50',
|
||||||
<button
|
)}
|
||||||
type="button"
|
aria-label={`Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}`}
|
||||||
className="flex-1 min-w-0 text-left cursor-pointer overflow-hidden"
|
aria-pressed={selectedStoryId === story.id}
|
||||||
onClick={() => setSelectedStoryId(story.id)}
|
onClick={() => setSelectedStoryId(story.id)}
|
||||||
>
|
onKeyDown={(e) => {
|
||||||
<h3 className="font-medium truncate">{story.name}</h3>
|
if (e.target !== e.currentTarget) return;
|
||||||
{story.description && (
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
<p className="text-sm text-muted-foreground mt-1 truncate">
|
e.preventDefault();
|
||||||
{story.description}
|
setSelectedStoryId(story.id);
|
||||||
</p>
|
}
|
||||||
)}
|
}}
|
||||||
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
>
|
||||||
<span>
|
<div className="flex items-start justify-between gap-2 w-full min-w-0">
|
||||||
{story.item_count} {story.item_count === 1 ? 'item' : 'items'}
|
<div className="flex-1 min-w-0 text-left overflow-hidden">
|
||||||
</span>
|
<h3 className="text-sm font-medium truncate">{story.name}</h3>
|
||||||
<span>•</span>
|
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
||||||
<span>{formatDate(story.updated_at)}</span>
|
<span>
|
||||||
|
{story.item_count} {story.item_count === 1 ? 'item' : 'items'}
|
||||||
|
</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{formatDate(story.updated_at)}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
<DropdownMenu>
|
||||||
<DropdownMenu>
|
<DropdownMenuTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="icon"
|
||||||
size="icon"
|
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
onClick={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => e.stopPropagation()}
|
aria-label={`Actions for ${story.name}`}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => handleEditClick(story)}>
|
<DropdownMenuItem onClick={() => handleEditClick(story)}>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleDeleteClick(story.id)}
|
onClick={() => handleDeleteClick(story.id)}
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
|
Check,
|
||||||
Copy,
|
Copy,
|
||||||
|
GalleryVerticalEnd,
|
||||||
GripHorizontal,
|
GripHorizontal,
|
||||||
Minus,
|
Minus,
|
||||||
Pause,
|
Pause,
|
||||||
@@ -12,6 +14,12 @@ import {
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import WaveSurfer from 'wavesurfer.js';
|
import WaveSurfer from 'wavesurfer.js';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { StoryItemDetail } from '@/lib/api/types';
|
import type { StoryItemDetail } from '@/lib/api/types';
|
||||||
@@ -19,6 +27,7 @@ import {
|
|||||||
useDuplicateStoryItem,
|
useDuplicateStoryItem,
|
||||||
useMoveStoryItem,
|
useMoveStoryItem,
|
||||||
useRemoveStoryItem,
|
useRemoveStoryItem,
|
||||||
|
useSetStoryItemVersion,
|
||||||
useSplitStoryItem,
|
useSplitStoryItem,
|
||||||
useTrimStoryItem,
|
useTrimStoryItem,
|
||||||
} from '@/lib/hooks/useStories';
|
} from '@/lib/hooks/useStories';
|
||||||
@@ -28,12 +37,14 @@ import { useStoryStore } from '@/stores/storyStore';
|
|||||||
// Clip waveform component with trim support
|
// Clip waveform component with trim support
|
||||||
function ClipWaveform({
|
function ClipWaveform({
|
||||||
generationId,
|
generationId,
|
||||||
|
versionId,
|
||||||
width,
|
width,
|
||||||
trimStartMs,
|
trimStartMs,
|
||||||
trimEndMs,
|
trimEndMs,
|
||||||
duration,
|
duration,
|
||||||
}: {
|
}: {
|
||||||
generationId: string;
|
generationId: string;
|
||||||
|
versionId?: string;
|
||||||
width: number;
|
width: number;
|
||||||
trimStartMs: number;
|
trimStartMs: number;
|
||||||
trimEndMs: number;
|
trimEndMs: number;
|
||||||
@@ -79,7 +90,9 @@ function ClipWaveform({
|
|||||||
|
|
||||||
wavesurferRef.current = wavesurfer;
|
wavesurferRef.current = wavesurfer;
|
||||||
|
|
||||||
const audioUrl = apiClient.getAudioUrl(generationId);
|
const audioUrl = versionId
|
||||||
|
? apiClient.getVersionAudioUrl(versionId)
|
||||||
|
: apiClient.getAudioUrl(generationId);
|
||||||
wavesurfer.load(audioUrl).catch(() => {
|
wavesurfer.load(audioUrl).catch(() => {
|
||||||
// Ignore load errors
|
// Ignore load errors
|
||||||
});
|
});
|
||||||
@@ -88,7 +101,7 @@ function ClipWaveform({
|
|||||||
wavesurfer.destroy();
|
wavesurfer.destroy();
|
||||||
wavesurferRef.current = null;
|
wavesurferRef.current = null;
|
||||||
};
|
};
|
||||||
}, [generationId, fullWaveformWidth]);
|
}, [generationId, versionId, fullWaveformWidth]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full opacity-60 overflow-hidden">
|
<div className="w-full h-full opacity-60 overflow-hidden">
|
||||||
@@ -135,12 +148,57 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
const splitItem = useSplitStoryItem();
|
const splitItem = useSplitStoryItem();
|
||||||
const duplicateItem = useDuplicateStoryItem();
|
const duplicateItem = useDuplicateStoryItem();
|
||||||
const removeItem = useRemoveStoryItem();
|
const removeItem = useRemoveStoryItem();
|
||||||
|
const setItemVersion = useSetStoryItemVersion();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// Selection state
|
// Selection state
|
||||||
const selectedClipId = useStoryStore((state) => state.selectedClipId);
|
const selectedClipId = useStoryStore((state) => state.selectedClipId);
|
||||||
const setSelectedClipId = useStoryStore((state) => state.setSelectedClipId);
|
const setSelectedClipId = useStoryStore((state) => state.setSelectedClipId);
|
||||||
|
|
||||||
|
// Selected clip item (for version picker)
|
||||||
|
const selectedItem = useMemo(
|
||||||
|
() => (selectedClipId ? items.find((i) => i.id === selectedClipId) : undefined),
|
||||||
|
[selectedClipId, items],
|
||||||
|
);
|
||||||
|
const selectedItemVersions = selectedItem?.versions;
|
||||||
|
const hasMultipleVersions = selectedItemVersions && selectedItemVersions.length > 1;
|
||||||
|
|
||||||
|
// Determine which version label is active for the selected clip
|
||||||
|
const activeVersionLabel = useMemo(() => {
|
||||||
|
if (!selectedItem || !selectedItemVersions) return null;
|
||||||
|
// If the item has a pinned version_id, find its label
|
||||||
|
if (selectedItem.version_id) {
|
||||||
|
const pinned = selectedItemVersions.find((v) => v.id === selectedItem.version_id);
|
||||||
|
return pinned?.label ?? null;
|
||||||
|
}
|
||||||
|
// Otherwise use the generation's default version
|
||||||
|
const defaultVersion = selectedItemVersions.find((v) => v.is_default);
|
||||||
|
return defaultVersion?.label ?? null;
|
||||||
|
}, [selectedItem, selectedItemVersions]);
|
||||||
|
|
||||||
|
const handleSetVersion = useCallback(
|
||||||
|
(versionId: string | null) => {
|
||||||
|
if (!selectedClipId) return;
|
||||||
|
setItemVersion.mutate(
|
||||||
|
{
|
||||||
|
storyId,
|
||||||
|
itemId: selectedClipId,
|
||||||
|
data: { version_id: versionId },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to set version',
|
||||||
|
description: error instanceof Error ? error.message : String(error),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[selectedClipId, storyId, setItemVersion, toast],
|
||||||
|
);
|
||||||
|
|
||||||
// Trim state
|
// Trim state
|
||||||
const [trimmingItem, setTrimmingItem] = useState<string | null>(null);
|
const [trimmingItem, setTrimmingItem] = useState<string | null>(null);
|
||||||
const [trimSide, setTrimSide] = useState<'start' | 'end' | null>(null);
|
const [trimSide, setTrimSide] = useState<'start' | 'end' | null>(null);
|
||||||
@@ -736,6 +794,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handlePlayPause}
|
onClick={handlePlayPause}
|
||||||
title="Play/Pause (Space)"
|
title="Play/Pause (Space)"
|
||||||
|
aria-label={isCurrentlyPlaying ? 'Pause' : 'Play'}
|
||||||
>
|
>
|
||||||
{isCurrentlyPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
{isCurrentlyPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -745,6 +804,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handleStop}
|
onClick={handleStop}
|
||||||
disabled={!isCurrentlyPlaying}
|
disabled={!isCurrentlyPlaying}
|
||||||
|
aria-label="Stop"
|
||||||
>
|
>
|
||||||
<Square className="h-3 w-3" />
|
<Square className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -762,6 +822,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handleSplit}
|
onClick={handleSplit}
|
||||||
title="Split at playhead (S)"
|
title="Split at playhead (S)"
|
||||||
|
aria-label="Split at playhead"
|
||||||
>
|
>
|
||||||
<Scissors className="h-4 w-4" />
|
<Scissors className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -771,6 +832,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handleDuplicate}
|
onClick={handleDuplicate}
|
||||||
title="Duplicate (Cmd/Ctrl+D)"
|
title="Duplicate (Cmd/Ctrl+D)"
|
||||||
|
aria-label="Duplicate clip"
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -780,19 +842,75 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
title="Delete (Delete/Backspace)"
|
title="Delete (Delete/Backspace)"
|
||||||
|
aria-label="Delete clip"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{hasMultipleVersions && (
|
||||||
|
<>
|
||||||
|
<div className="w-px h-4 bg-border mx-1" />
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 gap-1.5 px-2 text-xs"
|
||||||
|
title="Change version/take"
|
||||||
|
>
|
||||||
|
<GalleryVerticalEnd className="h-3.5 w-3.5" />
|
||||||
|
<span className="max-w-[80px] truncate">
|
||||||
|
{activeVersionLabel ?? 'default'}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="center" className="min-w-[160px]">
|
||||||
|
{selectedItemVersions.map((version) => {
|
||||||
|
const isActive = selectedItem?.version_id
|
||||||
|
? version.id === selectedItem.version_id
|
||||||
|
: version.is_default;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={version.id}
|
||||||
|
onClick={() => handleSetVersion(version.id)}
|
||||||
|
className="gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn('h-3 w-3', isActive ? 'opacity-100' : 'opacity-0')}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{version.label}</span>
|
||||||
|
{version.effects_chain && version.effects_chain.length > 0 && (
|
||||||
|
<span className="text-muted-foreground ml-auto text-[10px]">
|
||||||
|
{version.effects_chain.length} fx
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Zoom controls - right side */}
|
{/* Zoom controls - right side */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground">Zoom:</span>
|
<span className="text-xs text-muted-foreground">Zoom:</span>
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomOut}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
aria-label="Zoom out"
|
||||||
|
>
|
||||||
<Minus className="h-3 w-3" />
|
<Minus className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomIn}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
aria-label="Zoom in"
|
||||||
|
>
|
||||||
<Plus className="h-3 w-3" />
|
<Plus className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -941,6 +1059,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
<div className="absolute inset-0 top-3">
|
<div className="absolute inset-0 top-3">
|
||||||
<ClipWaveform
|
<ClipWaveform
|
||||||
generationId={item.generation_id}
|
generationId={item.generation_id}
|
||||||
|
versionId={item.version_id}
|
||||||
width={clipWidth}
|
width={clipWidth}
|
||||||
trimStartMs={displayTrimStart}
|
trimStartMs={displayTrimStart}
|
||||||
trimEndMs={displayTrimEnd}
|
trimEndMs={displayTrimEnd}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
|
const isWindows = navigator.userAgent.includes('Windows');
|
||||||
|
|
||||||
export function TitleBarDragRegion() {
|
export function TitleBarDragRegion() {
|
||||||
return (
|
if (isWindows) return null;
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
return <div data-tauri-drag-region className="fixed top-0 left-0 right-0 h-12 z-[9999]" />;
|
||||||
className="fixed top-0 left-0 right-0 h-12 z-[9999]"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export function AudioSampleRecording({
|
|||||||
// Request microphone access when component mounts
|
// Request microphone access when component mounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showWaveform) return;
|
if (!showWaveform) return;
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return;
|
||||||
|
|
||||||
let stream: MediaStream | null = null;
|
let stream: MediaStream | null = null;
|
||||||
|
|
||||||
@@ -139,7 +140,13 @@ export function AudioSampleRecording({
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="button" size="icon" variant="outline" onClick={onPlayPause}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onPlayPause}
|
||||||
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||||
|
>
|
||||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -77,7 +77,13 @@ export function AudioSampleSystem({
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="button" size="icon" variant="outline" onClick={onPlayPause}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onPlayPause}
|
||||||
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||||
|
>
|
||||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export function AudioSampleUpload({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onPlayPause}
|
onClick={onPlayPause}
|
||||||
disabled={isValidating}
|
disabled={isValidating}
|
||||||
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Download, Edit, Mic, Trash2 } from 'lucide-react';
|
import { Download, Edit, Sparkles, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||||
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
|
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
|
||||||
interface ProfileCardProps {
|
interface ProfileCardProps {
|
||||||
@@ -24,19 +23,16 @@ interface ProfileCardProps {
|
|||||||
|
|
||||||
export function ProfileCard({ profile }: ProfileCardProps) {
|
export function ProfileCard({ profile }: ProfileCardProps) {
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [avatarError, setAvatarError] = useState(false);
|
|
||||||
const deleteProfile = useDeleteProfile();
|
const deleteProfile = useDeleteProfile();
|
||||||
const exportProfile = useExportProfile();
|
const exportProfile = useExportProfile();
|
||||||
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
||||||
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||||
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
|
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
|
||||||
|
|
||||||
const isSelected = selectedProfileId === profile.id;
|
const isSelected = selectedProfileId === profile.id;
|
||||||
|
|
||||||
const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null;
|
|
||||||
|
|
||||||
const handleSelect = () => {
|
const handleSelect = () => {
|
||||||
setSelectedProfileId(isSelected ? null : profile.id);
|
setSelectedProfileId(isSelected ? null : profile.id);
|
||||||
};
|
};
|
||||||
@@ -61,32 +57,35 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
|||||||
exportProfile.mutate(profile.id);
|
exportProfile.mutate(profile.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('button')) return;
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectLabel = isSelected
|
||||||
|
? `${profile.name}, ${profile.language}. Selected as voice for generation.`
|
||||||
|
: `${profile.name}, ${profile.language}. Select as voice for generation.`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
'cursor-pointer hover:shadow-md transition-all flex flex-col',
|
'cursor-pointer hover:shadow-md transition-all flex flex-col h-[162px]',
|
||||||
isSelected && 'ring-2 ring-primary shadow-md',
|
isSelected && 'ring-2 ring-accent shadow-md',
|
||||||
)}
|
)}
|
||||||
onClick={handleSelect}
|
onClick={handleSelect}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
aria-label={selectLabel}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<CardHeader className="p-3 pb-2">
|
<CardHeader className="p-3 pb-2">
|
||||||
<CardTitle className="flex items-center gap-1.5 text-base font-medium">
|
<CardTitle className="text-base font-medium">
|
||||||
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
|
||||||
{avatarUrl && !avatarError ? (
|
|
||||||
<img
|
|
||||||
src={avatarUrl}
|
|
||||||
alt={`${profile.name} avatar`}
|
|
||||||
className={cn(
|
|
||||||
'h-full w-full object-cover transition-all duration-200',
|
|
||||||
!isSelected && 'grayscale',
|
|
||||||
)}
|
|
||||||
onError={() => setAvatarError(true)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Mic className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="break-words">{profile.name}</span>
|
<span className="break-words">{profile.name}</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -94,10 +93,13 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
|||||||
<p className="text-xs text-muted-foreground mb-1.5 line-clamp-2 leading-relaxed">
|
<p className="text-xs text-muted-foreground mb-1.5 line-clamp-2 leading-relaxed">
|
||||||
{profile.description || 'No description'}
|
{profile.description || 'No description'}
|
||||||
</p>
|
</p>
|
||||||
<div className="mb-2">
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
<Badge variant="outline" className="text-xs h-5 px-1.5 text-muted-foreground">
|
<Badge variant="outline" className="text-xs h-5 px-1.5 text-muted-foreground">
|
||||||
{profile.language}
|
{profile.language}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{profile.effects_chain && profile.effects_chain.length > 0 && (
|
||||||
|
<Sparkles className="h-3.5 w-3.5 text-accent fill-accent" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-0.5 justify-end items-end mt-auto">
|
<div className="flex gap-0.5 justify-end items-end mt-auto">
|
||||||
<CircleButton
|
<CircleButton
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Edit2, Mic, Monitor, Upload, X } from 'lucide-react';
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -30,6 +31,8 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { EffectConfig } from '@/lib/api/types';
|
||||||
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
|
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
|
||||||
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
|
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
|
||||||
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
||||||
@@ -43,7 +46,7 @@ import {
|
|||||||
} from '@/lib/hooks/useProfiles';
|
} from '@/lib/hooks/useProfiles';
|
||||||
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
||||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||||
import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
|
import { convertToWav, formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore';
|
import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore';
|
||||||
@@ -125,6 +128,8 @@ export function ProfileForm() {
|
|||||||
const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer();
|
const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer();
|
||||||
const isCreating = !editingProfileId;
|
const isCreating = !editingProfileId;
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
|
const [profileEffectsChain, setProfileEffectsChain] = useState<EffectConfig[]>([]);
|
||||||
|
const [effectsDirty, setEffectsDirty] = useState(false);
|
||||||
|
|
||||||
const form = useForm<ProfileFormValues>({
|
const form = useForm<ProfileFormValues>({
|
||||||
resolver: zodResolver(profileSchema),
|
resolver: zodResolver(profileSchema),
|
||||||
@@ -280,6 +285,8 @@ export function ProfileForm() {
|
|||||||
referenceText: undefined,
|
referenceText: undefined,
|
||||||
avatarFile: undefined,
|
avatarFile: undefined,
|
||||||
});
|
});
|
||||||
|
setProfileEffectsChain(editingProfile.effects_chain ?? []);
|
||||||
|
setEffectsDirty(false);
|
||||||
} else if (profileFormDraft && open) {
|
} else if (profileFormDraft && open) {
|
||||||
// Restore from draft when opening in create mode
|
// Restore from draft when opening in create mode
|
||||||
form.reset({
|
form.reset({
|
||||||
@@ -435,6 +442,24 @@ export function ProfileForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save effects chain if changed
|
||||||
|
if (effectsDirty) {
|
||||||
|
try {
|
||||||
|
await apiClient.updateProfileEffects(
|
||||||
|
editingProfileId,
|
||||||
|
profileEffectsChain.length > 0 ? profileEffectsChain : null,
|
||||||
|
);
|
||||||
|
} catch (fxError) {
|
||||||
|
toast({
|
||||||
|
title: 'Effects update failed',
|
||||||
|
description:
|
||||||
|
fxError instanceof Error ? fxError.message : 'Failed to save effects chain',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Voice updated',
|
title: 'Voice updated',
|
||||||
description: `"${data.name}" has been updated successfully.`,
|
description: `"${data.name}" has been updated successfully.`,
|
||||||
@@ -505,10 +530,23 @@ export function ProfileForm() {
|
|||||||
language: data.language,
|
language: data.language,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Convert non-WAV uploads to WAV so the backend can always use soundfile.
|
||||||
|
// Recorded audio is already WAV (from useAudioRecording's convertToWav call).
|
||||||
|
let fileToUpload: File = sampleFile;
|
||||||
|
if (!sampleFile.type.includes('wav') && !sampleFile.name.toLowerCase().endsWith('.wav')) {
|
||||||
|
try {
|
||||||
|
const wavBlob = await convertToWav(sampleFile);
|
||||||
|
const wavName = sampleFile.name.replace(/\.[^.]+$/, '.wav');
|
||||||
|
fileToUpload = new File([wavBlob], wavName, { type: 'audio/wav' });
|
||||||
|
} catch {
|
||||||
|
// If browser can't decode the format, send the original and let the backend try.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await addSample.mutateAsync({
|
await addSample.mutateAsync({
|
||||||
profileId: profile.id,
|
profileId: profile.id,
|
||||||
file: sampleFile,
|
file: fileToUpload,
|
||||||
referenceText: referenceText,
|
referenceText: referenceText,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -885,6 +923,23 @@ export function ProfileForm() {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{editingProfileId && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel>Default Effects</FormLabel>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Effects applied automatically to all new generations with this voice.
|
||||||
|
</p>
|
||||||
|
<EffectsChainEditor
|
||||||
|
value={profileEffectsChain}
|
||||||
|
onChange={(chain) => {
|
||||||
|
setProfileEffectsChain(chain);
|
||||||
|
setEffectsDirty(true);
|
||||||
|
}}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,11 @@ export function ProfileList() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 grid-cols-3 auto-rows-auto p-1 pb-[150px]">
|
<div className="flex gap-4 overflow-x-auto p-1 pb-1 lg:grid lg:grid-cols-3 lg:auto-rows-auto lg:overflow-x-visible lg:pb-[150px]">
|
||||||
{allProfiles.map((profile) => (
|
{allProfiles.map((profile) => (
|
||||||
<ProfileCard key={profile.id} profile={profile} />
|
<div key={profile.id} className="shrink-0 w-[200px] lg:w-auto lg:shrink">
|
||||||
|
<ProfileCard profile={profile} />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
|||||||
className="h-7 w-7 shrink-0"
|
className="h-7 w-7 shrink-0"
|
||||||
onClick={handlePlayPause}
|
onClick={handlePlayPause}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
aria-label={isPlaying ? 'Pause sample' : 'Play sample'}
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5 ml-0.5" />}
|
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5 ml-0.5" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -113,6 +114,8 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
|||||||
max={100}
|
max={100}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
|
aria-label="Sample playback position"
|
||||||
|
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 min-w-[70px]">
|
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 min-w-[70px]">
|
||||||
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
|
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
|
||||||
@@ -128,6 +131,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
|||||||
className="h-7 w-7 shrink-0"
|
className="h-7 w-7 shrink-0"
|
||||||
onClick={handleStop}
|
onClick={handleStop}
|
||||||
title="Stop"
|
title="Stop"
|
||||||
|
aria-label="Stop playback"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Edit2, Mic, X } from 'lucide-react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import * as z from 'zod';
|
||||||
|
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { SampleList } from '@/components/VoiceProfiles/SampleList';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { EffectConfig } from '@/lib/api/types';
|
||||||
|
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
|
||||||
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
|
import {
|
||||||
|
useDeleteAvatar,
|
||||||
|
useProfile,
|
||||||
|
useUpdateProfile,
|
||||||
|
useUploadAvatar,
|
||||||
|
} from '@/lib/hooks/useProfiles';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
|
const profileSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required').max(100),
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
|
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ProfileFormValues = z.infer<typeof profileSchema>;
|
||||||
|
|
||||||
|
interface VoiceInspectorProps {
|
||||||
|
profileId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||||
|
const { data: profile } = useProfile(profileId);
|
||||||
|
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||||
|
const isPlayerVisible = !!audioUrl;
|
||||||
|
const updateProfile = useUpdateProfile();
|
||||||
|
const uploadAvatar = useUploadAvatar();
|
||||||
|
const deleteAvatar = useDeleteAvatar();
|
||||||
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||||
|
const [avatarError, setAvatarError] = useState(false);
|
||||||
|
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [effectsChain, setEffectsChain] = useState<EffectConfig[]>([]);
|
||||||
|
const [effectsDirty, setEffectsDirty] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<ProfileFormValues>({
|
||||||
|
resolver: zodResolver(profileSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
language: 'en',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Populate form when profile loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (profile) {
|
||||||
|
form.reset({
|
||||||
|
name: profile.name,
|
||||||
|
description: profile.description || '',
|
||||||
|
language: profile.language as LanguageCode,
|
||||||
|
});
|
||||||
|
setEffectsChain(profile.effects_chain ?? []);
|
||||||
|
setEffectsDirty(false);
|
||||||
|
}
|
||||||
|
}, [profile, form]);
|
||||||
|
|
||||||
|
// Avatar preview
|
||||||
|
useEffect(() => {
|
||||||
|
if (profile?.avatar_path) {
|
||||||
|
setAvatarPreview(`${serverUrl}/profiles/${profile.id}/avatar`);
|
||||||
|
} else {
|
||||||
|
setAvatarPreview(null);
|
||||||
|
}
|
||||||
|
setAvatarError(false);
|
||||||
|
}, [profile, serverUrl]);
|
||||||
|
|
||||||
|
function handleAvatarFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
toast({
|
||||||
|
title: 'Invalid file type',
|
||||||
|
description: 'Please select 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;
|
||||||
|
}
|
||||||
|
// Upload immediately
|
||||||
|
uploadAvatar.mutate(
|
||||||
|
{ profileId, file },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setAvatarPreview(URL.createObjectURL(file));
|
||||||
|
toast({ title: 'Avatar updated' });
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast({
|
||||||
|
title: 'Avatar upload failed',
|
||||||
|
description: err instanceof Error ? err.message : 'Unknown error',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveAvatar() {
|
||||||
|
if (profile?.avatar_path) {
|
||||||
|
try {
|
||||||
|
await deleteAvatar.mutateAsync(profileId);
|
||||||
|
toast({ title: 'Avatar removed' });
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to remove avatar',
|
||||||
|
description: err instanceof Error ? err.message : 'Unknown error',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setAvatarPreview(null);
|
||||||
|
if (avatarInputRef.current) avatarInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(data: ProfileFormValues) {
|
||||||
|
try {
|
||||||
|
await updateProfile.mutateAsync({
|
||||||
|
profileId,
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
language: data.language,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (effectsDirty) {
|
||||||
|
try {
|
||||||
|
await apiClient.updateProfileEffects(
|
||||||
|
profileId,
|
||||||
|
effectsChain.length > 0 ? effectsChain : null,
|
||||||
|
);
|
||||||
|
setEffectsDirty(false);
|
||||||
|
} catch (fxError) {
|
||||||
|
toast({
|
||||||
|
title: 'Effects update failed',
|
||||||
|
description: fxError instanceof Error ? fxError.message : 'Failed to save effects',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({ title: 'Voice updated', description: `"${data.name}" saved.` });
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: error instanceof Error ? error.message : 'Failed to save profile',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirty = form.formState.isDirty || effectsDirty;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col overflow-hidden">
|
||||||
|
<div className={cn('flex-1 overflow-y-auto', isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-0">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="flex justify-center pt-5 pb-3">
|
||||||
|
<div className="relative group">
|
||||||
|
<div className="h-20 w-20 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden border-2 border-border">
|
||||||
|
{avatarPreview && !avatarError ? (
|
||||||
|
<img
|
||||||
|
src={avatarPreview}
|
||||||
|
alt={profile.name}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
onError={() => setAvatarError(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Mic className="h-8 w-8 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-5 w-5 text-accent-foreground" />
|
||||||
|
</button>
|
||||||
|
{avatarPreview && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveAvatar}
|
||||||
|
disabled={deleteAvatar.isPending}
|
||||||
|
className="absolute bottom-0 right-0 h-5 w-5 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 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={avatarInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
onChange={handleAvatarFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fields */}
|
||||||
|
<div className="space-y-3 px-5">
|
||||||
|
<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</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea placeholder="Describe this voice..." rows={2} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="language"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Language</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} value={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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Effects */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel>Default Effects</FormLabel>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Applied automatically to new generations with this voice.
|
||||||
|
</p>
|
||||||
|
<EffectsChainEditor
|
||||||
|
value={effectsChain}
|
||||||
|
onChange={(chain) => {
|
||||||
|
setEffectsChain(chain);
|
||||||
|
setEffectsDirty(true);
|
||||||
|
}}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save */}
|
||||||
|
{isDirty && (
|
||||||
|
<Button type="submit" className="w-full" disabled={updateProfile.isPending}>
|
||||||
|
{updateProfile.isPending ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Samples */}
|
||||||
|
<div className="px-5 pb-5">
|
||||||
|
<SampleList profileId={profileId} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Edit, MoreHorizontal, Plus, Trash2, Mic } from 'lucide-react';
|
import { Mic, Plus, Search, Sparkles } from 'lucide-react';
|
||||||
import { useMemo, useRef } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { Input } from '@/components/ui/input';
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import { MultiSelect } from '@/components/ui/multi-select';
|
import { MultiSelect } from '@/components/ui/multi-select';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -21,33 +17,46 @@ import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm';
|
|||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import { useHistory } from '@/lib/hooks/useHistory';
|
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||||
import { useDeleteProfile, useProfileSamples, useProfiles } from '@/lib/hooks/useProfiles';
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
import { VoiceInspector } from './VoiceInspector';
|
||||||
|
|
||||||
export function VoicesTab() {
|
export function VoicesTab() {
|
||||||
const { data: profiles, isLoading } = useProfiles();
|
const { data: profiles, isLoading } = useProfiles();
|
||||||
const { data: historyData } = useHistory({ limit: 1000 });
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||||
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
const selectedVoiceId = useUIStore((state) => state.selectedVoiceId);
|
||||||
const deleteProfile = useDeleteProfile();
|
const setSelectedVoiceId = useUIStore((state) => state.setSelectedVoiceId);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||||
const isPlayerVisible = !!audioUrl;
|
const isPlayerVisible = !!audioUrl;
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
// Get generation counts per profile
|
const filteredProfiles = useMemo(() => {
|
||||||
const generationCounts = useMemo(() => {
|
if (!profiles) return [];
|
||||||
const counts: Record<string, number> = {};
|
if (!search.trim()) return profiles;
|
||||||
if (historyData?.items) {
|
const q = search.toLowerCase();
|
||||||
historyData.items.forEach((item) => {
|
return profiles.filter(
|
||||||
counts[item.profile_id] = (counts[item.profile_id] || 0) + 1;
|
(p) =>
|
||||||
});
|
p.name.toLowerCase().includes(q) ||
|
||||||
|
p.description?.toLowerCase().includes(q) ||
|
||||||
|
p.language.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [profiles, search]);
|
||||||
|
|
||||||
|
// Auto-select first profile if none selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedVoiceId && profiles && profiles.length > 0) {
|
||||||
|
setSelectedVoiceId(profiles[0].id);
|
||||||
}
|
}
|
||||||
return counts;
|
// Clear selection if selected profile was deleted
|
||||||
}, [historyData]);
|
if (selectedVoiceId && profiles && !profiles.find((p) => p.id === selectedVoiceId)) {
|
||||||
|
setSelectedVoiceId(profiles.length > 0 ? profiles[0].id : null);
|
||||||
|
}
|
||||||
|
}, [profiles, selectedVoiceId, setSelectedVoiceId]);
|
||||||
|
|
||||||
// Get channel assignments for each profile
|
// Get channel assignments for each profile
|
||||||
const { data: channelAssignments } = useQuery({
|
const { data: channelAssignments } = useQuery({
|
||||||
@@ -74,17 +83,6 @@ export function VoicesTab() {
|
|||||||
queryFn: () => apiClient.listChannels(),
|
queryFn: () => apiClient.listChannels(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleEdit = (profileId: string) => {
|
|
||||||
setEditingProfileId(profileId);
|
|
||||||
setDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (profileId: string) => {
|
|
||||||
if (confirm('Are you sure you want to delete this profile?')) {
|
|
||||||
deleteProfile.mutate(profileId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChannelChange = async (profileId: string, channelIds: string[]) => {
|
const handleChannelChange = async (profileId: string, channelIds: string[]) => {
|
||||||
try {
|
try {
|
||||||
await apiClient.setProfileChannels(profileId, channelIds);
|
await apiClient.setProfileChannels(profileId, channelIds);
|
||||||
@@ -103,56 +101,76 @@ export function VoicesTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col relative overflow-hidden">
|
<div className="h-full flex gap-0 overflow-hidden -mx-8">
|
||||||
{/* Scroll Mask - Always visible, behind content */}
|
{/* Left: Table */}
|
||||||
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
<div className="flex-1 min-w-0 flex flex-col relative overflow-hidden">
|
||||||
|
{/* Scroll Mask */}
|
||||||
|
<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 */}
|
{/* Fixed Header */}
|
||||||
<div className="absolute top-0 left-0 right-0 z-20">
|
<div className="absolute top-0 left-0 right-0 z-20 pl-8 pr-8">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<h1 className="text-2xl font-bold">Voices</h1>
|
<h1 className="text-2xl font-bold">Voices</h1>
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
<div className="flex-1" />
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<div className="relative w-[240px]">
|
||||||
New Voice
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
</Button>
|
<Input
|
||||||
|
placeholder="Search voices..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="h-10 pl-8 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<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 overflow-x-hidden pt-16 relative z-0',
|
||||||
|
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Table className="table-fixed [&_td:first-child]:pl-8 [&_th:first-child]:pl-8">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[30%]">Name</TableHead>
|
||||||
|
<TableHead className="w-[10%]">Language</TableHead>
|
||||||
|
<TableHead className="w-[10%]">Generations</TableHead>
|
||||||
|
<TableHead className="w-[8%]">Samples</TableHead>
|
||||||
|
<TableHead className="w-[8%]">Effects</TableHead>
|
||||||
|
<TableHead className="w-[24%]">Channels</TableHead>
|
||||||
|
<TableHead className="w-6"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredProfiles.map((profile) => (
|
||||||
|
<VoiceRow
|
||||||
|
key={profile.id}
|
||||||
|
profile={profile}
|
||||||
|
isSelected={selectedVoiceId === profile.id}
|
||||||
|
onSelect={() => setSelectedVoiceId(profile.id)}
|
||||||
|
channelIds={channelAssignments?.[profile.id] || []}
|
||||||
|
channels={channels || []}
|
||||||
|
onChannelChange={(channelIds) => handleChannelChange(profile.id, channelIds)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Content */}
|
{/* Right: Inspector */}
|
||||||
<div
|
{selectedVoiceId && (
|
||||||
ref={scrollRef}
|
<div className="w-[340px] shrink-0 border-l border-t rounded-tl-xl bg-muted/30">
|
||||||
className={cn(
|
<VoiceInspector key={selectedVoiceId} profileId={selectedVoiceId} />
|
||||||
'flex-1 overflow-y-auto pt-16 relative z-0',
|
</div>
|
||||||
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={() => handleDelete(profile.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ProfileForm />
|
<ProfileForm />
|
||||||
</div>
|
</div>
|
||||||
@@ -161,43 +179,71 @@ export function VoicesTab() {
|
|||||||
|
|
||||||
interface VoiceRowProps {
|
interface VoiceRowProps {
|
||||||
profile: VoiceProfileResponse;
|
profile: VoiceProfileResponse;
|
||||||
generationCount: number;
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
channelIds: string[];
|
channelIds: string[];
|
||||||
channels: Array<{ id: string; name: string; is_default: boolean }>;
|
channels: Array<{ id: string; name: string; is_default: boolean }>;
|
||||||
onChannelChange: (channelIds: string[]) => void;
|
onChannelChange: (channelIds: string[]) => void;
|
||||||
onEdit: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function VoiceRow({
|
function VoiceRow({
|
||||||
profile,
|
profile,
|
||||||
generationCount,
|
isSelected,
|
||||||
|
onSelect,
|
||||||
channelIds,
|
channelIds,
|
||||||
channels,
|
channels,
|
||||||
onChannelChange,
|
onChannelChange,
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
}: VoiceRowProps) {
|
}: VoiceRowProps) {
|
||||||
const { data: samples } = useProfileSamples(profile.id);
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
|
const [avatarError, setAvatarError] = useState(false);
|
||||||
|
const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null;
|
||||||
|
|
||||||
|
const enabledEffects = profile.effects_chain?.filter((e) => e.enabled) ?? [];
|
||||||
|
const effectsSummary = enabledEffects.map((e) => e.type).join(' → ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow className="cursor-pointer" onClick={onEdit}>
|
<TableRow
|
||||||
|
className={cn('cursor-pointer', isSelected ? 'bg-muted/50' : 'hover:bg-muted/50')}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex w-full min-w-0 items-center gap-2">
|
||||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
||||||
<Mic className="h-4 w-4 text-muted-foreground" />
|
{avatarUrl && !avatarError ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={`${profile.name} avatar`}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
onError={() => setAvatarError(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Mic className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="font-medium">{profile.name}</div>
|
<div className="font-medium truncate">{profile.name}</div>
|
||||||
{profile.description && (
|
{profile.description && (
|
||||||
<div className="text-sm text-muted-foreground">{profile.description}</div>
|
<div className="text-sm text-muted-foreground truncate">{profile.description}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>{profile.language}</TableCell>
|
<TableCell>{profile.language}</TableCell>
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>{generationCount}</TableCell>
|
<TableCell>{profile.generation_count}</TableCell>
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>{samples?.length || 0}</TableCell>
|
<TableCell>{profile.sample_count}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{enabledEffects.length > 0 ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-accent"
|
||||||
|
title={effectsSummary}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-3 w-3 fill-accent" />
|
||||||
|
{enabledEffects.length}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
options={channels.map((ch) => ({
|
options={channels.map((ch) => ({
|
||||||
@@ -207,28 +253,10 @@ function VoiceRow({
|
|||||||
value={channelIds}
|
value={channelIds}
|
||||||
onChange={onChannelChange}
|
onChange={onChannelChange}
|
||||||
placeholder="Select channels..."
|
placeholder="Select channels..."
|
||||||
className="min-w-[200px]"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
<TableCell />
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<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>
|
</TableRow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { Check } from 'lucide-react';
|
import { Check } from 'lucide-react';
|
||||||
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
export interface CheckboxProps {
|
export interface CheckboxProps {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
import { MoreHorizontal } from 'lucide-react';
|
import { MoreHorizontal } from 'lucide-react';
|
||||||
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
@@ -73,7 +73,7 @@ const DropdownMenuItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
inset && 'pl-8',
|
inset && 'pl-8',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -154,7 +154,9 @@ const DropdownMenuSeparator = React.forwardRef<
|
|||||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />;
|
return (
|
||||||
|
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
|
||||||
|
);
|
||||||
};
|
};
|
||||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm 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 [&>span]:line-clamp-1',
|
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -108,7 +108,7 @@ const SelectItem = React.forwardRef<
|
|||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground focus:[&_*]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ const TableHeader = React.forwardRef<
|
|||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
<thead
|
||||||
|
ref={ref}
|
||||||
|
className={cn('[&_tr]:border-b [&_tr]:hover:bg-transparent', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
));
|
));
|
||||||
TableHeader.displayName = 'TableHeader';
|
TableHeader.displayName = 'TableHeader';
|
||||||
|
|
||||||
@@ -42,10 +46,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
|
|||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<tr
|
<tr
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn('border-b hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
|
||||||
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
import type { UpdateStatus } from '@/platform/types';
|
import type { UpdateStatus } from '@/platform/types';
|
||||||
|
|
||||||
@@ -7,9 +7,8 @@ export type { UpdateStatus };
|
|||||||
|
|
||||||
export function useAutoUpdater(checkOnMount = false) {
|
export function useAutoUpdater(checkOnMount = false) {
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
const [status, setStatus] = useState<UpdateStatus>(
|
const [status, setStatus] = useState<UpdateStatus>(platform.updater.getStatus());
|
||||||
platform.updater.getStatus(),
|
const hasCheckedRef = useRef(false);
|
||||||
);
|
|
||||||
|
|
||||||
// Subscribe to updater status changes
|
// Subscribe to updater status changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -17,25 +16,32 @@ export function useAutoUpdater(checkOnMount = false) {
|
|||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
});
|
});
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [platform]);
|
// Empty dependency array - platform is stable from context
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.subscribe]);
|
||||||
|
|
||||||
const checkForUpdates = useCallback(async () => {
|
const checkForUpdates = useCallback(async () => {
|
||||||
await platform.updater.checkForUpdates();
|
await platform.updater.checkForUpdates();
|
||||||
}, [platform]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.checkForUpdates]);
|
||||||
|
|
||||||
const downloadAndInstall = useCallback(async () => {
|
const downloadAndInstall = useCallback(async () => {
|
||||||
await platform.updater.downloadAndInstall();
|
await platform.updater.downloadAndInstall();
|
||||||
}, [platform]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.downloadAndInstall]);
|
||||||
|
|
||||||
const restartAndInstall = useCallback(async () => {
|
const restartAndInstall = useCallback(async () => {
|
||||||
await platform.updater.restartAndInstall();
|
await platform.updater.restartAndInstall();
|
||||||
}, [platform]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.restartAndInstall]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (checkOnMount && platform.metadata.isTauri) {
|
if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) {
|
||||||
|
hasCheckedRef.current = true;
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
}
|
}
|
||||||
}, [checkOnMount, checkForUpdates, platform.metadata.isTauri]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.metadata.isTauricheckOnMountcheckForUpdates]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { Download, RefreshCw } from 'lucide-react';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { ToastAction } from '@/components/ui/toast';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import type { UpdateStatus } from '@/platform/types';
|
||||||
|
|
||||||
|
// Re-export UpdateStatus for backwards compatibility
|
||||||
|
export type { UpdateStatus };
|
||||||
|
|
||||||
|
interface UseAutoUpdaterOptions {
|
||||||
|
checkOnMount?: boolean;
|
||||||
|
showToast?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutoUpdater(options: boolean | UseAutoUpdaterOptions = false) {
|
||||||
|
// Support both old boolean API and new options object
|
||||||
|
const { checkOnMount, showToast } =
|
||||||
|
typeof options === 'boolean'
|
||||||
|
? { checkOnMount: options, showToast: false }
|
||||||
|
: { checkOnMount: options.checkOnMount ?? false, showToast: options.showToast ?? false };
|
||||||
|
|
||||||
|
const platform = usePlatform();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [status, setStatus] = useState<UpdateStatus>(platform.updater.getStatus());
|
||||||
|
const hasCheckedRef = useRef(false);
|
||||||
|
const toastIdRef = useRef<string | null>(null);
|
||||||
|
const toastUpdateRef = useRef<
|
||||||
|
| ((props: {
|
||||||
|
title?: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
duration?: number;
|
||||||
|
variant?: 'default' | 'destructive';
|
||||||
|
open?: boolean;
|
||||||
|
action?: React.ReactElement<typeof ToastAction>;
|
||||||
|
}) => void)
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
// Subscribe to updater status changes
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = platform.updater.subscribe((newStatus) => {
|
||||||
|
setStatus(newStatus);
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
// Empty dependency array - platform is stable from context
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.subscribe]);
|
||||||
|
|
||||||
|
const checkForUpdates = useCallback(async () => {
|
||||||
|
await platform.updater.checkForUpdates();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.checkForUpdates]);
|
||||||
|
|
||||||
|
const downloadAndInstall = useCallback(async () => {
|
||||||
|
await platform.updater.downloadAndInstall();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.downloadAndInstall]);
|
||||||
|
|
||||||
|
const restartAndInstall = useCallback(async () => {
|
||||||
|
await platform.updater.restartAndInstall();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.restartAndInstall]);
|
||||||
|
|
||||||
|
// Check for updates on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) {
|
||||||
|
hasCheckedRef.current = true;
|
||||||
|
checkForUpdates().catch((error) => {
|
||||||
|
console.error('Auto update check failed:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Empty dependency array - only run once on mount
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.metadata.isTauricheckOnMountcheckForUpdates]);
|
||||||
|
|
||||||
|
// Show toast when update is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!showToast ||
|
||||||
|
!status.available ||
|
||||||
|
status.downloading ||
|
||||||
|
status.readyToInstall ||
|
||||||
|
toastIdRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateNow = async () => {
|
||||||
|
await downloadAndInstall();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toastResult = toast({
|
||||||
|
title: 'Update Available',
|
||||||
|
description: `Version ${status.version} is ready to download.`,
|
||||||
|
duration: Infinity,
|
||||||
|
action: (
|
||||||
|
<ToastAction altText="Update now" onClick={handleUpdateNow}>
|
||||||
|
Update Now
|
||||||
|
</ToastAction>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
toastIdRef.current = toastResult.id;
|
||||||
|
// Type assertion needed because update function has broader type than our ref
|
||||||
|
toastUpdateRef.current = toastResult.update as typeof toastUpdateRef.current;
|
||||||
|
}, [
|
||||||
|
showToast,
|
||||||
|
status.available,
|
||||||
|
status.downloading,
|
||||||
|
status.readyToInstall,
|
||||||
|
status.version,
|
||||||
|
downloadAndInstall,
|
||||||
|
toast,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update toast when downloading
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showToast || !status.downloading || !toastIdRef.current || !toastUpdateRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressPercent = status.downloadProgress || 0;
|
||||||
|
const progressText =
|
||||||
|
status.downloadedBytes !== undefined &&
|
||||||
|
status.totalBytes !== undefined &&
|
||||||
|
status.totalBytes > 0
|
||||||
|
? `${(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB / ${(status.totalBytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
toastUpdateRef.current({
|
||||||
|
title: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Download className="h-4 w-4 animate-pulse" />
|
||||||
|
<span>Downloading Update</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
description: (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm">Version {status.version}</div>
|
||||||
|
{progressPercent > 0 && (
|
||||||
|
<>
|
||||||
|
<Progress value={progressPercent} className="h-2" />
|
||||||
|
{progressText && <div className="text-xs text-muted-foreground">{progressText}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
duration: Infinity,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
showToast,
|
||||||
|
status.downloading,
|
||||||
|
status.downloadProgress,
|
||||||
|
status.downloadedBytes,
|
||||||
|
status.totalBytes,
|
||||||
|
status.version,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update toast when ready to install
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showToast || !status.readyToInstall || !toastIdRef.current || !toastUpdateRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestartNow = async () => {
|
||||||
|
await restartAndInstall();
|
||||||
|
};
|
||||||
|
|
||||||
|
toastUpdateRef.current({
|
||||||
|
title: 'Update Ready',
|
||||||
|
description: `Version ${status.version} has been downloaded and is ready to install.`,
|
||||||
|
duration: Infinity,
|
||||||
|
action: (
|
||||||
|
<ToastAction altText="Restart now" onClick={handleRestartNow}>
|
||||||
|
<RefreshCw className="h-3 w-3 mr-1" />
|
||||||
|
Restart Now
|
||||||
|
</ToastAction>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [showToast, status.readyToInstall, status.version, restartAndInstall]);
|
||||||
|
|
||||||
|
// Handle errors in toast
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showToast || !status.error || !toastIdRef.current || !toastUpdateRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toastUpdateRef.current({
|
||||||
|
title: 'Update Failed',
|
||||||
|
description: status.error,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toastIdRef.current = null;
|
||||||
|
toastUpdateRef.current = null;
|
||||||
|
}, 5000);
|
||||||
|
}, [showToast, status.error]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
checkForUpdates,
|
||||||
|
downloadAndInstall,
|
||||||
|
restartAndInstall,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
@import "tailwindcss" source(".");
|
@import "tailwindcss" source(".");
|
||||||
|
@import "loaders.css/loaders.min.css";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
@@ -155,3 +156,18 @@
|
|||||||
animation: fadeIn 0.5s ease-out 0.15s forwards;
|
animation: fadeIn 0.5s ease-out 0.15s forwards;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* react-loaders */
|
||||||
|
.line-scale-pulse-out-rapid > div,
|
||||||
|
.line-scale > div {
|
||||||
|
background-color: hsl(var(--accent)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-hidden {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-hidden > div > div {
|
||||||
|
animation-play-state: paused !important;
|
||||||
|
background-color: hsl(var(--muted-foreground)) !important;
|
||||||
|
}
|
||||||
|
|||||||
+236
-31
@@ -1,29 +1,37 @@
|
|||||||
import { useServerStore } from '@/stores/serverStore';
|
|
||||||
import type { LanguageCode } from '@/lib/constants/languages';
|
import type { LanguageCode } from '@/lib/constants/languages';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
import type {
|
import type {
|
||||||
VoiceProfileCreate,
|
ActiveTasksResponse,
|
||||||
VoiceProfileResponse,
|
ApplyEffectsRequest,
|
||||||
ProfileSampleResponse,
|
AvailableEffectsResponse,
|
||||||
|
CudaStatus,
|
||||||
|
EffectConfig,
|
||||||
|
EffectPresetCreate,
|
||||||
|
EffectPresetResponse,
|
||||||
GenerationRequest,
|
GenerationRequest,
|
||||||
GenerationResponse,
|
GenerationResponse,
|
||||||
HistoryQuery,
|
GenerationVersionResponse,
|
||||||
HistoryListResponse,
|
|
||||||
HistoryResponse,
|
|
||||||
TranscriptionResponse,
|
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
ModelStatusListResponse,
|
HistoryListResponse,
|
||||||
|
HistoryQuery,
|
||||||
|
HistoryResponse,
|
||||||
ModelDownloadRequest,
|
ModelDownloadRequest,
|
||||||
ActiveTasksResponse,
|
ModelStatusListResponse,
|
||||||
|
ProfileSampleResponse,
|
||||||
StoryCreate,
|
StoryCreate,
|
||||||
StoryResponse,
|
|
||||||
StoryDetailResponse,
|
StoryDetailResponse,
|
||||||
|
StoryItemBatchUpdate,
|
||||||
StoryItemCreate,
|
StoryItemCreate,
|
||||||
StoryItemDetail,
|
StoryItemDetail,
|
||||||
StoryItemBatchUpdate,
|
|
||||||
StoryItemReorder,
|
|
||||||
StoryItemMove,
|
StoryItemMove,
|
||||||
StoryItemTrim,
|
StoryItemReorder,
|
||||||
StoryItemSplit,
|
StoryItemSplit,
|
||||||
|
StoryItemTrim,
|
||||||
|
StoryItemVersionUpdate,
|
||||||
|
StoryResponse,
|
||||||
|
TranscriptionResponse,
|
||||||
|
VoiceProfileCreate,
|
||||||
|
VoiceProfileResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
@@ -199,6 +207,24 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async retryGeneration(generationId: string): Promise<GenerationResponse> {
|
||||||
|
return this.request<GenerationResponse>(`/generate/${generationId}/retry`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async regenerateGeneration(generationId: string): Promise<GenerationResponse> {
|
||||||
|
return this.request<GenerationResponse>(`/generate/${generationId}/regenerate`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleFavorite(generationId: string): Promise<{ is_favorited: boolean }> {
|
||||||
|
return this.request<{ is_favorited: boolean }>(`/history/${generationId}/favorite`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// History
|
// History
|
||||||
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
|
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -251,7 +277,13 @@ class ApiClient {
|
|||||||
return response.blob();
|
return response.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
async importGeneration(file: File): Promise<{ id: string; profile_id: string; profile_name: string; text: string; message: string }> {
|
async importGeneration(file: File): Promise<{
|
||||||
|
id: string;
|
||||||
|
profile_id: string;
|
||||||
|
profile_name: string;
|
||||||
|
text: string;
|
||||||
|
message: string;
|
||||||
|
}> {
|
||||||
const url = `${this.getBaseUrl()}/history/import`;
|
const url = `${this.getBaseUrl()}/history/import`;
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
@@ -271,6 +303,11 @@ class ApiClient {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generation status SSE
|
||||||
|
getGenerationStatusUrl(generationId: string): string {
|
||||||
|
return `${this.getBaseUrl()}/generate/${generationId}/status`;
|
||||||
|
}
|
||||||
|
|
||||||
// Audio
|
// Audio
|
||||||
getAudioUrl(audioId: string): string {
|
getAudioUrl(audioId: string): string {
|
||||||
return `${this.getBaseUrl()}/audio/${audioId}`;
|
return `${this.getBaseUrl()}/audio/${audioId}`;
|
||||||
@@ -309,11 +346,34 @@ class ApiClient {
|
|||||||
return this.request<ModelStatusListResponse>('/models/status');
|
return this.request<ModelStatusListResponse>('/models/status');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getModelsCacheDir(): Promise<{ path: string }> {
|
||||||
|
return this.request<{ path: string }>('/models/cache-dir');
|
||||||
|
}
|
||||||
|
|
||||||
|
async migrateModels(destination: string): Promise<{ source: string; destination: string }> {
|
||||||
|
return this.request('/models/migrate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ destination }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getMigrationProgressUrl(): string {
|
||||||
|
return `${this.getBaseUrl()}/models/migrate/progress`;
|
||||||
|
}
|
||||||
|
|
||||||
async triggerModelDownload(modelName: string): Promise<{ message: string }> {
|
async triggerModelDownload(modelName: string): Promise<{ message: string }> {
|
||||||
return this.request<{ message: string }>('/models/download', {
|
console.log(
|
||||||
|
'[API] triggerModelDownload called for:',
|
||||||
|
modelName,
|
||||||
|
'at',
|
||||||
|
new Date().toISOString(),
|
||||||
|
);
|
||||||
|
const result = await this.request<{ message: string }>('/models/download', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
|
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
|
||||||
});
|
});
|
||||||
|
console.log('[API] triggerModelDownload response:', result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteModel(modelName: string): Promise<{ message: string }> {
|
async deleteModel(modelName: string): Promise<{ message: string }> {
|
||||||
@@ -322,11 +382,28 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async unloadModel(modelName: string): Promise<{ message: string }> {
|
||||||
|
return this.request<{ message: string }>(`/models/${modelName}/unload`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelDownload(modelName: string): Promise<{ message: string }> {
|
||||||
|
return this.request<{ message: string }>('/models/download/cancel', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Task Management
|
// Task Management
|
||||||
async getActiveTasks(): Promise<ActiveTasksResponse> {
|
async getActiveTasks(): Promise<ActiveTasksResponse> {
|
||||||
return this.request<ActiveTasksResponse>('/tasks/active');
|
return this.request<ActiveTasksResponse>('/tasks/active');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async clearAllTasks(): Promise<{ message: string }> {
|
||||||
|
return this.request<{ message: string }>('/tasks/clear', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
// Audio Channels
|
// Audio Channels
|
||||||
async listChannels(): Promise<
|
async listChannels(): Promise<
|
||||||
Array<{
|
Array<{
|
||||||
@@ -340,10 +417,7 @@ class ApiClient {
|
|||||||
return this.request('/channels');
|
return this.request('/channels');
|
||||||
}
|
}
|
||||||
|
|
||||||
async createChannel(data: {
|
async createChannel(data: { name: string; device_ids: string[] }): Promise<{
|
||||||
name: string;
|
|
||||||
device_ids: string[];
|
|
||||||
}): Promise<{
|
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
@@ -385,10 +459,7 @@ class ApiClient {
|
|||||||
return this.request(`/channels/${channelId}/voices`);
|
return this.request(`/channels/${channelId}/voices`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setChannelVoices(
|
async setChannelVoices(channelId: string, profileIds: string[]): Promise<{ message: string }> {
|
||||||
channelId: string,
|
|
||||||
profileIds: string[],
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
return this.request(`/channels/${channelId}/voices`, {
|
return this.request(`/channels/${channelId}/voices`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ profile_ids: profileIds }),
|
body: JSON.stringify({ profile_ids: profileIds }),
|
||||||
@@ -399,16 +470,30 @@ class ApiClient {
|
|||||||
return this.request(`/profiles/${profileId}/channels`);
|
return this.request(`/profiles/${profileId}/channels`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setProfileChannels(
|
async setProfileChannels(profileId: string, channelIds: string[]): Promise<{ message: string }> {
|
||||||
profileId: string,
|
|
||||||
channelIds: string[],
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
return this.request(`/profiles/${profileId}/channels`, {
|
return this.request(`/profiles/${profileId}/channels`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ channel_ids: channelIds }),
|
body: JSON.stringify({ channel_ids: channelIds }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CUDA Backend Management
|
||||||
|
async getCudaStatus(): Promise<CudaStatus> {
|
||||||
|
return this.request<CudaStatus>('/backend/cuda-status');
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadCudaBackend(): Promise<{ message: string; progress_key: string }> {
|
||||||
|
return this.request<{ message: string; progress_key: string }>('/backend/download-cuda', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCudaBackend(): Promise<{ message: string }> {
|
||||||
|
return this.request<{ message: string }>('/backend/cuda', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Stories
|
// Stories
|
||||||
async listStories(): Promise<StoryResponse[]> {
|
async listStories(): Promise<StoryResponse[]> {
|
||||||
return this.request<StoryResponse[]>('/stories');
|
return this.request<StoryResponse[]>('/stories');
|
||||||
@@ -465,21 +550,33 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async moveStoryItem(storyId: string, itemId: string, data: StoryItemMove): Promise<StoryItemDetail> {
|
async moveStoryItem(
|
||||||
|
storyId: string,
|
||||||
|
itemId: string,
|
||||||
|
data: StoryItemMove,
|
||||||
|
): Promise<StoryItemDetail> {
|
||||||
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/move`, {
|
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/move`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async trimStoryItem(storyId: string, itemId: string, data: StoryItemTrim): Promise<StoryItemDetail> {
|
async trimStoryItem(
|
||||||
|
storyId: string,
|
||||||
|
itemId: string,
|
||||||
|
data: StoryItemTrim,
|
||||||
|
): Promise<StoryItemDetail> {
|
||||||
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/trim`, {
|
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/trim`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async splitStoryItem(storyId: string, itemId: string, data: StoryItemSplit): Promise<StoryItemDetail[]> {
|
async splitStoryItem(
|
||||||
|
storyId: string,
|
||||||
|
itemId: string,
|
||||||
|
data: StoryItemSplit,
|
||||||
|
): Promise<StoryItemDetail[]> {
|
||||||
return this.request<StoryItemDetail[]>(`/stories/${storyId}/items/${itemId}/split`, {
|
return this.request<StoryItemDetail[]>(`/stories/${storyId}/items/${itemId}/split`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
@@ -492,6 +589,17 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setStoryItemVersion(
|
||||||
|
storyId: string,
|
||||||
|
itemId: string,
|
||||||
|
data: StoryItemVersionUpdate,
|
||||||
|
): Promise<StoryItemDetail> {
|
||||||
|
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/version`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async exportStoryAudio(storyId: string): Promise<Blob> {
|
async exportStoryAudio(storyId: string): Promise<Blob> {
|
||||||
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
|
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
@@ -505,6 +613,103 @@ class ApiClient {
|
|||||||
|
|
||||||
return response.blob();
|
return response.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Effects & Versions
|
||||||
|
async getAvailableEffects(): Promise<AvailableEffectsResponse> {
|
||||||
|
return this.request<AvailableEffectsResponse>('/effects/available');
|
||||||
|
}
|
||||||
|
|
||||||
|
async listEffectPresets(): Promise<EffectPresetResponse[]> {
|
||||||
|
return this.request<EffectPresetResponse[]>('/effects/presets');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createEffectPreset(data: EffectPresetCreate): Promise<EffectPresetResponse> {
|
||||||
|
return this.request<EffectPresetResponse>('/effects/presets', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateEffectPreset(
|
||||||
|
presetId: string,
|
||||||
|
data: { name?: string; description?: string; effects_chain?: EffectConfig[] },
|
||||||
|
): Promise<EffectPresetResponse> {
|
||||||
|
return this.request<EffectPresetResponse>(`/effects/presets/${presetId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEffectPreset(presetId: string): Promise<void> {
|
||||||
|
await this.request<void>(`/effects/presets/${presetId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listGenerationVersions(generationId: string): Promise<GenerationVersionResponse[]> {
|
||||||
|
return this.request<GenerationVersionResponse[]>(`/generations/${generationId}/versions`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyEffectsToGeneration(
|
||||||
|
generationId: string,
|
||||||
|
data: ApplyEffectsRequest,
|
||||||
|
): Promise<GenerationVersionResponse> {
|
||||||
|
return this.request<GenerationVersionResponse>(
|
||||||
|
`/generations/${generationId}/versions/apply-effects`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setDefaultVersion(
|
||||||
|
generationId: string,
|
||||||
|
versionId: string,
|
||||||
|
): Promise<GenerationVersionResponse> {
|
||||||
|
return this.request<GenerationVersionResponse>(
|
||||||
|
`/generations/${generationId}/versions/${versionId}/set-default`,
|
||||||
|
{ method: 'PUT' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteGenerationVersion(generationId: string, versionId: string): Promise<void> {
|
||||||
|
await this.request<void>(`/generations/${generationId}/versions/${versionId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getVersionAudioUrl(versionId: string): string {
|
||||||
|
return `${this.getBaseUrl()}/audio/version/${versionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProfileEffects(
|
||||||
|
profileId: string,
|
||||||
|
effectsChain: EffectConfig[] | null,
|
||||||
|
): Promise<VoiceProfileResponse> {
|
||||||
|
return this.request<VoiceProfileResponse>(`/profiles/${profileId}/effects`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ effects_chain: effectsChain }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async previewEffects(generationId: string, effectsChain: EffectConfig[]): Promise<Blob> {
|
||||||
|
const url = `${this.getBaseUrl()}/effects/preview/${generationId}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ effects_chain: effectsChain }),
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
export const apiClient = new ApiClient();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type ModelStatus = {
|
|||||||
model_name: string;
|
model_name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
downloaded: boolean;
|
downloaded: boolean;
|
||||||
|
downloading?: boolean; // True if download is in progress
|
||||||
size_mb?: number | null;
|
size_mb?: number | null;
|
||||||
loaded?: boolean;
|
loaded?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
+139
-2
@@ -13,6 +13,9 @@ export interface VoiceProfileResponse {
|
|||||||
description?: string;
|
description?: string;
|
||||||
language: string;
|
language: string;
|
||||||
avatar_path?: string;
|
avatar_path?: string;
|
||||||
|
effects_chain?: EffectConfig[];
|
||||||
|
generation_count: number;
|
||||||
|
sample_count: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -28,12 +31,35 @@ export interface ProfileSampleResponse {
|
|||||||
reference_text: string;
|
reference_text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EffectConfig {
|
||||||
|
type: string;
|
||||||
|
enabled: boolean;
|
||||||
|
params: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GenerationRequest {
|
export interface GenerationRequest {
|
||||||
profile_id: string;
|
profile_id: string;
|
||||||
text: string;
|
text: string;
|
||||||
language: LanguageCode;
|
language: LanguageCode;
|
||||||
seed?: number;
|
seed?: number;
|
||||||
model_size?: '1.7B' | '0.6B';
|
model_size?: '1.7B' | '0.6B';
|
||||||
|
engine?: 'qwen' | 'luxtts' | 'chatterbox' | 'chatterbox_turbo';
|
||||||
|
instruct?: string;
|
||||||
|
max_chunk_chars?: number;
|
||||||
|
crossfade_ms?: number;
|
||||||
|
normalize?: boolean;
|
||||||
|
effects_chain?: EffectConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerationVersionResponse {
|
||||||
|
id: string;
|
||||||
|
generation_id: string;
|
||||||
|
label: string;
|
||||||
|
audio_path: string;
|
||||||
|
effects_chain?: EffectConfig[];
|
||||||
|
source_version_id?: string;
|
||||||
|
is_default: boolean;
|
||||||
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerationResponse {
|
export interface GenerationResponse {
|
||||||
@@ -41,10 +67,18 @@ export interface GenerationResponse {
|
|||||||
profile_id: string;
|
profile_id: string;
|
||||||
text: string;
|
text: string;
|
||||||
language: string;
|
language: string;
|
||||||
audio_path: string;
|
audio_path?: string;
|
||||||
duration: number;
|
duration?: number;
|
||||||
seed?: number;
|
seed?: number;
|
||||||
|
instruct?: string;
|
||||||
|
engine?: string;
|
||||||
|
model_size?: string;
|
||||||
|
status: 'loading_model' | 'generating' | 'completed' | 'failed';
|
||||||
|
error?: string;
|
||||||
|
is_favorited?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
versions?: GenerationVersionResponse[];
|
||||||
|
active_version_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryQuery {
|
export interface HistoryQuery {
|
||||||
@@ -56,6 +90,8 @@ export interface HistoryQuery {
|
|||||||
|
|
||||||
export interface HistoryResponse extends GenerationResponse {
|
export interface HistoryResponse extends GenerationResponse {
|
||||||
profile_name: string;
|
profile_name: string;
|
||||||
|
versions?: GenerationVersionResponse[];
|
||||||
|
active_version_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryListResponse {
|
export interface HistoryListResponse {
|
||||||
@@ -78,7 +114,29 @@ export interface HealthResponse {
|
|||||||
model_downloaded?: boolean;
|
model_downloaded?: boolean;
|
||||||
model_size?: string;
|
model_size?: string;
|
||||||
gpu_available: boolean;
|
gpu_available: boolean;
|
||||||
|
gpu_type?: string;
|
||||||
vram_used_mb?: number;
|
vram_used_mb?: number;
|
||||||
|
backend_type?: string;
|
||||||
|
backend_variant?: string; // "cpu" or "cuda"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CudaDownloadProgress {
|
||||||
|
model_name: string;
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
progress: number;
|
||||||
|
filename?: string;
|
||||||
|
status: 'downloading' | 'extracting' | 'complete' | 'error';
|
||||||
|
timestamp: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CudaStatus {
|
||||||
|
available: boolean; // CUDA binary exists on disk
|
||||||
|
active: boolean; // Currently running the CUDA binary
|
||||||
|
binary_path?: string;
|
||||||
|
downloading: boolean; // Download in progress
|
||||||
|
download_progress?: CudaDownloadProgress;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelProgress {
|
export interface ModelProgress {
|
||||||
@@ -95,11 +153,29 @@ export interface ModelProgress {
|
|||||||
export interface ModelStatus {
|
export interface ModelStatus {
|
||||||
model_name: string;
|
model_name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
|
hf_repo_id?: string; // HuggingFace repository ID
|
||||||
downloaded: boolean;
|
downloaded: boolean;
|
||||||
|
downloading: boolean; // True if download is in progress
|
||||||
size_mb?: number;
|
size_mb?: number;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HuggingFaceModelInfo {
|
||||||
|
id: string;
|
||||||
|
author: string;
|
||||||
|
lastModified: string;
|
||||||
|
pipeline_tag?: string;
|
||||||
|
library_name?: string;
|
||||||
|
downloads: number;
|
||||||
|
likes: number;
|
||||||
|
tags: string[];
|
||||||
|
cardData?: {
|
||||||
|
license?: string;
|
||||||
|
language?: string[];
|
||||||
|
pipeline_tag?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface ModelStatusListResponse {
|
export interface ModelStatusListResponse {
|
||||||
models: ModelStatus[];
|
models: ModelStatus[];
|
||||||
}
|
}
|
||||||
@@ -112,6 +188,11 @@ export interface ActiveDownloadTask {
|
|||||||
model_name: string;
|
model_name: string;
|
||||||
status: string;
|
status: string;
|
||||||
started_at: string;
|
started_at: string;
|
||||||
|
error?: string;
|
||||||
|
progress?: number; // 0-100 percentage
|
||||||
|
current?: number; // bytes downloaded
|
||||||
|
total?: number; // total bytes
|
||||||
|
filename?: string; // current file being downloaded
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveGenerationTask {
|
export interface ActiveGenerationTask {
|
||||||
@@ -144,6 +225,7 @@ export interface StoryItemDetail {
|
|||||||
id: string;
|
id: string;
|
||||||
story_id: string;
|
story_id: string;
|
||||||
generation_id: string;
|
generation_id: string;
|
||||||
|
version_id?: string;
|
||||||
start_time_ms: number;
|
start_time_ms: number;
|
||||||
track: number;
|
track: number;
|
||||||
trim_start_ms: number;
|
trim_start_ms: number;
|
||||||
@@ -158,6 +240,12 @@ export interface StoryItemDetail {
|
|||||||
seed?: number;
|
seed?: number;
|
||||||
instruct?: string;
|
instruct?: string;
|
||||||
generation_created_at: string;
|
generation_created_at: string;
|
||||||
|
versions?: GenerationVersionResponse[];
|
||||||
|
active_version_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemVersionUpdate {
|
||||||
|
version_id: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoryDetailResponse {
|
export interface StoryDetailResponse {
|
||||||
@@ -201,3 +289,52 @@ export interface StoryItemTrim {
|
|||||||
export interface StoryItemSplit {
|
export interface StoryItemSplit {
|
||||||
split_time_ms: number;
|
split_time_ms: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Effects
|
||||||
|
|
||||||
|
export interface EffectPresetResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
effects_chain: EffectConfig[];
|
||||||
|
is_builtin: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EffectPresetCreate {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
effects_chain: EffectConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EffectPresetUpdate {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
effects_chain?: EffectConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailableEffectParam {
|
||||||
|
default: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailableEffect {
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
params: Record<string, AvailableEffectParam>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailableEffectsResponse {
|
||||||
|
effects: AvailableEffect[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyEffectsRequest {
|
||||||
|
effects_chain: EffectConfig[];
|
||||||
|
source_version_id?: string;
|
||||||
|
label?: string;
|
||||||
|
set_as_default?: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,26 +1,86 @@
|
|||||||
/**
|
/**
|
||||||
* Supported languages for Qwen3-TTS
|
* Supported languages for voice generation, per engine.
|
||||||
* Based on: https://github.com/QwenLM/Qwen3-TTS
|
*
|
||||||
|
* Qwen3-TTS supports 10 languages.
|
||||||
|
* LuxTTS is English-only.
|
||||||
|
* Chatterbox Multilingual supports 23 languages.
|
||||||
|
* Chatterbox Turbo is English-only.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const SUPPORTED_LANGUAGES = {
|
/** All languages that any engine supports. */
|
||||||
zh: 'Chinese',
|
export const ALL_LANGUAGES = {
|
||||||
|
ar: 'Arabic',
|
||||||
|
da: 'Danish',
|
||||||
|
de: 'German',
|
||||||
|
el: 'Greek',
|
||||||
en: 'English',
|
en: 'English',
|
||||||
|
es: 'Spanish',
|
||||||
|
fi: 'Finnish',
|
||||||
|
fr: 'French',
|
||||||
|
he: 'Hebrew',
|
||||||
|
hi: 'Hindi',
|
||||||
|
it: 'Italian',
|
||||||
ja: 'Japanese',
|
ja: 'Japanese',
|
||||||
ko: 'Korean',
|
ko: 'Korean',
|
||||||
de: 'German',
|
ms: 'Malay',
|
||||||
fr: 'French',
|
nl: 'Dutch',
|
||||||
ru: 'Russian',
|
no: 'Norwegian',
|
||||||
|
pl: 'Polish',
|
||||||
pt: 'Portuguese',
|
pt: 'Portuguese',
|
||||||
es: 'Spanish',
|
ru: 'Russian',
|
||||||
it: 'Italian',
|
sv: 'Swedish',
|
||||||
|
sw: 'Swahili',
|
||||||
|
tr: 'Turkish',
|
||||||
|
zh: 'Chinese',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type LanguageCode = keyof typeof SUPPORTED_LANGUAGES;
|
export type LanguageCode = keyof typeof ALL_LANGUAGES;
|
||||||
|
|
||||||
export const LANGUAGE_CODES = Object.keys(SUPPORTED_LANGUAGES) as LanguageCode[];
|
/** Per-engine supported language codes. */
|
||||||
|
export const ENGINE_LANGUAGES: Record<string, readonly LanguageCode[]> = {
|
||||||
|
qwen: ['zh', 'en', 'ja', 'ko', 'de', 'fr', 'ru', 'pt', 'es', 'it'],
|
||||||
|
luxtts: ['en'],
|
||||||
|
chatterbox: [
|
||||||
|
'ar',
|
||||||
|
'da',
|
||||||
|
'de',
|
||||||
|
'el',
|
||||||
|
'en',
|
||||||
|
'es',
|
||||||
|
'fi',
|
||||||
|
'fr',
|
||||||
|
'he',
|
||||||
|
'hi',
|
||||||
|
'it',
|
||||||
|
'ja',
|
||||||
|
'ko',
|
||||||
|
'ms',
|
||||||
|
'nl',
|
||||||
|
'no',
|
||||||
|
'pl',
|
||||||
|
'pt',
|
||||||
|
'ru',
|
||||||
|
'sv',
|
||||||
|
'sw',
|
||||||
|
'tr',
|
||||||
|
'zh',
|
||||||
|
],
|
||||||
|
chatterbox_turbo: ['en'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Helper: get language options for a given engine. */
|
||||||
|
export function getLanguageOptionsForEngine(engine: string) {
|
||||||
|
const codes = ENGINE_LANGUAGES[engine] ?? ENGINE_LANGUAGES.qwen;
|
||||||
|
return codes.map((code) => ({
|
||||||
|
value: code,
|
||||||
|
label: ALL_LANGUAGES[code],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Backwards-compatible exports used elsewhere ──────────────────────
|
||||||
|
export const SUPPORTED_LANGUAGES = ALL_LANGUAGES;
|
||||||
|
export const LANGUAGE_CODES = Object.keys(ALL_LANGUAGES) as LanguageCode[];
|
||||||
export const LANGUAGE_OPTIONS = LANGUAGE_CODES.map((code) => ({
|
export const LANGUAGE_OPTIONS = LANGUAGE_CODES.map((code) => ({
|
||||||
value: code,
|
value: code,
|
||||||
label: SUPPORTED_LANGUAGES[code],
|
label: ALL_LANGUAGES[code],
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
* UI layout constants for safe area padding
|
* UI layout constants for safe area padding
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const isWindows = typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top safe area padding - height of the drag region bar
|
* Top safe area padding - height of the drag region bar
|
||||||
* Corresponds to Tailwind's pt-12 (3rem / 48px)
|
* On macOS this accounts for the overlay titlebar (48px).
|
||||||
|
* On Windows the native title bar is outside the webview, so no padding is needed.
|
||||||
*/
|
*/
|
||||||
export const TOP_SAFE_AREA_PADDING = 'pt-12';
|
export const TOP_SAFE_AREA_PADDING = isWindows ? 'pt-8' : 'pt-12';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bottom safe area padding - height of the audio player
|
* Bottom safe area padding - height of the audio player
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ export function useAudioRecording({
|
|||||||
const streamRef = useRef<MediaStream | null>(null);
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
const timerRef = useRef<number | null>(null);
|
const timerRef = useRef<number | null>(null);
|
||||||
const startTimeRef = useRef<number | null>(null);
|
const startTimeRef = useRef<number | null>(null);
|
||||||
|
const cancelledRef = useRef<boolean>(false);
|
||||||
|
|
||||||
const startRecording = useCallback(async () => {
|
const startRecording = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
chunksRef.current = [];
|
chunksRef.current = [];
|
||||||
|
cancelledRef.current = false;
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
|
|
||||||
// Check if getUserMedia is available
|
// Check if getUserMedia is available
|
||||||
@@ -87,31 +89,34 @@ export function useAudioRecording({
|
|||||||
};
|
};
|
||||||
|
|
||||||
mediaRecorder.onstop = async () => {
|
mediaRecorder.onstop = async () => {
|
||||||
|
// Snapshot the cancellation flag and recorded duration immediately —
|
||||||
|
// cancelRecording() clears chunks and sets cancelledRef synchronously
|
||||||
|
// before this async handler runs, so we must check it first.
|
||||||
|
const wasCancelled = cancelledRef.current;
|
||||||
|
const recordedDuration = startTimeRef.current
|
||||||
|
? (Date.now() - startTimeRef.current) / 1000
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||||
|
|
||||||
// Convert to WAV format to avoid needing ffmpeg on backend
|
// Stop all tracks now that we have the data
|
||||||
try {
|
|
||||||
const wavBlob = await convertToWav(webmBlob);
|
|
||||||
|
|
||||||
// Pass the actual recorded duration
|
|
||||||
const recordedDuration = startTimeRef.current
|
|
||||||
? (Date.now() - startTimeRef.current) / 1000
|
|
||||||
: undefined;
|
|
||||||
onRecordingComplete?.(wavBlob, recordedDuration);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error converting audio to WAV:', err);
|
|
||||||
// Fallback to original blob if conversion fails
|
|
||||||
const recordedDuration = startTimeRef.current
|
|
||||||
? (Date.now() - startTimeRef.current) / 1000
|
|
||||||
: undefined;
|
|
||||||
onRecordingComplete?.(webmBlob, recordedDuration);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop all tracks
|
|
||||||
streamRef.current?.getTracks().forEach((track) => {
|
streamRef.current?.getTracks().forEach((track) => {
|
||||||
track.stop();
|
track.stop();
|
||||||
});
|
});
|
||||||
streamRef.current = null;
|
streamRef.current = null;
|
||||||
|
|
||||||
|
// Don't fire completion callback if the recording was cancelled
|
||||||
|
if (wasCancelled) return;
|
||||||
|
|
||||||
|
// Convert to WAV format to avoid needing ffmpeg on backend
|
||||||
|
try {
|
||||||
|
const wavBlob = await convertToWav(webmBlob);
|
||||||
|
onRecordingComplete?.(wavBlob, recordedDuration);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error converting audio to WAV:', err);
|
||||||
|
// Fallback to original blob if conversion fails
|
||||||
|
onRecordingComplete?.(webmBlob, recordedDuration);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
mediaRecorder.onerror = (event) => {
|
mediaRecorder.onerror = (event) => {
|
||||||
@@ -167,9 +172,10 @@ export function useAudioRecording({
|
|||||||
|
|
||||||
const cancelRecording = useCallback(() => {
|
const cancelRecording = useCallback(() => {
|
||||||
if (mediaRecorderRef.current) {
|
if (mediaRecorderRef.current) {
|
||||||
|
cancelledRef.current = true; // Must be set before stop() triggers onstop
|
||||||
|
chunksRef.current = [];
|
||||||
mediaRecorderRef.current.stop();
|
mediaRecorderRef.current.stop();
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
chunksRef.current = [];
|
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,18 +4,20 @@ import { useForm } from 'react-hook-form';
|
|||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { EffectConfig } from '@/lib/api/types';
|
||||||
import { LANGUAGE_CODES, type LanguageCode } from '@/lib/constants/languages';
|
import { LANGUAGE_CODES, type LanguageCode } from '@/lib/constants/languages';
|
||||||
import { useGeneration } from '@/lib/hooks/useGeneration';
|
import { useGeneration } from '@/lib/hooks/useGeneration';
|
||||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
const generationSchema = z.object({
|
const generationSchema = z.object({
|
||||||
text: z.string().min(1, 'Text is required').max(5000),
|
text: z.string().min(1, '').max(50000),
|
||||||
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
||||||
seed: z.number().int().optional(),
|
seed: z.number().int().optional(),
|
||||||
modelSize: z.enum(['1.7B', '0.6B']).optional(),
|
modelSize: z.enum(['1.7B', '0.6B']).optional(),
|
||||||
instruct: z.string().max(500).optional(),
|
instruct: z.string().max(500).optional(),
|
||||||
|
engine: z.enum(['qwen', 'luxtts', 'chatterbox', 'chatterbox_turbo']).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type GenerationFormValues = z.infer<typeof generationSchema>;
|
export type GenerationFormValues = z.infer<typeof generationSchema>;
|
||||||
@@ -23,13 +25,16 @@ export type GenerationFormValues = z.infer<typeof generationSchema>;
|
|||||||
interface UseGenerationFormOptions {
|
interface UseGenerationFormOptions {
|
||||||
onSuccess?: (generationId: string) => void;
|
onSuccess?: (generationId: string) => void;
|
||||||
defaultValues?: Partial<GenerationFormValues>;
|
defaultValues?: Partial<GenerationFormValues>;
|
||||||
|
getEffectsChain?: () => EffectConfig[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const generation = useGeneration();
|
const generation = useGeneration();
|
||||||
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
|
||||||
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
|
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
|
||||||
|
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
|
||||||
|
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
|
||||||
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
|
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
|
||||||
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -47,6 +52,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
seed: undefined,
|
seed: undefined,
|
||||||
modelSize: '1.7B',
|
modelSize: '1.7B',
|
||||||
instruct: '',
|
instruct: '',
|
||||||
|
engine: 'qwen',
|
||||||
...options.defaultValues,
|
...options.defaultValues,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -65,11 +71,27 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsGenerating(true);
|
const engine = data.engine || 'qwen';
|
||||||
|
const modelName =
|
||||||
const modelName = `qwen-tts-${data.modelSize}`;
|
engine === 'luxtts'
|
||||||
const displayName = data.modelSize === '1.7B' ? 'Qwen TTS 1.7B' : 'Qwen TTS 0.6B';
|
? 'luxtts'
|
||||||
|
: engine === 'chatterbox'
|
||||||
|
? 'chatterbox-tts'
|
||||||
|
: engine === 'chatterbox_turbo'
|
||||||
|
? 'chatterbox-turbo'
|
||||||
|
: `qwen-tts-${data.modelSize}`;
|
||||||
|
const displayName =
|
||||||
|
engine === 'luxtts'
|
||||||
|
? 'LuxTTS'
|
||||||
|
: engine === 'chatterbox'
|
||||||
|
? 'Chatterbox TTS'
|
||||||
|
: engine === 'chatterbox_turbo'
|
||||||
|
? 'Chatterbox Turbo'
|
||||||
|
: data.modelSize === '1.7B'
|
||||||
|
? 'Qwen TTS 1.7B'
|
||||||
|
: 'Qwen TTS 0.6B';
|
||||||
|
|
||||||
|
// Check if model needs downloading
|
||||||
try {
|
try {
|
||||||
const modelStatus = await apiClient.getModelStatus();
|
const modelStatus = await apiClient.getModelStatus();
|
||||||
const model = modelStatus.models.find((m) => m.model_name === modelName);
|
const model = modelStatus.models.find((m) => m.model_name === modelName);
|
||||||
@@ -82,24 +104,35 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
console.error('Failed to check model status:', error);
|
console.error('Failed to check model status:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isQwen = engine === 'qwen';
|
||||||
|
const effectsChain = options.getEffectsChain?.();
|
||||||
|
// This now returns immediately with status="generating"
|
||||||
const result = await generation.mutateAsync({
|
const result = await generation.mutateAsync({
|
||||||
profile_id: selectedProfileId,
|
profile_id: selectedProfileId,
|
||||||
text: data.text,
|
text: data.text,
|
||||||
language: data.language,
|
language: data.language,
|
||||||
seed: data.seed,
|
seed: data.seed,
|
||||||
model_size: data.modelSize,
|
model_size: isQwen ? data.modelSize : undefined,
|
||||||
instruct: data.instruct || undefined,
|
engine,
|
||||||
|
instruct: isQwen ? data.instruct || undefined : undefined,
|
||||||
|
max_chunk_chars: maxChunkChars,
|
||||||
|
crossfade_ms: crossfadeMs,
|
||||||
|
normalize: normalizeAudio,
|
||||||
|
effects_chain: effectsChain?.length ? effectsChain : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
// Track this generation for SSE status updates
|
||||||
title: 'Generation complete!',
|
addPendingGeneration(result.id);
|
||||||
description: `Audio generated (${result.duration.toFixed(2)}s)`,
|
|
||||||
|
// Reset form immediately — user can start typing again
|
||||||
|
form.reset({
|
||||||
|
text: '',
|
||||||
|
language: data.language,
|
||||||
|
seed: undefined,
|
||||||
|
modelSize: data.modelSize,
|
||||||
|
instruct: '',
|
||||||
|
engine: data.engine,
|
||||||
});
|
});
|
||||||
|
|
||||||
const audioUrl = apiClient.getAudioUrl(result.id);
|
|
||||||
setAudioWithAutoPlay(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
|
|
||||||
|
|
||||||
form.reset();
|
|
||||||
options.onSuccess?.(result.id);
|
options.onSuccess?.(result.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
@@ -108,7 +141,6 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsGenerating(false);
|
|
||||||
setDownloadingModelName(null);
|
setDownloadingModelName(null);
|
||||||
setDownloadingDisplayName(null);
|
setDownloadingDisplayName(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
|
interface GenerationStatusEvent {
|
||||||
|
id: string;
|
||||||
|
status: 'loading_model' | 'generating' | 'completed' | 'failed' | 'not_found';
|
||||||
|
duration?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to SSE for all pending generations. When a generation completes,
|
||||||
|
* invalidates the history query, removes it from pending, and auto-plays
|
||||||
|
* if the player is idle.
|
||||||
|
*/
|
||||||
|
export function useGenerationProgress() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const pendingIds = useGenerationStore((s) => s.pendingGenerationIds);
|
||||||
|
const removePendingGeneration = useGenerationStore((s) => s.removePendingGeneration);
|
||||||
|
const removePendingStoryAdd = useGenerationStore((s) => s.removePendingStoryAdd);
|
||||||
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||||
|
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
|
||||||
|
const autoplayOnGenerate = useServerStore((s) => s.autoplayOnGenerate);
|
||||||
|
|
||||||
|
// Keep refs to avoid stale closures in EventSource handlers
|
||||||
|
const isPlayingRef = useRef(isPlaying);
|
||||||
|
const autoplayRef = useRef(autoplayOnGenerate);
|
||||||
|
isPlayingRef.current = isPlaying;
|
||||||
|
autoplayRef.current = autoplayOnGenerate;
|
||||||
|
|
||||||
|
// Track active EventSource instances
|
||||||
|
const eventSourcesRef = useRef<Map<string, EventSource>>(new Map());
|
||||||
|
|
||||||
|
// Unmount-only cleanup — close all SSE connections when the hook is torn down
|
||||||
|
useEffect(() => {
|
||||||
|
const sources = eventSourcesRef.current;
|
||||||
|
return () => {
|
||||||
|
for (const source of sources.values()) {
|
||||||
|
source.close();
|
||||||
|
}
|
||||||
|
sources.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentSources = eventSourcesRef.current;
|
||||||
|
|
||||||
|
// Close SSE connections for IDs no longer pending
|
||||||
|
for (const [id, source] of currentSources.entries()) {
|
||||||
|
if (!pendingIds.has(id)) {
|
||||||
|
source.close();
|
||||||
|
currentSources.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open SSE connections for new pending IDs
|
||||||
|
for (const id of pendingIds) {
|
||||||
|
if (currentSources.has(id)) continue;
|
||||||
|
|
||||||
|
const url = apiClient.getGenerationStatusUrl(id);
|
||||||
|
const source = new EventSource(url);
|
||||||
|
|
||||||
|
source.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data: GenerationStatusEvent = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
source.close();
|
||||||
|
currentSources.delete(id);
|
||||||
|
removePendingGeneration(id);
|
||||||
|
|
||||||
|
// Refresh history to pick up the completed generation
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
|
||||||
|
// If this generation was queued for a story, add it now
|
||||||
|
const storyId = removePendingStoryAdd(id);
|
||||||
|
if (storyId) {
|
||||||
|
apiClient
|
||||||
|
.addStoryItem(storyId, { generation_id: id })
|
||||||
|
.then(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', storyId] });
|
||||||
|
toast({
|
||||||
|
title: 'Added to story',
|
||||||
|
description: data.duration
|
||||||
|
? `Audio generated (${data.duration.toFixed(2)}s) and added to story`
|
||||||
|
: 'Audio generated and added to story',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast({
|
||||||
|
title: 'Generation complete',
|
||||||
|
description: 'Audio generated but failed to add to story',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// toast({
|
||||||
|
// title: 'Generation complete!',
|
||||||
|
// description: data.duration
|
||||||
|
// ? `Audio generated (${data.duration.toFixed(2)}s)`
|
||||||
|
// : 'Audio generated',
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-play if enabled and nothing is currently playing
|
||||||
|
if (autoplayRef.current && !isPlayingRef.current) {
|
||||||
|
const genAudioUrl = apiClient.getAudioUrl(id);
|
||||||
|
setAudioWithAutoPlay(genAudioUrl, id, '', '');
|
||||||
|
}
|
||||||
|
} else if (data.status === 'failed' || data.status === 'not_found') {
|
||||||
|
source.close();
|
||||||
|
currentSources.delete(id);
|
||||||
|
removePendingGeneration(id);
|
||||||
|
removePendingStoryAdd(id);
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: data.status === 'not_found' ? 'Generation not found' : 'Generation failed',
|
||||||
|
description: data.error || 'An error occurred during generation',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors from heartbeats etc
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
source.onerror = () => {
|
||||||
|
// EventSource auto-reconnects, but if we get repeated errors
|
||||||
|
// just clean up
|
||||||
|
source.close();
|
||||||
|
currentSources.delete(id);
|
||||||
|
removePendingGeneration(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
currentSources.set(id, source);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
pendingIds,
|
||||||
|
removePendingGeneration,
|
||||||
|
removePendingStoryAdd,
|
||||||
|
queryClient,
|
||||||
|
toast,
|
||||||
|
setAudioWithAutoPlay,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import type { ModelProgress } from '@/lib/api/types';
|
import type { ModelProgress } from '@/lib/api/types';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
interface UseModelDownloadToastOptions {
|
interface UseModelDownloadToastOptions {
|
||||||
modelName: string;
|
modelName: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
onComplete?: () => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,47 +21,64 @@ export function useModelDownloadToast({
|
|||||||
modelName,
|
modelName,
|
||||||
displayName,
|
displayName,
|
||||||
enabled = false,
|
enabled = false,
|
||||||
|
onComplete,
|
||||||
|
onError,
|
||||||
}: UseModelDownloadToastOptions) {
|
}: UseModelDownloadToastOptions) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
const toastIdRef = useRef<string | null>(null);
|
const toastIdRef = useRef<string | null>(null);
|
||||||
const toastUpdateRef = useRef<
|
// biome-ignore lint: Using any for toast update ref to handle complex toast types
|
||||||
((props: {
|
const toastUpdateRef = useRef<any>(null);
|
||||||
title?: React.ReactNode;
|
|
||||||
description?: React.ReactNode;
|
|
||||||
duration?: number;
|
|
||||||
variant?: 'default' | 'destructive';
|
|
||||||
open?: boolean;
|
|
||||||
}) => void) | null
|
|
||||||
>(null);
|
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
|
||||||
const formatBytes = (bytes: number): string => {
|
const formatBytes = useCallback((bytes: number): string => {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.log('[useModelDownloadToast] useEffect triggered', {
|
||||||
|
enabled,
|
||||||
|
serverUrl,
|
||||||
|
modelName,
|
||||||
|
displayName,
|
||||||
|
});
|
||||||
|
|
||||||
if (!enabled || !serverUrl || !modelName) {
|
if (!enabled || !serverUrl || !modelName) {
|
||||||
|
console.log('[useModelDownloadToast] Not enabled, skipping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[useModelDownloadToast] Creating toast and EventSource for:', modelName);
|
||||||
|
|
||||||
// Create initial toast
|
// Create initial toast
|
||||||
const toastResult = toast({
|
const toastResult = toast({
|
||||||
title: displayName,
|
title: displayName,
|
||||||
description: 'Starting download...',
|
description: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span>Connecting to download...</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
duration: Infinity, // Don't auto-dismiss, we'll handle it manually
|
duration: Infinity, // Don't auto-dismiss, we'll handle it manually
|
||||||
});
|
});
|
||||||
toastIdRef.current = toastResult.id;
|
toastIdRef.current = toastResult.id;
|
||||||
toastUpdateRef.current = toastResult.update;
|
toastUpdateRef.current = toastResult.update;
|
||||||
|
|
||||||
// Subscribe to progress updates via Server-Sent Events
|
// Subscribe to progress updates via Server-Sent Events
|
||||||
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
const eventSourceUrl = `${serverUrl}/models/progress/${modelName}`;
|
||||||
|
console.log('[useModelDownloadToast] Creating EventSource to:', eventSourceUrl);
|
||||||
|
const eventSource = new EventSource(eventSourceUrl);
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
console.log('[useModelDownloadToast] EventSource connection opened for:', modelName);
|
||||||
|
};
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
|
console.log('[useModelDownloadToast] Received SSE message:', event.data);
|
||||||
try {
|
try {
|
||||||
const progress = JSON.parse(event.data) as ModelProgress;
|
const progress = JSON.parse(event.data) as ModelProgress;
|
||||||
|
|
||||||
@@ -82,11 +101,11 @@ export function useModelDownloadToast({
|
|||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
statusIcon = <XCircle className="h-4 w-4 text-destructive" />;
|
statusIcon = <XCircle className="h-4 w-4 text-destructive" />;
|
||||||
statusText = `Error: ${progress.error || 'Unknown error'}`;
|
statusText = 'Download failed. See Problems panel for details.';
|
||||||
break;
|
break;
|
||||||
case 'downloading':
|
case 'downloading':
|
||||||
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
||||||
statusText = progress.filename ? `Downloading ${progress.filename}...` : 'Downloading...';
|
statusText = progress.filename || 'Downloading...';
|
||||||
break;
|
break;
|
||||||
case 'extracting':
|
case 'extracting':
|
||||||
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
||||||
@@ -112,26 +131,44 @@ export function useModelDownloadToast({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
duration: progress.status === 'complete' ? 5000 : Infinity,
|
duration: progress.status === 'complete' || progress.status === 'error' ? 5000 : Infinity,
|
||||||
variant: progress.status === 'error' ? 'destructive' : 'default',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close connection and dismiss toast on completion or error
|
// Close connection and dismiss toast on completion or error
|
||||||
if (progress.status === 'complete' || progress.status === 'error') {
|
// Also treat progress >= 100% as complete
|
||||||
|
const isComplete = progress.status === 'complete' || progress.progress >= 100;
|
||||||
|
const isError = progress.status === 'error';
|
||||||
|
|
||||||
|
if (isComplete || isError) {
|
||||||
|
console.log('[useModelDownloadToast] Download finished:', {
|
||||||
|
isComplete,
|
||||||
|
isError,
|
||||||
|
progress: progress.progress,
|
||||||
|
});
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
|
|
||||||
// Auto-dismiss on completion after delay
|
// Update toast to show completion state before callbacks
|
||||||
if (progress.status === 'complete') {
|
if (isComplete && toastUpdateRef.current) {
|
||||||
setTimeout(() => {
|
toastUpdateRef.current({
|
||||||
if (toastIdRef.current && toastUpdateRef.current) {
|
title: (
|
||||||
toastUpdateRef.current({
|
<div className="flex items-center gap-2">
|
||||||
open: false,
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
});
|
<span>{displayName}</span>
|
||||||
toastIdRef.current = null;
|
</div>
|
||||||
toastUpdateRef.current = null;
|
),
|
||||||
}
|
description: 'Download complete',
|
||||||
}, 5000);
|
duration: 3000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call callbacks
|
||||||
|
if (isComplete && onComplete) {
|
||||||
|
console.log('[useModelDownloadToast] Download complete, calling onComplete callback');
|
||||||
|
onComplete();
|
||||||
|
} else if (isError && onError) {
|
||||||
|
console.log('[useModelDownloadToast] Download error, calling onError callback');
|
||||||
|
onError(progress.error || 'Unknown error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,7 +178,8 @@ export function useModelDownloadToast({
|
|||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onerror = (error) => {
|
eventSource.onerror = (error) => {
|
||||||
console.error('SSE error:', error);
|
console.error('[useModelDownloadToast] SSE error for:', modelName, error);
|
||||||
|
console.log('[useModelDownloadToast] EventSource readyState:', eventSource.readyState);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
|
|
||||||
@@ -162,15 +200,16 @@ export function useModelDownloadToast({
|
|||||||
|
|
||||||
// Cleanup on unmount or when disabled
|
// Cleanup on unmount or when disabled
|
||||||
return () => {
|
return () => {
|
||||||
|
console.log('[useModelDownloadToast] Cleanup - closing EventSource for:', modelName);
|
||||||
if (eventSourceRef.current) {
|
if (eventSourceRef.current) {
|
||||||
eventSourceRef.current.close();
|
eventSourceRef.current.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
}
|
}
|
||||||
// Note: We don't dismiss the toast here as it might still be showing completion state
|
// Note: We don't dismiss the toast here as it might still be showing completion state
|
||||||
};
|
};
|
||||||
}, [enabled, serverUrl, modelName, displayName, toast]);
|
}, [enabled, serverUrl, modelName, displayName, toast, formatBytes, onComplete, onError]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isTracking: enabled && eventSourceRef.current !== null,
|
isTracking: enabled && eventSourceRef.current !== null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
|
||||||
import type { ActiveDownloadTask } from '@/lib/api/types';
|
import type { ActiveDownloadTask } from '@/lib/api/types';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
|
|
||||||
// Polling interval in milliseconds
|
// Polling interval in milliseconds
|
||||||
const POLL_INTERVAL = 2000;
|
const POLL_INTERVAL = 30000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to monitor active tasks (downloads and generations).
|
* Hook to monitor active tasks (downloads and generations).
|
||||||
* Polls the server periodically to catch downloads triggered from anywhere
|
* Polls the server periodically to catch downloads triggered from anywhere
|
||||||
* (transcription, generation, explicit download, etc.).
|
* (transcription, generation, explicit download, etc.).
|
||||||
*
|
*
|
||||||
* Returns the active downloads so components can render download toasts.
|
* Returns the active downloads so components can render download toasts.
|
||||||
*/
|
*/
|
||||||
export function useRestoreActiveTasks() {
|
export function useRestoreActiveTasks() {
|
||||||
const [activeDownloads, setActiveDownloads] = useState<ActiveDownloadTask[]>([]);
|
const [activeDownloads, setActiveDownloads] = useState<ActiveDownloadTask[]>([]);
|
||||||
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
|
|
||||||
const setActiveGenerationId = useGenerationStore((state) => state.setActiveGenerationId);
|
const setActiveGenerationId = useGenerationStore((state) => state.setActiveGenerationId);
|
||||||
|
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
|
||||||
|
|
||||||
// Track which downloads we've seen to detect new ones
|
// Track which downloads we've seen to detect new ones
|
||||||
const seenDownloadsRef = useRef<Set<string>>(new Set());
|
const seenDownloadsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
@@ -25,15 +25,15 @@ export function useRestoreActiveTasks() {
|
|||||||
try {
|
try {
|
||||||
const tasks = await apiClient.getActiveTasks();
|
const tasks = await apiClient.getActiveTasks();
|
||||||
|
|
||||||
// Update generation state
|
// Restore pending generations (e.g., after page refresh)
|
||||||
if (tasks.generations.length > 0) {
|
if (tasks.generations.length > 0) {
|
||||||
setIsGenerating(true);
|
|
||||||
setActiveGenerationId(tasks.generations[0].task_id);
|
setActiveGenerationId(tasks.generations[0].task_id);
|
||||||
|
for (const gen of tasks.generations) {
|
||||||
|
addPendingGeneration(gen.task_id);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Only clear if we were tracking a generation
|
|
||||||
const currentId = useGenerationStore.getState().activeGenerationId;
|
const currentId = useGenerationStore.getState().activeGenerationId;
|
||||||
if (currentId) {
|
if (currentId) {
|
||||||
setIsGenerating(false);
|
|
||||||
setActiveGenerationId(null);
|
setActiveGenerationId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,14 +41,14 @@ export function useRestoreActiveTasks() {
|
|||||||
// Update active downloads
|
// Update active downloads
|
||||||
// Keep track of all active downloads (including new ones)
|
// Keep track of all active downloads (including new ones)
|
||||||
const currentDownloadNames = new Set(tasks.downloads.map((d) => d.model_name));
|
const currentDownloadNames = new Set(tasks.downloads.map((d) => d.model_name));
|
||||||
|
|
||||||
// Remove completed downloads from our seen set
|
// Remove completed downloads from our seen set
|
||||||
for (const name of seenDownloadsRef.current) {
|
for (const name of seenDownloadsRef.current) {
|
||||||
if (!currentDownloadNames.has(name)) {
|
if (!currentDownloadNames.has(name)) {
|
||||||
seenDownloadsRef.current.delete(name);
|
seenDownloadsRef.current.delete(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new downloads to seen set
|
// Add new downloads to seen set
|
||||||
for (const download of tasks.downloads) {
|
for (const download of tasks.downloads) {
|
||||||
seenDownloadsRef.current.add(download.model_name);
|
seenDownloadsRef.current.add(download.model_name);
|
||||||
@@ -59,7 +59,7 @@ export function useRestoreActiveTasks() {
|
|||||||
// Silently fail - server might be temporarily unavailable
|
// Silently fail - server might be temporarily unavailable
|
||||||
console.debug('Failed to fetch active tasks:', error);
|
console.debug('Failed to fetch active tasks:', error);
|
||||||
}
|
}
|
||||||
}, [setIsGenerating, setActiveGenerationId]);
|
}, [setActiveGenerationId, addPendingGeneration]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetch immediately on mount
|
// Fetch immediately on mount
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder, StoryItemMove, StoryItemTrim, StoryItemSplit } from '@/lib/api/types';
|
import type {
|
||||||
|
StoryCreate,
|
||||||
|
StoryItemBatchUpdate,
|
||||||
|
StoryItemCreate,
|
||||||
|
StoryItemMove,
|
||||||
|
StoryItemReorder,
|
||||||
|
StoryItemSplit,
|
||||||
|
StoryItemTrim,
|
||||||
|
StoryItemVersionUpdate,
|
||||||
|
} from '@/lib/api/types';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
export function useStories() {
|
export function useStories() {
|
||||||
@@ -109,8 +118,15 @@ export function useMoveStoryItem() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemMove }) =>
|
mutationFn: ({
|
||||||
apiClient.moveStoryItem(storyId, itemId, data),
|
storyId,
|
||||||
|
itemId,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
storyId: string;
|
||||||
|
itemId: string;
|
||||||
|
data: StoryItemMove;
|
||||||
|
}) => apiClient.moveStoryItem(storyId, itemId, data),
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
@@ -122,8 +138,15 @@ export function useTrimStoryItem() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemTrim }) =>
|
mutationFn: ({
|
||||||
apiClient.trimStoryItem(storyId, itemId, data),
|
storyId,
|
||||||
|
itemId,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
storyId: string;
|
||||||
|
itemId: string;
|
||||||
|
data: StoryItemTrim;
|
||||||
|
}) => apiClient.trimStoryItem(storyId, itemId, data),
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
@@ -135,8 +158,15 @@ export function useSplitStoryItem() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemSplit }) =>
|
mutationFn: ({
|
||||||
apiClient.splitStoryItem(storyId, itemId, data),
|
storyId,
|
||||||
|
itemId,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
storyId: string;
|
||||||
|
itemId: string;
|
||||||
|
data: StoryItemSplit;
|
||||||
|
}) => apiClient.splitStoryItem(storyId, itemId, data),
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
@@ -157,6 +187,26 @@ export function useDuplicateStoryItem() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSetStoryItemVersion() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
storyId,
|
||||||
|
itemId,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
storyId: string;
|
||||||
|
itemId: string;
|
||||||
|
data: StoryItemVersionUpdate;
|
||||||
|
}) => apiClient.setStoryItemVersion(storyId, itemId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useExportStoryAudio() {
|
export function useExportStoryAudio() {
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
|
|
||||||
@@ -165,7 +215,10 @@ export function useExportStoryAudio() {
|
|||||||
const blob = await apiClient.exportStoryAudio(storyId);
|
const blob = await apiClient.exportStoryAudio(storyId);
|
||||||
|
|
||||||
// Create safe filename
|
// Create safe filename
|
||||||
const safeName = storyName.substring(0, 50).replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
const safeName = storyName
|
||||||
|
.substring(0, 50)
|
||||||
|
.replace(/[^a-z0-9]/gi, '-')
|
||||||
|
.toLowerCase();
|
||||||
const filename = `${safeName || 'story'}.wav`;
|
const filename = `${safeName || 'story'}.wav`;
|
||||||
|
|
||||||
await platform.filesystem.saveFile(filename, blob, [
|
await platform.filesystem.saveFile(filename, blob, [
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Resolve the audio buffer key and URL for an item.
|
||||||
|
// When a version_id is pinned, use that version's audio; otherwise use the generation default.
|
||||||
|
const getAudioKey = (item: StoryItemDetail) =>
|
||||||
|
item.version_id ? `v:${item.version_id}` : item.generation_id;
|
||||||
|
|
||||||
|
const getAudioUrlForItem = (item: StoryItemDetail) =>
|
||||||
|
item.version_id
|
||||||
|
? apiClient.getVersionAudioUrl(item.version_id)
|
||||||
|
: apiClient.getAudioUrl(item.generation_id);
|
||||||
|
|
||||||
// Preload audio files as AudioBuffers
|
// Preload audio files as AudioBuffers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!items || items.length === 0) {
|
if (!items || items.length === 0) {
|
||||||
@@ -78,12 +88,12 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentIds = new Set(items.map((item) => item.generation_id));
|
const currentKeys = new Set(items.map(getAudioKey));
|
||||||
const audioContext = getAudioContext();
|
const audioContext = getAudioContext();
|
||||||
|
|
||||||
// Remove buffers for items that no longer exist
|
// Remove buffers for items that no longer exist
|
||||||
for (const [id] of audioBuffersRef.current) {
|
for (const [id] of audioBuffersRef.current) {
|
||||||
if (!currentIds.has(id)) {
|
if (!currentKeys.has(id)) {
|
||||||
audioBuffersRef.current.delete(id);
|
audioBuffersRef.current.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,24 +101,25 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
|||||||
// Preload audio for new items
|
// Preload audio for new items
|
||||||
const preloadPromises: Promise<void>[] = [];
|
const preloadPromises: Promise<void>[] = [];
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (!audioBuffersRef.current.has(item.generation_id)) {
|
const key = getAudioKey(item);
|
||||||
const audioUrl = apiClient.getAudioUrl(item.generation_id);
|
if (!audioBuffersRef.current.has(key)) {
|
||||||
console.log('[StoryPlayback] Preloading audio buffer:', item.generation_id);
|
const audioUrl = getAudioUrlForItem(item);
|
||||||
|
console.log('[StoryPlayback] Preloading audio buffer:', key);
|
||||||
|
|
||||||
const preloadPromise = fetch(audioUrl)
|
const preloadPromise = fetch(audioUrl)
|
||||||
.then((response) => response.arrayBuffer())
|
.then((response) => response.arrayBuffer())
|
||||||
.then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer))
|
.then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer))
|
||||||
.then((audioBuffer) => {
|
.then((audioBuffer) => {
|
||||||
audioBuffersRef.current.set(item.generation_id, audioBuffer);
|
audioBuffersRef.current.set(key, audioBuffer);
|
||||||
console.log(
|
console.log(
|
||||||
'[StoryPlayback] Preloaded buffer:',
|
'[StoryPlayback] Preloaded buffer:',
|
||||||
item.generation_id,
|
key,
|
||||||
'duration:',
|
'duration:',
|
||||||
audioBuffer.duration,
|
audioBuffer.duration,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('[StoryPlayback] Failed to preload audio:', item.generation_id, err);
|
console.error('[StoryPlayback] Failed to preload audio:', key, err);
|
||||||
});
|
});
|
||||||
|
|
||||||
preloadPromises.push(preloadPromise);
|
preloadPromises.push(preloadPromise);
|
||||||
@@ -216,15 +227,16 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
|||||||
// Schedule new sources for items that should be playing
|
// Schedule new sources for items that should be playing
|
||||||
for (const item of shouldBePlaying) {
|
for (const item of shouldBePlaying) {
|
||||||
if (!activeSourcesRef.current.has(item.id)) {
|
if (!activeSourcesRef.current.has(item.id)) {
|
||||||
const buffer = audioBuffersRef.current.get(item.generation_id);
|
const bufferKey = getAudioKey(item);
|
||||||
|
const buffer = audioBuffersRef.current.get(bufferKey);
|
||||||
if (!buffer) {
|
if (!buffer) {
|
||||||
console.warn('[StoryPlayback] Buffer not loaded for:', item.generation_id);
|
console.warn('[StoryPlayback] Buffer not loaded for:', bufferKey);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate when this item should start in AudioContext time
|
// Calculate when this item should start in AudioContext time
|
||||||
const itemStartContextTime = storyTimeToContextTime(item.start_time_ms);
|
const itemStartContextTime = storyTimeToContextTime(item.start_time_ms);
|
||||||
|
|
||||||
// Calculate effective duration and trim offsets
|
// Calculate effective duration and trim offsets
|
||||||
const trimStartSec = (item.trim_start_ms || 0) / 1000;
|
const trimStartSec = (item.trim_start_ms || 0) / 1000;
|
||||||
const trimEndSec = (item.trim_end_ms || 0) / 1000;
|
const trimEndSec = (item.trim_end_ms || 0) / 1000;
|
||||||
|
|||||||
+36
-18
@@ -22,6 +22,11 @@ export function formatAudioDuration(seconds: number): string {
|
|||||||
* If the file has a recordedDuration property (from recording hooks),
|
* If the file has a recordedDuration property (from recording hooks),
|
||||||
* use that instead of trying to read metadata. This fixes issues on Windows
|
* use that instead of trying to read metadata. This fixes issues on Windows
|
||||||
* where WebM files from MediaRecorder don't have proper duration metadata.
|
* where WebM files from MediaRecorder don't have proper duration metadata.
|
||||||
|
*
|
||||||
|
* For uploaded files we use AudioContext.decodeAudioData which fully decodes
|
||||||
|
* the audio and returns the exact duration. This is more reliable than
|
||||||
|
* HTMLMediaElement.duration which can return incorrect large values for VBR
|
||||||
|
* MP3 files that lack a proper XING/VBRI header.
|
||||||
*/
|
*/
|
||||||
export async function getAudioDuration(
|
export async function getAudioDuration(
|
||||||
file: File & { recordedDuration?: number },
|
file: File & { recordedDuration?: number },
|
||||||
@@ -30,26 +35,39 @@ export async function getAudioDuration(
|
|||||||
return file.recordedDuration;
|
return file.recordedDuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
// Use Web Audio API for accurate duration — avoids VBR MP3 metadata issues.
|
||||||
const audio = new Audio();
|
try {
|
||||||
const url = URL.createObjectURL(file);
|
const audioContext = new AudioContext();
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||||
|
return audioBuffer.duration;
|
||||||
|
} finally {
|
||||||
|
await audioContext.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback: read duration from the media element (less accurate but works for WAV).
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const audio = new Audio();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
|
||||||
audio.addEventListener('loadedmetadata', () => {
|
audio.addEventListener('loadedmetadata', () => {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
||||||
resolve(audio.duration);
|
resolve(audio.duration);
|
||||||
} else {
|
} else {
|
||||||
reject(new Error('Audio file has invalid duration metadata'));
|
reject(new Error('Audio file has invalid duration metadata'));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
audio.addEventListener('error', () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error('Failed to load audio file'));
|
||||||
|
});
|
||||||
|
|
||||||
|
audio.src = url;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
audio.addEventListener('error', () => {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
reject(new Error('Failed to load audio file'));
|
|
||||||
});
|
|
||||||
|
|
||||||
audio.src = url;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -21,10 +21,25 @@ export function formatDate(date: string | Date): string {
|
|||||||
} else {
|
} else {
|
||||||
dateObj = date;
|
dateObj = date;
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatDistance(dateObj, new Date(), { addSuffix: true }).replace(/^about /i, '');
|
return formatDistance(dateObj, new Date(), { addSuffix: true }).replace(/^about /i, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ENGINE_DISPLAY_NAMES: Record<string, string> = {
|
||||||
|
qwen: 'Qwen',
|
||||||
|
luxtts: 'LuxTTS',
|
||||||
|
chatterbox: 'Chatterbox',
|
||||||
|
chatterbox_turbo: 'Chatterbox Turbo',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatEngineName(engine?: string, modelSize?: string): string {
|
||||||
|
const name = ENGINE_DISPLAY_NAMES[engine ?? 'qwen'] ?? engine ?? 'Qwen';
|
||||||
|
if (engine === 'qwen' && modelSize) {
|
||||||
|
return `${name} ${modelSize}`;
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatFileSize(bytes: number): string {
|
export function formatFileSize(bytes: number): string {
|
||||||
if (bytes === 0) return '0 Bytes';
|
if (bytes === 0) return '0 Bytes';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface FileFilter {
|
|||||||
|
|
||||||
export interface PlatformFilesystem {
|
export interface PlatformFilesystem {
|
||||||
saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise<void>;
|
saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise<void>;
|
||||||
|
openPath(path: string): Promise<void>;
|
||||||
|
pickDirectory(title: string): Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateStatus {
|
export interface UpdateStatus {
|
||||||
@@ -49,8 +51,9 @@ export interface PlatformAudio {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PlatformLifecycle {
|
export interface PlatformLifecycle {
|
||||||
startServer(remote?: boolean): Promise<string>;
|
startServer(remote?: boolean, modelsDir?: string | null): Promise<string>;
|
||||||
stopServer(): Promise<void>;
|
stopServer(): Promise<void>;
|
||||||
|
restartServer(modelsDir?: string | null): Promise<string>;
|
||||||
setKeepServerRunning(keep: boolean): Promise<void>;
|
setKeepServerRunning(keep: boolean): Promise<void>;
|
||||||
setupWindowCloseHandler(): Promise<void>;
|
setupWindowCloseHandler(): Promise<void>;
|
||||||
onServerReady?: () => void;
|
onServerReady?: () => void;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router';
|
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router';
|
||||||
import { AppFrame } from '@/components/AppFrame/AppFrame';
|
import { AppFrame } from '@/components/AppFrame/AppFrame';
|
||||||
import { AudioTab } from '@/components/AudioTab/AudioTab';
|
import { AudioTab } from '@/components/AudioTab/AudioTab';
|
||||||
|
import { EffectsTab } from '@/components/EffectsTab/EffectsTab';
|
||||||
import { MainEditor } from '@/components/MainEditor/MainEditor';
|
import { MainEditor } from '@/components/MainEditor/MainEditor';
|
||||||
import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
|
import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
|
||||||
import { ServerTab } from '@/components/ServerTab/ServerTab';
|
import { ServerTab } from '@/components/ServerTab/ServerTab';
|
||||||
@@ -8,8 +9,10 @@ import { Sidebar } from '@/components/Sidebar';
|
|||||||
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
|
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
|
||||||
import { Toaster } from '@/components/ui/toaster';
|
import { Toaster } from '@/components/ui/toaster';
|
||||||
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
|
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
|
||||||
|
import { useGenerationProgress } from '@/lib/hooks/useGenerationProgress';
|
||||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||||
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
|
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
|
||||||
|
|
||||||
// Simple platform check that works in both web and Tauri
|
// Simple platform check that works in both web and Tauri
|
||||||
const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
|
const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
|
||||||
|
|
||||||
@@ -18,6 +21,9 @@ function RootLayout() {
|
|||||||
// Monitor active downloads/generations and show toasts for them
|
// Monitor active downloads/generations and show toasts for them
|
||||||
const activeDownloads = useRestoreActiveTasks();
|
const activeDownloads = useRestoreActiveTasks();
|
||||||
|
|
||||||
|
// Subscribe to SSE for pending generations — handles completion, auto-play, and history refresh
|
||||||
|
useGenerationProgress();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppFrame>
|
<AppFrame>
|
||||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
@@ -100,6 +106,13 @@ const audioRoute = createRoute({
|
|||||||
component: AudioTab,
|
component: AudioTab,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Effects route
|
||||||
|
const effectsRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/effects',
|
||||||
|
component: EffectsTab,
|
||||||
|
});
|
||||||
|
|
||||||
// Models route
|
// Models route
|
||||||
const modelsRoute = createRoute({
|
const modelsRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
@@ -120,6 +133,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
storiesRoute,
|
storiesRoute,
|
||||||
voicesRoute,
|
voicesRoute,
|
||||||
audioRoute,
|
audioRoute,
|
||||||
|
effectsRoute,
|
||||||
modelsRoute,
|
modelsRoute,
|
||||||
serverRoute,
|
serverRoute,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { EffectConfig } from '@/lib/api/types';
|
||||||
|
|
||||||
|
interface EffectsStore {
|
||||||
|
selectedPresetId: string | null;
|
||||||
|
setSelectedPresetId: (id: string | null) => void;
|
||||||
|
|
||||||
|
// Working chain for the detail panel (editing a preset or building a new one)
|
||||||
|
workingChain: EffectConfig[];
|
||||||
|
setWorkingChain: (chain: EffectConfig[]) => void;
|
||||||
|
|
||||||
|
// Track if editing an existing preset vs creating new
|
||||||
|
isCreatingNew: boolean;
|
||||||
|
setIsCreatingNew: (v: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEffectsStore = create<EffectsStore>((set) => ({
|
||||||
|
selectedPresetId: null,
|
||||||
|
setSelectedPresetId: (id) => set({ selectedPresetId: id, isCreatingNew: false }),
|
||||||
|
|
||||||
|
workingChain: [],
|
||||||
|
setWorkingChain: (chain) => set({ workingChain: chain }),
|
||||||
|
|
||||||
|
isCreatingNew: false,
|
||||||
|
setIsCreatingNew: (v) => set({ isCreatingNew: v, ...(v && { selectedPresetId: null }) }),
|
||||||
|
}));
|
||||||
@@ -1,15 +1,58 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
interface GenerationState {
|
interface GenerationState {
|
||||||
|
/** IDs of generations currently in progress */
|
||||||
|
pendingGenerationIds: Set<string>;
|
||||||
|
/** Whether any generation is in progress (derived from pendingGenerationIds) */
|
||||||
isGenerating: boolean;
|
isGenerating: boolean;
|
||||||
activeGenerationId: string | null;
|
/** Map of generationId → storyId for deferred story additions */
|
||||||
setIsGenerating: (generating: boolean) => void;
|
pendingStoryAdds: Map<string, string>;
|
||||||
|
addPendingGeneration: (id: string) => void;
|
||||||
|
removePendingGeneration: (id: string) => void;
|
||||||
|
addPendingStoryAdd: (generationId: string, storyId: string) => void;
|
||||||
|
removePendingStoryAdd: (generationId: string) => string | undefined;
|
||||||
setActiveGenerationId: (id: string | null) => void;
|
setActiveGenerationId: (id: string | null) => void;
|
||||||
|
activeGenerationId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGenerationStore = create<GenerationState>((set) => ({
|
export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||||
|
pendingGenerationIds: new Set(),
|
||||||
isGenerating: false,
|
isGenerating: false,
|
||||||
activeGenerationId: null,
|
activeGenerationId: null,
|
||||||
setIsGenerating: (generating) => set({ isGenerating: generating }),
|
pendingStoryAdds: new Map(),
|
||||||
|
|
||||||
|
addPendingGeneration: (id) =>
|
||||||
|
set((state) => {
|
||||||
|
const next = new Set(state.pendingGenerationIds);
|
||||||
|
next.add(id);
|
||||||
|
return { pendingGenerationIds: next, isGenerating: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
removePendingGeneration: (id) =>
|
||||||
|
set((state) => {
|
||||||
|
const next = new Set(state.pendingGenerationIds);
|
||||||
|
next.delete(id);
|
||||||
|
return { pendingGenerationIds: next, isGenerating: next.size > 0 };
|
||||||
|
}),
|
||||||
|
|
||||||
|
addPendingStoryAdd: (generationId, storyId) =>
|
||||||
|
set((state) => {
|
||||||
|
const next = new Map(state.pendingStoryAdds);
|
||||||
|
next.set(generationId, storyId);
|
||||||
|
return { pendingStoryAdds: next };
|
||||||
|
}),
|
||||||
|
|
||||||
|
removePendingStoryAdd: (generationId) => {
|
||||||
|
const storyId = get().pendingStoryAdds.get(generationId);
|
||||||
|
if (storyId) {
|
||||||
|
set((state) => {
|
||||||
|
const next = new Map(state.pendingStoryAdds);
|
||||||
|
next.delete(generationId);
|
||||||
|
return { pendingStoryAdds: next };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return storyId;
|
||||||
|
},
|
||||||
|
|
||||||
setActiveGenerationId: (id) => set({ activeGenerationId: id }),
|
setActiveGenerationId: (id) => set({ activeGenerationId: id }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -13,6 +13,21 @@ interface ServerStore {
|
|||||||
|
|
||||||
keepServerRunningOnClose: boolean;
|
keepServerRunningOnClose: boolean;
|
||||||
setKeepServerRunningOnClose: (keepRunning: boolean) => void;
|
setKeepServerRunningOnClose: (keepRunning: boolean) => void;
|
||||||
|
|
||||||
|
maxChunkChars: number;
|
||||||
|
setMaxChunkChars: (value: number) => void;
|
||||||
|
|
||||||
|
crossfadeMs: number;
|
||||||
|
setCrossfadeMs: (value: number) => void;
|
||||||
|
|
||||||
|
normalizeAudio: boolean;
|
||||||
|
setNormalizeAudio: (value: boolean) => void;
|
||||||
|
|
||||||
|
autoplayOnGenerate: boolean;
|
||||||
|
setAutoplayOnGenerate: (value: boolean) => void;
|
||||||
|
|
||||||
|
customModelsDir: string | null;
|
||||||
|
setCustomModelsDir: (dir: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useServerStore = create<ServerStore>()(
|
export const useServerStore = create<ServerStore>()(
|
||||||
@@ -29,6 +44,21 @@ export const useServerStore = create<ServerStore>()(
|
|||||||
|
|
||||||
keepServerRunningOnClose: false,
|
keepServerRunningOnClose: false,
|
||||||
setKeepServerRunningOnClose: (keepRunning) => set({ keepServerRunningOnClose: keepRunning }),
|
setKeepServerRunningOnClose: (keepRunning) => set({ keepServerRunningOnClose: keepRunning }),
|
||||||
|
|
||||||
|
maxChunkChars: 800,
|
||||||
|
setMaxChunkChars: (value) => set({ maxChunkChars: value }),
|
||||||
|
|
||||||
|
crossfadeMs: 50,
|
||||||
|
setCrossfadeMs: (value) => set({ crossfadeMs: value }),
|
||||||
|
|
||||||
|
normalizeAudio: true,
|
||||||
|
setNormalizeAudio: (value) => set({ normalizeAudio: value }),
|
||||||
|
|
||||||
|
autoplayOnGenerate: true,
|
||||||
|
setAutoplayOnGenerate: (value) => set({ autoplayOnGenerate: value }),
|
||||||
|
|
||||||
|
customModelsDir: null,
|
||||||
|
setCustomModelsDir: (dir) => set({ customModelsDir: dir }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'voicebox-server',
|
name: 'voicebox-server',
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ interface UIStore {
|
|||||||
selectedProfileId: string | null;
|
selectedProfileId: string | null;
|
||||||
setSelectedProfileId: (id: string | null) => void;
|
setSelectedProfileId: (id: string | null) => void;
|
||||||
|
|
||||||
|
// Selected voice in Voices tab inspector
|
||||||
|
selectedVoiceId: string | null;
|
||||||
|
setSelectedVoiceId: (id: string | null) => void;
|
||||||
|
|
||||||
// Profile form draft (for persisting create voice modal state)
|
// Profile form draft (for persisting create voice modal state)
|
||||||
profileFormDraft: ProfileFormDraft | null;
|
profileFormDraft: ProfileFormDraft | null;
|
||||||
setProfileFormDraft: (draft: ProfileFormDraft | null) => void;
|
setProfileFormDraft: (draft: ProfileFormDraft | null) => void;
|
||||||
@@ -55,6 +59,9 @@ export const useUIStore = create<UIStore>((set) => ({
|
|||||||
selectedProfileId: null,
|
selectedProfileId: null,
|
||||||
setSelectedProfileId: (id) => set({ selectedProfileId: id }),
|
setSelectedProfileId: (id) => set({ selectedProfileId: id }),
|
||||||
|
|
||||||
|
selectedVoiceId: null,
|
||||||
|
setSelectedVoiceId: (id) => set({ selectedVoiceId: id }),
|
||||||
|
|
||||||
profileFormDraft: null,
|
profileFormDraft: null,
|
||||||
setProfileFormDraft: (draft) => set({ profileFormDraft: draft }),
|
setProfileFormDraft: (draft) => set({ profileFormDraft: draft }),
|
||||||
|
|
||||||
|
|||||||
+108
-432
@@ -1,459 +1,135 @@
|
|||||||
# voicebox Backend
|
# Voicebox Backend
|
||||||
|
|
||||||
Production-quality FastAPI backend for Qwen3-TTS voice cloning.
|
FastAPI server powering voice cloning, speech generation, and audio processing. Runs locally as a Tauri sidecar or standalone via `python -m backend.main`.
|
||||||
|
|
||||||
## Features
|
## Running
|
||||||
|
|
||||||
- ✅ **Voice Profile Management** - Create, update, delete voice profiles with multi-sample support
|
```bash
|
||||||
- ✅ **Voice Cloning** - Generate speech using voice profiles with caching
|
# Via justfile (recommended)
|
||||||
- ✅ **Generation History** - Full history tracking with search and filtering
|
just dev:server
|
||||||
- ✅ **Transcription** - Whisper-based audio transcription
|
|
||||||
- ✅ **Multi-Sample Profiles** - Combine multiple reference samples for better quality
|
# Standalone
|
||||||
- ✅ **Voice Prompt Caching** - Dual memory + disk caching for fast generation
|
python -m backend.main --host 127.0.0.1 --port 17493
|
||||||
- ✅ **Audio Validation** - Automatic validation of reference audio quality
|
|
||||||
- ✅ **Model Management** - Lazy loading and VRAM management
|
# With custom data directory
|
||||||
|
python -m backend.main --data-dir /path/to/data
|
||||||
|
```
|
||||||
|
|
||||||
|
The server auto-initializes the SQLite database on first startup. Models are downloaded from HuggingFace on first use.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/
|
backend/
|
||||||
├── main.py # FastAPI app with all routes
|
app.py # FastAPI app factory, CORS, lifecycle events
|
||||||
├── models.py # Pydantic request/response models
|
main.py # Entry point (imports app, runs uvicorn)
|
||||||
├── platform_detect.py # Platform detection for backend selection
|
config.py # Data directory paths and configuration
|
||||||
├── tts.py # TTS backend abstraction (delegates to MLX or PyTorch)
|
models.py # Pydantic request/response schemas
|
||||||
├── transcribe.py # STT backend abstraction (delegates to MLX or PyTorch)
|
server.py # Tauri sidecar launcher, parent-pid watchdog
|
||||||
├── backends/ # Backend implementations
|
|
||||||
│ ├── __init__.py # Backend factory and protocols
|
routes/ # Thin HTTP handlers — validation, delegation, response formatting
|
||||||
│ ├── mlx_backend.py # MLX backend (Apple Silicon)
|
services/ # Business logic, CRUD, orchestration
|
||||||
│ └── pytorch_backend.py # PyTorch backend (Windows/Linux/Intel)
|
backends/ # TTS/STT engine implementations (MLX, PyTorch, etc.)
|
||||||
├── profiles.py # Voice profile CRUD
|
database/ # ORM models, session management, migrations, seed data
|
||||||
├── history.py # Generation history
|
utils/ # Shared utilities (audio, effects, caching, progress tracking)
|
||||||
├── studio.py # Audio editing (TODO)
|
|
||||||
├── database.py # SQLite ORM
|
|
||||||
└── utils/
|
|
||||||
├── audio.py # Audio processing utilities
|
|
||||||
├── cache.py # Voice prompt caching
|
|
||||||
└── validation.py # Input validation
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend Selection
|
### Request flow
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
#### `GET /`
|
|
||||||
Root endpoint with version info.
|
|
||||||
|
|
||||||
#### `GET /health`
|
|
||||||
Health check with model status.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "healthy",
|
|
||||||
"model_loaded": true,
|
|
||||||
"gpu_available": true,
|
|
||||||
"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.
|
|
||||||
|
|
||||||
**Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "My Voice",
|
|
||||||
"description": "Optional description",
|
|
||||||
"language": "en"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "uuid",
|
|
||||||
"name": "My Voice",
|
|
||||||
"description": "Optional description",
|
|
||||||
"language": "en",
|
|
||||||
"created_at": "2024-01-01T00:00:00Z",
|
|
||||||
"updated_at": "2024-01-01T00:00:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `GET /profiles`
|
|
||||||
List all voice profiles.
|
|
||||||
|
|
||||||
#### `GET /profiles/{profile_id}`
|
|
||||||
Get a specific profile.
|
|
||||||
|
|
||||||
#### `PUT /profiles/{profile_id}`
|
|
||||||
Update a profile.
|
|
||||||
|
|
||||||
#### `DELETE /profiles/{profile_id}`
|
|
||||||
Delete a profile and all associated samples.
|
|
||||||
|
|
||||||
#### `POST /profiles/{profile_id}/samples`
|
|
||||||
Add a sample to a profile.
|
|
||||||
|
|
||||||
**Form Data:**
|
|
||||||
- `file`: Audio file (WAV, MP3, etc.)
|
|
||||||
- `reference_text`: Transcript of the audio
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "sample-uuid",
|
|
||||||
"profile_id": "profile-uuid",
|
|
||||||
"audio_path": "/path/to/sample.wav",
|
|
||||||
"reference_text": "This is my voice"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `GET /profiles/{profile_id}/samples`
|
|
||||||
List all samples for a profile.
|
|
||||||
|
|
||||||
#### `DELETE /profiles/samples/{sample_id}`
|
|
||||||
Delete a specific sample.
|
|
||||||
|
|
||||||
### Generation
|
|
||||||
|
|
||||||
#### `POST /generate`
|
|
||||||
Generate speech from text using a voice profile.
|
|
||||||
|
|
||||||
**Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"profile_id": "uuid",
|
|
||||||
"text": "Hello, this is a test.",
|
|
||||||
"language": "en",
|
|
||||||
"seed": 42
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "generation-uuid",
|
|
||||||
"profile_id": "profile-uuid",
|
|
||||||
"text": "Hello, this is a test.",
|
|
||||||
"language": "en",
|
|
||||||
"audio_path": "/path/to/audio.wav",
|
|
||||||
"duration": 2.5,
|
|
||||||
"seed": 42,
|
|
||||||
"created_at": "2024-01-01T00:00:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### History
|
|
||||||
|
|
||||||
#### `GET /history`
|
|
||||||
List generation history with optional filters.
|
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
- `profile_id` (optional): Filter by profile
|
|
||||||
- `search` (optional): Search in text content
|
|
||||||
- `limit` (default: 50): Results per page
|
|
||||||
- `offset` (default: 0): Pagination offset
|
|
||||||
|
|
||||||
#### `GET /history/{generation_id}`
|
|
||||||
Get a specific generation.
|
|
||||||
|
|
||||||
#### `DELETE /history/{generation_id}`
|
|
||||||
Delete a generation.
|
|
||||||
|
|
||||||
#### `GET /history/stats`
|
|
||||||
Get generation statistics.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"total_generations": 100,
|
|
||||||
"total_duration_seconds": 250.5,
|
|
||||||
"generations_by_profile": {
|
|
||||||
"profile-uuid-1": 50,
|
|
||||||
"profile-uuid-2": 50
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Audio Files
|
|
||||||
|
|
||||||
#### `GET /audio/{generation_id}`
|
|
||||||
Download generated audio file.
|
|
||||||
|
|
||||||
Returns WAV file with appropriate headers.
|
|
||||||
|
|
||||||
### Transcription
|
|
||||||
|
|
||||||
#### `POST /transcribe`
|
|
||||||
Transcribe audio file to text.
|
|
||||||
|
|
||||||
**Form Data:**
|
|
||||||
- `file`: Audio file
|
|
||||||
- `language` (optional): Language hint (en or zh)
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"text": "Transcribed text here",
|
|
||||||
"duration": 5.5
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Model Management
|
|
||||||
|
|
||||||
#### `POST /models/load`
|
|
||||||
Manually load TTS model.
|
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
- `model_size`: Model size (1.7B or 0.6B)
|
|
||||||
|
|
||||||
#### `POST /models/unload`
|
|
||||||
Unload TTS model to free memory.
|
|
||||||
|
|
||||||
## Database Schema
|
|
||||||
|
|
||||||
### profiles
|
|
||||||
- `id`: UUID primary key
|
|
||||||
- `name`: Profile name (unique)
|
|
||||||
- `description`: Optional description
|
|
||||||
- `language`: Language code (en/zh)
|
|
||||||
- `created_at`: Creation timestamp
|
|
||||||
- `updated_at`: Last update timestamp
|
|
||||||
|
|
||||||
### profile_samples
|
|
||||||
- `id`: UUID primary key
|
|
||||||
- `profile_id`: Foreign key to profiles
|
|
||||||
- `audio_path`: Path to audio file
|
|
||||||
- `reference_text`: Transcript
|
|
||||||
|
|
||||||
### generations
|
|
||||||
- `id`: UUID primary key
|
|
||||||
- `profile_id`: Foreign key to profiles
|
|
||||||
- `text`: Generated text
|
|
||||||
- `language`: Language code
|
|
||||||
- `audio_path`: Path to audio file
|
|
||||||
- `duration`: Duration in seconds
|
|
||||||
- `seed`: Random seed (optional)
|
|
||||||
- `created_at`: Creation timestamp
|
|
||||||
|
|
||||||
### projects
|
|
||||||
- `id`: UUID primary key
|
|
||||||
- `name`: Project name
|
|
||||||
- `data`: JSON data
|
|
||||||
- `created_at`: Creation timestamp
|
|
||||||
- `updated_at`: Last update timestamp
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
```
|
||||||
data/
|
HTTP request
|
||||||
├── profiles/
|
-> routes/ (validate input, parse params)
|
||||||
│ └── {profile_id}/
|
-> services/ (business logic, database queries, orchestration)
|
||||||
│ ├── {sample_id}.wav
|
-> backends/ (TTS/STT inference)
|
||||||
│ └── ...
|
-> utils/ (audio processing, effects, caching)
|
||||||
├── generations/
|
|
||||||
│ └── {generation_id}.wav
|
|
||||||
├── cache/
|
|
||||||
│ └── {hash}.prompt
|
|
||||||
├── projects/
|
|
||||||
│ └── {project_id}.json
|
|
||||||
└── voicebox.db
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Setup
|
Route handlers are intentionally thin. They validate input, delegate to a service function, and format the response. All business logic lives in `services/`.
|
||||||
|
|
||||||
### 1. Install Dependencies
|
### Key modules
|
||||||
|
|
||||||
|
**services/generation.py** -- Single `run_generation()` function that handles all three generation modes (generate, retry, regenerate). Manages model loading, voice prompt creation, chunked inference, normalization, effects, and version persistence.
|
||||||
|
|
||||||
|
**services/task_queue.py** -- Serial generation queue. Ensures only one GPU inference runs at a time. Background tasks are tracked to prevent garbage collection.
|
||||||
|
|
||||||
|
**backends/__init__.py** -- Protocol definitions (`TTSBackend`, `STTBackend`), model config registry, and factory functions. Adding a new engine means implementing the protocol and registering a config entry.
|
||||||
|
|
||||||
|
**backends/base.py** -- Shared utilities used across all engine implementations: HuggingFace cache checks, device detection, voice prompt combination, progress tracking.
|
||||||
|
|
||||||
|
**database/** -- SQLAlchemy ORM models with a re-exporting `__init__.py` for backward compatibility. Migrations run automatically on startup.
|
||||||
|
|
||||||
|
### Backend selection
|
||||||
|
|
||||||
|
The server detects the best inference backend at startup:
|
||||||
|
|
||||||
|
| Platform | Backend | Acceleration |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| macOS (Apple Silicon) | MLX | Metal / Neural Engine |
|
||||||
|
| Windows / Linux (NVIDIA) | PyTorch | CUDA |
|
||||||
|
| Linux (AMD) | PyTorch | ROCm |
|
||||||
|
| Intel Arc | PyTorch | IPEX / XPU |
|
||||||
|
| Windows (any GPU) | PyTorch | DirectML |
|
||||||
|
| Any | PyTorch | CPU fallback |
|
||||||
|
|
||||||
|
Detection is handled by `utils/platform_detect.py`. Both backends implement the same `TTSBackend` protocol, so the API layer is engine-agnostic.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
90 endpoints organized by domain. Full interactive documentation available at `http://localhost:17493/docs` when the server is running.
|
||||||
|
|
||||||
|
| Domain | Prefix | Description |
|
||||||
|
|--------|--------|-------------|
|
||||||
|
| Health | `/`, `/health` | Server status, GPU info, filesystem checks |
|
||||||
|
| Profiles | `/profiles` | Voice profile CRUD, samples, avatars, import/export |
|
||||||
|
| Channels | `/channels` | Audio channel management and voice assignment |
|
||||||
|
| Generation | `/generate` | TTS generation, retry, regenerate, status SSE |
|
||||||
|
| History | `/history` | Generation history, search, favorites, export |
|
||||||
|
| Transcription | `/transcribe` | Whisper-based audio-to-text |
|
||||||
|
| Stories | `/stories` | Multi-track timeline editor, audio export |
|
||||||
|
| Effects | `/effects` | Effect presets, preview, version management |
|
||||||
|
| Audio | `/audio`, `/samples` | Audio file serving |
|
||||||
|
| Models | `/models` | Load, unload, download, migrate, status |
|
||||||
|
| Tasks | `/tasks`, `/cache` | Active task tracking, cache management |
|
||||||
|
| CUDA | `/backend/cuda-*` | CUDA binary download and management |
|
||||||
|
|
||||||
|
### Quick examples
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
# Generate speech
|
||||||
```
|
curl -X POST http://localhost:17493/generate \
|
||||||
|
|
||||||
**Note:** On Apple Silicon, also install MLX dependencies for faster inference:
|
|
||||||
```bash
|
|
||||||
pip install -r requirements-mlx.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Download Models (Automatic)
|
|
||||||
|
|
||||||
The Qwen3-TTS models are automatically downloaded from HuggingFace Hub on first use, similar to how Whisper models work.
|
|
||||||
|
|
||||||
**No manual download required!** The models will be cached locally after the first download.
|
|
||||||
|
|
||||||
Available models:
|
|
||||||
- **1.7B** (recommended): `Qwen/Qwen3-TTS-12Hz-1.7B-Base` (~4GB)
|
|
||||||
- **0.6B** (faster): `Qwen/Qwen3-TTS-12Hz-0.6B-Base` (~2GB)
|
|
||||||
|
|
||||||
**Note:** The first generation will take longer as the model downloads. Subsequent generations will use the cached model.
|
|
||||||
|
|
||||||
#### Manual Download (Optional)
|
|
||||||
|
|
||||||
If you prefer to download models manually or have limited internet during runtime:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install huggingface-cli
|
|
||||||
pip install huggingface_hub
|
|
||||||
|
|
||||||
# Download 1.7B model
|
|
||||||
huggingface-cli download Qwen/Qwen3-TTS-12Hz-1.7B-Base
|
|
||||||
|
|
||||||
# Or use Python
|
|
||||||
python -c "from huggingface_hub import snapshot_download; snapshot_download('Qwen/Qwen3-TTS-12Hz-1.7B-Base')"
|
|
||||||
```
|
|
||||||
|
|
||||||
Models are cached in `~/.cache/huggingface/hub/` by default.
|
|
||||||
|
|
||||||
### 4. Run Server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Development (local only)
|
|
||||||
python -m backend.main
|
|
||||||
|
|
||||||
# Production (allow remote access)
|
|
||||||
python -m backend.main --host 0.0.0.0 --port 8000
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
|
|
||||||
### Creating a Voice Profile
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Create profile
|
|
||||||
curl -X POST http://localhost:8000/profiles \
|
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"name": "My Voice", "language": "en"}'
|
-d '{"text": "Hello world", "profile_id": "...", "language": "en"}'
|
||||||
|
|
||||||
# Response: {"id": "abc-123", ...}
|
# List profiles
|
||||||
|
curl http://localhost:17493/profiles
|
||||||
|
|
||||||
# 2. Add sample
|
# Stream generation status (SSE)
|
||||||
curl -X POST http://localhost:8000/profiles/abc-123/samples \
|
curl http://localhost:17493/generate/{id}/status
|
||||||
-F "file=@sample.wav" \
|
|
||||||
-F "reference_text=This is my voice sample"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Generating Speech
|
## Data directory
|
||||||
|
|
||||||
|
```
|
||||||
|
{data_dir}/
|
||||||
|
voicebox.db # SQLite database
|
||||||
|
profiles/{id}/ # Voice samples per profile
|
||||||
|
generations/ # Generated audio files
|
||||||
|
cache/ # Voice prompt cache (memory + disk)
|
||||||
|
backends/ # Downloaded CUDA binary (if applicable)
|
||||||
|
```
|
||||||
|
|
||||||
|
Default location is the OS-specific app data directory. Override with `--data-dir` or the `VOICEBOX_DATA_DIR` environment variable.
|
||||||
|
|
||||||
|
## Code quality
|
||||||
|
|
||||||
|
Linting and formatting are enforced by [ruff](https://docs.astral.sh/ruff/), configured in `pyproject.toml`. See `STYLE_GUIDE.md` for conventions.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8000/generate \
|
just check-python # lint + format check
|
||||||
-H "Content-Type: application/json" \
|
just fix-python # auto-fix lint issues + reformat
|
||||||
-d '{
|
just test # run pytest
|
||||||
"profile_id": "abc-123",
|
|
||||||
"text": "Hello, this is a test.",
|
|
||||||
"language": "en",
|
|
||||||
"seed": 42
|
|
||||||
}'
|
|
||||||
|
|
||||||
# Response: {"id": "gen-456", "audio_path": "/path/to/audio.wav", ...}
|
|
||||||
|
|
||||||
# Download audio
|
|
||||||
curl http://localhost:8000/audio/gen-456 -o output.wav
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Transcribing Audio
|
## Dependencies
|
||||||
|
|
||||||
```bash
|
Runtime dependencies are in `requirements.txt`. macOS-only MLX dependencies are in `requirements-mlx.txt`. Dev tools (ruff, pytest) are installed automatically by `just setup-python`.
|
||||||
curl -X POST http://localhost:8000/transcribe \
|
|
||||||
-F "file=@audio.wav" \
|
|
||||||
-F "language=en"
|
|
||||||
|
|
||||||
# Response: {"text": "Transcribed text", "duration": 5.5}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Advanced Features
|
|
||||||
|
|
||||||
### Multi-Sample Profiles
|
|
||||||
|
|
||||||
Add multiple samples to a profile for better quality:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Add first sample
|
|
||||||
curl -X POST http://localhost:8000/profiles/abc-123/samples \
|
|
||||||
-F "file=@sample1.wav" \
|
|
||||||
-F "reference_text=First sample"
|
|
||||||
|
|
||||||
# Add second sample
|
|
||||||
curl -X POST http://localhost:8000/profiles/abc-123/samples \
|
|
||||||
-F "file=@sample2.wav" \
|
|
||||||
-F "reference_text=Second sample"
|
|
||||||
|
|
||||||
# Generation will automatically combine all samples
|
|
||||||
```
|
|
||||||
|
|
||||||
### Voice Prompt Caching
|
|
||||||
|
|
||||||
Voice prompts are automatically cached for faster generation:
|
|
||||||
- First generation: ~5-10 seconds (creates prompt)
|
|
||||||
- Subsequent generations: ~1-2 seconds (uses cached prompt)
|
|
||||||
|
|
||||||
Cache is stored in `data/cache/` and persists across server restarts.
|
|
||||||
|
|
||||||
### VRAM Management
|
|
||||||
|
|
||||||
Models are lazy-loaded and can be manually unloaded:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Unload TTS model
|
|
||||||
curl -X POST http://localhost:8000/models/unload
|
|
||||||
|
|
||||||
# Load specific model size
|
|
||||||
curl -X POST "http://localhost:8000/models/load?model_size=0.6B"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
All endpoints return proper HTTP status codes:
|
|
||||||
|
|
||||||
- `200 OK`: Success
|
|
||||||
- `400 Bad Request`: Invalid input
|
|
||||||
- `404 Not Found`: Resource not found
|
|
||||||
- `500 Internal Server Error`: Server error
|
|
||||||
|
|
||||||
Error responses include details:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"detail": "Profile not found"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Tips
|
|
||||||
|
|
||||||
1. **Use multi-sample profiles** - Better quality than single sample
|
|
||||||
2. **Let caching work** - Voice prompts are cached automatically
|
|
||||||
3. **Use 0.6B model on CPU** - Faster than 1.7B with acceptable quality
|
|
||||||
4. **Use 1.7B model on GPU** - Best quality, still fast
|
|
||||||
5. **Unload Whisper after transcription** - Frees VRAM for TTS
|
|
||||||
|
|
||||||
## TODO
|
|
||||||
|
|
||||||
- [ ] WebSocket support for generation progress
|
|
||||||
- [ ] Batch generation endpoint
|
|
||||||
- [ ] Audio effects (M3GAN, etc.)
|
|
||||||
- [ ] Voice design (text-to-voice)
|
|
||||||
- [ ] Audio studio timeline features
|
|
||||||
- [ ] Project management
|
|
||||||
- [ ] Authentication & rate limiting
|
|
||||||
- [ ] Export/import profiles
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
See main project LICENSE.
|
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
# Python Style Guide
|
||||||
|
|
||||||
|
Target: **Python 3.12+** | Formatter/Linter: **Ruff** | Config: `backend/pyproject.toml`
|
||||||
|
|
||||||
|
This guide codifies the conventions used across the backend, and prescribes the target style for code written during the refactor (Phases 3-6). Existing code should be migrated incrementally -- don't reformat entire files in unrelated PRs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Formatting
|
||||||
|
|
||||||
|
Enforced by `ruff format` (Black-compatible).
|
||||||
|
|
||||||
|
- **Line length**: 120 characters.
|
||||||
|
- **Indent**: 4 spaces. No tabs.
|
||||||
|
- **Trailing commas**: Required on multi-line function signatures, arguments, collections.
|
||||||
|
- **Quotes**: Double quotes (`"`) for strings. Single quotes are acceptable in f-string expressions and dict keys inside f-strings where avoiding escapes improves readability.
|
||||||
|
|
||||||
|
Run: `ruff format backend/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Imports
|
||||||
|
|
||||||
|
Enforced by ruff's `isort` rules (rule set `I`).
|
||||||
|
|
||||||
|
**Grouping** -- three blocks separated by a blank line:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio # 1. stdlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np # 2. third-party
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.config import get_data_dir # 3. local (absolute)
|
||||||
|
from .database import get_db # or relative
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- Within the `backend` package, use **relative imports** for sibling/child modules: `from .database import get_db`, `from ..utils.audio import load_audio`.
|
||||||
|
- Absolute imports are fine for top-level references from entry points (`main.py`, `server.py`).
|
||||||
|
- Never use wildcard imports (`from module import *`).
|
||||||
|
- One import per line for `from X import Y` when there are 4+ names; below that, comma-separated is fine.
|
||||||
|
- **Lazy imports** are acceptable for heavy dependencies (torch, transformers, mlx) inside functions to reduce startup time. Add a comment: `# lazy: heavy import`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Type Annotations
|
||||||
|
|
||||||
|
Python 3.12 means we use **built-in generics and union syntax natively**. No `from __future__ import annotations`, no `typing.List`/`typing.Dict`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Yes
|
||||||
|
def process(items: list[str], config: dict[str, int] | None = None) -> tuple[int, str]: ...
|
||||||
|
|
||||||
|
# No
|
||||||
|
from typing import List, Dict, Optional, Tuple
|
||||||
|
def process(items: List[str], config: Optional[Dict[str, int]] = None) -> Tuple[int, str]: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**What to annotate:**
|
||||||
|
- All public function signatures (parameters + return type).
|
||||||
|
- Private functions: parameters at minimum; return type encouraged.
|
||||||
|
- Module-level variables: only when the type isn't obvious from the assignment.
|
||||||
|
- Route handlers: parameters are annotated via FastAPI's dependency injection. Add explicit `-> SomeResponse` return types when the route doesn't use `response_model`.
|
||||||
|
|
||||||
|
**Imports from `typing` that are still needed** (no built-in equivalent):
|
||||||
|
`Literal`, `TypeAlias`, `Protocol`, `runtime_checkable`, `Callable`, `Any`, `ClassVar`, `TypeVar`, `overload`, `TYPE_CHECKING`.
|
||||||
|
|
||||||
|
Use `collections.abc` for abstract types: `Sequence`, `Mapping`, `Iterable`, `Iterator`, `Generator`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Naming
|
||||||
|
|
||||||
|
| Thing | Convention | Example |
|
||||||
|
|-------|-----------|---------|
|
||||||
|
| Module | `snake_case` | `task_queue.py` |
|
||||||
|
| Class | `PascalCase` | `ProgressManager` |
|
||||||
|
| Function / method | `snake_case` | `create_profile` |
|
||||||
|
| Variable | `snake_case` | `sample_rate` |
|
||||||
|
| Constant | `UPPER_SNAKE_CASE` | `DEFAULT_SAMPLE_RATE` |
|
||||||
|
| Private | `_leading_underscore` | `_generation_queue` |
|
||||||
|
| Type alias | `PascalCase` | `EffectChain = list[dict[str, Any]]` |
|
||||||
|
|
||||||
|
**Specific conventions:**
|
||||||
|
- Database ORM models imported with `DB` prefix alias: `from .database import VoiceProfile as DBVoiceProfile`.
|
||||||
|
- Pydantic models use descriptive suffixes: `VoiceProfileCreate`, `VoiceProfileResponse`, `GenerationRequest`.
|
||||||
|
- Backend classes use engine-name prefix: `MLXTTSBackend`, `PyTorchSTTBackend`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docstrings
|
||||||
|
|
||||||
|
**Google style**. Required on all public functions, classes, and modules.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def combine_voice_prompts(
|
||||||
|
profile_dir: Path,
|
||||||
|
*,
|
||||||
|
target_sr: int = 24000,
|
||||||
|
) -> tuple[np.ndarray, int]:
|
||||||
|
"""Load and concatenate all voice prompt files for a profile.
|
||||||
|
|
||||||
|
Reads .wav/.mp3/.flac files from the profile directory, resamples to
|
||||||
|
the target sample rate, normalizes, and concatenates into a single array.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile_dir: Path to the voice profile directory containing audio files.
|
||||||
|
target_sr: Target sample rate for the output. Defaults to 24000.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (concatenated audio array, sample rate).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If profile_dir does not exist.
|
||||||
|
ValueError: If no valid audio files are found.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Short form** is fine for simple functions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_db_path() -> Path:
|
||||||
|
"""Get the path to the SQLite database file."""
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to skip**: Private helpers under ~5 lines where the name and signature make intent obvious.
|
||||||
|
|
||||||
|
**Module docstrings**: A single sentence at the top of every file describing its purpose.
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Voice profile CRUD operations."""
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
Comments explain **why**, not **what**. If the code needs a comment to explain what it does, the code should be rewritten to be clearer. The exceptions are non-obvious performance choices, external constraints, and concurrency/race-condition reasoning -- those always deserve a comment.
|
||||||
|
|
||||||
|
### No section dividers
|
||||||
|
|
||||||
|
Do not use ASCII dividers to create visual sections in files:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# No -- any of these:
|
||||||
|
# ============================================
|
||||||
|
# GENERATION ENDPOINTS
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Device detection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# --- Load model --------------------------------------------------
|
||||||
|
```
|
||||||
|
|
||||||
|
If a file needs section dividers to be navigable, the file is too long. Split it into modules. Within a function, if you need labeled sections to follow the logic, extract those sections into named functions.
|
||||||
|
|
||||||
|
### Inline comments
|
||||||
|
|
||||||
|
Inline comments (end-of-line) are fine when they add information the code can't express:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Yes -- explains a non-obvious constraint or gives context:
|
||||||
|
audio, sr = load_audio(path, sr=24000) # Qwen expects 24kHz mono
|
||||||
|
_generation_queue: asyncio.Queue = None # type: ignore # initialized at startup
|
||||||
|
"tauri://localhost", # Tauri webview (macOS)
|
||||||
|
|
||||||
|
# No -- restates the code:
|
||||||
|
# Check if profile name already exists
|
||||||
|
existing = db.query(DBVoiceProfile).filter_by(name=data.name).first()
|
||||||
|
|
||||||
|
# Delete from database
|
||||||
|
db.delete(sample)
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
profile.name = data.name
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete comments that narrate what the next line of code obviously does. If the function name, variable name, or method call already communicates intent, the comment is noise.
|
||||||
|
|
||||||
|
### Block comments
|
||||||
|
|
||||||
|
Use block comments for **why** explanations -- constraints, workarounds, non-obvious decisions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# PyInstaller + multiprocessing: child processes re-execute the frozen binary
|
||||||
|
# with internal arguments. freeze_support() handles this and exits early.
|
||||||
|
multiprocessing.freeze_support()
|
||||||
|
|
||||||
|
# Mark any stale "generating" records as failed -- these are leftovers
|
||||||
|
# from a previous process that was killed mid-generation.
|
||||||
|
db.query(Generation).filter_by(status="generating").update({"status": "failed"})
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep block comments tight. Two to three lines is normal. If you need a paragraph, it probably belongs in the docstring or a design doc.
|
||||||
|
|
||||||
|
### Linter/type-checker suppression
|
||||||
|
|
||||||
|
Always add a reason after `noqa` and `type: ignore`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import intel_extension_for_pytorch # noqa: F401 -- side-effect import enables XPU
|
||||||
|
_queue: asyncio.Queue = None # type: ignore[assignment] # initialized at startup
|
||||||
|
```
|
||||||
|
|
||||||
|
Bare `# noqa` or `# type: ignore` with no explanation are not allowed.
|
||||||
|
|
||||||
|
### TODO / FIXME
|
||||||
|
|
||||||
|
Use sparingly. Every `TODO` must include a brief description of what needs doing. Don't use them as a substitute for tracking work properly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# TODO: replace with async SQLAlchemy once CRUD modules are migrated (Phase 5)
|
||||||
|
result = await asyncio.to_thread(profiles.get_profile, profile_id, db)
|
||||||
|
```
|
||||||
|
|
||||||
|
Never commit `HACK`, `XXX`, or `FIXME` -- fix the problem or file an issue.
|
||||||
|
|
||||||
|
### Commented-out code
|
||||||
|
|
||||||
|
Delete it. That's what git is for. If you need to document that something was intentionally removed, a short tombstone comment is acceptable:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Removed config.json-only check -- too lenient, doesn't confirm weights exist.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The refactor is standardizing on a **two-layer pattern**:
|
||||||
|
|
||||||
|
### 1. Domain layer -- raise plain exceptions
|
||||||
|
|
||||||
|
CRUD modules and services raise `ValueError`, `FileNotFoundError`, or (post-refactor) custom exceptions defined in `backend/errors.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/errors.py (to be created in Phase 4)
|
||||||
|
class NotFoundError(Exception):
|
||||||
|
"""Raised when a requested resource does not exist."""
|
||||||
|
|
||||||
|
class ConflictError(Exception):
|
||||||
|
"""Raised on uniqueness constraint violations."""
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In a service or CRUD module:
|
||||||
|
raise NotFoundError(f"Profile {profile_id} not found")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Route layer -- translate to HTTPException
|
||||||
|
|
||||||
|
Route handlers catch domain exceptions and convert:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.post("/profiles")
|
||||||
|
async def create_profile(data: VoiceProfileCreate, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
return await profiles.create_profile(data, db)
|
||||||
|
except ConflictError as e:
|
||||||
|
raise HTTPException(status_code=409, detail=str(e))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Background tasks** catch `Exception` broadly, log with `logger.exception()`, and update the task status to `"failed"`.
|
||||||
|
|
||||||
|
**Never**: silently swallow exceptions, use bare `except:`, or catch `BaseException`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Async
|
||||||
|
|
||||||
|
### Rules for the refactor
|
||||||
|
|
||||||
|
1. **Don't declare `async def` unless the function awaits something.** Several service modules still declare `async def` without awaiting -- these should be migrated to sync functions with `asyncio.to_thread()` at the route layer, or to real async SQLAlchemy.
|
||||||
|
2. **CPU-bound work** (audio processing, numpy operations) goes through `asyncio.to_thread()`:
|
||||||
|
```python
|
||||||
|
audio, sr = await asyncio.to_thread(load_audio, source_path)
|
||||||
|
```
|
||||||
|
3. **GPU-bound TTS inference** is serialized through the generation queue (`services/task_queue.py`). Never call a backend's `generate()` directly from a route handler.
|
||||||
|
4. **Fire-and-forget tasks**: use `asyncio.create_task()` and track the task reference to prevent garbage collection:
|
||||||
|
```python
|
||||||
|
task = asyncio.create_task(some_coro())
|
||||||
|
_background_tasks.add(task)
|
||||||
|
task.add_done_callback(_background_tasks.discard)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Use the `logging` module. Not `print()`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
logger.info("Loading model %s on %s", model_name, device)
|
||||||
|
logger.warning("Cache miss for %s, downloading", repo_id)
|
||||||
|
logger.exception("Generation %s failed") # logs traceback automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- Use `%s`-style placeholders in log calls (not f-strings). This avoids formatting the string if the log level is filtered out.
|
||||||
|
- Use `logger.exception()` inside `except` blocks -- it captures the traceback.
|
||||||
|
- Logger name should be `__name__` (yields `backend.utils.audio`, etc.).
|
||||||
|
- Existing `print()` calls should be migrated to logging as files are touched during the refactor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Constants
|
||||||
|
|
||||||
|
- Define at **module level** in the file where they're primarily used.
|
||||||
|
- Use `UPPER_SNAKE_CASE`.
|
||||||
|
- Shared/cross-cutting constants (sample rates, file size limits, CORS origins) go in `backend/config.py` after Phase 6 consolidation.
|
||||||
|
- Magic numbers in function bodies should be extracted to named constants:
|
||||||
|
```python
|
||||||
|
# No
|
||||||
|
if len(audio) > 24000 * 60 * 10:
|
||||||
|
|
||||||
|
# Yes
|
||||||
|
MAX_AUDIO_DURATION_SAMPLES = SAMPLE_RATE * 60 * 10
|
||||||
|
if len(audio) > MAX_AUDIO_DURATION_SAMPLES:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Function Signatures
|
||||||
|
|
||||||
|
- **Keyword-only arguments** (after `*`) for functions with 3+ parameters, especially when several share the same type:
|
||||||
|
```python
|
||||||
|
def is_model_cached(
|
||||||
|
hf_repo: str,
|
||||||
|
*,
|
||||||
|
weight_extensions: tuple[str, ...] = (".safetensors", ".bin"),
|
||||||
|
required_files: list[str] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
```
|
||||||
|
- Parameters on **separate lines** when the signature exceeds ~100 characters or has 3+ params.
|
||||||
|
- **Trailing comma** after the last parameter in multi-line signatures.
|
||||||
|
- Default values inline with the parameter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## String Formatting
|
||||||
|
|
||||||
|
- **f-strings** for runtime string construction.
|
||||||
|
- **`%s`-style** for `logging` calls (lazy evaluation).
|
||||||
|
- **`.format()`**: avoid; f-strings are preferred.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Framework: **pytest** with `pytest-asyncio`.
|
||||||
|
|
||||||
|
- Test files: `test_<module>.py` in `backend/tests/`.
|
||||||
|
- Use `conftest.py` for shared fixtures (db sessions, test client, mock backends).
|
||||||
|
- Group related tests in classes: `class TestProfileCRUD:`.
|
||||||
|
- Use `@pytest.mark.asyncio` for async tests.
|
||||||
|
- Use `@pytest.mark.parametrize` to reduce repetition.
|
||||||
|
- Manual integration scripts stay in `tests/` but are clearly marked (filename prefix `manual_` or documented in `tests/README.md`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
app.py # FastAPI app factory, CORS, lifecycle events
|
||||||
|
main.py # Entry point (imports app, runs uvicorn)
|
||||||
|
config.py # Data directory paths
|
||||||
|
models.py # Pydantic request/response schemas
|
||||||
|
server.py # Tauri sidecar launcher, parent-pid watchdog
|
||||||
|
routes/ # Thin HTTP handlers (validation, delegation, response formatting)
|
||||||
|
services/ # Business logic, CRUD, orchestration
|
||||||
|
backends/ # TTS/STT engine implementations
|
||||||
|
database/ # ORM models, session management, migrations, seeds
|
||||||
|
utils/ # Shared utilities (audio, effects, caching, progress)
|
||||||
|
tests/ # pytest suite
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ruff Adoption
|
||||||
|
|
||||||
|
`pyproject.toml` configures ruff for linting and formatting. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lint (check)
|
||||||
|
ruff check backend/
|
||||||
|
|
||||||
|
# Lint (auto-fix)
|
||||||
|
ruff check backend/ --fix
|
||||||
|
|
||||||
|
# Format
|
||||||
|
ruff format backend/
|
||||||
|
```
|
||||||
|
|
||||||
|
Introduce ruff fixes file-by-file as you touch them. Don't run `--fix` across the entire codebase in one shot -- that creates unreviewable diffs.
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
# Backend package
|
# Backend package
|
||||||
|
|
||||||
__version__ = "0.1.11"
|
__version__ = "0.2.3"
|
||||||
|
|||||||
+215
@@ -0,0 +1,215 @@
|
|||||||
|
"""FastAPI application factory, middleware, and lifecycle events."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class ColoredFormatter(logging.Formatter):
|
||||||
|
"""Custom formatter to add colors matching uvicorn's style."""
|
||||||
|
|
||||||
|
COLORS = {
|
||||||
|
"DEBUG": "\033[36m", # Cyan
|
||||||
|
"INFO": "\033[32m", # Green
|
||||||
|
"WARNING": "\033[33m", # Yellow
|
||||||
|
"ERROR": "\033[31m", # Red
|
||||||
|
"CRITICAL": "\033[35m", # Magenta
|
||||||
|
}
|
||||||
|
RESET = "\033[0m"
|
||||||
|
|
||||||
|
def format(self, record):
|
||||||
|
log_color = self.COLORS.get(record.levelname, self.RESET)
|
||||||
|
record.levelname = f"{log_color}{record.levelname}{self.RESET}"
|
||||||
|
return super().format(record)
|
||||||
|
|
||||||
|
|
||||||
|
# Configure logging to match uvicorn's format with colors
|
||||||
|
handler = logging.StreamHandler(sys.stderr)
|
||||||
|
handler.setFormatter(ColoredFormatter("%(levelname)s: %(message)s"))
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
handlers=[handler],
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# AMD GPU environment variables must be set before torch import
|
||||||
|
if not os.environ.get("HSA_OVERRIDE_GFX_VERSION"):
|
||||||
|
os.environ["HSA_OVERRIDE_GFX_VERSION"] = "10.3.0"
|
||||||
|
if not os.environ.get("MIOPEN_LOG_LEVEL"):
|
||||||
|
os.environ["MIOPEN_LOG_LEVEL"] = "4"
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from . import __version__, config, database
|
||||||
|
from .services import tts, transcribe
|
||||||
|
from .database import get_db
|
||||||
|
from .utils.platform_detect import get_backend_type
|
||||||
|
from .utils.progress import get_progress_manager
|
||||||
|
from .services.task_queue import create_background_task, init_queue
|
||||||
|
from .routes import register_routers
|
||||||
|
|
||||||
|
|
||||||
|
def safe_content_disposition(disposition_type: str, filename: str) -> str:
|
||||||
|
"""Build a Content-Disposition header safe for non-ASCII filenames.
|
||||||
|
|
||||||
|
Uses RFC 5987 ``filename*`` parameter so browsers can decode UTF-8
|
||||||
|
filenames while the ``filename`` fallback stays ASCII-only.
|
||||||
|
"""
|
||||||
|
ascii_name = "".join(c for c in filename if c.isascii() and (c.isalnum() or c in " -_.")).strip() or "download"
|
||||||
|
utf8_name = quote(filename, safe="")
|
||||||
|
return f"{disposition_type}; filename=\"{ascii_name}\"; filename*=UTF-8''{utf8_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
"""Create and configure the FastAPI application."""
|
||||||
|
application = FastAPI(
|
||||||
|
title="voicebox API",
|
||||||
|
description="Production-quality Qwen3-TTS voice cloning API",
|
||||||
|
version=__version__,
|
||||||
|
)
|
||||||
|
|
||||||
|
_configure_cors(application)
|
||||||
|
register_routers(application)
|
||||||
|
_register_lifecycle(application)
|
||||||
|
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_cors(application: FastAPI) -> None:
|
||||||
|
"""Set up CORS middleware with local-first defaults."""
|
||||||
|
default_origins = [
|
||||||
|
"http://localhost:5173", # Vite dev server
|
||||||
|
"http://127.0.0.1:5173",
|
||||||
|
"http://localhost:17493",
|
||||||
|
"http://127.0.0.1:17493",
|
||||||
|
"tauri://localhost", # Tauri webview (macOS)
|
||||||
|
"https://tauri.localhost", # Tauri webview (Windows/Linux)
|
||||||
|
"http://tauri.localhost", # Tauri webview (Windows, some builds)
|
||||||
|
]
|
||||||
|
env_origins = os.environ.get("VOICEBOX_CORS_ORIGINS", "")
|
||||||
|
all_origins = default_origins + [o.strip() for o in env_origins.split(",") if o.strip()]
|
||||||
|
|
||||||
|
application.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=all_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_gpu_status() -> str:
|
||||||
|
"""Return a human-readable string describing GPU availability."""
|
||||||
|
backend_type = get_backend_type()
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
device_name = torch.cuda.get_device_name(0)
|
||||||
|
is_rocm = hasattr(torch.version, "hip") and torch.version.hip is not None
|
||||||
|
if is_rocm:
|
||||||
|
return f"ROCm ({device_name})"
|
||||||
|
return f"CUDA ({device_name})"
|
||||||
|
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||||
|
return "MPS (Apple Silicon)"
|
||||||
|
elif backend_type == "mlx":
|
||||||
|
return "Metal (Apple Silicon via MLX)"
|
||||||
|
return "None (CPU only)"
|
||||||
|
|
||||||
|
|
||||||
|
def _register_lifecycle(application: FastAPI) -> None:
|
||||||
|
"""Attach startup and shutdown event handlers."""
|
||||||
|
|
||||||
|
@application.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
|
||||||
|
logger.info("Voicebox v%s starting up", __version__)
|
||||||
|
logger.info(
|
||||||
|
"Python %s on %s %s (%s)",
|
||||||
|
sys.version.split()[0],
|
||||||
|
platform.system(),
|
||||||
|
platform.release(),
|
||||||
|
platform.machine(),
|
||||||
|
)
|
||||||
|
|
||||||
|
database.init_db()
|
||||||
|
|
||||||
|
from .database.session import _db_path
|
||||||
|
|
||||||
|
logger.info("Database: %s", _db_path)
|
||||||
|
logger.info("Data directory: %s", config.get_data_dir())
|
||||||
|
|
||||||
|
init_queue()
|
||||||
|
|
||||||
|
# Mark stale "generating" records as failed -- leftovers from a killed process
|
||||||
|
from sqlalchemy import text as sa_text
|
||||||
|
|
||||||
|
db = next(get_db())
|
||||||
|
try:
|
||||||
|
result = db.execute(
|
||||||
|
sa_text(
|
||||||
|
"UPDATE generations SET status = 'failed', "
|
||||||
|
"error = 'Server was shut down during generation' "
|
||||||
|
"WHERE status IN ('generating', 'loading_model')"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if result.rowcount > 0:
|
||||||
|
logger.info("Marked %d stale generation(s) as failed", result.rowcount)
|
||||||
|
|
||||||
|
from .database import VoiceProfile as DBVoiceProfile, Generation as DBGeneration
|
||||||
|
|
||||||
|
profile_count = db.query(DBVoiceProfile).count()
|
||||||
|
generation_count = db.query(DBGeneration).count()
|
||||||
|
logger.info("Profiles: %d, Generations: %d", profile_count, generation_count)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.warning("Could not clean up stale generations: %s", e)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
backend_type = get_backend_type()
|
||||||
|
logger.info("Backend: %s", backend_type.upper())
|
||||||
|
logger.info("GPU: %s", _get_gpu_status())
|
||||||
|
|
||||||
|
from .services.cuda import check_and_update_cuda_binary
|
||||||
|
|
||||||
|
create_background_task(check_and_update_cuda_binary())
|
||||||
|
|
||||||
|
try:
|
||||||
|
progress_manager = get_progress_manager()
|
||||||
|
progress_manager._set_main_loop(asyncio.get_running_loop())
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not initialize progress manager event loop: %s", e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
|
||||||
|
cache_dir = Path(hf_constants.HF_HUB_CACHE)
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
logger.info("Model cache: %s", cache_dir)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not create HuggingFace cache directory: %s", e)
|
||||||
|
|
||||||
|
logger.info("Ready")
|
||||||
|
|
||||||
|
@application.on_event("shutdown")
|
||||||
|
async def shutdown_event():
|
||||||
|
logger.info("Voicebox server shutting down...")
|
||||||
|
try:
|
||||||
|
tts.unload_tts_model()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to unload TTS model")
|
||||||
|
try:
|
||||||
|
transcribe.unload_whisper_model()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to unload Whisper model")
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
+399
-37
@@ -1,24 +1,66 @@
|
|||||||
"""
|
"""
|
||||||
Backend abstraction layer for TTS and STT.
|
Backend abstraction layer for TTS and STT.
|
||||||
|
|
||||||
Provides a unified interface for MLX and PyTorch backends.
|
Provides a unified interface for MLX and PyTorch backends,
|
||||||
|
and a model config registry that eliminates per-engine dispatch maps.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from typing import Protocol, Optional, Tuple, List
|
from typing import Protocol, Optional, Tuple, List
|
||||||
from typing_extensions import runtime_checkable
|
from typing_extensions import runtime_checkable
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from ..platform_detect import get_backend_type
|
from ..utils.platform_detect import get_backend_type
|
||||||
|
|
||||||
|
LANGUAGE_CODE_TO_NAME = {
|
||||||
|
"zh": "chinese",
|
||||||
|
"en": "english",
|
||||||
|
"ja": "japanese",
|
||||||
|
"ko": "korean",
|
||||||
|
"de": "german",
|
||||||
|
"fr": "french",
|
||||||
|
"ru": "russian",
|
||||||
|
"pt": "portuguese",
|
||||||
|
"es": "spanish",
|
||||||
|
"it": "italian",
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelConfig:
|
||||||
|
"""Declarative config for a downloadable model variant."""
|
||||||
|
|
||||||
|
model_name: str # e.g. "luxtts", "chatterbox-tts"
|
||||||
|
display_name: str # e.g. "LuxTTS (Fast, CPU-friendly)"
|
||||||
|
engine: str # e.g. "luxtts", "chatterbox"
|
||||||
|
hf_repo_id: str # e.g. "YatharthS/LuxTTS"
|
||||||
|
model_size: str = "default"
|
||||||
|
size_mb: int = 0
|
||||||
|
needs_trim: bool = False
|
||||||
|
supports_instruct: bool = False
|
||||||
|
languages: list[str] = field(default_factory=lambda: ["en"])
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class TTSBackend(Protocol):
|
class TTSBackend(Protocol):
|
||||||
"""Protocol for TTS backend implementations."""
|
"""Protocol for TTS backend implementations."""
|
||||||
|
|
||||||
|
# Each backend class should define MODEL_CONFIGS as a class variable:
|
||||||
|
# MODEL_CONFIGS: list[ModelConfig]
|
||||||
|
|
||||||
async def load_model(self, model_size: str) -> None:
|
async def load_model(self, model_size: str) -> None:
|
||||||
"""Load TTS model."""
|
"""Load TTS model."""
|
||||||
...
|
...
|
||||||
|
|
||||||
async def create_voice_prompt(
|
async def create_voice_prompt(
|
||||||
self,
|
self,
|
||||||
audio_path: str,
|
audio_path: str,
|
||||||
@@ -27,12 +69,12 @@ class TTSBackend(Protocol):
|
|||||||
) -> Tuple[dict, bool]:
|
) -> Tuple[dict, bool]:
|
||||||
"""
|
"""
|
||||||
Create voice prompt from reference audio.
|
Create voice prompt from reference audio.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (voice_prompt_dict, was_cached)
|
Tuple of (voice_prompt_dict, was_cached)
|
||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
async def combine_voice_prompts(
|
async def combine_voice_prompts(
|
||||||
self,
|
self,
|
||||||
audio_paths: List[str],
|
audio_paths: List[str],
|
||||||
@@ -40,12 +82,12 @@ class TTSBackend(Protocol):
|
|||||||
) -> Tuple[np.ndarray, str]:
|
) -> Tuple[np.ndarray, str]:
|
||||||
"""
|
"""
|
||||||
Combine multiple voice prompts.
|
Combine multiple voice prompts.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (combined_audio_array, combined_text)
|
Tuple of (combined_audio_array, combined_text)
|
||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
async def generate(
|
async def generate(
|
||||||
self,
|
self,
|
||||||
text: str,
|
text: str,
|
||||||
@@ -56,24 +98,24 @@ class TTSBackend(Protocol):
|
|||||||
) -> Tuple[np.ndarray, int]:
|
) -> Tuple[np.ndarray, int]:
|
||||||
"""
|
"""
|
||||||
Generate audio from text.
|
Generate audio from text.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (audio_array, sample_rate)
|
Tuple of (audio_array, sample_rate)
|
||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def unload_model(self) -> None:
|
def unload_model(self) -> None:
|
||||||
"""Unload model to free memory."""
|
"""Unload model to free memory."""
|
||||||
...
|
...
|
||||||
|
|
||||||
def is_loaded(self) -> bool:
|
def is_loaded(self) -> bool:
|
||||||
"""Check if model is loaded."""
|
"""Check if model is loaded."""
|
||||||
...
|
...
|
||||||
|
|
||||||
def _get_model_path(self, model_size: str) -> str:
|
def _get_model_path(self, model_size: str) -> str:
|
||||||
"""
|
"""
|
||||||
Get model path for a given size.
|
Get model path for a given size.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Model path or HuggingFace Hub ID
|
Model path or HuggingFace Hub ID
|
||||||
"""
|
"""
|
||||||
@@ -83,11 +125,11 @@ class TTSBackend(Protocol):
|
|||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class STTBackend(Protocol):
|
class STTBackend(Protocol):
|
||||||
"""Protocol for STT (Speech-to-Text) backend implementations."""
|
"""Protocol for STT (Speech-to-Text) backend implementations."""
|
||||||
|
|
||||||
async def load_model(self, model_size: str) -> None:
|
async def load_model(self, model_size: str) -> None:
|
||||||
"""Load STT model."""
|
"""Load STT model."""
|
||||||
...
|
...
|
||||||
|
|
||||||
async def transcribe(
|
async def transcribe(
|
||||||
self,
|
self,
|
||||||
audio_path: str,
|
audio_path: str,
|
||||||
@@ -95,16 +137,16 @@ class STTBackend(Protocol):
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Transcribe audio to text.
|
Transcribe audio to text.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Transcribed text
|
Transcribed text
|
||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def unload_model(self) -> None:
|
def unload_model(self) -> None:
|
||||||
"""Unload model to free memory."""
|
"""Unload model to free memory."""
|
||||||
...
|
...
|
||||||
|
|
||||||
def is_loaded(self) -> bool:
|
def is_loaded(self) -> bool:
|
||||||
"""Check if model is loaded."""
|
"""Check if model is loaded."""
|
||||||
...
|
...
|
||||||
@@ -112,55 +154,375 @@ class STTBackend(Protocol):
|
|||||||
|
|
||||||
# Global backend instances
|
# Global backend instances
|
||||||
_tts_backend: Optional[TTSBackend] = None
|
_tts_backend: Optional[TTSBackend] = None
|
||||||
|
_tts_backends: dict[str, TTSBackend] = {}
|
||||||
|
_tts_backends_lock = threading.Lock()
|
||||||
_stt_backend: Optional[STTBackend] = None
|
_stt_backend: Optional[STTBackend] = None
|
||||||
|
|
||||||
|
# Supported TTS engines — keyed by engine name, value is the backend class import path.
|
||||||
|
# The factory function uses this for the if/elif chain; the model configs live on the backend classes.
|
||||||
|
TTS_ENGINES = {
|
||||||
|
"qwen": "Qwen TTS",
|
||||||
|
"luxtts": "LuxTTS",
|
||||||
|
"chatterbox": "Chatterbox TTS",
|
||||||
|
"chatterbox_turbo": "Chatterbox Turbo",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_qwen_model_configs() -> list[ModelConfig]:
|
||||||
|
"""Return Qwen model configs with backend-aware HF repo IDs."""
|
||||||
|
backend_type = get_backend_type()
|
||||||
|
if backend_type == "mlx":
|
||||||
|
repo_1_7b = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16"
|
||||||
|
repo_0_6b = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16" # 0.6B not available in MLX, falls back
|
||||||
|
else:
|
||||||
|
repo_1_7b = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
||||||
|
repo_0_6b = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
|
||||||
|
|
||||||
|
return [
|
||||||
|
ModelConfig(
|
||||||
|
model_name="qwen-tts-1.7B",
|
||||||
|
display_name="Qwen TTS 1.7B",
|
||||||
|
engine="qwen",
|
||||||
|
hf_repo_id=repo_1_7b,
|
||||||
|
model_size="1.7B",
|
||||||
|
size_mb=3500,
|
||||||
|
supports_instruct=False, # Base model drops instruct silently
|
||||||
|
languages=["zh", "en", "ja", "ko", "de", "fr", "ru", "pt", "es", "it"],
|
||||||
|
),
|
||||||
|
ModelConfig(
|
||||||
|
model_name="qwen-tts-0.6B",
|
||||||
|
display_name="Qwen TTS 0.6B",
|
||||||
|
engine="qwen",
|
||||||
|
hf_repo_id=repo_0_6b,
|
||||||
|
model_size="0.6B",
|
||||||
|
size_mb=1200,
|
||||||
|
supports_instruct=False,
|
||||||
|
languages=["zh", "en", "ja", "ko", "de", "fr", "ru", "pt", "es", "it"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_non_qwen_tts_configs() -> list[ModelConfig]:
|
||||||
|
"""Return model configs for non-Qwen TTS engines.
|
||||||
|
|
||||||
|
These are static — no backend-type branching needed.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
ModelConfig(
|
||||||
|
model_name="luxtts",
|
||||||
|
display_name="LuxTTS (Fast, CPU-friendly)",
|
||||||
|
engine="luxtts",
|
||||||
|
hf_repo_id="YatharthS/LuxTTS",
|
||||||
|
size_mb=300,
|
||||||
|
languages=["en"],
|
||||||
|
),
|
||||||
|
ModelConfig(
|
||||||
|
model_name="chatterbox-tts",
|
||||||
|
display_name="Chatterbox TTS (Multilingual)",
|
||||||
|
engine="chatterbox",
|
||||||
|
hf_repo_id="ResembleAI/chatterbox",
|
||||||
|
size_mb=3200,
|
||||||
|
needs_trim=True,
|
||||||
|
languages=[
|
||||||
|
"zh",
|
||||||
|
"en",
|
||||||
|
"ja",
|
||||||
|
"ko",
|
||||||
|
"de",
|
||||||
|
"fr",
|
||||||
|
"ru",
|
||||||
|
"pt",
|
||||||
|
"es",
|
||||||
|
"it",
|
||||||
|
"he",
|
||||||
|
"ar",
|
||||||
|
"da",
|
||||||
|
"el",
|
||||||
|
"fi",
|
||||||
|
"hi",
|
||||||
|
"ms",
|
||||||
|
"nl",
|
||||||
|
"no",
|
||||||
|
"pl",
|
||||||
|
"sv",
|
||||||
|
"sw",
|
||||||
|
"tr",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ModelConfig(
|
||||||
|
model_name="chatterbox-turbo",
|
||||||
|
display_name="Chatterbox Turbo (English, Tags)",
|
||||||
|
engine="chatterbox_turbo",
|
||||||
|
hf_repo_id="ResembleAI/chatterbox-turbo",
|
||||||
|
size_mb=1500,
|
||||||
|
needs_trim=True,
|
||||||
|
languages=["en"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_whisper_configs() -> list[ModelConfig]:
|
||||||
|
"""Return Whisper STT model configs."""
|
||||||
|
return [
|
||||||
|
ModelConfig(
|
||||||
|
model_name="whisper-base",
|
||||||
|
display_name="Whisper Base",
|
||||||
|
engine="whisper",
|
||||||
|
hf_repo_id="openai/whisper-base",
|
||||||
|
model_size="base",
|
||||||
|
),
|
||||||
|
ModelConfig(
|
||||||
|
model_name="whisper-small",
|
||||||
|
display_name="Whisper Small",
|
||||||
|
engine="whisper",
|
||||||
|
hf_repo_id="openai/whisper-small",
|
||||||
|
model_size="small",
|
||||||
|
),
|
||||||
|
ModelConfig(
|
||||||
|
model_name="whisper-medium",
|
||||||
|
display_name="Whisper Medium",
|
||||||
|
engine="whisper",
|
||||||
|
hf_repo_id="openai/whisper-medium",
|
||||||
|
model_size="medium",
|
||||||
|
),
|
||||||
|
ModelConfig(
|
||||||
|
model_name="whisper-large",
|
||||||
|
display_name="Whisper Large",
|
||||||
|
engine="whisper",
|
||||||
|
hf_repo_id="openai/whisper-large-v3",
|
||||||
|
model_size="large",
|
||||||
|
),
|
||||||
|
ModelConfig(
|
||||||
|
model_name="whisper-turbo",
|
||||||
|
display_name="Whisper Turbo",
|
||||||
|
engine="whisper",
|
||||||
|
hf_repo_id="openai/whisper-large-v3-turbo",
|
||||||
|
model_size="turbo",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_model_configs() -> list[ModelConfig]:
|
||||||
|
"""Return the full list of model configs (TTS + STT)."""
|
||||||
|
return _get_qwen_model_configs() + _get_non_qwen_tts_configs() + _get_whisper_configs()
|
||||||
|
|
||||||
|
|
||||||
|
def get_tts_model_configs() -> list[ModelConfig]:
|
||||||
|
"""Return only TTS model configs."""
|
||||||
|
return _get_qwen_model_configs() + _get_non_qwen_tts_configs()
|
||||||
|
|
||||||
|
|
||||||
|
# Lookup helpers — these replace the if/elif chains in main.py
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_config(model_name: str) -> Optional[ModelConfig]:
|
||||||
|
"""Look up a model config by model_name."""
|
||||||
|
for cfg in get_all_model_configs():
|
||||||
|
if cfg.model_name == model_name:
|
||||||
|
return cfg
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def engine_needs_trim(engine: str) -> bool:
|
||||||
|
"""Whether this engine's output should be run through trim_tts_output."""
|
||||||
|
for cfg in get_tts_model_configs():
|
||||||
|
if cfg.engine == engine:
|
||||||
|
return cfg.needs_trim
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def engine_has_model_sizes(engine: str) -> bool:
|
||||||
|
"""Whether this engine supports multiple model sizes (only Qwen currently)."""
|
||||||
|
configs = [c for c in get_tts_model_configs() if c.engine == engine]
|
||||||
|
return len(configs) > 1
|
||||||
|
|
||||||
|
|
||||||
|
async def load_engine_model(engine: str, model_size: str = "default") -> None:
|
||||||
|
"""Load a model for the given engine, handling the Qwen model_size special case."""
|
||||||
|
backend = get_tts_backend_for_engine(engine)
|
||||||
|
if engine == "qwen":
|
||||||
|
await backend.load_model_async(model_size)
|
||||||
|
else:
|
||||||
|
await backend.load_model()
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_model_cached_or_raise(engine: str, model_size: str = "default") -> None:
|
||||||
|
"""Check if a model is cached, raise HTTPException if not. Used by streaming endpoint."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
backend = get_tts_backend_for_engine(engine)
|
||||||
|
cfg = None
|
||||||
|
for c in get_tts_model_configs():
|
||||||
|
if c.engine == engine and c.model_size == model_size:
|
||||||
|
cfg = c
|
||||||
|
break
|
||||||
|
|
||||||
|
if engine == "qwen":
|
||||||
|
if not backend._is_model_cached(model_size):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Model {model_size} is not downloaded yet. Use /generate to trigger a download.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if not backend._is_model_cached():
|
||||||
|
display = cfg.display_name if cfg else engine
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"{display} model is not downloaded yet. Use /generate to trigger a download.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unload_model_by_config(config: ModelConfig) -> bool:
|
||||||
|
"""Unload a model given its config. Returns True if it was loaded, False otherwise."""
|
||||||
|
from . import get_tts_backend_for_engine
|
||||||
|
from ..services import tts, transcribe
|
||||||
|
|
||||||
|
if config.engine == "whisper":
|
||||||
|
whisper_model = transcribe.get_whisper_model()
|
||||||
|
if whisper_model.is_loaded() and whisper_model.model_size == config.model_size:
|
||||||
|
transcribe.unload_whisper_model()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
if config.engine == "qwen":
|
||||||
|
tts_model = tts.get_tts_model()
|
||||||
|
loaded_size = getattr(tts_model, "_current_model_size", None) or getattr(tts_model, "model_size", None)
|
||||||
|
if tts_model.is_loaded() and loaded_size == config.model_size:
|
||||||
|
tts.unload_tts_model()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# All other TTS engines
|
||||||
|
backend = get_tts_backend_for_engine(config.engine)
|
||||||
|
if backend.is_loaded():
|
||||||
|
backend.unload_model()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_model_loaded(config: ModelConfig) -> bool:
|
||||||
|
"""Check if a model is currently loaded."""
|
||||||
|
from . import get_tts_backend_for_engine
|
||||||
|
from ..services import tts, transcribe
|
||||||
|
|
||||||
|
try:
|
||||||
|
if config.engine == "whisper":
|
||||||
|
whisper_model = transcribe.get_whisper_model()
|
||||||
|
return whisper_model.is_loaded() and getattr(whisper_model, "model_size", None) == config.model_size
|
||||||
|
|
||||||
|
if config.engine == "qwen":
|
||||||
|
tts_model = tts.get_tts_model()
|
||||||
|
loaded_size = getattr(tts_model, "_current_model_size", None) or getattr(tts_model, "model_size", None)
|
||||||
|
return tts_model.is_loaded() and loaded_size == config.model_size
|
||||||
|
|
||||||
|
backend = get_tts_backend_for_engine(config.engine)
|
||||||
|
return backend.is_loaded()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_load_func(config: ModelConfig):
|
||||||
|
"""Return a callable that loads/downloads the model."""
|
||||||
|
from . import get_tts_backend_for_engine
|
||||||
|
from ..services import tts, transcribe
|
||||||
|
|
||||||
|
if config.engine == "whisper":
|
||||||
|
return lambda: transcribe.get_whisper_model().load_model(config.model_size)
|
||||||
|
|
||||||
|
if config.engine == "qwen":
|
||||||
|
return lambda: tts.get_tts_model().load_model(config.model_size)
|
||||||
|
|
||||||
|
return lambda: get_tts_backend_for_engine(config.engine).load_model()
|
||||||
|
|
||||||
|
|
||||||
def get_tts_backend() -> TTSBackend:
|
def get_tts_backend() -> TTSBackend:
|
||||||
"""
|
"""
|
||||||
Get or create TTS backend instance based on platform.
|
Get or create the default (Qwen) TTS backend instance based on platform.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
TTS backend instance (MLX or PyTorch)
|
TTS backend instance (MLX or PyTorch)
|
||||||
"""
|
"""
|
||||||
global _tts_backend
|
return get_tts_backend_for_engine("qwen")
|
||||||
|
|
||||||
if _tts_backend is None:
|
|
||||||
backend_type = get_backend_type()
|
def get_tts_backend_for_engine(engine: str) -> TTSBackend:
|
||||||
|
"""
|
||||||
if backend_type == "mlx":
|
Get or create a TTS backend for the given engine.
|
||||||
from .mlx_backend import MLXTTSBackend
|
|
||||||
_tts_backend = MLXTTSBackend()
|
Args:
|
||||||
|
engine: Engine name (e.g. "qwen", "luxtts", "chatterbox", "chatterbox_turbo")
|
||||||
|
|
||||||
|
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:
|
else:
|
||||||
from .pytorch_backend import PyTorchTTSBackend
|
raise ValueError(f"Unknown TTS engine: {engine}. Supported: {list(TTS_ENGINES.keys())}")
|
||||||
_tts_backend = PyTorchTTSBackend()
|
|
||||||
|
_tts_backends[engine] = backend
|
||||||
return _tts_backend
|
return backend
|
||||||
|
|
||||||
|
|
||||||
def get_stt_backend() -> STTBackend:
|
def get_stt_backend() -> STTBackend:
|
||||||
"""
|
"""
|
||||||
Get or create STT backend instance based on platform.
|
Get or create STT backend instance based on platform.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
STT backend instance (MLX or PyTorch)
|
STT backend instance (MLX or PyTorch)
|
||||||
"""
|
"""
|
||||||
global _stt_backend
|
global _stt_backend
|
||||||
|
|
||||||
if _stt_backend is None:
|
if _stt_backend is None:
|
||||||
backend_type = get_backend_type()
|
backend_type = get_backend_type()
|
||||||
|
|
||||||
if backend_type == "mlx":
|
if backend_type == "mlx":
|
||||||
from .mlx_backend import MLXSTTBackend
|
from .mlx_backend import MLXSTTBackend
|
||||||
|
|
||||||
_stt_backend = MLXSTTBackend()
|
_stt_backend = MLXSTTBackend()
|
||||||
else:
|
else:
|
||||||
from .pytorch_backend import PyTorchSTTBackend
|
from .pytorch_backend import PyTorchSTTBackend
|
||||||
|
|
||||||
_stt_backend = PyTorchSTTBackend()
|
_stt_backend = PyTorchSTTBackend()
|
||||||
|
|
||||||
return _stt_backend
|
return _stt_backend
|
||||||
|
|
||||||
|
|
||||||
def reset_backends():
|
def reset_backends():
|
||||||
"""Reset backend instances (useful for testing)."""
|
"""Reset backend instances (useful for testing)."""
|
||||||
global _tts_backend, _stt_backend
|
global _tts_backend, _tts_backends, _stt_backend
|
||||||
_tts_backend = None
|
_tts_backend = None
|
||||||
|
_tts_backends.clear()
|
||||||
_stt_backend = None
|
_stt_backend = None
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""
|
||||||
|
Shared utilities for TTS/STT backend implementations.
|
||||||
|
|
||||||
|
Eliminates duplication of cache checking, device detection,
|
||||||
|
voice prompt combination, and model loading progress tracking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import platform
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def is_model_cached(
|
||||||
|
hf_repo: str,
|
||||||
|
*,
|
||||||
|
weight_extensions: tuple[str, ...] = (".safetensors", ".bin"),
|
||||||
|
required_files: Optional[list[str]] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a HuggingFace model is fully cached locally.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hf_repo: HuggingFace repo ID (e.g. "Qwen/Qwen3-TTS-12Hz-1.7B-Base")
|
||||||
|
weight_extensions: File extensions that count as model weights.
|
||||||
|
required_files: If set, check that these specific filenames exist
|
||||||
|
in snapshots instead of checking by extension.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if model is fully cached, False if missing or incomplete.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
|
||||||
|
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + hf_repo.replace("/", "--"))
|
||||||
|
|
||||||
|
if not repo_cache.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Incomplete blobs mean a download is still in progress
|
||||||
|
blobs_dir = repo_cache / "blobs"
|
||||||
|
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
|
||||||
|
logger.debug(f"Found .incomplete files for {hf_repo}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
snapshots_dir = repo_cache / "snapshots"
|
||||||
|
if not snapshots_dir.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
if required_files:
|
||||||
|
# Check that every required filename exists somewhere in snapshots
|
||||||
|
for fname in required_files:
|
||||||
|
if not any(snapshots_dir.rglob(fname)):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check that at least one weight file exists
|
||||||
|
for ext in weight_extensions:
|
||||||
|
if any(snapshots_dir.rglob(f"*{ext}")):
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.debug(f"No model weights found for {hf_repo}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error checking cache for {hf_repo}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_torch_device(
|
||||||
|
*,
|
||||||
|
allow_xpu: bool = False,
|
||||||
|
allow_directml: bool = False,
|
||||||
|
allow_mps: bool = False,
|
||||||
|
force_cpu_on_mac: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Detect the best available torch device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
allow_xpu: Check for Intel XPU (IPEX) support.
|
||||||
|
allow_directml: Check for DirectML (Windows) support.
|
||||||
|
allow_mps: Allow MPS (Apple Silicon). If False, MPS falls back to CPU.
|
||||||
|
force_cpu_on_mac: Force CPU on macOS regardless of GPU availability.
|
||||||
|
"""
|
||||||
|
if force_cpu_on_mac and platform.system() == "Darwin":
|
||||||
|
return "cpu"
|
||||||
|
|
||||||
|
import torch
|
||||||
|
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
return "cuda"
|
||||||
|
|
||||||
|
if allow_xpu:
|
||||||
|
try:
|
||||||
|
import intel_extension_for_pytorch # noqa: F401
|
||||||
|
|
||||||
|
if hasattr(torch, "xpu") and torch.xpu.is_available():
|
||||||
|
return "xpu"
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if allow_directml:
|
||||||
|
try:
|
||||||
|
import torch_directml
|
||||||
|
|
||||||
|
if torch_directml.device_count() > 0:
|
||||||
|
return torch_directml.device(0)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if allow_mps:
|
||||||
|
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||||
|
return "mps"
|
||||||
|
|
||||||
|
return "cpu"
|
||||||
|
|
||||||
|
|
||||||
|
async def combine_voice_prompts(
|
||||||
|
audio_paths: List[str],
|
||||||
|
reference_texts: List[str],
|
||||||
|
*,
|
||||||
|
sample_rate: Optional[int] = None,
|
||||||
|
) -> Tuple[np.ndarray, str]:
|
||||||
|
"""
|
||||||
|
Combine multiple reference audio samples into one.
|
||||||
|
|
||||||
|
Loads each audio file, normalizes, concatenates, and joins texts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_paths: Paths to reference audio files.
|
||||||
|
reference_texts: Corresponding transcripts.
|
||||||
|
sample_rate: If set, resample audio to this rate during loading.
|
||||||
|
"""
|
||||||
|
combined_audio = []
|
||||||
|
|
||||||
|
for path in audio_paths:
|
||||||
|
kwargs = {"sample_rate": sample_rate} if sample_rate else {}
|
||||||
|
audio, _sr = load_audio(path, **kwargs)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def model_load_progress(
|
||||||
|
model_name: str,
|
||||||
|
is_cached: bool,
|
||||||
|
filter_non_downloads: Optional[bool] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Context manager for model loading with HF download progress tracking.
|
||||||
|
|
||||||
|
Handles the tqdm patching, progress_manager/task_manager lifecycle,
|
||||||
|
and error reporting that every backend duplicates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_name: Progress tracking key (e.g. "qwen-tts-1.7B", "whisper-base").
|
||||||
|
is_cached: Whether the model is already downloaded.
|
||||||
|
filter_non_downloads: Whether to filter non-download tqdm bars.
|
||||||
|
Defaults to `is_cached`.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
The tracker context (already entered). The caller loads the model
|
||||||
|
inside the `with` block. The tqdm patch is torn down on exit.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
with model_load_progress("qwen-tts-1.7B", is_cached) as ctx:
|
||||||
|
self.model = SomeModel.from_pretrained(...)
|
||||||
|
"""
|
||||||
|
if filter_non_downloads is None:
|
||||||
|
filter_non_downloads = is_cached
|
||||||
|
|
||||||
|
progress_manager = get_progress_manager()
|
||||||
|
task_manager = get_task_manager()
|
||||||
|
|
||||||
|
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||||
|
tracker = HFProgressTracker(progress_callback, filter_non_downloads=filter_non_downloads)
|
||||||
|
|
||||||
|
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:
|
||||||
|
yield tracker_context
|
||||||
|
except Exception as e:
|
||||||
|
# Report error to both managers
|
||||||
|
progress_manager.mark_error(model_name, str(e))
|
||||||
|
task_manager.error_download(model_name, str(e))
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
# Only mark complete if we were tracking a download
|
||||||
|
if not is_cached:
|
||||||
|
progress_manager.mark_complete(model_name)
|
||||||
|
task_manager.complete_download(model_name)
|
||||||
|
finally:
|
||||||
|
tracker_context.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def patch_chatterbox_f32(model) -> None:
|
||||||
|
"""
|
||||||
|
Patch float64 -> float32 dtype mismatches in upstream chatterbox.
|
||||||
|
|
||||||
|
librosa.load returns float64 numpy arrays. Multiple upstream code paths
|
||||||
|
convert these to torch tensors via torch.from_numpy() without casting,
|
||||||
|
then matmul against float32 model weights. This patches the two known
|
||||||
|
entry points:
|
||||||
|
|
||||||
|
1. S3Tokenizer.log_mel_spectrogram — audio tensor hits _mel_filters (f32)
|
||||||
|
2. VoiceEncoder.forward — float64 mel spectrograms hit LSTM weights (f32)
|
||||||
|
"""
|
||||||
|
import types
|
||||||
|
|
||||||
|
# Patch S3Tokenizer
|
||||||
|
_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)
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"""
|
||||||
|
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 threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import ClassVar, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from . import TTSBackend
|
||||||
|
from .base import (
|
||||||
|
is_model_cached,
|
||||||
|
get_torch_device,
|
||||||
|
combine_voice_prompts as _combine_voice_prompts,
|
||||||
|
model_load_progress,
|
||||||
|
patch_chatterbox_f32,
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
|
return get_torch_device(force_cpu_on_mac=True)
|
||||||
|
|
||||||
|
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:
|
||||||
|
return is_model_cached(CHATTERBOX_HF_REPO, required_files=_MTL_WEIGHT_FILES)
|
||||||
|
|
||||||
|
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."""
|
||||||
|
model_name = "chatterbox-tts"
|
||||||
|
is_cached = self._is_model_cached()
|
||||||
|
|
||||||
|
with model_load_progress(model_name, is_cached):
|
||||||
|
device = self._get_device()
|
||||||
|
self._device = device
|
||||||
|
logger.info(f"Loading Chatterbox Multilingual TTS on {device}...")
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from chatterbox.mtl_tts import ChatterboxMultilingualTTS
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Fix sdpa attention for output_attentions support
|
||||||
|
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"
|
||||||
|
|
||||||
|
patch_chatterbox_f32(model)
|
||||||
|
self.model = model
|
||||||
|
|
||||||
|
logger.info("Chatterbox Multilingual TTS loaded successfully")
|
||||||
|
|
||||||
|
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]:
|
||||||
|
return await _combine_voice_prompts(audio_paths, reference_texts)
|
||||||
|
|
||||||
|
# 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,210 @@
|
|||||||
|
"""
|
||||||
|
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 threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import ClassVar, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from . import TTSBackend
|
||||||
|
from .base import (
|
||||||
|
is_model_cached,
|
||||||
|
get_torch_device,
|
||||||
|
combine_voice_prompts as _combine_voice_prompts,
|
||||||
|
model_load_progress,
|
||||||
|
patch_chatterbox_f32,
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
|
return get_torch_device(force_cpu_on_mac=True)
|
||||||
|
|
||||||
|
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:
|
||||||
|
return is_model_cached(CHATTERBOX_TURBO_HF_REPO, required_files=_TURBO_WEIGHT_FILES)
|
||||||
|
|
||||||
|
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."""
|
||||||
|
model_name = "chatterbox-turbo"
|
||||||
|
is_cached = self._is_model_cached()
|
||||||
|
|
||||||
|
with model_load_progress(model_name, is_cached):
|
||||||
|
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
|
||||||
|
|
||||||
|
local_path = snapshot_download(
|
||||||
|
repo_id=CHATTERBOX_TURBO_HF_REPO,
|
||||||
|
token=None,
|
||||||
|
allow_patterns=["*.safetensors", "*.json", "*.txt", "*.pt", "*.model"],
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
patch_chatterbox_f32(model)
|
||||||
|
self.model = model
|
||||||
|
|
||||||
|
logger.info("Chatterbox Turbo TTS loaded successfully")
|
||||||
|
|
||||||
|
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]:
|
||||||
|
return await _combine_voice_prompts(audio_paths, reference_texts)
|
||||||
|
|
||||||
|
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,178 @@
|
|||||||
|
"""
|
||||||
|
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 typing import Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from . import TTSBackend
|
||||||
|
from .base import is_model_cached, get_torch_device, combine_voice_prompts as _combine_voice_prompts, model_load_progress
|
||||||
|
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
|
||||||
|
|
||||||
|
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:
|
||||||
|
return get_torch_device(allow_mps=True)
|
||||||
|
|
||||||
|
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:
|
||||||
|
return is_model_cached(
|
||||||
|
LUXTTS_HF_REPO,
|
||||||
|
weight_extensions=(".pt", ".safetensors", ".onnx", ".bin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
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):
|
||||||
|
model_name = "luxtts"
|
||||||
|
is_cached = self._is_model_cached()
|
||||||
|
|
||||||
|
with model_load_progress(model_name, is_cached):
|
||||||
|
from zipvoice.luxvoice import LuxTTS
|
||||||
|
|
||||||
|
device = self.device
|
||||||
|
logger.info(f"Loading LuxTTS on {device}...")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
logger.info("LuxTTS loaded successfully")
|
||||||
|
|
||||||
|
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, reference_texts):
|
||||||
|
return await _combine_voice_prompts(audio_paths, reference_texts, sample_rate=24000)
|
||||||
|
|
||||||
|
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)
|
||||||
+123
-212
@@ -4,36 +4,44 @@ MLX backend implementation for TTS and STT using mlx-audio.
|
|||||||
|
|
||||||
from typing import Optional, List, Tuple
|
from typing import Optional, List, Tuple
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from . import TTSBackend, STTBackend
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 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, LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS
|
||||||
|
from .base import is_model_cached, combine_voice_prompts as _combine_voice_prompts, model_load_progress
|
||||||
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
|
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
|
||||||
from ..utils.audio import normalize_audio, load_audio
|
|
||||||
from ..utils.progress import get_progress_manager
|
|
||||||
from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
|
|
||||||
from ..utils.tasks import get_task_manager
|
|
||||||
|
|
||||||
|
|
||||||
class MLXTTSBackend:
|
class MLXTTSBackend:
|
||||||
"""MLX-based TTS backend using mlx-audio."""
|
"""MLX-based TTS backend using mlx-audio."""
|
||||||
|
|
||||||
def __init__(self, model_size: str = "1.7B"):
|
def __init__(self, model_size: str = "1.7B"):
|
||||||
self.model = None
|
self.model = None
|
||||||
self.model_size = model_size
|
self.model_size = model_size
|
||||||
self._current_model_size = None
|
self._current_model_size = None
|
||||||
|
|
||||||
def is_loaded(self) -> bool:
|
def is_loaded(self) -> bool:
|
||||||
"""Check if model is loaded."""
|
"""Check if model is loaded."""
|
||||||
return self.model is not None
|
return self.model is not None
|
||||||
|
|
||||||
def _get_model_path(self, model_size: str) -> str:
|
def _get_model_path(self, model_size: str) -> str:
|
||||||
"""
|
"""
|
||||||
Get the MLX model path.
|
Get the MLX model path.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model_size: Model size (1.7B or 0.6B)
|
model_size: Model size (1.7B or 0.6B)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
HuggingFace Hub model ID for MLX
|
HuggingFace Hub model ID for MLX
|
||||||
"""
|
"""
|
||||||
@@ -43,109 +51,90 @@ class MLXTTSBackend:
|
|||||||
# 0.6B not yet converted to MLX format
|
# 0.6B not yet converted to MLX format
|
||||||
"0.6B": "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16", # Fallback to 1.7B
|
"0.6B": "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16", # Fallback to 1.7B
|
||||||
}
|
}
|
||||||
|
|
||||||
if model_size not in mlx_model_map:
|
if model_size not in mlx_model_map:
|
||||||
raise ValueError(f"Unknown model size: {model_size}")
|
raise ValueError(f"Unknown model size: {model_size}")
|
||||||
|
|
||||||
hf_model_id = mlx_model_map[model_size]
|
hf_model_id = mlx_model_map[model_size]
|
||||||
print(f"Will download MLX model from HuggingFace Hub: {hf_model_id}")
|
logger.info("Will download MLX model from HuggingFace Hub: %s", hf_model_id)
|
||||||
|
|
||||||
return hf_model_id
|
return hf_model_id
|
||||||
|
|
||||||
|
def _is_model_cached(self, model_size: str) -> bool:
|
||||||
|
return is_model_cached(
|
||||||
|
self._get_model_path(model_size),
|
||||||
|
weight_extensions=(".safetensors", ".bin", ".npz"),
|
||||||
|
)
|
||||||
|
|
||||||
async def load_model_async(self, model_size: Optional[str] = None):
|
async def load_model_async(self, model_size: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Lazy load the MLX TTS model.
|
Lazy load the MLX TTS model.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model_size: Model size to load (1.7B or 0.6B)
|
model_size: Model size to load (1.7B or 0.6B)
|
||||||
"""
|
"""
|
||||||
if model_size is None:
|
if model_size is None:
|
||||||
model_size = self.model_size
|
model_size = self.model_size
|
||||||
|
|
||||||
# If already loaded with correct size, return
|
# If already loaded with correct size, return
|
||||||
if self.model is not None and self._current_model_size == model_size:
|
if self.model is not None and self._current_model_size == model_size:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Unload existing model if different size requested
|
# Unload existing model if different size requested
|
||||||
if self.model is not None and self._current_model_size != model_size:
|
if self.model is not None and self._current_model_size != model_size:
|
||||||
self.unload_model()
|
self.unload_model()
|
||||||
|
|
||||||
# Run blocking load in thread pool
|
# Run blocking load in thread pool
|
||||||
await asyncio.to_thread(self._load_model_sync, model_size)
|
await asyncio.to_thread(self._load_model_sync, model_size)
|
||||||
|
|
||||||
# Alias for compatibility
|
# Alias for compatibility
|
||||||
load_model = load_model_async
|
load_model = load_model_async
|
||||||
|
|
||||||
def _load_model_sync(self, model_size: str):
|
def _load_model_sync(self, model_size: str):
|
||||||
"""Synchronous model loading."""
|
"""Synchronous model loading."""
|
||||||
|
model_path = self._get_model_path(model_size)
|
||||||
|
model_name = f"qwen-tts-{model_size}"
|
||||||
|
is_cached = self._is_model_cached(model_size)
|
||||||
|
|
||||||
|
# Force offline mode when cached to avoid network requests
|
||||||
|
original_hf_hub_offline = os.environ.get("HF_HUB_OFFLINE")
|
||||||
|
if is_cached:
|
||||||
|
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||||
|
logger.info("[PATCH] Model %s is cached, forcing HF_HUB_OFFLINE=1 to avoid network requests", model_size)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from mlx_audio.tts import load
|
with model_load_progress(model_name, is_cached):
|
||||||
|
from mlx_audio.tts import load
|
||||||
# Get model path
|
|
||||||
model_path = self._get_model_path(model_size)
|
logger.info("Loading MLX TTS model %s...", model_size)
|
||||||
|
|
||||||
# Set up progress tracking
|
try:
|
||||||
progress_manager = get_progress_manager()
|
self.model = load(model_path)
|
||||||
model_name = f"qwen-tts-{model_size}"
|
except Exception as load_error:
|
||||||
|
if is_cached and "offline" in str(load_error).lower():
|
||||||
# Start tracking download task
|
logger.warning("[PATCH] Offline load failed, trying with network: %s", load_error)
|
||||||
task_manager = get_task_manager()
|
os.environ.pop("HF_HUB_OFFLINE", None)
|
||||||
task_manager.start_download(model_name)
|
self.model = load(model_path)
|
||||||
|
else:
|
||||||
print(f"Loading MLX TTS model {model_size}...")
|
raise
|
||||||
|
finally:
|
||||||
# Initialize progress state
|
if original_hf_hub_offline is not None:
|
||||||
progress_manager.update_progress(
|
os.environ["HF_HUB_OFFLINE"] = original_hf_hub_offline
|
||||||
model_name=model_name,
|
else:
|
||||||
current=0,
|
os.environ.pop("HF_HUB_OFFLINE", None)
|
||||||
total=1,
|
|
||||||
filename="",
|
self._current_model_size = model_size
|
||||||
status="downloading",
|
self.model_size = model_size
|
||||||
)
|
logger.info("MLX TTS model %s loaded successfully", model_size)
|
||||||
|
|
||||||
# Set up progress callback
|
|
||||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
|
||||||
tracker = HFProgressTracker(progress_callback)
|
|
||||||
|
|
||||||
# Use progress tracker during download
|
|
||||||
with tracker.patch_download():
|
|
||||||
# Load MLX model (downloads automatically)
|
|
||||||
self.model = load(model_path)
|
|
||||||
|
|
||||||
self._current_model_size = model_size
|
|
||||||
self.model_size = model_size
|
|
||||||
|
|
||||||
# Mark as complete
|
|
||||||
progress_manager.mark_complete(model_name)
|
|
||||||
task_manager.complete_download(model_name)
|
|
||||||
|
|
||||||
print(f"MLX TTS model {model_size} loaded successfully")
|
|
||||||
|
|
||||||
except ImportError as e:
|
|
||||||
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):
|
def unload_model(self):
|
||||||
"""Unload the model to free memory."""
|
"""Unload the model to free memory."""
|
||||||
if self.model is not None:
|
if self.model is not None:
|
||||||
del self.model
|
del self.model
|
||||||
self.model = None
|
self.model = None
|
||||||
self._current_model_size = None
|
self._current_model_size = None
|
||||||
print("MLX TTS model unloaded")
|
logger.info("MLX TTS model unloaded")
|
||||||
|
|
||||||
async def create_voice_prompt(
|
async def create_voice_prompt(
|
||||||
self,
|
self,
|
||||||
audio_path: str,
|
audio_path: str,
|
||||||
@@ -154,20 +143,20 @@ class MLXTTSBackend:
|
|||||||
) -> Tuple[dict, bool]:
|
) -> Tuple[dict, bool]:
|
||||||
"""
|
"""
|
||||||
Create voice prompt from reference audio.
|
Create voice prompt from reference audio.
|
||||||
|
|
||||||
MLX backend stores voice prompt as a dict with audio path and text.
|
MLX backend stores voice prompt as a dict with audio path and text.
|
||||||
The actual voice prompt processing happens during generation.
|
The actual voice prompt processing happens during generation.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
audio_path: Path to reference audio file
|
audio_path: Path to reference audio file
|
||||||
reference_text: Transcript of reference audio
|
reference_text: Transcript of reference audio
|
||||||
use_cache: Whether to use cached prompt if available
|
use_cache: Whether to use cached prompt if available
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (voice_prompt_dict, was_cached)
|
Tuple of (voice_prompt_dict, was_cached)
|
||||||
"""
|
"""
|
||||||
await self.load_model_async(None)
|
await self.load_model_async(None)
|
||||||
|
|
||||||
# Check cache if enabled
|
# Check cache if enabled
|
||||||
if use_cache:
|
if use_cache:
|
||||||
cache_key = get_cache_key(audio_path, reference_text)
|
cache_key = get_cache_key(audio_path, reference_text)
|
||||||
@@ -181,53 +170,25 @@ class MLXTTSBackend:
|
|||||||
return cached_prompt, True
|
return cached_prompt, True
|
||||||
else:
|
else:
|
||||||
# Cached file no longer exists, invalidate cache
|
# Cached file no longer exists, invalidate cache
|
||||||
print(f"Cached audio file not found: {cached_audio_path}, regenerating prompt")
|
logger.warning("Cached audio file not found: %s, regenerating prompt", cached_audio_path)
|
||||||
|
|
||||||
# MLX voice prompt format - store audio path and text
|
# MLX voice prompt format - store audio path and text
|
||||||
# The model will process this during generation
|
# The model will process this during generation
|
||||||
voice_prompt_items = {
|
voice_prompt_items = {
|
||||||
"ref_audio": str(audio_path),
|
"ref_audio": str(audio_path),
|
||||||
"ref_text": reference_text,
|
"ref_text": reference_text,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cache if enabled
|
# Cache if enabled
|
||||||
if use_cache:
|
if use_cache:
|
||||||
cache_key = get_cache_key(audio_path, reference_text)
|
cache_key = get_cache_key(audio_path, reference_text)
|
||||||
cache_voice_prompt(cache_key, voice_prompt_items)
|
cache_voice_prompt(cache_key, voice_prompt_items)
|
||||||
|
|
||||||
return voice_prompt_items, False
|
return voice_prompt_items, False
|
||||||
|
|
||||||
async def combine_voice_prompts(
|
async def combine_voice_prompts(self, audio_paths, reference_texts):
|
||||||
self,
|
return await _combine_voice_prompts(audio_paths, reference_texts)
|
||||||
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(
|
async def generate(
|
||||||
self,
|
self,
|
||||||
text: str,
|
text: str,
|
||||||
@@ -251,31 +212,33 @@ class MLXTTSBackend:
|
|||||||
"""
|
"""
|
||||||
await self.load_model_async(None)
|
await self.load_model_async(None)
|
||||||
|
|
||||||
print(f"Generating audio for text: {text}")
|
logger.info("Generating audio for text: %s", text)
|
||||||
|
|
||||||
def _generate_sync():
|
def _generate_sync():
|
||||||
"""Run synchronous generation in thread pool."""
|
"""Run synchronous generation in thread pool."""
|
||||||
# MLX generate() returns a generator yielding GenerationResult objects
|
# MLX generate() returns a generator yielding GenerationResult objects
|
||||||
audio_chunks = []
|
audio_chunks = []
|
||||||
sample_rate = 24000
|
sample_rate = 24000
|
||||||
|
lang = LANGUAGE_CODE_TO_NAME.get(language, "auto")
|
||||||
|
|
||||||
# Set seed if provided (MLX uses numpy random)
|
# Set seed if provided (MLX uses numpy random)
|
||||||
if seed is not None:
|
if seed is not None:
|
||||||
import mlx.core as mx
|
import mlx.core as mx
|
||||||
|
|
||||||
np.random.seed(seed)
|
np.random.seed(seed)
|
||||||
mx.random.seed(seed)
|
mx.random.seed(seed)
|
||||||
|
|
||||||
# Extract voice prompt info
|
# Extract voice prompt info
|
||||||
ref_audio = voice_prompt.get("ref_audio") or voice_prompt.get("ref_audio_path")
|
ref_audio = voice_prompt.get("ref_audio") or voice_prompt.get("ref_audio_path")
|
||||||
ref_text = voice_prompt.get("ref_text", "")
|
ref_text = voice_prompt.get("ref_text", "")
|
||||||
|
|
||||||
# Validate that the audio file exists
|
# Validate that the audio file exists
|
||||||
if ref_audio and not Path(ref_audio).exists():
|
if ref_audio and not Path(ref_audio).exists():
|
||||||
print(f"Warning: Audio file not found: {ref_audio}")
|
logger.warning("Audio file not found: %s", ref_audio)
|
||||||
print("This may be due to a cached voice prompt referencing a deleted temp file.")
|
logger.warning("This may be due to a cached voice prompt referencing a deleted temp file.")
|
||||||
print("Regenerating without voice prompt.")
|
logger.warning("Regenerating without voice prompt.")
|
||||||
ref_audio = None
|
ref_audio = None
|
||||||
|
|
||||||
# Check if model supports voice cloning via generate method
|
# Check if model supports voice cloning via generate method
|
||||||
# MLX API may support ref_audio parameter directly
|
# MLX API may support ref_audio parameter directly
|
||||||
try:
|
try:
|
||||||
@@ -283,36 +246,37 @@ class MLXTTSBackend:
|
|||||||
if ref_audio:
|
if ref_audio:
|
||||||
# Check if generate accepts ref_audio parameter
|
# Check if generate accepts ref_audio parameter
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
sig = inspect.signature(self.model.generate)
|
sig = inspect.signature(self.model.generate)
|
||||||
if "ref_audio" in sig.parameters:
|
if "ref_audio" in sig.parameters:
|
||||||
# Generate with voice cloning
|
# Generate with voice cloning
|
||||||
for result in self.model.generate(text, ref_audio=ref_audio, ref_text=ref_text):
|
for result in self.model.generate(text, ref_audio=ref_audio, ref_text=ref_text, lang_code=lang):
|
||||||
audio_chunks.append(np.array(result.audio))
|
audio_chunks.append(np.array(result.audio))
|
||||||
sample_rate = result.sample_rate
|
sample_rate = result.sample_rate
|
||||||
else:
|
else:
|
||||||
# Fallback: generate without voice cloning
|
# Fallback: generate without voice cloning
|
||||||
for result in self.model.generate(text):
|
for result in self.model.generate(text, lang_code=lang):
|
||||||
audio_chunks.append(np.array(result.audio))
|
audio_chunks.append(np.array(result.audio))
|
||||||
sample_rate = result.sample_rate
|
sample_rate = result.sample_rate
|
||||||
else:
|
else:
|
||||||
# No voice prompt, generate normally
|
# No voice prompt, generate normally
|
||||||
for result in self.model.generate(text):
|
for result in self.model.generate(text, lang_code=lang):
|
||||||
audio_chunks.append(np.array(result.audio))
|
audio_chunks.append(np.array(result.audio))
|
||||||
sample_rate = result.sample_rate
|
sample_rate = result.sample_rate
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# If voice cloning fails, try without it
|
# If voice cloning fails, try without it
|
||||||
print(f"Warning: Voice cloning failed, generating without voice prompt: {e}")
|
logger.warning("Voice cloning failed, generating without voice prompt: %s", e)
|
||||||
for result in self.model.generate(text):
|
for result in self.model.generate(text, lang_code=lang):
|
||||||
audio_chunks.append(np.array(result.audio))
|
audio_chunks.append(np.array(result.audio))
|
||||||
sample_rate = result.sample_rate
|
sample_rate = result.sample_rate
|
||||||
|
|
||||||
# Concatenate all chunks
|
# Concatenate all chunks
|
||||||
if audio_chunks:
|
if audio_chunks:
|
||||||
audio = np.concatenate([np.asarray(chunk, dtype=np.float32) for chunk in audio_chunks])
|
audio = np.concatenate([np.asarray(chunk, dtype=np.float32) for chunk in audio_chunks])
|
||||||
else:
|
else:
|
||||||
# Fallback: empty audio
|
# Fallback: empty audio
|
||||||
audio = np.array([], dtype=np.float32)
|
audio = np.array([], dtype=np.float32)
|
||||||
|
|
||||||
return audio, sample_rate
|
return audio, sample_rate
|
||||||
|
|
||||||
# Run blocking inference in thread pool
|
# Run blocking inference in thread pool
|
||||||
@@ -323,113 +287,60 @@ class MLXTTSBackend:
|
|||||||
|
|
||||||
class MLXSTTBackend:
|
class MLXSTTBackend:
|
||||||
"""MLX-based STT backend using mlx-audio Whisper."""
|
"""MLX-based STT backend using mlx-audio Whisper."""
|
||||||
|
|
||||||
def __init__(self, model_size: str = "base"):
|
def __init__(self, model_size: str = "base"):
|
||||||
self.model = None
|
self.model = None
|
||||||
self.model_size = model_size
|
self.model_size = model_size
|
||||||
|
|
||||||
def is_loaded(self) -> bool:
|
def is_loaded(self) -> bool:
|
||||||
"""Check if model is loaded."""
|
"""Check if model is loaded."""
|
||||||
return self.model is not None
|
return self.model is not None
|
||||||
|
|
||||||
|
def _is_model_cached(self, model_size: str) -> bool:
|
||||||
|
hf_repo = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
|
||||||
|
return is_model_cached(hf_repo, weight_extensions=(".safetensors", ".bin", ".npz"))
|
||||||
|
|
||||||
async def load_model_async(self, model_size: Optional[str] = None):
|
async def load_model_async(self, model_size: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Lazy load the MLX Whisper model.
|
Lazy load the MLX Whisper model.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model_size: Model size (tiny, base, small, medium, large)
|
model_size: Model size (tiny, base, small, medium, large)
|
||||||
"""
|
"""
|
||||||
if model_size is None:
|
if model_size is None:
|
||||||
model_size = self.model_size
|
model_size = self.model_size
|
||||||
|
|
||||||
if self.model is not None and self.model_size == model_size:
|
if self.model is not None and self.model_size == model_size:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Run blocking load in thread pool
|
# Run blocking load in thread pool
|
||||||
await asyncio.to_thread(self._load_model_sync, model_size)
|
await asyncio.to_thread(self._load_model_sync, model_size)
|
||||||
|
|
||||||
# Alias for compatibility
|
# Alias for compatibility
|
||||||
load_model = load_model_async
|
load_model = load_model_async
|
||||||
|
|
||||||
def _load_model_sync(self, model_size: str):
|
def _load_model_sync(self, model_size: str):
|
||||||
"""Synchronous model loading."""
|
"""Synchronous model loading."""
|
||||||
try:
|
progress_model_name = f"whisper-{model_size}"
|
||||||
# IMPORTANT: Set up progress tracking BEFORE importing mlx_audio
|
is_cached = self._is_model_cached(model_size)
|
||||||
# This ensures tqdm is patched before any HuggingFace Hub imports
|
|
||||||
progress_manager = get_progress_manager()
|
|
||||||
progress_model_name = f"whisper-{model_size}"
|
|
||||||
|
|
||||||
# Set up progress callback and tracker
|
with model_load_progress(progress_model_name, is_cached):
|
||||||
progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
|
|
||||||
tracker = HFProgressTracker(progress_callback)
|
|
||||||
|
|
||||||
# Patch tqdm BEFORE importing mlx_audio
|
|
||||||
# This is critical because mlx_audio imports huggingface_hub which imports tqdm
|
|
||||||
print("[DEBUG] Starting tqdm patch BEFORE mlx_audio import")
|
|
||||||
tracker_context = tracker.patch_download()
|
|
||||||
tracker_context.__enter__()
|
|
||||||
print("[DEBUG] tqdm patched, now importing mlx_audio")
|
|
||||||
|
|
||||||
# NOW import mlx_audio - it will use our patched tqdm
|
|
||||||
from mlx_audio.stt import load
|
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}")
|
||||||
model_name = f"openai/whisper-{model_size}"
|
logger.info("Loading MLX Whisper model %s...", model_size)
|
||||||
|
self.model = load(model_name)
|
||||||
# Start tracking download task
|
|
||||||
task_manager = get_task_manager()
|
|
||||||
task_manager.start_download(progress_model_name)
|
|
||||||
|
|
||||||
print(f"Loading MLX Whisper model {model_size}...")
|
self.model_size = model_size
|
||||||
|
logger.info("MLX Whisper model %s loaded successfully", model_size)
|
||||||
|
|
||||||
# Initialize progress state
|
|
||||||
progress_manager.update_progress(
|
|
||||||
model_name=progress_model_name,
|
|
||||||
current=0,
|
|
||||||
total=1,
|
|
||||||
filename="",
|
|
||||||
status="downloading",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Load the model (tqdm is already patched from above)
|
|
||||||
try:
|
|
||||||
self.model = load(model_name)
|
|
||||||
finally:
|
|
||||||
# Exit the patch context
|
|
||||||
tracker_context.__exit__(None, None, None)
|
|
||||||
|
|
||||||
self.model_size = model_size
|
|
||||||
|
|
||||||
# Mark as complete
|
|
||||||
progress_manager.mark_complete(progress_model_name)
|
|
||||||
task_manager.complete_download(progress_model_name)
|
|
||||||
|
|
||||||
print(f"MLX Whisper model {model_size} loaded successfully")
|
|
||||||
|
|
||||||
except ImportError as e:
|
|
||||||
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):
|
def unload_model(self):
|
||||||
"""Unload the model to free memory."""
|
"""Unload the model to free memory."""
|
||||||
if self.model is not None:
|
if self.model is not None:
|
||||||
del self.model
|
del self.model
|
||||||
self.model = None
|
self.model = None
|
||||||
print("MLX Whisper model unloaded")
|
logger.info("MLX Whisper model unloaded")
|
||||||
|
|
||||||
async def transcribe(
|
async def transcribe(
|
||||||
self,
|
self,
|
||||||
audio_path: str,
|
audio_path: str,
|
||||||
|
|||||||
+101
-227
@@ -4,47 +4,47 @@ PyTorch backend implementation for TTS and STT.
|
|||||||
|
|
||||||
from typing import Optional, List, Tuple
|
from typing import Optional, List, Tuple
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import torch
|
import torch
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from . import TTSBackend, STTBackend
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
from . import TTSBackend, STTBackend, LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS
|
||||||
|
from .base import (
|
||||||
|
is_model_cached,
|
||||||
|
get_torch_device,
|
||||||
|
combine_voice_prompts as _combine_voice_prompts,
|
||||||
|
model_load_progress,
|
||||||
|
)
|
||||||
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
|
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
|
||||||
from ..utils.audio import normalize_audio, load_audio
|
from ..utils.audio import 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
|
|
||||||
|
|
||||||
|
|
||||||
class PyTorchTTSBackend:
|
class PyTorchTTSBackend:
|
||||||
"""PyTorch-based TTS backend using Qwen3-TTS."""
|
"""PyTorch-based TTS backend using Qwen3-TTS."""
|
||||||
|
|
||||||
def __init__(self, model_size: str = "1.7B"):
|
def __init__(self, model_size: str = "1.7B"):
|
||||||
self.model = None
|
self.model = None
|
||||||
self.model_size = model_size
|
self.model_size = model_size
|
||||||
self.device = self._get_device()
|
self.device = self._get_device()
|
||||||
self._current_model_size = None
|
self._current_model_size = None
|
||||||
|
|
||||||
def _get_device(self) -> str:
|
def _get_device(self) -> str:
|
||||||
"""Get the best available device."""
|
"""Get the best available device."""
|
||||||
if torch.cuda.is_available():
|
return get_torch_device(allow_xpu=True, allow_directml=True)
|
||||||
return "cuda"
|
|
||||||
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
|
||||||
# MPS can have issues, use CPU for stability
|
|
||||||
return "cpu"
|
|
||||||
return "cpu"
|
|
||||||
|
|
||||||
def is_loaded(self) -> bool:
|
def is_loaded(self) -> bool:
|
||||||
"""Check if model is loaded."""
|
"""Check if model is loaded."""
|
||||||
return self.model is not None
|
return self.model is not None
|
||||||
|
|
||||||
def _get_model_path(self, model_size: str) -> str:
|
def _get_model_path(self, model_size: str) -> str:
|
||||||
"""
|
"""
|
||||||
Get the HuggingFace Hub model ID.
|
Get the HuggingFace Hub model ID.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model_size: Model size (1.7B or 0.6B)
|
model_size: Model size (1.7B or 0.6B)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
HuggingFace Hub model ID
|
HuggingFace Hub model ID
|
||||||
"""
|
"""
|
||||||
@@ -52,123 +52,79 @@ class PyTorchTTSBackend:
|
|||||||
"1.7B": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
|
"1.7B": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
|
||||||
"0.6B": "Qwen/Qwen3-TTS-12Hz-0.6B-Base",
|
"0.6B": "Qwen/Qwen3-TTS-12Hz-0.6B-Base",
|
||||||
}
|
}
|
||||||
|
|
||||||
if model_size not in hf_model_map:
|
if model_size not in hf_model_map:
|
||||||
raise ValueError(f"Unknown model size: {model_size}")
|
raise ValueError(f"Unknown model size: {model_size}")
|
||||||
|
|
||||||
return hf_model_map[model_size]
|
return hf_model_map[model_size]
|
||||||
|
|
||||||
|
def _is_model_cached(self, model_size: str) -> bool:
|
||||||
|
return is_model_cached(self._get_model_path(model_size))
|
||||||
|
|
||||||
async def load_model_async(self, model_size: Optional[str] = None):
|
async def load_model_async(self, model_size: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Lazy load the TTS model with automatic downloading from HuggingFace Hub.
|
Lazy load the TTS model with automatic downloading from HuggingFace Hub.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model_size: Model size to load (1.7B or 0.6B)
|
model_size: Model size to load (1.7B or 0.6B)
|
||||||
"""
|
"""
|
||||||
if model_size is None:
|
if model_size is None:
|
||||||
model_size = self.model_size
|
model_size = self.model_size
|
||||||
|
|
||||||
# If already loaded with correct size, return
|
# If already loaded with correct size, return
|
||||||
if self.model is not None and self._current_model_size == model_size:
|
if self.model is not None and self._current_model_size == model_size:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Unload existing model if different size requested
|
# Unload existing model if different size requested
|
||||||
if self.model is not None and self._current_model_size != model_size:
|
if self.model is not None and self._current_model_size != model_size:
|
||||||
self.unload_model()
|
self.unload_model()
|
||||||
|
|
||||||
# Run blocking load in thread pool
|
# Run blocking load in thread pool
|
||||||
await asyncio.to_thread(self._load_model_sync, model_size)
|
await asyncio.to_thread(self._load_model_sync, model_size)
|
||||||
|
|
||||||
# Alias for compatibility
|
# Alias for compatibility
|
||||||
load_model = load_model_async
|
load_model = load_model_async
|
||||||
|
|
||||||
def _load_model_sync(self, model_size: str):
|
def _load_model_sync(self, model_size: str):
|
||||||
"""Synchronous model loading."""
|
"""Synchronous model loading."""
|
||||||
try:
|
model_name = f"qwen-tts-{model_size}"
|
||||||
# IMPORTANT: Set up progress tracking BEFORE importing qwen_tts
|
is_cached = self._is_model_cached(model_size)
|
||||||
# This ensures tqdm is patched before any HuggingFace Hub imports
|
|
||||||
progress_manager = get_progress_manager()
|
|
||||||
model_name = f"qwen-tts-{model_size}"
|
|
||||||
|
|
||||||
# Set up progress callback and tracker
|
with model_load_progress(model_name, is_cached):
|
||||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
|
||||||
tracker = HFProgressTracker(progress_callback)
|
|
||||||
|
|
||||||
# Patch tqdm BEFORE importing qwen_tts
|
|
||||||
tracker_context = tracker.patch_download()
|
|
||||||
tracker_context.__enter__()
|
|
||||||
|
|
||||||
# NOW import qwen_tts - it will use our patched tqdm
|
|
||||||
from qwen_tts import Qwen3TTSModel
|
from qwen_tts import Qwen3TTSModel
|
||||||
|
|
||||||
# Get model path (local or HuggingFace Hub ID)
|
|
||||||
model_path = self._get_model_path(model_size)
|
model_path = self._get_model_path(model_size)
|
||||||
|
logger.info("Loading TTS model %s on %s...", model_size, self.device)
|
||||||
|
|
||||||
print(f"Loading TTS model {model_size} on {self.device}...")
|
if self.device == "cpu":
|
||||||
|
self.model = Qwen3TTSModel.from_pretrained(
|
||||||
# Start tracking download task
|
model_path,
|
||||||
task_manager = get_task_manager()
|
torch_dtype=torch.float32,
|
||||||
task_manager.start_download(model_name)
|
low_cpu_mem_usage=False,
|
||||||
|
)
|
||||||
# Initialize progress state to show download has started
|
else:
|
||||||
progress_manager.update_progress(
|
|
||||||
model_name=model_name,
|
|
||||||
current=0,
|
|
||||||
total=1, # Set to 1 initially, will be updated by callback
|
|
||||||
filename="",
|
|
||||||
status="downloading",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Load the model (tqdm is already patched from above)
|
|
||||||
try:
|
|
||||||
self.model = Qwen3TTSModel.from_pretrained(
|
self.model = Qwen3TTSModel.from_pretrained(
|
||||||
model_path,
|
model_path,
|
||||||
device_map=self.device,
|
device_map=self.device,
|
||||||
torch_dtype=torch.float32 if self.device == "cpu" else torch.bfloat16,
|
torch_dtype=torch.bfloat16,
|
||||||
)
|
)
|
||||||
finally:
|
|
||||||
# Exit the patch context
|
self._current_model_size = model_size
|
||||||
tracker_context.__exit__(None, None, None)
|
self.model_size = model_size
|
||||||
|
logger.info("TTS model %s loaded successfully", model_size)
|
||||||
# Mark as complete
|
|
||||||
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):
|
def unload_model(self):
|
||||||
"""Unload the model to free memory."""
|
"""Unload the model to free memory."""
|
||||||
if self.model is not None:
|
if self.model is not None:
|
||||||
del self.model
|
del self.model
|
||||||
self.model = None
|
self.model = None
|
||||||
self._current_model_size = None
|
self._current_model_size = None
|
||||||
|
|
||||||
if torch.cuda.is_available():
|
if torch.cuda.is_available():
|
||||||
torch.cuda.empty_cache()
|
torch.cuda.empty_cache()
|
||||||
|
|
||||||
print("TTS model unloaded")
|
logger.info("TTS model unloaded")
|
||||||
|
|
||||||
async def create_voice_prompt(
|
async def create_voice_prompt(
|
||||||
self,
|
self,
|
||||||
audio_path: str,
|
audio_path: str,
|
||||||
@@ -177,17 +133,17 @@ class PyTorchTTSBackend:
|
|||||||
) -> Tuple[dict, bool]:
|
) -> Tuple[dict, bool]:
|
||||||
"""
|
"""
|
||||||
Create voice prompt from reference audio.
|
Create voice prompt from reference audio.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
audio_path: Path to reference audio file
|
audio_path: Path to reference audio file
|
||||||
reference_text: Transcript of reference audio
|
reference_text: Transcript of reference audio
|
||||||
use_cache: Whether to use cached prompt if available
|
use_cache: Whether to use cached prompt if available
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (voice_prompt_dict, was_cached)
|
Tuple of (voice_prompt_dict, was_cached)
|
||||||
"""
|
"""
|
||||||
await self.load_model_async(None)
|
await self.load_model_async(None)
|
||||||
|
|
||||||
# Check cache if enabled
|
# Check cache if enabled
|
||||||
if use_cache:
|
if use_cache:
|
||||||
cache_key = get_cache_key(audio_path, reference_text)
|
cache_key = get_cache_key(audio_path, reference_text)
|
||||||
@@ -203,7 +159,7 @@ class PyTorchTTSBackend:
|
|||||||
# Legacy cache format - convert to dict
|
# Legacy cache format - convert to dict
|
||||||
# This shouldn't happen in practice, but handle it
|
# This shouldn't happen in practice, but handle it
|
||||||
return {"prompt": cached_prompt}, True
|
return {"prompt": cached_prompt}, True
|
||||||
|
|
||||||
def _create_prompt_sync():
|
def _create_prompt_sync():
|
||||||
"""Run synchronous voice prompt creation in thread pool."""
|
"""Run synchronous voice prompt creation in thread pool."""
|
||||||
return self.model.create_voice_clone_prompt(
|
return self.model.create_voice_clone_prompt(
|
||||||
@@ -211,48 +167,24 @@ class PyTorchTTSBackend:
|
|||||||
ref_text=reference_text,
|
ref_text=reference_text,
|
||||||
x_vector_only_mode=False,
|
x_vector_only_mode=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Run blocking operation in thread pool
|
# Run blocking operation in thread pool
|
||||||
voice_prompt_items = await asyncio.to_thread(_create_prompt_sync)
|
voice_prompt_items = await asyncio.to_thread(_create_prompt_sync)
|
||||||
|
|
||||||
# Cache if enabled
|
# Cache if enabled
|
||||||
if use_cache:
|
if use_cache:
|
||||||
cache_key = get_cache_key(audio_path, reference_text)
|
cache_key = get_cache_key(audio_path, reference_text)
|
||||||
cache_voice_prompt(cache_key, voice_prompt_items)
|
cache_voice_prompt(cache_key, voice_prompt_items)
|
||||||
|
|
||||||
return voice_prompt_items, False
|
return voice_prompt_items, False
|
||||||
|
|
||||||
async def combine_voice_prompts(
|
async def combine_voice_prompts(
|
||||||
self,
|
self,
|
||||||
audio_paths: List[str],
|
audio_paths: List[str],
|
||||||
reference_texts: List[str],
|
reference_texts: List[str],
|
||||||
) -> Tuple[np.ndarray, str]:
|
) -> Tuple[np.ndarray, str]:
|
||||||
"""
|
return await _combine_voice_prompts(audio_paths, reference_texts)
|
||||||
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(
|
async def generate(
|
||||||
self,
|
self,
|
||||||
text: str,
|
text: str,
|
||||||
@@ -289,6 +221,7 @@ class PyTorchTTSBackend:
|
|||||||
wavs, sample_rate = self.model.generate_voice_clone(
|
wavs, sample_rate = self.model.generate_voice_clone(
|
||||||
text=text,
|
text=text,
|
||||||
voice_clone_prompt=voice_prompt,
|
voice_clone_prompt=voice_prompt,
|
||||||
|
language=LANGUAGE_CODE_TO_NAME.get(language, "auto"),
|
||||||
instruct=instruct,
|
instruct=instruct,
|
||||||
)
|
)
|
||||||
return wavs[0], sample_rate
|
return wavs[0], sample_rate
|
||||||
@@ -301,26 +234,25 @@ class PyTorchTTSBackend:
|
|||||||
|
|
||||||
class PyTorchSTTBackend:
|
class PyTorchSTTBackend:
|
||||||
"""PyTorch-based STT backend using Whisper."""
|
"""PyTorch-based STT backend using Whisper."""
|
||||||
|
|
||||||
def __init__(self, model_size: str = "base"):
|
def __init__(self, model_size: str = "base"):
|
||||||
self.model = None
|
self.model = None
|
||||||
self.processor = None
|
self.processor = None
|
||||||
self.model_size = model_size
|
self.model_size = model_size
|
||||||
self.device = self._get_device()
|
self.device = self._get_device()
|
||||||
|
|
||||||
def _get_device(self) -> str:
|
def _get_device(self) -> str:
|
||||||
"""Get the best available device."""
|
"""Get the best available device."""
|
||||||
if torch.cuda.is_available():
|
return get_torch_device(allow_xpu=True, allow_directml=True)
|
||||||
return "cuda"
|
|
||||||
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
|
||||||
# MPS support for Whisper
|
|
||||||
return "cpu" # Use CPU for stability
|
|
||||||
return "cpu"
|
|
||||||
|
|
||||||
def is_loaded(self) -> bool:
|
def is_loaded(self) -> bool:
|
||||||
"""Check if model is loaded."""
|
"""Check if model is loaded."""
|
||||||
return self.model is not None
|
return self.model is not None
|
||||||
|
|
||||||
|
def _is_model_cached(self, model_size: str) -> bool:
|
||||||
|
hf_repo = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
|
||||||
|
return is_model_cached(hf_repo)
|
||||||
|
|
||||||
async def load_model_async(self, model_size: Optional[str] = None):
|
async def load_model_async(self, model_size: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Lazy load the Whisper model.
|
Lazy load the Whisper model.
|
||||||
@@ -328,92 +260,35 @@ class PyTorchSTTBackend:
|
|||||||
Args:
|
Args:
|
||||||
model_size: Model size (tiny, base, small, medium, large)
|
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:
|
if model_size is None:
|
||||||
model_size = self.model_size
|
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:
|
if self.model is not None and self.model_size == model_size:
|
||||||
print(f"[DEBUG] Early return - model already loaded")
|
|
||||||
return
|
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)
|
await asyncio.to_thread(self._load_model_sync, model_size)
|
||||||
print(f"[DEBUG] asyncio.to_thread completed")
|
|
||||||
|
|
||||||
# Alias for compatibility
|
# Alias for compatibility
|
||||||
load_model = load_model_async
|
load_model = load_model_async
|
||||||
|
|
||||||
def _load_model_sync(self, model_size: str):
|
def _load_model_sync(self, model_size: str):
|
||||||
"""Synchronous model loading."""
|
"""Synchronous model loading."""
|
||||||
print(f"[DEBUG] _load_model_sync called for Whisper {model_size}")
|
progress_model_name = f"whisper-{model_size}"
|
||||||
try:
|
is_cached = self._is_model_cached(model_size)
|
||||||
# IMPORTANT: Set up progress tracking BEFORE importing transformers
|
|
||||||
# This ensures tqdm is patched before any HuggingFace Hub imports
|
|
||||||
progress_manager = get_progress_manager()
|
|
||||||
progress_model_name = f"whisper-{model_size}"
|
|
||||||
|
|
||||||
# Set up progress callback and tracker
|
with model_load_progress(progress_model_name, is_cached):
|
||||||
progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
|
|
||||||
tracker = HFProgressTracker(progress_callback)
|
|
||||||
|
|
||||||
# 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")
|
|
||||||
|
|
||||||
# NOW import transformers - it will use our patched tqdm
|
|
||||||
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
||||||
|
|
||||||
model_name = f"openai/whisper-{model_size}"
|
model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
|
||||||
print(f"[DEBUG] Model name: {model_name}")
|
logger.info("Loading Whisper model %s on %s...", model_size, self.device)
|
||||||
|
|
||||||
# Start tracking download task
|
self.processor = WhisperProcessor.from_pretrained(model_name)
|
||||||
task_manager = get_task_manager()
|
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
|
||||||
task_manager.start_download(progress_model_name)
|
|
||||||
print(f"[DEBUG] Task manager started download")
|
|
||||||
|
|
||||||
print(f"Loading Whisper model {model_size} on {self.device}...")
|
self.model.to(self.device)
|
||||||
|
self.model_size = model_size
|
||||||
|
logger.info("Whisper model %s loaded successfully", model_size)
|
||||||
|
|
||||||
# Initialize progress state to show download has started
|
|
||||||
print(f"[DEBUG] Calling update_progress...")
|
|
||||||
progress_manager.update_progress(
|
|
||||||
model_name=progress_model_name,
|
|
||||||
current=0,
|
|
||||||
total=1, # Set to 1 initially, will be updated by callback
|
|
||||||
filename="",
|
|
||||||
status="downloading",
|
|
||||||
)
|
|
||||||
print(f"[DEBUG] update_progress called, listeners: {len(progress_manager._listeners.get(progress_model_name, []))}")
|
|
||||||
|
|
||||||
# Load models (tqdm is already patched from above)
|
|
||||||
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)
|
|
||||||
|
|
||||||
self.model.to(self.device)
|
|
||||||
self.model_size = model_size
|
|
||||||
|
|
||||||
# Mark as complete
|
|
||||||
progress_manager.mark_complete(progress_model_name)
|
|
||||||
task_manager.complete_download(progress_model_name)
|
|
||||||
|
|
||||||
print(f"Whisper model {model_size} loaded successfully")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
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):
|
def unload_model(self):
|
||||||
"""Unload the model to free memory."""
|
"""Unload the model to free memory."""
|
||||||
if self.model is not None:
|
if self.model is not None:
|
||||||
@@ -421,12 +296,12 @@ class PyTorchSTTBackend:
|
|||||||
del self.processor
|
del self.processor
|
||||||
self.model = None
|
self.model = None
|
||||||
self.processor = None
|
self.processor = None
|
||||||
|
|
||||||
if torch.cuda.is_available():
|
if torch.cuda.is_available():
|
||||||
torch.cuda.empty_cache()
|
torch.cuda.empty_cache()
|
||||||
|
|
||||||
print("Whisper model unloaded")
|
logger.info("Whisper model unloaded")
|
||||||
|
|
||||||
async def transcribe(
|
async def transcribe(
|
||||||
self,
|
self,
|
||||||
audio_path: str,
|
audio_path: str,
|
||||||
@@ -434,21 +309,21 @@ class PyTorchSTTBackend:
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Transcribe audio to text.
|
Transcribe audio to text.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
audio_path: Path to audio file
|
audio_path: Path to audio file
|
||||||
language: Optional language hint (en or zh)
|
language: Optional language hint (en or zh)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Transcribed text
|
Transcribed text
|
||||||
"""
|
"""
|
||||||
await self.load_model_async(None)
|
await self.load_model_async(None)
|
||||||
|
|
||||||
def _transcribe_sync():
|
def _transcribe_sync():
|
||||||
"""Run synchronous transcription in thread pool."""
|
"""Run synchronous transcription in thread pool."""
|
||||||
# Load audio
|
# Load audio
|
||||||
audio, sr = load_audio(audio_path, sample_rate=16000)
|
audio, sr = load_audio(audio_path, sample_rate=16000)
|
||||||
|
|
||||||
# Process audio
|
# Process audio
|
||||||
inputs = self.processor(
|
inputs = self.processor(
|
||||||
audio,
|
audio,
|
||||||
@@ -456,31 +331,30 @@ class PyTorchSTTBackend:
|
|||||||
return_tensors="pt",
|
return_tensors="pt",
|
||||||
)
|
)
|
||||||
inputs = inputs.to(self.device)
|
inputs = inputs.to(self.device)
|
||||||
|
|
||||||
# Set language if provided
|
# Generate transcription
|
||||||
forced_decoder_ids = None
|
# If language is provided, force it; otherwise let Whisper auto-detect
|
||||||
|
generate_kwargs = {}
|
||||||
if language:
|
if language:
|
||||||
# Support all languages from frontend: en, zh, ja, ko, de, fr, ru, pt, es, it
|
|
||||||
# Whisper supports these and many more
|
|
||||||
forced_decoder_ids = self.processor.get_decoder_prompt_ids(
|
forced_decoder_ids = self.processor.get_decoder_prompt_ids(
|
||||||
language=language,
|
language=language,
|
||||||
task="transcribe",
|
task="transcribe",
|
||||||
)
|
)
|
||||||
|
generate_kwargs["forced_decoder_ids"] = forced_decoder_ids
|
||||||
# Generate transcription
|
|
||||||
with torch.no_grad():
|
with torch.no_grad():
|
||||||
predicted_ids = self.model.generate(
|
predicted_ids = self.model.generate(
|
||||||
inputs["input_features"],
|
inputs["input_features"],
|
||||||
forced_decoder_ids=forced_decoder_ids,
|
**generate_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Decode
|
# Decode
|
||||||
transcription = self.processor.batch_decode(
|
transcription = self.processor.batch_decode(
|
||||||
predicted_ids,
|
predicted_ids,
|
||||||
skip_special_tokens=True,
|
skip_special_tokens=True,
|
||||||
)[0]
|
)[0]
|
||||||
|
|
||||||
return transcription.strip()
|
return transcription.strip()
|
||||||
|
|
||||||
# Run blocking transcription in thread pool
|
# Run blocking transcription in thread pool
|
||||||
return await asyncio.to_thread(_transcribe_sync)
|
return await asyncio.to_thread(_transcribe_sync)
|
||||||
|
|||||||
+309
-74
@@ -1,108 +1,343 @@
|
|||||||
"""
|
"""
|
||||||
PyInstaller build script for creating standalone Python server binary.
|
PyInstaller build script for creating standalone Python server binary.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python build_binary.py # Build default (CPU) server binary
|
||||||
|
python build_binary.py --cuda # Build CUDA-enabled server binary
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import PyInstaller.__main__
|
import PyInstaller.__main__
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def is_apple_silicon():
|
def is_apple_silicon():
|
||||||
"""Check if running on Apple Silicon."""
|
"""Check if running on Apple Silicon."""
|
||||||
return platform.system() == "Darwin" and platform.machine() == "arm64"
|
return platform.system() == "Darwin" and platform.machine() == "arm64"
|
||||||
|
|
||||||
|
|
||||||
def build_server():
|
def build_server(cuda=False):
|
||||||
"""Build Python server as standalone binary."""
|
"""Build Python server as standalone binary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cuda: If True, build with CUDA support and name the binary
|
||||||
|
voicebox-server-cuda instead of voicebox-server.
|
||||||
|
"""
|
||||||
backend_dir = Path(__file__).parent
|
backend_dir = Path(__file__).parent
|
||||||
|
|
||||||
|
binary_name = "voicebox-server-cuda" if cuda else "voicebox-server"
|
||||||
|
|
||||||
# PyInstaller arguments
|
# PyInstaller arguments
|
||||||
args = [
|
args = [
|
||||||
'server.py', # Use server.py as entry point instead of main.py
|
"server.py", # Use server.py as entry point instead of main.py
|
||||||
'--onefile',
|
"--onefile",
|
||||||
'--name', 'voicebox-server',
|
"--name",
|
||||||
|
binary_name,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Hide console window on Windows only. On macOS/Linux the sidecar needs
|
||||||
|
# stdout/stderr for Tauri to capture logs.
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
args.append("--noconsole")
|
||||||
|
|
||||||
# Add local qwen_tts path if specified (for editable installs)
|
# Add local qwen_tts path if specified (for editable installs)
|
||||||
qwen_tts_path = os.getenv('QWEN_TTS_PATH')
|
qwen_tts_path = os.getenv("QWEN_TTS_PATH")
|
||||||
if qwen_tts_path and Path(qwen_tts_path).exists():
|
if qwen_tts_path and Path(qwen_tts_path).exists():
|
||||||
args.extend(['--paths', str(qwen_tts_path)])
|
args.extend(["--paths", str(qwen_tts_path)])
|
||||||
print(f"Using local qwen_tts source from: {qwen_tts_path}")
|
logger.info("Using local qwen_tts source from: %s", qwen_tts_path)
|
||||||
|
|
||||||
# Add common hidden imports
|
# Add common hidden imports
|
||||||
args.extend([
|
args.extend(
|
||||||
'--hidden-import', 'backend',
|
[
|
||||||
'--hidden-import', 'backend.main',
|
"--hidden-import",
|
||||||
'--hidden-import', 'backend.config',
|
"backend",
|
||||||
'--hidden-import', 'backend.database',
|
"--hidden-import",
|
||||||
'--hidden-import', 'backend.models',
|
"backend.main",
|
||||||
'--hidden-import', 'backend.profiles',
|
"--hidden-import",
|
||||||
'--hidden-import', 'backend.history',
|
"backend.config",
|
||||||
'--hidden-import', 'backend.tts',
|
"--hidden-import",
|
||||||
'--hidden-import', 'backend.transcribe',
|
"backend.database",
|
||||||
'--hidden-import', 'backend.platform_detect',
|
"--hidden-import",
|
||||||
'--hidden-import', 'backend.backends',
|
"backend.models",
|
||||||
'--hidden-import', 'backend.backends.pytorch_backend',
|
"--hidden-import",
|
||||||
'--hidden-import', 'backend.utils.audio',
|
"backend.services.profiles",
|
||||||
'--hidden-import', 'backend.utils.cache',
|
"--hidden-import",
|
||||||
'--hidden-import', 'backend.utils.progress',
|
"backend.services.history",
|
||||||
'--hidden-import', 'backend.utils.hf_progress',
|
"--hidden-import",
|
||||||
'--hidden-import', 'backend.utils.validation',
|
"backend.services.tts",
|
||||||
'--hidden-import', 'torch',
|
"--hidden-import",
|
||||||
'--hidden-import', 'transformers',
|
"backend.services.transcribe",
|
||||||
'--hidden-import', 'fastapi',
|
"--hidden-import",
|
||||||
'--hidden-import', 'uvicorn',
|
"backend.utils.platform_detect",
|
||||||
'--hidden-import', 'sqlalchemy',
|
"--hidden-import",
|
||||||
'--hidden-import', 'librosa',
|
"backend.backends",
|
||||||
'--hidden-import', 'soundfile',
|
"--hidden-import",
|
||||||
'--hidden-import', 'qwen_tts',
|
"backend.backends.pytorch_backend",
|
||||||
'--hidden-import', 'qwen_tts.inference',
|
"--hidden-import",
|
||||||
'--hidden-import', 'qwen_tts.inference.qwen3_tts_model',
|
"backend.utils.audio",
|
||||||
'--hidden-import', 'qwen_tts.inference.qwen3_tts_tokenizer',
|
"--hidden-import",
|
||||||
'--hidden-import', 'qwen_tts.core',
|
"backend.utils.cache",
|
||||||
'--hidden-import', 'qwen_tts.cli',
|
"--hidden-import",
|
||||||
'--copy-metadata', 'qwen-tts',
|
"backend.utils.progress",
|
||||||
'--collect-submodules', 'qwen_tts',
|
"--hidden-import",
|
||||||
'--collect-data', 'qwen_tts',
|
"backend.utils.hf_progress",
|
||||||
# Fix for pkg_resources and jaraco namespace packages
|
"--hidden-import",
|
||||||
'--hidden-import', 'pkg_resources.extern',
|
"backend.services.cuda",
|
||||||
'--collect-submodules', 'jaraco',
|
"--hidden-import",
|
||||||
])
|
"backend.services.effects",
|
||||||
|
"--hidden-import",
|
||||||
|
"backend.utils.effects",
|
||||||
|
"--hidden-import",
|
||||||
|
"backend.services.versions",
|
||||||
|
"--hidden-import",
|
||||||
|
"pedalboard",
|
||||||
|
"--hidden-import",
|
||||||
|
"chatterbox",
|
||||||
|
"--hidden-import",
|
||||||
|
"chatterbox.tts_turbo",
|
||||||
|
"--hidden-import",
|
||||||
|
"chatterbox.mtl_tts",
|
||||||
|
"--hidden-import",
|
||||||
|
"backend.backends.chatterbox_backend",
|
||||||
|
"--hidden-import",
|
||||||
|
"backend.backends.chatterbox_turbo_backend",
|
||||||
|
"--hidden-import",
|
||||||
|
"backend.backends.luxtts_backend",
|
||||||
|
"--hidden-import",
|
||||||
|
"zipvoice",
|
||||||
|
"--hidden-import",
|
||||||
|
"zipvoice.luxvoice",
|
||||||
|
"--collect-all",
|
||||||
|
"zipvoice",
|
||||||
|
"--collect-all",
|
||||||
|
"linacodec",
|
||||||
|
"--hidden-import",
|
||||||
|
"torch",
|
||||||
|
"--hidden-import",
|
||||||
|
"transformers",
|
||||||
|
"--hidden-import",
|
||||||
|
"fastapi",
|
||||||
|
"--hidden-import",
|
||||||
|
"uvicorn",
|
||||||
|
"--hidden-import",
|
||||||
|
"sqlalchemy",
|
||||||
|
"--hidden-import",
|
||||||
|
"librosa",
|
||||||
|
"--hidden-import",
|
||||||
|
"soundfile",
|
||||||
|
"--hidden-import",
|
||||||
|
"qwen_tts",
|
||||||
|
"--hidden-import",
|
||||||
|
"qwen_tts.inference",
|
||||||
|
"--hidden-import",
|
||||||
|
"qwen_tts.inference.qwen3_tts_model",
|
||||||
|
"--hidden-import",
|
||||||
|
"qwen_tts.inference.qwen3_tts_tokenizer",
|
||||||
|
"--hidden-import",
|
||||||
|
"qwen_tts.core",
|
||||||
|
"--hidden-import",
|
||||||
|
"qwen_tts.cli",
|
||||||
|
"--copy-metadata",
|
||||||
|
"qwen-tts",
|
||||||
|
"--copy-metadata",
|
||||||
|
"requests",
|
||||||
|
"--copy-metadata",
|
||||||
|
"transformers",
|
||||||
|
"--copy-metadata",
|
||||||
|
"huggingface-hub",
|
||||||
|
"--copy-metadata",
|
||||||
|
"tokenizers",
|
||||||
|
"--copy-metadata",
|
||||||
|
"safetensors",
|
||||||
|
"--copy-metadata",
|
||||||
|
"tqdm",
|
||||||
|
"--hidden-import",
|
||||||
|
"requests",
|
||||||
|
"--collect-submodules",
|
||||||
|
"qwen_tts",
|
||||||
|
"--collect-data",
|
||||||
|
"qwen_tts",
|
||||||
|
# Fix for pkg_resources and jaraco namespace packages
|
||||||
|
"--hidden-import",
|
||||||
|
"pkg_resources.extern",
|
||||||
|
"--collect-submodules",
|
||||||
|
"jaraco",
|
||||||
|
# inflect uses typeguard @typechecked which calls inspect.getsource()
|
||||||
|
# at import time — needs .py source files, not just .pyc bytecode
|
||||||
|
"--collect-all",
|
||||||
|
"inflect",
|
||||||
|
# perth ships pretrained watermark model files (hparams.yaml, .pth.tar)
|
||||||
|
# in perth/perth_net/pretrained/ — needed by chatterbox at runtime
|
||||||
|
"--collect-all",
|
||||||
|
"perth",
|
||||||
|
# piper_phonemize ships espeak-ng-data/ (phoneme tables, language dicts)
|
||||||
|
# needed by LuxTTS for text-to-phoneme conversion
|
||||||
|
"--collect-all",
|
||||||
|
"piper_phonemize",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
# Add MLX-specific imports if building on Apple Silicon
|
# Add CUDA-specific hidden imports
|
||||||
if is_apple_silicon():
|
if cuda:
|
||||||
print("Building for Apple Silicon - including MLX dependencies")
|
logger.info("Building with CUDA support")
|
||||||
args.extend([
|
args.extend(
|
||||||
'--hidden-import', 'backend.backends.mlx_backend',
|
[
|
||||||
'--hidden-import', 'mlx',
|
"--hidden-import",
|
||||||
'--hidden-import', 'mlx.core',
|
"torch.cuda",
|
||||||
'--hidden-import', 'mlx.nn',
|
"--hidden-import",
|
||||||
'--hidden-import', 'mlx_audio',
|
"torch.backends.cudnn",
|
||||||
'--hidden-import', 'mlx_audio.tts',
|
]
|
||||||
'--hidden-import', 'mlx_audio.stt',
|
)
|
||||||
'--collect-submodules', 'mlx',
|
|
||||||
'--collect-submodules', 'mlx_audio',
|
|
||||||
# Collect MLX data files including Metal shader libraries (.metallib)
|
|
||||||
'--collect-data', 'mlx',
|
|
||||||
'--collect-data', 'mlx_audio',
|
|
||||||
])
|
|
||||||
else:
|
else:
|
||||||
print("Building for non-Apple Silicon platform - PyTorch only")
|
# Exclude NVIDIA CUDA packages from CPU-only builds to keep binary small.
|
||||||
|
# When building from a venv with CUDA torch installed, PyInstaller would
|
||||||
|
# bundle ~3GB of NVIDIA shared libraries. We exclude both the Python
|
||||||
|
# modules and the binary DLLs.
|
||||||
|
nvidia_packages = [
|
||||||
|
"nvidia",
|
||||||
|
"nvidia.cublas",
|
||||||
|
"nvidia.cuda_cupti",
|
||||||
|
"nvidia.cuda_nvrtc",
|
||||||
|
"nvidia.cuda_runtime",
|
||||||
|
"nvidia.cudnn",
|
||||||
|
"nvidia.cufft",
|
||||||
|
"nvidia.curand",
|
||||||
|
"nvidia.cusolver",
|
||||||
|
"nvidia.cusparse",
|
||||||
|
"nvidia.nccl",
|
||||||
|
"nvidia.nvjitlink",
|
||||||
|
"nvidia.nvtx",
|
||||||
|
]
|
||||||
|
for pkg in nvidia_packages:
|
||||||
|
args.extend(["--exclude-module", pkg])
|
||||||
|
|
||||||
args.extend([
|
# Add MLX-specific imports if building on Apple Silicon (never for CUDA builds)
|
||||||
'--noconfirm',
|
if is_apple_silicon() and not cuda:
|
||||||
'--clean',
|
logger.info("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:
|
||||||
|
logger.info("Building for non-Apple Silicon platform - PyTorch only")
|
||||||
|
|
||||||
|
dist_dir = str(backend_dir / "dist")
|
||||||
|
build_dir = str(backend_dir / "build")
|
||||||
|
|
||||||
|
args.extend(
|
||||||
|
[
|
||||||
|
"--distpath",
|
||||||
|
dist_dir,
|
||||||
|
"--workpath",
|
||||||
|
build_dir,
|
||||||
|
"--noconfirm",
|
||||||
|
"--clean",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
# Change to backend directory
|
# Change to backend directory
|
||||||
os.chdir(backend_dir)
|
os.chdir(backend_dir)
|
||||||
|
|
||||||
|
# For CPU builds on Windows, ensure we're using CPU-only torch.
|
||||||
|
# If CUDA torch is installed (local dev), swap to CPU torch before building,
|
||||||
|
# then restore CUDA torch after. This prevents PyInstaller from bundling
|
||||||
|
# ~3GB of CUDA DLLs into the CPU binary.
|
||||||
|
restore_cuda = False
|
||||||
|
if not cuda and platform.system() == "Windows":
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-c", "import torch; print(torch.version.cuda or '')"], capture_output=True, text=True
|
||||||
|
)
|
||||||
|
has_cuda_torch = bool(result.stdout.strip())
|
||||||
|
if has_cuda_torch:
|
||||||
|
logger.info("CUDA torch detected — installing CPU torch for CPU build...")
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"pip",
|
||||||
|
"install",
|
||||||
|
"torch",
|
||||||
|
"torchvision",
|
||||||
|
"torchaudio",
|
||||||
|
"--index-url",
|
||||||
|
"https://download.pytorch.org/whl/cpu",
|
||||||
|
"--force-reinstall",
|
||||||
|
"-q",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
restore_cuda = True
|
||||||
|
|
||||||
# Run PyInstaller
|
# Run PyInstaller
|
||||||
PyInstaller.__main__.run(args)
|
try:
|
||||||
|
PyInstaller.__main__.run(args)
|
||||||
print(f"Binary built in {backend_dir / 'dist' / 'voicebox-server'}")
|
finally:
|
||||||
|
# Restore CUDA torch if we swapped it out (even on build failure)
|
||||||
|
if restore_cuda:
|
||||||
|
logger.info("Restoring CUDA torch...")
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"pip",
|
||||||
|
"install",
|
||||||
|
"torch",
|
||||||
|
"torchvision",
|
||||||
|
"torchaudio",
|
||||||
|
"--index-url",
|
||||||
|
"https://download.pytorch.org/whl/cu126",
|
||||||
|
"--force-reinstall",
|
||||||
|
"-q",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Binary built in %s", backend_dir / "dist" / binary_name)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
build_server()
|
parser = argparse.ArgumentParser(description="Build voicebox-server binary")
|
||||||
|
parser.add_argument(
|
||||||
|
"--cuda",
|
||||||
|
action="store_true",
|
||||||
|
help="Build CUDA-enabled binary (voicebox-server-cuda)",
|
||||||
|
)
|
||||||
|
cli_args = parser.parse_args()
|
||||||
|
build_server(cuda=cli_args.cuda)
|
||||||
|
|||||||
+20
-1
@@ -4,11 +4,24 @@ Configuration module for voicebox backend.
|
|||||||
Handles data directory configuration for production bundling.
|
Handles data directory configuration for production bundling.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
logger.info("Model download path set to: %s", _custom_models_dir)
|
||||||
|
|
||||||
# Default data directory (used in development)
|
# Default data directory (used in development)
|
||||||
_data_dir = Path("data")
|
_data_dir = Path("data")
|
||||||
|
|
||||||
|
|
||||||
def set_data_dir(path: str | Path):
|
def set_data_dir(path: str | Path):
|
||||||
"""
|
"""
|
||||||
Set the data directory path.
|
Set the data directory path.
|
||||||
@@ -19,7 +32,8 @@ def set_data_dir(path: str | Path):
|
|||||||
global _data_dir
|
global _data_dir
|
||||||
_data_dir = Path(path)
|
_data_dir = Path(path)
|
||||||
_data_dir.mkdir(parents=True, exist_ok=True)
|
_data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
print(f"Data directory set to: {_data_dir.absolute()}")
|
logger.info("Data directory set to: %s", _data_dir.absolute())
|
||||||
|
|
||||||
|
|
||||||
def get_data_dir() -> Path:
|
def get_data_dir() -> Path:
|
||||||
"""
|
"""
|
||||||
@@ -30,28 +44,33 @@ def get_data_dir() -> Path:
|
|||||||
"""
|
"""
|
||||||
return _data_dir
|
return _data_dir
|
||||||
|
|
||||||
|
|
||||||
def get_db_path() -> Path:
|
def get_db_path() -> Path:
|
||||||
"""Get database file path."""
|
"""Get database file path."""
|
||||||
return _data_dir / "voicebox.db"
|
return _data_dir / "voicebox.db"
|
||||||
|
|
||||||
|
|
||||||
def get_profiles_dir() -> Path:
|
def get_profiles_dir() -> Path:
|
||||||
"""Get profiles directory path."""
|
"""Get profiles directory path."""
|
||||||
path = _data_dir / "profiles"
|
path = _data_dir / "profiles"
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def get_generations_dir() -> Path:
|
def get_generations_dir() -> Path:
|
||||||
"""Get generations directory path."""
|
"""Get generations directory path."""
|
||||||
path = _data_dir / "generations"
|
path = _data_dir / "generations"
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def get_cache_dir() -> Path:
|
def get_cache_dir() -> Path:
|
||||||
"""Get cache directory path."""
|
"""Get cache directory path."""
|
||||||
path = _data_dir / "cache"
|
path = _data_dir / "cache"
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def get_models_dir() -> Path:
|
def get_models_dir() -> Path:
|
||||||
"""Get models directory path."""
|
"""Get models directory path."""
|
||||||
path = _data_dir / "models"
|
path = _data_dir / "models"
|
||||||
|
|||||||
@@ -1,298 +0,0 @@
|
|||||||
"""
|
|
||||||
SQLite database ORM using SQLAlchemy.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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
|
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from . import config
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileSample(Base):
|
|
||||||
"""Voice profile sample database model."""
|
|
||||||
__tablename__ = "profile_samples"
|
|
||||||
|
|
||||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
||||||
profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
|
|
||||||
audio_path = Column(String, nullable=False)
|
|
||||||
reference_text = Column(Text, nullable=False)
|
|
||||||
|
|
||||||
|
|
||||||
class Generation(Base):
|
|
||||||
"""Generation history database model."""
|
|
||||||
__tablename__ = "generations"
|
|
||||||
|
|
||||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
||||||
profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
|
|
||||||
text = Column(Text, nullable=False)
|
|
||||||
language = Column(String, default="en")
|
|
||||||
audio_path = Column(String, nullable=False)
|
|
||||||
duration = Column(Float, nullable=False)
|
|
||||||
seed = Column(Integer)
|
|
||||||
instruct = Column(Text)
|
|
||||||
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"
|
|
||||||
|
|
||||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
||||||
name = Column(String, nullable=False)
|
|
||||||
data = Column(Text) # JSON string
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
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
|
|
||||||
_db_path = None
|
|
||||||
|
|
||||||
|
|
||||||
def init_db():
|
|
||||||
"""Initialize database tables."""
|
|
||||||
global engine, SessionLocal, _db_path
|
|
||||||
|
|
||||||
_db_path = config.get_db_path()
|
|
||||||
_db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
engine = create_engine(
|
|
||||||
f"sqlite:///{_db_path}",
|
|
||||||
connect_args={"check_same_thread": False},
|
|
||||||
)
|
|
||||||
|
|
||||||
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():
|
|
||||||
"""Get database session (generator for dependency injection)."""
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
yield db
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Database package — ORM models, session management, and migrations.
|
||||||
|
|
||||||
|
Re-exports all public symbols so that ``from .database import get_db``
|
||||||
|
and ``from .database import Generation as DBGeneration`` continue to work
|
||||||
|
without changing any importers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
Base,
|
||||||
|
AudioChannel,
|
||||||
|
ChannelDeviceMapping,
|
||||||
|
EffectPreset,
|
||||||
|
Generation,
|
||||||
|
GenerationVersion,
|
||||||
|
ProfileChannelMapping,
|
||||||
|
ProfileSample,
|
||||||
|
Project,
|
||||||
|
Story,
|
||||||
|
StoryItem,
|
||||||
|
VoiceProfile,
|
||||||
|
)
|
||||||
|
from .session import engine, SessionLocal, _db_path, init_db, get_db
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Models
|
||||||
|
"Base",
|
||||||
|
"AudioChannel",
|
||||||
|
"ChannelDeviceMapping",
|
||||||
|
"EffectPreset",
|
||||||
|
"Generation",
|
||||||
|
"GenerationVersion",
|
||||||
|
"ProfileChannelMapping",
|
||||||
|
"ProfileSample",
|
||||||
|
"Project",
|
||||||
|
"Story",
|
||||||
|
"StoryItem",
|
||||||
|
"VoiceProfile",
|
||||||
|
# Session
|
||||||
|
"engine",
|
||||||
|
"SessionLocal",
|
||||||
|
"_db_path",
|
||||||
|
"init_db",
|
||||||
|
"get_db",
|
||||||
|
]
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Column-level migrations for the voicebox SQLite database.
|
||||||
|
|
||||||
|
Why not Alembic? voicebox is a single-user desktop app shipping as a
|
||||||
|
PyInstaller binary. Every user has exactly one SQLite file. Alembic's
|
||||||
|
strengths -- migration tracking across environments, rollback, team
|
||||||
|
coordination -- don't apply here and would add bundling complexity
|
||||||
|
(alembic.ini, env.py, versions/ directory all need to survive
|
||||||
|
PyInstaller). The column-existence checks below are idempotent, run in
|
||||||
|
<50 ms on startup, and have worked reliably across 12 schema changes.
|
||||||
|
If the project ever moves to a server-based deployment or Postgres, this
|
||||||
|
decision should be revisited.
|
||||||
|
|
||||||
|
Adding a new migration:
|
||||||
|
1. Append a new ``_migrate_*`` helper at the bottom of this file.
|
||||||
|
2. Call it from ``run_migrations()`` in the appropriate spot.
|
||||||
|
3. The helper should check column/table existence before acting
|
||||||
|
(idempotent) and print a short message when it does real work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations(engine) -> None:
|
||||||
|
"""Run all schema migrations. Safe to call on every startup."""
|
||||||
|
inspector = inspect(engine)
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
|
||||||
|
_migrate_story_items(engine, inspector, tables)
|
||||||
|
_migrate_profiles(engine, inspector, tables)
|
||||||
|
_migrate_generations(engine, inspector, tables)
|
||||||
|
_migrate_effect_presets(engine, inspector, tables)
|
||||||
|
_migrate_generation_versions(engine, inspector, tables)
|
||||||
|
|
||||||
|
|
||||||
|
# -- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_columns(inspector, table: str) -> set[str]:
|
||||||
|
return {col["name"] for col in inspector.get_columns(table)}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_column(engine, table: str, column_sql: str, label: str) -> None:
|
||||||
|
"""Add a column if it doesn't already exist."""
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column_sql}"))
|
||||||
|
conn.commit()
|
||||||
|
logger.info("Added %s column to %s", label, table)
|
||||||
|
|
||||||
|
|
||||||
|
# -- per-table migrations --------------------------------------------------
|
||||||
|
|
||||||
|
def _migrate_story_items(engine, inspector, tables: set[str]) -> None:
|
||||||
|
if "story_items" not in tables:
|
||||||
|
return
|
||||||
|
|
||||||
|
columns = _get_columns(inspector, "story_items")
|
||||||
|
|
||||||
|
# Replace position-based ordering with absolute timecodes
|
||||||
|
if "position" in columns:
|
||||||
|
logger.info("Migrating story_items: removing position column, using start_time_ms")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
if "start_time_ms" not in columns:
|
||||||
|
conn.execute(text(
|
||||||
|
"ALTER TABLE story_items ADD COLUMN start_time_ms INTEGER DEFAULT 0"
|
||||||
|
))
|
||||||
|
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
|
||||||
|
"""))
|
||||||
|
current_story_id = None
|
||||||
|
current_time_ms = 0
|
||||||
|
for item_id, story_id, _position, duration in result.fetchall():
|
||||||
|
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 or 0) * 1000) + 200
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Recreate table without the position column (SQLite lacks DROP COLUMN)
|
||||||
|
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,
|
||||||
|
track INTEGER NOT NULL DEFAULT 0,
|
||||||
|
trim_start_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
trim_end_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
version_id VARCHAR,
|
||||||
|
created_at DATETIME,
|
||||||
|
FOREIGN KEY (story_id) REFERENCES stories(id),
|
||||||
|
FOREIGN KEY (generation_id) REFERENCES generations(id)
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
conn.execute(text("""
|
||||||
|
INSERT INTO story_items_new (id, story_id, generation_id, start_time_ms, track, trim_start_ms, trim_end_ms, version_id, created_at)
|
||||||
|
SELECT id, story_id, generation_id, start_time_ms,
|
||||||
|
COALESCE(track, 0), COALESCE(trim_start_ms, 0), COALESCE(trim_end_ms, 0), version_id, created_at
|
||||||
|
FROM story_items
|
||||||
|
"""))
|
||||||
|
conn.execute(text("DROP TABLE story_items"))
|
||||||
|
conn.execute(text("ALTER TABLE story_items_new RENAME TO story_items"))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Re-read after table recreation
|
||||||
|
columns = _get_columns(inspector, "story_items")
|
||||||
|
|
||||||
|
if "track" not in columns:
|
||||||
|
_add_column(engine, "story_items", "track INTEGER NOT NULL DEFAULT 0", "track")
|
||||||
|
# Re-read so subsequent checks see new columns
|
||||||
|
columns = _get_columns(inspector, "story_items")
|
||||||
|
if "trim_start_ms" not in columns:
|
||||||
|
_add_column(engine, "story_items", "trim_start_ms INTEGER NOT NULL DEFAULT 0", "trim_start_ms")
|
||||||
|
if "trim_end_ms" not in columns:
|
||||||
|
_add_column(engine, "story_items", "trim_end_ms INTEGER NOT NULL DEFAULT 0", "trim_end_ms")
|
||||||
|
if "version_id" not in columns:
|
||||||
|
_add_column(engine, "story_items", "version_id VARCHAR", "version_id")
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_profiles(engine, inspector, tables: set[str]) -> None:
|
||||||
|
if "profiles" not in tables:
|
||||||
|
return
|
||||||
|
columns = _get_columns(inspector, "profiles")
|
||||||
|
if "avatar_path" not in columns:
|
||||||
|
_add_column(engine, "profiles", "avatar_path VARCHAR", "avatar_path")
|
||||||
|
if "effects_chain" not in columns:
|
||||||
|
_add_column(engine, "profiles", "effects_chain TEXT", "effects_chain")
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_generations(engine, inspector, tables: set[str]) -> None:
|
||||||
|
if "generations" not in tables:
|
||||||
|
return
|
||||||
|
columns = _get_columns(inspector, "generations")
|
||||||
|
if "status" not in columns:
|
||||||
|
_add_column(engine, "generations", "status VARCHAR DEFAULT 'completed'", "status")
|
||||||
|
if "error" not in columns:
|
||||||
|
_add_column(engine, "generations", "error TEXT", "error")
|
||||||
|
if "engine" not in columns:
|
||||||
|
_add_column(engine, "generations", "engine VARCHAR DEFAULT 'qwen'", "engine")
|
||||||
|
# Re-read after engine column (variable name shadows outer scope in old code)
|
||||||
|
columns = _get_columns(inspector, "generations")
|
||||||
|
if "model_size" not in columns:
|
||||||
|
_add_column(engine, "generations", "model_size VARCHAR", "model_size")
|
||||||
|
if "is_favorited" not in columns:
|
||||||
|
_add_column(engine, "generations", "is_favorited BOOLEAN DEFAULT 0", "is_favorited")
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_effect_presets(engine, inspector, tables: set[str]) -> None:
|
||||||
|
if "effect_presets" not in tables:
|
||||||
|
return
|
||||||
|
columns = _get_columns(inspector, "effect_presets")
|
||||||
|
if "sort_order" not in columns:
|
||||||
|
_add_column(engine, "effect_presets", "sort_order INTEGER DEFAULT 100", "sort_order")
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_generation_versions(engine, inspector, tables: set[str]) -> None:
|
||||||
|
if "generation_versions" not in tables:
|
||||||
|
return
|
||||||
|
columns = _get_columns(inspector, "generation_versions")
|
||||||
|
if "source_version_id" not in columns:
|
||||||
|
_add_column(engine, "generation_versions", "source_version_id VARCHAR", "source_version_id")
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""ORM model definitions for the voicebox SQLite database."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceProfile(Base):
|
||||||
|
"""Voice profile."""
|
||||||
|
|
||||||
|
__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)
|
||||||
|
effects_chain = Column(Text, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileSample(Base):
|
||||||
|
"""Audio sample attached to a voice profile."""
|
||||||
|
|
||||||
|
__tablename__ = "profile_samples"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
|
||||||
|
audio_path = Column(String, nullable=False)
|
||||||
|
reference_text = Column(Text, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Generation(Base):
|
||||||
|
"""A single TTS generation."""
|
||||||
|
|
||||||
|
__tablename__ = "generations"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
|
||||||
|
text = Column(Text, nullable=False)
|
||||||
|
language = Column(String, default="en")
|
||||||
|
audio_path = Column(String, nullable=True)
|
||||||
|
duration = Column(Float, nullable=True)
|
||||||
|
seed = Column(Integer)
|
||||||
|
instruct = Column(Text)
|
||||||
|
engine = Column(String, default="qwen")
|
||||||
|
model_size = Column(String, nullable=True)
|
||||||
|
status = Column(String, default="completed")
|
||||||
|
error = Column(Text, nullable=True)
|
||||||
|
is_favorited = Column(Boolean, default=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Story(Base):
|
||||||
|
"""A story that sequences multiple generations."""
|
||||||
|
|
||||||
|
__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):
|
||||||
|
"""Links a generation to a story at a specific timecode."""
|
||||||
|
|
||||||
|
__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)
|
||||||
|
version_id = Column(String, ForeignKey("generation_versions.id"), nullable=True)
|
||||||
|
start_time_ms = Column(Integer, nullable=False, default=0)
|
||||||
|
track = Column(Integer, nullable=False, default=0)
|
||||||
|
trim_start_ms = Column(Integer, nullable=False, default=0)
|
||||||
|
trim_end_ms = Column(Integer, nullable=False, default=0)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Project(Base):
|
||||||
|
"""Audio studio project (JSON blob)."""
|
||||||
|
|
||||||
|
__tablename__ = "projects"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
data = Column(Text)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationVersion(Base):
|
||||||
|
"""A version of a generation's audio (original, processed, alternate takes)."""
|
||||||
|
|
||||||
|
__tablename__ = "generation_versions"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
generation_id = Column(String, ForeignKey("generations.id"), nullable=False)
|
||||||
|
label = Column(String, nullable=False)
|
||||||
|
audio_path = Column(String, nullable=False)
|
||||||
|
effects_chain = Column(Text, nullable=True)
|
||||||
|
source_version_id = Column(String, ForeignKey("generation_versions.id"), nullable=True)
|
||||||
|
is_default = Column(Boolean, default=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class EffectPreset(Base):
|
||||||
|
"""Saved effect chain preset."""
|
||||||
|
|
||||||
|
__tablename__ = "effect_presets"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
name = Column(String, unique=True, nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
effects_chain = Column(Text, nullable=False)
|
||||||
|
is_builtin = Column(Boolean, default=False)
|
||||||
|
sort_order = Column(Integer, default=100)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class AudioChannel(Base):
|
||||||
|
"""Audio output channel (bus)."""
|
||||||
|
|
||||||
|
__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 a channel and an OS audio device."""
|
||||||
|
|
||||||
|
__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)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileChannelMapping(Base):
|
||||||
|
"""Many-to-many mapping between voice profiles and audio channels."""
|
||||||
|
|
||||||
|
__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)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Post-migration data seeding and backfills."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_generation_versions(SessionLocal, Generation, GenerationVersion) -> None:
|
||||||
|
"""Create 'clean' version entries for generations that predate the versions feature."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
existing_version_gen_ids = {
|
||||||
|
row[0] for row in db.query(GenerationVersion.generation_id).all()
|
||||||
|
}
|
||||||
|
generations = db.query(Generation).filter(
|
||||||
|
Generation.status == "completed",
|
||||||
|
Generation.audio_path.isnot(None),
|
||||||
|
Generation.audio_path != "",
|
||||||
|
).all()
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for gen in generations:
|
||||||
|
if gen.id in existing_version_gen_ids:
|
||||||
|
continue
|
||||||
|
if not Path(gen.audio_path).exists():
|
||||||
|
continue
|
||||||
|
version = GenerationVersion(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
generation_id=gen.id,
|
||||||
|
label="clean",
|
||||||
|
audio_path=gen.audio_path,
|
||||||
|
effects_chain=None,
|
||||||
|
is_default=True,
|
||||||
|
)
|
||||||
|
db.add(version)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
if count > 0:
|
||||||
|
db.commit()
|
||||||
|
logger.info("Backfilled %d generation version entries", count)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def seed_builtin_presets(SessionLocal, EffectPreset) -> None:
|
||||||
|
"""Ensure built-in effect presets exist in the database."""
|
||||||
|
from ..utils.effects import BUILTIN_PRESETS
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
for idx, (_key, preset_data) in enumerate(BUILTIN_PRESETS.items()):
|
||||||
|
sort_order = preset_data.get("sort_order", idx)
|
||||||
|
existing = db.query(EffectPreset).filter_by(name=preset_data["name"]).first()
|
||||||
|
if not existing:
|
||||||
|
preset = EffectPreset(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
name=preset_data["name"],
|
||||||
|
description=preset_data.get("description"),
|
||||||
|
effects_chain=json.dumps(preset_data["effects_chain"]),
|
||||||
|
is_builtin=True,
|
||||||
|
sort_order=sort_order,
|
||||||
|
)
|
||||||
|
db.add(preset)
|
||||||
|
elif existing.sort_order != sort_order:
|
||||||
|
existing.sort_order = sort_order
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Engine creation, initialization, and session management."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from .. import config
|
||||||
|
from .models import (
|
||||||
|
Base,
|
||||||
|
AudioChannel,
|
||||||
|
EffectPreset,
|
||||||
|
Generation,
|
||||||
|
GenerationVersion,
|
||||||
|
ProfileChannelMapping,
|
||||||
|
VoiceProfile,
|
||||||
|
)
|
||||||
|
from .migrations import run_migrations
|
||||||
|
from .seed import backfill_generation_versions, seed_builtin_presets
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Initialized by init_db()
|
||||||
|
engine = None
|
||||||
|
SessionLocal = None
|
||||||
|
_db_path = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
"""Initialize the database engine, run migrations, create tables, and seed data."""
|
||||||
|
global engine, SessionLocal, _db_path
|
||||||
|
|
||||||
|
_db_path = config.get_db_path()
|
||||||
|
_db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
f"sqlite:///{_db_path}",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
run_migrations(engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
# Create default audio 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)
|
||||||
|
|
||||||
|
for profile in db.query(VoiceProfile).all():
|
||||||
|
db.add(ProfileChannelMapping(
|
||||||
|
profile_id=profile.id,
|
||||||
|
channel_id=default_channel.id,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
backfill_generation_versions(SessionLocal, Generation, GenerationVersion)
|
||||||
|
seed_builtin_presets(SessionLocal, EffectPreset)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""Yield a database session (FastAPI dependency)."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
"""
|
|
||||||
Example usage of the voicebox backend API.
|
|
||||||
|
|
||||||
This script demonstrates how to:
|
|
||||||
1. Create a voice profile
|
|
||||||
2. Add samples to the profile
|
|
||||||
3. Generate speech
|
|
||||||
4. List history
|
|
||||||
"""
|
|
||||||
|
|
||||||
import requests
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# API base URL
|
|
||||||
BASE_URL = "http://localhost:8000"
|
|
||||||
|
|
||||||
|
|
||||||
def check_health():
|
|
||||||
"""Check if the server is running."""
|
|
||||||
response = requests.get(f"{BASE_URL}/health")
|
|
||||||
data = response.json()
|
|
||||||
print(f"Server status: {data['status']}")
|
|
||||||
print(f"Model loaded: {data['model_loaded']}")
|
|
||||||
print(f"GPU available: {data['gpu_available']}")
|
|
||||||
print()
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def create_profile(name: str, description: str = None, language: str = "en"):
|
|
||||||
"""Create a new voice profile."""
|
|
||||||
response = requests.post(
|
|
||||||
f"{BASE_URL}/profiles",
|
|
||||||
json={
|
|
||||||
"name": name,
|
|
||||||
"description": description,
|
|
||||||
"language": language,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
profile = response.json()
|
|
||||||
print(f"Created profile: {profile['name']} (ID: {profile['id']})")
|
|
||||||
return profile
|
|
||||||
|
|
||||||
|
|
||||||
def add_sample(profile_id: str, audio_file: str, reference_text: str):
|
|
||||||
"""Add a sample to a voice profile."""
|
|
||||||
with open(audio_file, "rb") as f:
|
|
||||||
files = {"file": f}
|
|
||||||
data = {"reference_text": reference_text}
|
|
||||||
response = requests.post(
|
|
||||||
f"{BASE_URL}/profiles/{profile_id}/samples",
|
|
||||||
files=files,
|
|
||||||
data=data,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
sample = response.json()
|
|
||||||
print(f"Added sample: {sample['id']}")
|
|
||||||
return sample
|
|
||||||
|
|
||||||
|
|
||||||
def generate_speech(profile_id: str, text: str, language: str = "en", seed: int = None):
|
|
||||||
"""Generate speech using a voice profile."""
|
|
||||||
print(f"Generating speech: '{text[:50]}...'")
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
response = requests.post(
|
|
||||||
f"{BASE_URL}/generate",
|
|
||||||
json={
|
|
||||||
"profile_id": profile_id,
|
|
||||||
"text": text,
|
|
||||||
"language": language,
|
|
||||||
"seed": seed,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
generation = response.json()
|
|
||||||
|
|
||||||
elapsed = time.time() - start_time
|
|
||||||
print(f"Generated in {elapsed:.2f}s (duration: {generation['duration']:.2f}s)")
|
|
||||||
print(f"Generation ID: {generation['id']}")
|
|
||||||
return generation
|
|
||||||
|
|
||||||
|
|
||||||
def download_audio(generation_id: str, output_file: str):
|
|
||||||
"""Download generated audio."""
|
|
||||||
response = requests.get(f"{BASE_URL}/audio/{generation_id}")
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
with open(output_file, "wb") as f:
|
|
||||||
f.write(response.content)
|
|
||||||
|
|
||||||
print(f"Saved audio to: {output_file}")
|
|
||||||
|
|
||||||
|
|
||||||
def list_profiles():
|
|
||||||
"""List all voice profiles."""
|
|
||||||
response = requests.get(f"{BASE_URL}/profiles")
|
|
||||||
response.raise_for_status()
|
|
||||||
profiles = response.json()
|
|
||||||
|
|
||||||
print(f"Found {len(profiles)} profiles:")
|
|
||||||
for profile in profiles:
|
|
||||||
print(f" - {profile['name']} (ID: {profile['id']})")
|
|
||||||
|
|
||||||
return profiles
|
|
||||||
|
|
||||||
|
|
||||||
def list_history(profile_id: str = None, limit: int = 10):
|
|
||||||
"""List generation history."""
|
|
||||||
params = {"limit": limit}
|
|
||||||
if profile_id:
|
|
||||||
params["profile_id"] = profile_id
|
|
||||||
|
|
||||||
response = requests.get(f"{BASE_URL}/history", params=params)
|
|
||||||
response.raise_for_status()
|
|
||||||
history = response.json()
|
|
||||||
|
|
||||||
print(f"Found {len(history)} generations:")
|
|
||||||
for gen in history:
|
|
||||||
print(f" - {gen['text'][:50]}... ({gen['duration']:.2f}s)")
|
|
||||||
|
|
||||||
return history
|
|
||||||
|
|
||||||
|
|
||||||
def transcribe_audio(audio_file: str, language: str = None):
|
|
||||||
"""Transcribe audio file."""
|
|
||||||
print(f"Transcribing: {audio_file}")
|
|
||||||
|
|
||||||
with open(audio_file, "rb") as f:
|
|
||||||
files = {"file": f}
|
|
||||||
data = {}
|
|
||||||
if language:
|
|
||||||
data["language"] = language
|
|
||||||
|
|
||||||
response = requests.post(
|
|
||||||
f"{BASE_URL}/transcribe",
|
|
||||||
files=files,
|
|
||||||
data=data,
|
|
||||||
)
|
|
||||||
|
|
||||||
response.raise_for_status()
|
|
||||||
result = response.json()
|
|
||||||
|
|
||||||
print(f"Transcription: {result['text']}")
|
|
||||||
print(f"Duration: {result['duration']:.2f}s")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Run example workflow."""
|
|
||||||
print("=" * 60)
|
|
||||||
print("voicebox Backend API Example")
|
|
||||||
print("=" * 60)
|
|
||||||
print()
|
|
||||||
|
|
||||||
# 1. Check health
|
|
||||||
print("1. Checking server health...")
|
|
||||||
check_health()
|
|
||||||
|
|
||||||
# 2. Create a profile
|
|
||||||
print("2. Creating voice profile...")
|
|
||||||
profile = create_profile(
|
|
||||||
name="Example Voice",
|
|
||||||
description="A test voice profile",
|
|
||||||
language="en",
|
|
||||||
)
|
|
||||||
profile_id = profile["id"]
|
|
||||||
print()
|
|
||||||
|
|
||||||
# 3. Add samples (you'll need actual audio files)
|
|
||||||
print("3. Adding samples...")
|
|
||||||
print(" (Skipping - add your own audio files here)")
|
|
||||||
# Uncomment and add your audio file:
|
|
||||||
# sample = add_sample(
|
|
||||||
# profile_id,
|
|
||||||
# "path/to/your/sample.wav",
|
|
||||||
# "This is the transcript of the audio",
|
|
||||||
# )
|
|
||||||
print()
|
|
||||||
|
|
||||||
# 4. Generate speech (requires samples to be added first)
|
|
||||||
print("4. Generating speech...")
|
|
||||||
print(" (Skipping - add samples first)")
|
|
||||||
# Uncomment after adding samples:
|
|
||||||
# generation = generate_speech(
|
|
||||||
# profile_id,
|
|
||||||
# "Hello, this is a test of the voice cloning system.",
|
|
||||||
# language="en",
|
|
||||||
# seed=42,
|
|
||||||
# )
|
|
||||||
#
|
|
||||||
# # 5. Download audio
|
|
||||||
# print("\n5. Downloading audio...")
|
|
||||||
# download_audio(generation["id"], "output.wav")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# 6. List profiles
|
|
||||||
print("6. Listing all profiles...")
|
|
||||||
list_profiles()
|
|
||||||
print()
|
|
||||||
|
|
||||||
# 7. List history
|
|
||||||
print("7. Listing generation history...")
|
|
||||||
list_history(limit=5)
|
|
||||||
print()
|
|
||||||
|
|
||||||
# 8. Transcribe audio (you'll need an audio file)
|
|
||||||
print("8. Transcribing audio...")
|
|
||||||
print(" (Skipping - add your own audio file here)")
|
|
||||||
# Uncomment and add your audio file:
|
|
||||||
# transcribe_audio("path/to/audio.wav", language="en")
|
|
||||||
print()
|
|
||||||
|
|
||||||
print("=" * 60)
|
|
||||||
print("Example complete!")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
+7
-1632
File diff suppressed because it is too large
Load Diff
@@ -1,48 +0,0 @@
|
|||||||
"""
|
|
||||||
Database migration script to add instruct column to generations table.
|
|
||||||
|
|
||||||
Run this once to update existing databases:
|
|
||||||
python -m backend.migrate_add_instruct
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def migrate():
|
|
||||||
"""Add instruct column to generations table if it doesn't exist."""
|
|
||||||
# Get data directory
|
|
||||||
data_dir = os.environ.get("VOICEBOX_DATA_DIR")
|
|
||||||
if data_dir:
|
|
||||||
db_path = Path(data_dir) / "voicebox.db"
|
|
||||||
else:
|
|
||||||
db_path = Path.cwd() / "data" / "voicebox.db"
|
|
||||||
|
|
||||||
if not db_path.exists():
|
|
||||||
print(f"Database not found at {db_path}, skipping migration")
|
|
||||||
return
|
|
||||||
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Check if instruct column already exists
|
|
||||||
cursor.execute("PRAGMA table_info(generations)")
|
|
||||||
columns = [row[1] for row in cursor.fetchall()]
|
|
||||||
|
|
||||||
if 'instruct' in columns:
|
|
||||||
print("instruct column already exists, skipping migration")
|
|
||||||
conn.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
# Add instruct column
|
|
||||||
print("Adding instruct column to generations table...")
|
|
||||||
cursor.execute("ALTER TABLE generations ADD COLUMN instruct TEXT")
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
print("Migration complete!")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
migrate()
|
|
||||||
+220
-11
@@ -9,18 +9,25 @@ from datetime import datetime
|
|||||||
|
|
||||||
class VoiceProfileCreate(BaseModel):
|
class VoiceProfileCreate(BaseModel):
|
||||||
"""Request model for creating a voice profile."""
|
"""Request model for creating a voice profile."""
|
||||||
|
|
||||||
name: str = Field(..., min_length=1, max_length=100)
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
description: Optional[str] = Field(None, max_length=500)
|
description: Optional[str] = Field(None, max_length=500)
|
||||||
language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it)$")
|
language: str = Field(
|
||||||
|
default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he|ar|da|el|fi|hi|ms|nl|no|pl|sv|sw|tr)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class VoiceProfileResponse(BaseModel):
|
class VoiceProfileResponse(BaseModel):
|
||||||
"""Response model for voice profile."""
|
"""Response model for voice profile."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str]
|
description: Optional[str]
|
||||||
language: str
|
language: str
|
||||||
avatar_path: Optional[str] = None
|
avatar_path: Optional[str] = None
|
||||||
|
effects_chain: Optional[List["EffectConfig"]] = None
|
||||||
|
generation_count: int = 0
|
||||||
|
sample_count: int = 0
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -30,16 +37,19 @@ class VoiceProfileResponse(BaseModel):
|
|||||||
|
|
||||||
class ProfileSampleCreate(BaseModel):
|
class ProfileSampleCreate(BaseModel):
|
||||||
"""Request model for adding a sample to a profile."""
|
"""Request model for adding a sample to a profile."""
|
||||||
|
|
||||||
reference_text: str = Field(..., min_length=1, max_length=1000)
|
reference_text: str = Field(..., min_length=1, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
class ProfileSampleUpdate(BaseModel):
|
class ProfileSampleUpdate(BaseModel):
|
||||||
"""Request model for updating a profile sample."""
|
"""Request model for updating a profile sample."""
|
||||||
|
|
||||||
reference_text: str = Field(..., min_length=1, max_length=1000)
|
reference_text: str = Field(..., min_length=1, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
class ProfileSampleResponse(BaseModel):
|
class ProfileSampleResponse(BaseModel):
|
||||||
"""Response model for profile sample."""
|
"""Response model for profile sample."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
profile_id: str
|
profile_id: str
|
||||||
audio_path: str
|
audio_path: str
|
||||||
@@ -51,25 +61,45 @@ class ProfileSampleResponse(BaseModel):
|
|||||||
|
|
||||||
class GenerationRequest(BaseModel):
|
class GenerationRequest(BaseModel):
|
||||||
"""Request model for voice generation."""
|
"""Request model for voice generation."""
|
||||||
|
|
||||||
profile_id: str
|
profile_id: str
|
||||||
text: str = Field(..., min_length=1, max_length=5000)
|
text: str = Field(..., min_length=1, max_length=50000)
|
||||||
language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it)$")
|
language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he|ar|da|el|fi|hi|ms|nl|no|pl|sv|sw|tr)$")
|
||||||
seed: Optional[int] = Field(None, ge=0)
|
seed: Optional[int] = Field(None, ge=0)
|
||||||
model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B)$")
|
model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B)$")
|
||||||
instruct: Optional[str] = Field(None, max_length=500)
|
instruct: Optional[str] = Field(None, max_length=500)
|
||||||
|
engine: Optional[str] = Field(default="qwen", pattern="^(qwen|luxtts|chatterbox|chatterbox_turbo)$")
|
||||||
|
max_chunk_chars: int = Field(
|
||||||
|
default=800, ge=100, le=5000, description="Max characters per chunk for long text splitting"
|
||||||
|
)
|
||||||
|
crossfade_ms: int = Field(
|
||||||
|
default=50, ge=0, le=500, description="Crossfade duration in ms between chunks (0 for hard cut)"
|
||||||
|
)
|
||||||
|
normalize: bool = Field(default=True, description="Normalize output audio volume")
|
||||||
|
effects_chain: Optional[List["EffectConfig"]] = Field(
|
||||||
|
None, description="Effects chain to apply after generation (overrides profile default)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GenerationResponse(BaseModel):
|
class GenerationResponse(BaseModel):
|
||||||
"""Response model for voice generation."""
|
"""Response model for voice generation."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
profile_id: str
|
profile_id: str
|
||||||
text: str
|
text: str
|
||||||
language: str
|
language: str
|
||||||
audio_path: str
|
audio_path: Optional[str] = None
|
||||||
duration: float
|
duration: Optional[float] = None
|
||||||
seed: Optional[int]
|
seed: Optional[int] = None
|
||||||
instruct: Optional[str]
|
instruct: Optional[str] = None
|
||||||
|
engine: Optional[str] = "qwen"
|
||||||
|
model_size: Optional[str] = None
|
||||||
|
status: str = "completed"
|
||||||
|
error: Optional[str] = None
|
||||||
|
is_favorited: bool = False
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
versions: Optional[List["GenerationVersionResponse"]] = None
|
||||||
|
active_version_id: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -77,6 +107,7 @@ class GenerationResponse(BaseModel):
|
|||||||
|
|
||||||
class HistoryQuery(BaseModel):
|
class HistoryQuery(BaseModel):
|
||||||
"""Query model for generation history."""
|
"""Query model for generation history."""
|
||||||
|
|
||||||
profile_id: Optional[str] = None
|
profile_id: Optional[str] = None
|
||||||
search: Optional[str] = None
|
search: Optional[str] = None
|
||||||
limit: int = Field(default=50, ge=1, le=100)
|
limit: int = Field(default=50, ge=1, le=100)
|
||||||
@@ -85,16 +116,24 @@ class HistoryQuery(BaseModel):
|
|||||||
|
|
||||||
class HistoryResponse(BaseModel):
|
class HistoryResponse(BaseModel):
|
||||||
"""Response model for history entry (includes profile name)."""
|
"""Response model for history entry (includes profile name)."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
profile_id: str
|
profile_id: str
|
||||||
profile_name: str
|
profile_name: str
|
||||||
text: str
|
text: str
|
||||||
language: str
|
language: str
|
||||||
audio_path: str
|
audio_path: Optional[str] = None
|
||||||
duration: float
|
duration: Optional[float] = None
|
||||||
seed: Optional[int]
|
seed: Optional[int] = None
|
||||||
instruct: Optional[str]
|
instruct: Optional[str] = None
|
||||||
|
engine: Optional[str] = "qwen"
|
||||||
|
model_size: Optional[str] = None
|
||||||
|
status: str = "completed"
|
||||||
|
error: Optional[str] = None
|
||||||
|
is_favorited: bool = False
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
versions: Optional[List["GenerationVersionResponse"]] = None
|
||||||
|
active_version_id: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -102,23 +141,27 @@ class HistoryResponse(BaseModel):
|
|||||||
|
|
||||||
class HistoryListResponse(BaseModel):
|
class HistoryListResponse(BaseModel):
|
||||||
"""Response model for history list."""
|
"""Response model for history list."""
|
||||||
|
|
||||||
items: List[HistoryResponse]
|
items: List[HistoryResponse]
|
||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
|
||||||
class TranscriptionRequest(BaseModel):
|
class TranscriptionRequest(BaseModel):
|
||||||
"""Request model for audio transcription."""
|
"""Request model for audio transcription."""
|
||||||
|
|
||||||
language: Optional[str] = Field(None, pattern="^(en|zh)$")
|
language: Optional[str] = Field(None, pattern="^(en|zh)$")
|
||||||
|
|
||||||
|
|
||||||
class TranscriptionResponse(BaseModel):
|
class TranscriptionResponse(BaseModel):
|
||||||
"""Response model for transcription."""
|
"""Response model for transcription."""
|
||||||
|
|
||||||
text: str
|
text: str
|
||||||
duration: float
|
duration: float
|
||||||
|
|
||||||
|
|
||||||
class HealthResponse(BaseModel):
|
class HealthResponse(BaseModel):
|
||||||
"""Response model for health check."""
|
"""Response model for health check."""
|
||||||
|
|
||||||
status: str
|
status: str
|
||||||
model_loaded: bool
|
model_loaded: bool
|
||||||
model_downloaded: Optional[bool] = None # Whether model is cached/downloaded
|
model_downloaded: Optional[bool] = None # Whether model is cached/downloaded
|
||||||
@@ -127,36 +170,73 @@ class HealthResponse(BaseModel):
|
|||||||
gpu_type: Optional[str] = None # GPU type (CUDA, MPS, or None)
|
gpu_type: Optional[str] = None # GPU type (CUDA, MPS, or None)
|
||||||
vram_used_mb: Optional[float] = None
|
vram_used_mb: Optional[float] = None
|
||||||
backend_type: Optional[str] = None # Backend type (mlx or pytorch)
|
backend_type: Optional[str] = None # Backend type (mlx or pytorch)
|
||||||
|
backend_variant: Optional[str] = None # Binary variant (cpu or cuda)
|
||||||
|
|
||||||
|
|
||||||
|
class DirectoryCheck(BaseModel):
|
||||||
|
"""Health status for a single directory."""
|
||||||
|
|
||||||
|
path: str
|
||||||
|
exists: bool
|
||||||
|
writable: bool
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FilesystemHealthResponse(BaseModel):
|
||||||
|
"""Response model for filesystem health check."""
|
||||||
|
|
||||||
|
healthy: bool
|
||||||
|
disk_free_mb: Optional[float] = None
|
||||||
|
disk_total_mb: Optional[float] = None
|
||||||
|
directories: List[DirectoryCheck]
|
||||||
|
|
||||||
|
|
||||||
class ModelStatus(BaseModel):
|
class ModelStatus(BaseModel):
|
||||||
"""Response model for model status."""
|
"""Response model for model status."""
|
||||||
|
|
||||||
model_name: str
|
model_name: str
|
||||||
display_name: str
|
display_name: str
|
||||||
|
hf_repo_id: Optional[str] = None # HuggingFace repository ID
|
||||||
downloaded: bool
|
downloaded: bool
|
||||||
|
downloading: bool = False # True if download is in progress
|
||||||
size_mb: Optional[float] = None
|
size_mb: Optional[float] = None
|
||||||
loaded: bool = False
|
loaded: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ModelStatusListResponse(BaseModel):
|
class ModelStatusListResponse(BaseModel):
|
||||||
"""Response model for model status list."""
|
"""Response model for model status list."""
|
||||||
|
|
||||||
models: List[ModelStatus]
|
models: List[ModelStatus]
|
||||||
|
|
||||||
|
|
||||||
class ModelDownloadRequest(BaseModel):
|
class ModelDownloadRequest(BaseModel):
|
||||||
"""Request model for triggering model download."""
|
"""Request model for triggering model download."""
|
||||||
|
|
||||||
model_name: str
|
model_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ModelMigrateRequest(BaseModel):
|
||||||
|
"""Request model for migrating models to a new directory."""
|
||||||
|
|
||||||
|
destination: str
|
||||||
|
|
||||||
|
|
||||||
class ActiveDownloadTask(BaseModel):
|
class ActiveDownloadTask(BaseModel):
|
||||||
"""Response model for active download task."""
|
"""Response model for active download task."""
|
||||||
|
|
||||||
model_name: str
|
model_name: str
|
||||||
status: str
|
status: str
|
||||||
started_at: datetime
|
started_at: datetime
|
||||||
|
error: Optional[str] = None
|
||||||
|
progress: Optional[float] = None # 0-100 percentage
|
||||||
|
current: Optional[int] = None # bytes downloaded
|
||||||
|
total: Optional[int] = None # total bytes
|
||||||
|
filename: Optional[str] = None # current file being downloaded
|
||||||
|
|
||||||
|
|
||||||
class ActiveGenerationTask(BaseModel):
|
class ActiveGenerationTask(BaseModel):
|
||||||
"""Response model for active generation task."""
|
"""Response model for active generation task."""
|
||||||
|
|
||||||
task_id: str
|
task_id: str
|
||||||
profile_id: str
|
profile_id: str
|
||||||
text_preview: str
|
text_preview: str
|
||||||
@@ -165,24 +245,28 @@ class ActiveGenerationTask(BaseModel):
|
|||||||
|
|
||||||
class ActiveTasksResponse(BaseModel):
|
class ActiveTasksResponse(BaseModel):
|
||||||
"""Response model for active tasks."""
|
"""Response model for active tasks."""
|
||||||
|
|
||||||
downloads: List[ActiveDownloadTask]
|
downloads: List[ActiveDownloadTask]
|
||||||
generations: List[ActiveGenerationTask]
|
generations: List[ActiveGenerationTask]
|
||||||
|
|
||||||
|
|
||||||
class AudioChannelCreate(BaseModel):
|
class AudioChannelCreate(BaseModel):
|
||||||
"""Request model for creating an audio channel."""
|
"""Request model for creating an audio channel."""
|
||||||
|
|
||||||
name: str = Field(..., min_length=1, max_length=100)
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
device_ids: List[str] = Field(default_factory=list)
|
device_ids: List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class AudioChannelUpdate(BaseModel):
|
class AudioChannelUpdate(BaseModel):
|
||||||
"""Request model for updating an audio channel."""
|
"""Request model for updating an audio channel."""
|
||||||
|
|
||||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
device_ids: Optional[List[str]] = None
|
device_ids: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class AudioChannelResponse(BaseModel):
|
class AudioChannelResponse(BaseModel):
|
||||||
"""Response model for audio channel."""
|
"""Response model for audio channel."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
is_default: bool
|
is_default: bool
|
||||||
@@ -195,22 +279,26 @@ class AudioChannelResponse(BaseModel):
|
|||||||
|
|
||||||
class ChannelVoiceAssignment(BaseModel):
|
class ChannelVoiceAssignment(BaseModel):
|
||||||
"""Request model for assigning voices to a channel."""
|
"""Request model for assigning voices to a channel."""
|
||||||
|
|
||||||
profile_ids: List[str]
|
profile_ids: List[str]
|
||||||
|
|
||||||
|
|
||||||
class ProfileChannelAssignment(BaseModel):
|
class ProfileChannelAssignment(BaseModel):
|
||||||
"""Request model for assigning channels to a profile."""
|
"""Request model for assigning channels to a profile."""
|
||||||
|
|
||||||
channel_ids: List[str]
|
channel_ids: List[str]
|
||||||
|
|
||||||
|
|
||||||
class StoryCreate(BaseModel):
|
class StoryCreate(BaseModel):
|
||||||
"""Request model for creating a story."""
|
"""Request model for creating a story."""
|
||||||
|
|
||||||
name: str = Field(..., min_length=1, max_length=100)
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
description: Optional[str] = Field(None, max_length=500)
|
description: Optional[str] = Field(None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
class StoryResponse(BaseModel):
|
class StoryResponse(BaseModel):
|
||||||
"""Response model for story (list view)."""
|
"""Response model for story (list view)."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str]
|
description: Optional[str]
|
||||||
@@ -224,9 +312,11 @@ class StoryResponse(BaseModel):
|
|||||||
|
|
||||||
class StoryItemDetail(BaseModel):
|
class StoryItemDetail(BaseModel):
|
||||||
"""Detail model for story item with generation info."""
|
"""Detail model for story item with generation info."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
story_id: str
|
story_id: str
|
||||||
generation_id: str
|
generation_id: str
|
||||||
|
version_id: Optional[str] = None
|
||||||
start_time_ms: int
|
start_time_ms: int
|
||||||
track: int = 0
|
track: int = 0
|
||||||
trim_start_ms: int = 0
|
trim_start_ms: int = 0
|
||||||
@@ -242,6 +332,9 @@ class StoryItemDetail(BaseModel):
|
|||||||
seed: Optional[int]
|
seed: Optional[int]
|
||||||
instruct: Optional[str]
|
instruct: Optional[str]
|
||||||
generation_created_at: datetime
|
generation_created_at: datetime
|
||||||
|
# Versions available for this generation
|
||||||
|
versions: Optional[List["GenerationVersionResponse"]] = None
|
||||||
|
active_version_id: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -249,6 +342,7 @@ class StoryItemDetail(BaseModel):
|
|||||||
|
|
||||||
class StoryDetailResponse(BaseModel):
|
class StoryDetailResponse(BaseModel):
|
||||||
"""Response model for story with items."""
|
"""Response model for story with items."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str]
|
description: Optional[str]
|
||||||
@@ -262,6 +356,7 @@ class StoryDetailResponse(BaseModel):
|
|||||||
|
|
||||||
class StoryItemCreate(BaseModel):
|
class StoryItemCreate(BaseModel):
|
||||||
"""Request model for adding a generation to a story."""
|
"""Request model for adding a generation to a story."""
|
||||||
|
|
||||||
generation_id: str
|
generation_id: str
|
||||||
start_time_ms: Optional[int] = None # If not provided, will be calculated automatically
|
start_time_ms: Optional[int] = None # If not provided, will be calculated automatically
|
||||||
track: Optional[int] = 0 # Track number (0 = main track)
|
track: Optional[int] = 0 # Track number (0 = main track)
|
||||||
@@ -269,32 +364,146 @@ class StoryItemCreate(BaseModel):
|
|||||||
|
|
||||||
class StoryItemUpdateTime(BaseModel):
|
class StoryItemUpdateTime(BaseModel):
|
||||||
"""Request model for updating a story item's timecode."""
|
"""Request model for updating a story item's timecode."""
|
||||||
|
|
||||||
generation_id: str
|
generation_id: str
|
||||||
start_time_ms: int = Field(..., ge=0)
|
start_time_ms: int = Field(..., ge=0)
|
||||||
|
|
||||||
|
|
||||||
class StoryItemBatchUpdate(BaseModel):
|
class StoryItemBatchUpdate(BaseModel):
|
||||||
"""Request model for batch updating story item timecodes."""
|
"""Request model for batch updating story item timecodes."""
|
||||||
|
|
||||||
updates: List[StoryItemUpdateTime]
|
updates: List[StoryItemUpdateTime]
|
||||||
|
|
||||||
|
|
||||||
class StoryItemReorder(BaseModel):
|
class StoryItemReorder(BaseModel):
|
||||||
"""Request model for reordering story items."""
|
"""Request model for reordering story items."""
|
||||||
|
|
||||||
generation_ids: List[str] = Field(..., min_length=1)
|
generation_ids: List[str] = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
class StoryItemMove(BaseModel):
|
class StoryItemMove(BaseModel):
|
||||||
"""Request model for moving a story item (position and/or track)."""
|
"""Request model for moving a story item (position and/or track)."""
|
||||||
|
|
||||||
start_time_ms: int = Field(..., ge=0)
|
start_time_ms: int = Field(..., ge=0)
|
||||||
track: int = 0
|
track: int = 0
|
||||||
|
|
||||||
|
|
||||||
class StoryItemTrim(BaseModel):
|
class StoryItemTrim(BaseModel):
|
||||||
"""Request model for trimming a story item."""
|
"""Request model for trimming a story item."""
|
||||||
|
|
||||||
trim_start_ms: int = Field(..., ge=0)
|
trim_start_ms: int = Field(..., ge=0)
|
||||||
trim_end_ms: int = Field(..., ge=0)
|
trim_end_ms: int = Field(..., ge=0)
|
||||||
|
|
||||||
|
|
||||||
class StoryItemSplit(BaseModel):
|
class StoryItemSplit(BaseModel):
|
||||||
"""Request model for splitting a story item."""
|
"""Request model for splitting a story item."""
|
||||||
|
|
||||||
split_time_ms: int = Field(..., ge=0) # Time within the clip to split at (relative to clip start)
|
split_time_ms: int = Field(..., ge=0) # Time within the clip to split at (relative to clip start)
|
||||||
|
|
||||||
|
|
||||||
|
class StoryItemVersionUpdate(BaseModel):
|
||||||
|
"""Request model for setting a story item's pinned version."""
|
||||||
|
|
||||||
|
version_id: Optional[str] = None # null = use generation default
|
||||||
|
|
||||||
|
|
||||||
|
class EffectConfig(BaseModel):
|
||||||
|
"""A single effect in an effects chain."""
|
||||||
|
|
||||||
|
type: str
|
||||||
|
enabled: bool = True
|
||||||
|
params: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class EffectsChain(BaseModel):
|
||||||
|
"""An ordered list of effects to apply."""
|
||||||
|
|
||||||
|
effects: List[EffectConfig] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class EffectPresetCreate(BaseModel):
|
||||||
|
"""Request model for creating an effect preset."""
|
||||||
|
|
||||||
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
description: Optional[str] = Field(None, max_length=500)
|
||||||
|
effects_chain: List[EffectConfig]
|
||||||
|
|
||||||
|
|
||||||
|
class EffectPresetUpdate(BaseModel):
|
||||||
|
"""Request model for updating an effect preset."""
|
||||||
|
|
||||||
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
|
description: Optional[str] = None
|
||||||
|
effects_chain: Optional[List[EffectConfig]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class EffectPresetResponse(BaseModel):
|
||||||
|
"""Response model for effect preset."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
effects_chain: List[EffectConfig]
|
||||||
|
is_builtin: bool = False
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationVersionResponse(BaseModel):
|
||||||
|
"""Response model for a generation version."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
generation_id: str
|
||||||
|
label: str
|
||||||
|
audio_path: str
|
||||||
|
effects_chain: Optional[List[EffectConfig]] = None
|
||||||
|
source_version_id: Optional[str] = None
|
||||||
|
is_default: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyEffectsRequest(BaseModel):
|
||||||
|
"""Request to apply effects to an existing generation."""
|
||||||
|
|
||||||
|
effects_chain: List[EffectConfig]
|
||||||
|
source_version_id: Optional[str] = Field(
|
||||||
|
None, description="Version to use as source audio (defaults to clean/original)"
|
||||||
|
)
|
||||||
|
label: Optional[str] = Field(None, max_length=100, description="Label for this version (auto-generated if omitted)")
|
||||||
|
set_as_default: bool = Field(default=True, description="Set this version as the default")
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileEffectsUpdate(BaseModel):
|
||||||
|
"""Request to update the default effects chain on a profile."""
|
||||||
|
|
||||||
|
effects_chain: Optional[List[EffectConfig]] = Field(None, description="Effects chain (null to remove)")
|
||||||
|
|
||||||
|
|
||||||
|
class AvailableEffectParam(BaseModel):
|
||||||
|
"""Description of a single effect parameter."""
|
||||||
|
|
||||||
|
default: float
|
||||||
|
min: float
|
||||||
|
max: float
|
||||||
|
step: float
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
class AvailableEffect(BaseModel):
|
||||||
|
"""Description of an available effect type."""
|
||||||
|
|
||||||
|
type: str
|
||||||
|
label: str
|
||||||
|
description: str
|
||||||
|
params: dict # param_name -> AvailableEffectParam
|
||||||
|
|
||||||
|
|
||||||
|
class AvailableEffectsResponse(BaseModel):
|
||||||
|
"""Response listing all available effect types."""
|
||||||
|
|
||||||
|
effects: List[AvailableEffect]
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user