mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-28 06:35:18 -07:00
Compare commits
332
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
1040625a88 | ||
|
|
6f4503b521 | ||
|
|
f5b6edc2e7 | ||
|
|
8197f0724c | ||
|
|
d40f7d2676 | ||
|
|
99fbcca7f4 | ||
|
|
04f9880c9a | ||
|
|
b9c858295d | ||
|
|
610f64c762 | ||
|
|
220333b3bb | ||
|
|
e194e95512 | ||
|
|
e796412c2c | ||
|
|
cb541521d2 | ||
|
|
2bc243f93e | ||
|
|
0209008d73 | ||
|
|
9bde534860 | ||
|
|
97eb570b28 | ||
|
|
7d0557a099 | ||
|
|
60a03c56a9 | ||
|
|
d3393fb940 | ||
|
|
07c0aba883 | ||
|
|
77418a52ae | ||
|
|
46f6806e14 | ||
|
|
20851ccc2b | ||
|
|
e5f4606a6c | ||
|
|
146ef5aaeb | ||
|
|
971604d14f | ||
|
|
0b17073345 | ||
|
|
17106b1e40 | ||
|
|
953e6ec7d8 | ||
|
|
d3c65fc6c2 | ||
|
|
7fcca09f24 | ||
|
|
b6e772c6ac | ||
|
|
a6b070201b | ||
|
|
30352e2419 | ||
|
|
bfa38b36b7 | ||
|
|
1b66a528d1 | ||
|
|
bef4092e6e | ||
|
|
9654f7b642 | ||
|
|
eba1244add | ||
|
|
94487f32a5 | ||
|
|
081f45e680 | ||
|
|
86768288ce | ||
|
|
0fd063442a | ||
|
|
a0c2493e98 | ||
|
|
b39f48cc81 | ||
|
|
4ff775bc98 | ||
|
|
6351aa75e9 | ||
|
|
43873a883b | ||
|
|
60012b81c0 | ||
|
|
89f3127c37 | ||
|
|
ef3c3a7f8c | ||
|
|
7b5e73cfa8 | ||
|
|
462f104494 | ||
|
|
fadb57164e | ||
|
|
e870d65136 | ||
|
|
3df40278cc | ||
|
|
deeef5a474 | ||
|
|
236e464525 | ||
|
|
cf3cf3f002 | ||
|
|
9d98e1e768 | ||
|
|
3be8980f48 | ||
|
|
76bc070f5b | ||
|
|
01838f4773 | ||
|
|
39e4f9d08c | ||
|
|
341d71470c | ||
|
|
bb6cea24ba | ||
|
|
fa7ac88abc | ||
|
|
c68ddc45b1 | ||
|
|
f89dc66d0c | ||
|
|
cba7d7bc23 | ||
|
|
3c89b068f3 | ||
|
|
229841e05e | ||
|
|
123e8215e4 | ||
|
|
2a3afec2ca | ||
|
|
99ddd5a0b4 | ||
|
|
8d730621bc | ||
|
|
e23118f610 | ||
|
|
d4bfdc0d68 | ||
|
|
116c108906 | ||
|
|
2d23c8e06a | ||
|
|
3973a59ba3 | ||
|
|
d9aa75253a | ||
|
|
b22bf36565 | ||
|
|
33f4ed9b44 | ||
|
|
cc37e04221 | ||
|
|
2b4fbe5173 | ||
|
|
423d69b7cc | ||
|
|
ea943876dc | ||
|
|
b55d8cc567 | ||
|
|
036d90dc8e | ||
|
|
c513451277 | ||
|
|
27ae6dfbab | ||
|
|
51b9e2fd3d | ||
|
|
be25ddbe0e | ||
|
|
9cd4921291 | ||
|
|
2349bd24ba | ||
|
|
cd82ed0664 | ||
|
|
c4884a0443 | ||
|
|
232d231788 | ||
|
|
1cf90c81dd | ||
|
|
3204e193fa | ||
|
|
9d5d6cb56a | ||
|
|
153eaba5f3 | ||
|
|
3370e3b419 | ||
|
|
615bd188a0 | ||
|
|
7208f51eee | ||
|
|
07a91a2381 | ||
|
|
70cc36857d | ||
|
|
7f66d02591 | ||
|
|
9f7a5a492e | ||
|
|
cb44377b09 | ||
|
|
a42a946586 | ||
|
|
a3cbe7f2b6 | ||
|
|
d8d9eeaa6a | ||
|
|
f7cb219f6d | ||
|
|
7f18c09628 | ||
|
|
d9c7121c5b | ||
|
|
008b58f91c | ||
|
|
c7404411d5 | ||
|
|
ac08c4fcf4 | ||
|
|
3ce7498495 | ||
|
|
ce2f09d29e | ||
|
|
f1be633dca | ||
|
|
5f58c4dc3d | ||
|
|
892f363e3a | ||
|
|
535cf362de | ||
|
|
f1116e05a6 | ||
|
|
f9aca9d418 | ||
|
|
d913a9ae2a | ||
|
|
36031a0df5 | ||
|
|
e59f86aa63 | ||
|
|
b59c0f44e5 | ||
|
|
f8c5e54962 | ||
|
|
8b67faf96d | ||
|
|
30ea627ae8 | ||
|
|
cd77b80b4f | ||
|
|
12174010f9 | ||
|
|
57c68040bc | ||
|
|
d65704aa68 | ||
|
|
83a6aca1ff | ||
|
|
f18abc0da6 | ||
|
|
0f616ff1c1 | ||
|
|
7c4b1d4dd2 | ||
|
|
48acc10422 | ||
|
|
1fdf61ca2e | ||
|
|
6a0601bd6c | ||
|
|
88d41f342b | ||
|
|
b1cf7926c7 | ||
|
|
834323068d | ||
|
|
8cd868d33f | ||
|
|
446182e16c | ||
|
|
c7c401b98c | ||
|
|
595d735143 | ||
|
|
85935c1bbb | ||
|
|
8058360744 | ||
|
|
47e4da7ce2 | ||
|
|
b7ab4410a6 | ||
|
|
afd0381243 | ||
|
|
2ceccaec51 | ||
|
|
551abc9856 | ||
|
|
333cb262e0 | ||
|
|
04bc1aded4 | ||
|
|
b2659e6a6d | ||
|
|
090b1f6dde | ||
|
|
d943e1d6d4 | ||
|
|
d1273c3d33 | ||
|
|
1ce62e8b15 | ||
|
|
240b9b71a7 | ||
|
|
9396c6c86d | ||
|
|
82431dc5f5 | ||
|
|
f6e5111f68 | ||
|
|
658e967558 | ||
|
|
777e73f195 | ||
|
|
c7004d5776 | ||
|
|
5d731d900b | ||
|
|
fd89831d83 | ||
|
|
b4b3762ef0 | ||
|
|
af2f37ccb1 | ||
|
|
5feda1519c | ||
|
|
e56bfdc694 | ||
|
|
caff18dabe | ||
|
|
36bd2d2656 | ||
|
|
9ca0f43afb | ||
|
|
c14fb937ef | ||
|
|
7d43938b49 | ||
|
|
a7463968e4 | ||
|
|
d9de8f04f2 | ||
|
|
1f075c1c15 | ||
|
|
9125a4abe0 | ||
|
|
c62f615162 | ||
|
|
6acad47335 | ||
|
|
75520e0c29 | ||
|
|
e6a05f7208 | ||
|
|
57880fc2c7 | ||
|
|
dc44a128de | ||
|
|
2617936d39 | ||
|
|
05adc4e013 | ||
|
|
b479178e91 | ||
|
|
a57e7dbc54 | ||
|
|
530ee407ee | ||
|
|
bc21b4c422 |
@@ -0,0 +1,39 @@
|
|||||||
|
[bumpversion]
|
||||||
|
current_version = 0.2.0
|
||||||
|
commit = True
|
||||||
|
tag = True
|
||||||
|
tag_name = v{new_version}
|
||||||
|
tag_message = Release v{new_version}
|
||||||
|
message = Bump version: {current_version} → {new_version}
|
||||||
|
|
||||||
|
[bumpversion:file:tauri/src-tauri/tauri.conf.json]
|
||||||
|
search = "version": "{current_version}"
|
||||||
|
replace = "version": "{new_version}"
|
||||||
|
|
||||||
|
[bumpversion:file:tauri/src-tauri/Cargo.toml]
|
||||||
|
search = version = "{current_version}"
|
||||||
|
replace = version = "{new_version}"
|
||||||
|
|
||||||
|
[bumpversion:file:package.json]
|
||||||
|
search = "version": "{current_version}"
|
||||||
|
replace = "version": "{new_version}"
|
||||||
|
|
||||||
|
[bumpversion:file:app/package.json]
|
||||||
|
search = "version": "{current_version}"
|
||||||
|
replace = "version": "{new_version}"
|
||||||
|
|
||||||
|
[bumpversion:file:tauri/package.json]
|
||||||
|
search = "version": "{current_version}"
|
||||||
|
replace = "version": "{new_version}"
|
||||||
|
|
||||||
|
[bumpversion:file:landing/package.json]
|
||||||
|
search = "version": "{current_version}"
|
||||||
|
replace = "version": "{new_version}"
|
||||||
|
|
||||||
|
[bumpversion:file:web/package.json]
|
||||||
|
search = "version": "{current_version}"
|
||||||
|
replace = "version": "{new_version}"
|
||||||
|
|
||||||
|
[bumpversion:file:backend/__init__.py]
|
||||||
|
search = __version__ = "{current_version}"
|
||||||
|
replace = __version__ = "{new_version}"
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Version control
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Desktop-only (not needed in web container)
|
||||||
|
tauri/
|
||||||
|
landing/
|
||||||
|
docs/
|
||||||
|
mlx-test/
|
||||||
|
scripts/
|
||||||
|
|
||||||
|
# Dependencies & build artifacts (rebuilt in Docker)
|
||||||
|
node_modules/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Data (will be bind-mounted)
|
||||||
|
data/
|
||||||
|
backend/data/
|
||||||
|
|
||||||
|
# IDE & OS
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Config files not needed in container
|
||||||
|
biome.json
|
||||||
|
.biomeignore
|
||||||
|
.bumpversion.cfg
|
||||||
|
.npmrc
|
||||||
|
Makefile
|
||||||
|
CHANGELOG.md
|
||||||
|
CONTRIBUTING.md
|
||||||
|
SECURITY.md
|
||||||
|
LICENSE
|
||||||
|
README.md
|
||||||
|
backend/README.md
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
@@ -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
|
||||||
+117
-23
@@ -4,7 +4,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
@@ -14,18 +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"
|
||||||
- platform: 'macos-15-intel'
|
backend: "mlx"
|
||||||
args: '--target x86_64-apple-darwin'
|
- platform: "macos-15-intel"
|
||||||
python-version: '3.12'
|
args: "--target x86_64-apple-darwin"
|
||||||
- platform: 'ubuntu-22.04'
|
python-version: "3.12"
|
||||||
args: ''
|
backend: "pytorch"
|
||||||
python-version: '3.12'
|
- platform: "windows-latest"
|
||||||
- platform: 'windows-latest'
|
args: ""
|
||||||
args: ''
|
python-version: "3.12"
|
||||||
python-version: '3.12'
|
backend: "pytorch"
|
||||||
|
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
|
|
||||||
@@ -33,23 +33,28 @@ 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'
|
||||||
run: |
|
run: |
|
||||||
brew install llvm
|
brew install llvm@20
|
||||||
echo "$(brew --prefix llvm)/bin" >> $GITHUB_PATH
|
echo "$(brew --prefix llvm@20)/bin" >> $GITHUB_PATH
|
||||||
echo "LLVM_CONFIG=$(brew --prefix llvm)/bin/llvm-config" >> $GITHUB_ENV
|
echo "LLVM_CONFIG=$(brew --prefix llvm@20)/bin/llvm-config" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
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: |
|
||||||
@@ -57,6 +62,11 @@ jobs:
|
|||||||
pip install pyinstaller
|
pip install pyinstaller
|
||||||
pip install -r backend/requirements.txt
|
pip install -r backend/requirements.txt
|
||||||
|
|
||||||
|
- name: Install MLX dependencies (Apple Silicon only)
|
||||||
|
if: matrix.backend == 'mlx'
|
||||||
|
run: |
|
||||||
|
pip install -r backend/requirements-mlx.txt
|
||||||
|
|
||||||
- name: Build Python server (Linux/macOS)
|
- name: Build Python server (Linux/macOS)
|
||||||
if: matrix.platform != 'windows-latest'
|
if: matrix.platform != 'windows-latest'
|
||||||
run: |
|
run: |
|
||||||
@@ -91,31 +101,115 @@ 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
|
||||||
|
|
||||||
|
- name: Install Apple API key
|
||||||
|
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.appstoreconnect/private_keys/
|
||||||
|
cd ~/.appstoreconnect/private_keys/
|
||||||
|
echo ${{ secrets.APPLE_API_KEY_BASE64 }} >> AuthKey_${{ secrets.APPLE_API_KEY }}.p8.base64
|
||||||
|
base64 --decode -i AuthKey_${{ secrets.APPLE_API_KEY }}.p8.base64 -o AuthKey_${{ secrets.APPLE_API_KEY }}.p8
|
||||||
|
rm AuthKey_${{ secrets.APPLE_API_KEY }}.p8.base64
|
||||||
|
|
||||||
|
- name: Install Codesigning Certificate
|
||||||
|
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
|
||||||
|
uses: apple-actions/import-codesign-certs@v3
|
||||||
|
with:
|
||||||
|
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
|
||||||
- uses: tauri-apps/tauri-action@v0
|
- uses: tauri-apps/tauri-action@v0
|
||||||
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 }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
APPLE_PROVIDER_SHORT_NAME: ${{ secrets.APPLE_PROVIDER_SHORT_NAME }}
|
||||||
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
|
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||||
with:
|
with:
|
||||||
projectPath: tauri
|
projectPath: tauri
|
||||||
tagName: v__VERSION__
|
tagName: v__VERSION__
|
||||||
releaseName: 'voicebox v__VERSION__'
|
releaseName: "voicebox v__VERSION__"
|
||||||
releaseBody: |
|
releaseBody: |
|
||||||
## What's Changed
|
## What's Changed
|
||||||
See the assets below to download and install this version.
|
See the assets below to download and install this version.
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
- **macOS**: Download the `.dmg` file
|
- **macOS (Apple Silicon)**: Download the `aarch64.dmg` file - uses MLX for fast native inference
|
||||||
|
- **macOS (Intel)**: Download the `x64.dmg` file - uses PyTorch
|
||||||
- **Windows**: Download the `.msi` installer
|
- **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
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|||||||
+1
-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
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to Voicebox will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Profile Name Validation** - Added proper validation to prevent duplicate profile names ([#134](https://github.com/jamiepine/voicebox/issues/134))
|
||||||
|
- Users now receive clear error messages when attempting to create or update profiles with duplicate names
|
||||||
|
- Improved error handling in create and update profile API endpoints
|
||||||
|
- Added comprehensive test suite for duplicate name validation
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-01-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### Core Features
|
||||||
|
- **Voice Cloning** - Clone voices from audio samples using Qwen3-TTS (1.7B and 0.6B models)
|
||||||
|
- **Voice Profile Management** - Create, edit, and organize voice profiles with multiple samples
|
||||||
|
- **Speech Generation** - Generate high-quality speech from text using cloned voices
|
||||||
|
- **Generation History** - Track all generations with search and filtering capabilities
|
||||||
|
- **Audio Transcription** - Automatic transcription powered by Whisper
|
||||||
|
- **In-App Recording** - Record audio samples directly in the app with waveform visualization
|
||||||
|
|
||||||
|
#### Desktop App
|
||||||
|
- **Tauri Desktop App** - Native desktop application for macOS, Windows, and Linux
|
||||||
|
- **Local Server Mode** - Embedded Python server runs automatically
|
||||||
|
- **Remote Server Mode** - Connect to a remote Voicebox server on your network
|
||||||
|
- **Auto-Updates** - Automatic update notifications and installation
|
||||||
|
|
||||||
|
#### API
|
||||||
|
- **REST API** - Full REST API for voice synthesis and profile management
|
||||||
|
- **OpenAPI Documentation** - Interactive API docs at `/docs` endpoint
|
||||||
|
- **Type-Safe Client** - Auto-generated TypeScript client from OpenAPI schema
|
||||||
|
|
||||||
|
#### Technical
|
||||||
|
- **Voice Prompt Caching** - Fast regeneration with cached voice prompts
|
||||||
|
- **Multi-Sample Support** - Combine multiple audio samples for better voice quality
|
||||||
|
- **GPU/CPU/MPS Support** - Automatic device detection and optimization
|
||||||
|
- **Model Management** - Lazy loading and VRAM management
|
||||||
|
- **SQLite Database** - Local data persistence
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
- Built with Tauri v2 (Rust + React)
|
||||||
|
- FastAPI backend with async Python
|
||||||
|
- TypeScript frontend with React Query and Zustand
|
||||||
|
- Qwen3-TTS for voice cloning
|
||||||
|
- Whisper for transcription
|
||||||
|
|
||||||
|
### Platform Support
|
||||||
|
|
||||||
|
- macOS (Apple Silicon and Intel)
|
||||||
|
- Windows
|
||||||
|
- Linux (AppImage)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Audio export failing when Tauri save dialog returns object instead of string path
|
||||||
|
- OpenAPI client generator script now documents the local backend port and avoids an unused loop variable warning
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Makefile** - Comprehensive development workflow automation with commands for setup, development, building, testing, and code quality checks
|
||||||
|
- Includes Python version detection and compatibility warnings
|
||||||
|
- Self-documenting help system with `make help`
|
||||||
|
- Colored output for better readability
|
||||||
|
- Supports parallel development server execution
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **README** - Added Makefile reference and updated Quick Start with Makefile-based setup instructions alongside manual setup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Unreleased - Planned]
|
||||||
|
|
||||||
|
### Planned
|
||||||
|
- Real-time streaming synthesis
|
||||||
|
- Conversation mode with multiple speakers
|
||||||
|
- Voice effects (pitch shift, reverb, M3GAN-style)
|
||||||
|
- Timeline-based audio editor
|
||||||
|
- Additional voice models (XTTS, Bark)
|
||||||
|
- Voice design from text descriptions
|
||||||
|
- Project system for saving sessions
|
||||||
|
- Plugin architecture
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[0.1.0]: https://github.com/jamiepine/voicebox/releases/tag/v0.1.0
|
||||||
+453
@@ -0,0 +1,453 @@
|
|||||||
|
# Contributing to Voicebox
|
||||||
|
|
||||||
|
Thank you for your interest in contributing to Voicebox! This document provides guidelines and instructions for contributing.
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
- Be respectful and inclusive
|
||||||
|
- Welcome newcomers and help them learn
|
||||||
|
- Focus on constructive feedback
|
||||||
|
- Respect different viewpoints and experiences
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **[Bun](https://bun.sh)** - Fast JavaScript runtime and package manager
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://bun.sh/install | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
- **[Python 3.11+](https://python.org)** - For backend development
|
||||||
|
```bash
|
||||||
|
python --version # Should be 3.11 or higher
|
||||||
|
```
|
||||||
|
|
||||||
|
- **[Rust](https://rustup.rs)** - For Tauri desktop app (installed automatically by Tauri CLI)
|
||||||
|
```bash
|
||||||
|
rustc --version # Check if installed
|
||||||
|
```
|
||||||
|
- **[Tauri Prerequisites](https://v2.tauri.app/start/prerequisites)** - Tauri-specific system dependencies (varies by OS).
|
||||||
|
|
||||||
|
- **Git** - Version control
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
**Using `just` (recommended):**
|
||||||
|
|
||||||
|
Install [just](https://github.com/casey/just) (`brew install just` or `cargo install just`), then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just setup # creates venv, installs Python + JS deps
|
||||||
|
just dev # starts backend + desktop app in one terminal
|
||||||
|
```
|
||||||
|
|
||||||
|
Other useful commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just dev-web # backend + web app (no Tauri/Rust build)
|
||||||
|
just dev-backend # backend only
|
||||||
|
just kill # stop all dev processes
|
||||||
|
just clean-all # nuke everything and start fresh
|
||||||
|
just --list # see all available commands
|
||||||
|
```
|
||||||
|
|
||||||
|
**Using the Makefile:** Run `make setup` then `make dev`. See `make help` for all commands.
|
||||||
|
|
||||||
|
**Manual setup (required for Windows):**
|
||||||
|
|
||||||
|
1. **Fork and clone the repository**
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/YOUR_USERNAME/voicebox.git
|
||||||
|
cd voicebox
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install JavaScript dependencies**
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
This installs dependencies for:
|
||||||
|
- `app/` - Shared React frontend
|
||||||
|
- `tauri/` - Tauri desktop wrapper
|
||||||
|
- `web/` - Web deployment wrapper
|
||||||
|
|
||||||
|
3. **Set up Python backend**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Create virtual environment
|
||||||
|
python -m venv venv
|
||||||
|
|
||||||
|
# Activate virtual environment
|
||||||
|
source venv/bin/activate # On macOS/Linux
|
||||||
|
# or
|
||||||
|
venv\Scripts\activate # On Windows
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Install MLX dependencies (Apple Silicon only - for faster inference)
|
||||||
|
# On Apple Silicon, this enables native Metal acceleration
|
||||||
|
if [[ $(uname -m) == "arm64" ]]; then
|
||||||
|
pip install -r requirements-mlx.txt
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install Qwen3-TTS (required for voice synthesis)
|
||||||
|
pip install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Start development servers**
|
||||||
|
|
||||||
|
Development requires two terminals: one for the Python backend, one for the Tauri app.
|
||||||
|
|
||||||
|
**Terminal 1: Backend server** (start this first)
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
source venv/bin/activate # Activate venv if not already active
|
||||||
|
bun run dev:server
|
||||||
|
# Or manually: uvicorn main:app --reload --port 17493
|
||||||
|
```
|
||||||
|
Backend will be available at `http://localhost:17493`
|
||||||
|
|
||||||
|
**Terminal 2: Desktop app**
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
This will:
|
||||||
|
- Create a placeholder sidecar binary (for Tauri compilation)
|
||||||
|
- Start Vite dev server on port 5173
|
||||||
|
- Launch Tauri window pointing to localhost:5173
|
||||||
|
- Connect to the Python server you started in Terminal 1
|
||||||
|
- Enable hot reload
|
||||||
|
|
||||||
|
> **Note:** In dev mode, the app connects to your manually-started Python server.
|
||||||
|
> The bundled server binary is only used in production builds.
|
||||||
|
|
||||||
|
**Optional: Web app**
|
||||||
|
```bash
|
||||||
|
bun run dev:web
|
||||||
|
```
|
||||||
|
Web app will be available at `http://localhost:5174`
|
||||||
|
|
||||||
|
### Model Downloads
|
||||||
|
|
||||||
|
Models are automatically downloaded from HuggingFace Hub on first use:
|
||||||
|
- **Whisper** (transcription): Auto-downloads on first transcription
|
||||||
|
- **Qwen3-TTS** (voice cloning): Auto-downloads on first generation (~2-4GB)
|
||||||
|
|
||||||
|
First-time usage will be slower due to model downloads, but subsequent runs will use cached models.
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
**Build everything (recommended):**
|
||||||
|
```bash
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
This automatically:
|
||||||
|
1. Builds the Python server binary (`./scripts/build-server.sh`)
|
||||||
|
2. Builds the Tauri desktop app (`cd tauri && bun run tauri build`)
|
||||||
|
|
||||||
|
Creates platform-specific installers (`.dmg`, `.msi`, `.AppImage`) in `tauri/src-tauri/target/release/bundle/`.
|
||||||
|
|
||||||
|
**Note:** The build process detects your platform and includes the appropriate backend (MLX for Apple Silicon, PyTorch for others).
|
||||||
|
|
||||||
|
**Build server binary only:**
|
||||||
|
```bash
|
||||||
|
bun run build:server
|
||||||
|
# or
|
||||||
|
./scripts/build-server.sh
|
||||||
|
```
|
||||||
|
Creates platform-specific binary in `tauri/src-tauri/binaries/`
|
||||||
|
|
||||||
|
**Building with local Qwen3-TTS development version:**
|
||||||
|
|
||||||
|
If you're actively developing or modifying the Qwen3-TTS library, set the `QWEN_TTS_PATH` environment variable to point to your local clone:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export QWEN_TTS_PATH=~/path/to/your/Qwen3-TTS
|
||||||
|
bun run build:server
|
||||||
|
```
|
||||||
|
|
||||||
|
This makes PyInstaller use your local qwen-tts version instead of the pip-installed package. Useful when testing changes to the TTS library before they're published to PyPI or when using an editable install (`pip install -e`).
|
||||||
|
|
||||||
|
**Build web app:**
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
Output in `web/dist/`
|
||||||
|
|
||||||
|
### Generate OpenAPI Client
|
||||||
|
|
||||||
|
After starting the backend server:
|
||||||
|
```bash
|
||||||
|
./scripts/generate-api.sh
|
||||||
|
```
|
||||||
|
This downloads the OpenAPI schema and generates the TypeScript client in `app/src/lib/api/`
|
||||||
|
|
||||||
|
### Convert Assets to Web Formats
|
||||||
|
|
||||||
|
To optimize images and videos for the web, run:
|
||||||
|
```bash
|
||||||
|
bun run convert:assets
|
||||||
|
```
|
||||||
|
|
||||||
|
This script:
|
||||||
|
- Converts PNG → WebP (better compression, same quality)
|
||||||
|
- Converts MOV → WebM (VP9 codec, smaller file size)
|
||||||
|
- Processes files in `landing/public/` and `docs/public/`
|
||||||
|
- **Deletes original files** after successful conversion
|
||||||
|
|
||||||
|
**Requirements:** Install `webp` and `ffmpeg`:
|
||||||
|
```bash
|
||||||
|
brew install webp ffmpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** Run this before committing new images or videos to keep the repository size small.
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### 1. Create a Branch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b feature/your-feature-name
|
||||||
|
# or
|
||||||
|
git checkout -b fix/your-bug-fix
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Make Your Changes
|
||||||
|
|
||||||
|
- Write clean, readable code
|
||||||
|
- Follow existing code style
|
||||||
|
- Add comments for complex logic
|
||||||
|
- Update documentation as needed
|
||||||
|
|
||||||
|
### 3. Test Your Changes
|
||||||
|
|
||||||
|
- Test manually in the app
|
||||||
|
- Ensure backend API endpoints work
|
||||||
|
- Check for TypeScript/Python errors
|
||||||
|
- Verify UI components render correctly
|
||||||
|
|
||||||
|
### 4. Commit Your Changes
|
||||||
|
|
||||||
|
Write clear, descriptive commit messages:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "Add feature: voice profile export"
|
||||||
|
git commit -m "Fix: audio playback stops after 30 seconds"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Push and Create Pull Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin feature/your-feature-name
|
||||||
|
```
|
||||||
|
|
||||||
|
Then create a pull request on GitHub with:
|
||||||
|
- Clear description of changes
|
||||||
|
- Screenshots (for UI changes)
|
||||||
|
- Reference to related issues
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
### TypeScript/React
|
||||||
|
|
||||||
|
- Use TypeScript strict mode
|
||||||
|
- Follow React best practices
|
||||||
|
- Use functional components with hooks
|
||||||
|
- Prefer named exports
|
||||||
|
- Format with Biome (runs automatically)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good
|
||||||
|
export function ProfileCard({ profile }: { profile: Profile }) {
|
||||||
|
return <div>{profile.name}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid
|
||||||
|
export const ProfileCard = (props) => { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python
|
||||||
|
|
||||||
|
- Follow PEP 8 style guide
|
||||||
|
- Use type hints
|
||||||
|
- Use async/await for I/O operations
|
||||||
|
- Format with Black (if configured)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Good
|
||||||
|
async def create_profile(name: str, language: str) -> Profile:
|
||||||
|
"""Create a new voice profile."""
|
||||||
|
...
|
||||||
|
|
||||||
|
# Avoid
|
||||||
|
def create_profile(name, language):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rust
|
||||||
|
|
||||||
|
- Follow Rust conventions
|
||||||
|
- Use meaningful variable names
|
||||||
|
- Handle errors explicitly
|
||||||
|
- Format with `rustfmt`
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
voicebox/
|
||||||
|
├── app/ # Shared React frontend
|
||||||
|
│ └── src/
|
||||||
|
│ ├── components/ # UI components
|
||||||
|
│ ├── lib/ # Utilities and API client
|
||||||
|
│ └── hooks/ # React hooks
|
||||||
|
├── backend/ # Python FastAPI server
|
||||||
|
│ ├── main.py # API routes
|
||||||
|
│ ├── tts.py # Voice synthesis
|
||||||
|
│ └── ...
|
||||||
|
├── tauri/ # Desktop app wrapper
|
||||||
|
│ └── src-tauri/ # Rust backend
|
||||||
|
└── scripts/ # Build scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Areas for Contribution
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- Check existing issues for bugs to fix
|
||||||
|
- Test your fix thoroughly
|
||||||
|
- Add tests if possible
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
- Check the roadmap in README.md
|
||||||
|
- Discuss major features in an issue first
|
||||||
|
- Keep features focused and well-scoped
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
- Improve README clarity
|
||||||
|
- Add code comments
|
||||||
|
- Write API documentation
|
||||||
|
- Create tutorials or guides
|
||||||
|
|
||||||
|
### 🎨 UI/UX Improvements
|
||||||
|
|
||||||
|
- Improve accessibility
|
||||||
|
- Enhance visual design
|
||||||
|
- Optimize performance
|
||||||
|
- Add animations/transitions
|
||||||
|
|
||||||
|
### 🔧 Infrastructure
|
||||||
|
|
||||||
|
- Improve build process
|
||||||
|
- Add CI/CD improvements
|
||||||
|
- Optimize bundle size
|
||||||
|
- Add testing infrastructure
|
||||||
|
|
||||||
|
## API Development
|
||||||
|
|
||||||
|
When adding new API endpoints:
|
||||||
|
|
||||||
|
1. **Add route in `backend/main.py`**
|
||||||
|
2. **Create Pydantic models in `backend/models.py`**
|
||||||
|
3. **Implement business logic in appropriate module**
|
||||||
|
4. **Update OpenAPI schema** (automatic with FastAPI)
|
||||||
|
5. **Regenerate TypeScript client:**
|
||||||
|
```bash
|
||||||
|
bun run generate:api
|
||||||
|
```
|
||||||
|
6. **Update `backend/README.md`** with endpoint documentation
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Currently, testing is primarily manual. When adding tests:
|
||||||
|
|
||||||
|
- **Backend**: Use pytest for Python tests
|
||||||
|
- **Frontend**: Use Vitest for React component tests
|
||||||
|
- **E2E**: Use Playwright for end-to-end tests (future)
|
||||||
|
|
||||||
|
## Pull Request Process
|
||||||
|
|
||||||
|
1. **Update documentation** if needed
|
||||||
|
2. **Ensure code follows style guidelines**
|
||||||
|
3. **Test your changes thoroughly**
|
||||||
|
4. **Update CHANGELOG.md** with your changes
|
||||||
|
5. **Request review** from maintainers
|
||||||
|
|
||||||
|
### PR Checklist
|
||||||
|
|
||||||
|
- [ ] Code follows style guidelines
|
||||||
|
- [ ] Documentation updated
|
||||||
|
- [ ] Changes tested
|
||||||
|
- [ ] No breaking changes (or documented)
|
||||||
|
- [ ] CHANGELOG.md updated
|
||||||
|
|
||||||
|
## Release Process
|
||||||
|
|
||||||
|
Releases are managed by maintainers:
|
||||||
|
|
||||||
|
1. **Bump version using bumpversion:**
|
||||||
|
```bash
|
||||||
|
# Install bumpversion (if not already installed)
|
||||||
|
pip install bumpversion
|
||||||
|
|
||||||
|
# Bump patch version (0.1.0 -> 0.1.1)
|
||||||
|
bumpversion patch
|
||||||
|
|
||||||
|
# Or bump minor version (0.1.0 -> 0.2.0)
|
||||||
|
bumpversion minor
|
||||||
|
|
||||||
|
# Or bump major version (0.1.0 -> 1.0.0)
|
||||||
|
bumpversion major
|
||||||
|
```
|
||||||
|
|
||||||
|
This automatically:
|
||||||
|
- Updates version numbers in all files (`tauri.conf.json`, `Cargo.toml`, all `package.json` files, `backend/main.py`)
|
||||||
|
- Creates a git commit with the version bump
|
||||||
|
- Creates a git tag (e.g., `v0.1.1`, `v0.2.0`)
|
||||||
|
|
||||||
|
2. **Update CHANGELOG.md** with release notes
|
||||||
|
|
||||||
|
3. **Push commits and tags:**
|
||||||
|
```bash
|
||||||
|
git push
|
||||||
|
git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **GitHub Actions builds and releases** automatically when tags are pushed
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues and solutions.
|
||||||
|
|
||||||
|
**Quick fixes:**
|
||||||
|
|
||||||
|
- **Backend won't start:** Check Python version (3.11+), ensure venv is activated, install dependencies
|
||||||
|
- **Tauri build fails:** Ensure Rust is installed, clean build with `cd tauri/src-tauri && cargo clean`
|
||||||
|
- **OpenAPI client generation fails:** Ensure backend is running, check `curl http://localhost:17493/openapi.json`
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
- Open an issue for bugs or feature requests
|
||||||
|
- Check existing issues and discussions
|
||||||
|
- Review the codebase to understand patterns
|
||||||
|
- See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- [README.md](README.md) - Project overview
|
||||||
|
- [backend/README.md](backend/README.md) - API documentation
|
||||||
|
- [docs/AUTOUPDATER_QUICKSTART.md](docs/AUTOUPDATER_QUICKSTART.md) - Auto-updater setup
|
||||||
|
- [SECURITY.md](SECURITY.md) - Security policy
|
||||||
|
- [CHANGELOG.md](CHANGELOG.md) - Version history
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
By contributing, you agree that your contributions will be licensed under the MIT License.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Thank you for contributing to Voicebox! 🎉
|
||||||
@@ -1,447 +0,0 @@
|
|||||||
# voicebox - Current State Overview
|
|
||||||
|
|
||||||
**Last Updated:** January 25, 2026
|
|
||||||
**Status:** ✅ MVP Core Features Working - Voice generation from Tauri app successful!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 What We Have
|
|
||||||
|
|
||||||
### ✅ **Fully Implemented & Working**
|
|
||||||
|
|
||||||
#### **Backend (Python FastAPI)**
|
|
||||||
- **Voice Profile Management**
|
|
||||||
- Create, read, update, delete profiles
|
|
||||||
- Add multiple audio samples per profile
|
|
||||||
- Multi-reference voice combination (combines multiple samples)
|
|
||||||
- Profile storage in SQLite + file system (`data/profiles/`)
|
|
||||||
|
|
||||||
- **Voice Generation**
|
|
||||||
- Qwen3-TTS model integration (1.7B and 0.6B support)
|
|
||||||
- Automatic model downloading from HuggingFace Hub
|
|
||||||
- Voice prompt caching for instant re-generation
|
|
||||||
- Support for English and Chinese
|
|
||||||
- Seed-based reproducibility
|
|
||||||
- GPU/CPU/MPS device detection
|
|
||||||
|
|
||||||
- **Generation History**
|
|
||||||
- Full CRUD operations
|
|
||||||
- Search by text content
|
|
||||||
- Filter by profile
|
|
||||||
- Pagination support
|
|
||||||
- Statistics endpoint
|
|
||||||
- Audio file storage (`data/generations/`)
|
|
||||||
|
|
||||||
- **Audio Transcription**
|
|
||||||
- Whisper integration for speech-to-text
|
|
||||||
- Language detection/selection
|
|
||||||
- Used for reference text extraction from samples
|
|
||||||
|
|
||||||
- **Database**
|
|
||||||
- SQLite with SQLAlchemy ORM
|
|
||||||
- Tables: `profiles`, `profile_samples`, `generations`, `projects` (ready for future)
|
|
||||||
- Automatic schema initialization
|
|
||||||
|
|
||||||
- **API Endpoints**
|
|
||||||
- RESTful API with FastAPI
|
|
||||||
- OpenAPI schema generation
|
|
||||||
- CORS enabled
|
|
||||||
- Health check endpoint
|
|
||||||
- File serving for audio files
|
|
||||||
|
|
||||||
#### **Frontend (React + TypeScript + Tauri)**
|
|
||||||
- **Voice Profile UI**
|
|
||||||
- Profile list with cards
|
|
||||||
- Create/edit profile dialog
|
|
||||||
- Upload audio samples with transcription
|
|
||||||
- Sample management (view/delete)
|
|
||||||
- Profile detail view
|
|
||||||
|
|
||||||
- **Generation UI**
|
|
||||||
- Form with profile selection
|
|
||||||
- Text input (up to 5000 chars)
|
|
||||||
- Language selection (en/zh)
|
|
||||||
- Optional seed input
|
|
||||||
- Loading states and error handling
|
|
||||||
|
|
||||||
- **History UI**
|
|
||||||
- Table view with pagination
|
|
||||||
- Search functionality
|
|
||||||
- Play audio inline
|
|
||||||
- Download audio files
|
|
||||||
- Delete generations
|
|
||||||
|
|
||||||
- **Server Settings**
|
|
||||||
- Connection form (local/remote mode)
|
|
||||||
- Server status display
|
|
||||||
- Health check integration
|
|
||||||
|
|
||||||
- **State Management**
|
|
||||||
- React Query for server state
|
|
||||||
- Zustand for client state (server URL, connection status)
|
|
||||||
- Type-safe API client
|
|
||||||
|
|
||||||
- **UI Components**
|
|
||||||
- shadcn/ui component library
|
|
||||||
- Tailwind CSS styling
|
|
||||||
- Responsive design
|
|
||||||
- Toast notifications
|
|
||||||
- Form validation with Zod
|
|
||||||
|
|
||||||
#### **Tauri Desktop App**
|
|
||||||
- **Rust Backend**
|
|
||||||
- Sidecar management for Python server
|
|
||||||
- Start/stop server commands
|
|
||||||
- Remote mode support (0.0.0.0 binding)
|
|
||||||
- Process lifecycle management
|
|
||||||
|
|
||||||
- **Build System**
|
|
||||||
- Tauri v2 configuration
|
|
||||||
- Platform-specific builds
|
|
||||||
- Dev tools in debug mode
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏗️ Architecture
|
|
||||||
|
|
||||||
### **Project Structure**
|
|
||||||
```
|
|
||||||
voicebox/
|
|
||||||
├── app/ # Shared React frontend
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── components/ # React components
|
|
||||||
│ │ │ ├── VoiceProfiles/ ✅ Complete
|
|
||||||
│ │ │ ├── Generation/ ✅ Complete
|
|
||||||
│ │ │ ├── History/ ✅ Complete
|
|
||||||
│ │ │ ├── ServerSettings/ ✅ Complete
|
|
||||||
│ │ │ └── AudioStudio/ 📦 Placeholder (future)
|
|
||||||
│ │ ├── lib/
|
|
||||||
│ │ │ ├── api/ # Type-safe API client ✅
|
|
||||||
│ │ │ ├── hooks/ # React Query hooks ✅
|
|
||||||
│ │ │ └── utils/ # Utilities ✅
|
|
||||||
│ │ └── stores/ # Zustand stores ✅
|
|
||||||
│
|
|
||||||
├── backend/ # Python FastAPI server
|
|
||||||
│ ├── main.py # FastAPI app + routes ✅
|
|
||||||
│ ├── models.py # Pydantic models ✅
|
|
||||||
│ ├── database.py # SQLAlchemy ORM ✅
|
|
||||||
│ ├── profiles.py # Profile management ✅
|
|
||||||
│ ├── history.py # History management ✅
|
|
||||||
│ ├── tts.py # Qwen3-TTS integration ✅
|
|
||||||
│ ├── transcribe.py # Whisper integration ✅
|
|
||||||
│ ├── studio.py # Audio studio (future)
|
|
||||||
│ └── utils/
|
|
||||||
│ ├── audio.py # Audio processing ✅
|
|
||||||
│ ├── cache.py # Voice prompt caching ✅
|
|
||||||
│ └── validation.py # Validation helpers ✅
|
|
||||||
│
|
|
||||||
├── tauri/ # Tauri desktop wrapper
|
|
||||||
│ ├── src/ # React entry point ✅
|
|
||||||
│ └── src-tauri/ # Rust backend ✅
|
|
||||||
│ └── src/main.rs # Sidecar management ✅
|
|
||||||
│
|
|
||||||
├── data/ # User data directory
|
|
||||||
│ ├── profiles/ # Profile audio samples
|
|
||||||
│ ├── generations/ # Generated audio files
|
|
||||||
│ ├── cache/ # Cached voice prompts
|
|
||||||
│ └── voicebox.db # SQLite database
|
|
||||||
│
|
|
||||||
└── scripts/ # Build & generation scripts
|
|
||||||
├── generate-api.sh # OpenAPI client generation
|
|
||||||
└── build-server.sh # Python binary build
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Data Flow**
|
|
||||||
|
|
||||||
```
|
|
||||||
User Action (Tauri App)
|
|
||||||
↓
|
|
||||||
React Component (Form Submit)
|
|
||||||
↓
|
|
||||||
React Query Hook (useGeneration)
|
|
||||||
↓
|
|
||||||
API Client (apiClient.generateSpeech)
|
|
||||||
↓
|
|
||||||
HTTP Request → FastAPI Backend
|
|
||||||
↓
|
|
||||||
Backend Route Handler (/generate)
|
|
||||||
↓
|
|
||||||
Business Logic:
|
|
||||||
1. Get profile from DB
|
|
||||||
2. Create voice prompt (with caching)
|
|
||||||
3. Generate audio with Qwen3-TTS
|
|
||||||
4. Save audio file
|
|
||||||
5. Create history entry
|
|
||||||
↓
|
|
||||||
Response (GenerationResponse)
|
|
||||||
↓
|
|
||||||
React Query Cache Update
|
|
||||||
↓
|
|
||||||
UI Refresh (History table updates)
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Key Technologies**
|
|
||||||
|
|
||||||
| Layer | Technology | Purpose |
|
|
||||||
|-------|-----------|---------|
|
|
||||||
| **Desktop Framework** | Tauri v2 | Native desktop app wrapper |
|
|
||||||
| **Frontend Framework** | React 18 | UI components |
|
|
||||||
| **Language** | TypeScript | Type safety |
|
|
||||||
| **Styling** | Tailwind CSS | Utility-first CSS |
|
|
||||||
| **UI Components** | shadcn/ui | Component library |
|
|
||||||
| **State Management** | React Query + Zustand | Server & client state |
|
|
||||||
| **Form Handling** | React Hook Form + Zod | Form validation |
|
|
||||||
| **Backend Framework** | FastAPI | Async REST API |
|
|
||||||
| **Database** | SQLite + SQLAlchemy | Data persistence |
|
|
||||||
| **ML Models** | Qwen3-TTS + Whisper | Voice cloning + transcription |
|
|
||||||
| **Audio Processing** | librosa + soundfile | Audio I/O and processing |
|
|
||||||
| **Package Manager** | Bun | Fast JS/TS package management |
|
|
||||||
| **Build Tool** | Vite | Frontend bundling |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔑 Key Features & Capabilities
|
|
||||||
|
|
||||||
### **1. Voice Profile System**
|
|
||||||
- **Multi-sample support**: Add multiple audio samples per profile
|
|
||||||
- **Automatic combination**: Multiple samples are combined for better quality
|
|
||||||
- **Voice prompt caching**: Re-use voice prompts for instant re-generation
|
|
||||||
- **Audio validation**: Ensures samples meet quality requirements
|
|
||||||
|
|
||||||
### **2. Generation Pipeline**
|
|
||||||
- **Lazy model loading**: Model loads on first use
|
|
||||||
- **Device detection**: Automatically uses GPU if available
|
|
||||||
- **Caching layer**: Voice prompts cached by audio hash + text
|
|
||||||
- **Error handling**: Graceful degradation and clear error messages
|
|
||||||
|
|
||||||
### **3. History & Search**
|
|
||||||
- **Full-text search**: Search generations by text content
|
|
||||||
- **Pagination**: Efficient loading of large histories
|
|
||||||
- **Audio playback**: Inline audio player
|
|
||||||
- **File management**: Download and delete operations
|
|
||||||
|
|
||||||
### **4. Server/Client Architecture**
|
|
||||||
- **Local mode**: Backend runs alongside Tauri app
|
|
||||||
- **Remote mode**: Connect to remote GPU machine
|
|
||||||
- **One-click server**: Start server from UI
|
|
||||||
- **Connection management**: Persistent server URL storage
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Database Schema
|
|
||||||
|
|
||||||
### **Tables**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Voice Profiles
|
|
||||||
profiles
|
|
||||||
- id (PK, UUID)
|
|
||||||
- name (unique)
|
|
||||||
- description
|
|
||||||
- language (en/zh)
|
|
||||||
- created_at
|
|
||||||
- updated_at
|
|
||||||
|
|
||||||
-- Profile Samples
|
|
||||||
profile_samples
|
|
||||||
- id (PK, UUID)
|
|
||||||
- profile_id (FK → profiles.id)
|
|
||||||
- audio_path
|
|
||||||
- reference_text
|
|
||||||
|
|
||||||
-- Generations
|
|
||||||
generations
|
|
||||||
- id (PK, UUID)
|
|
||||||
- profile_id (FK → profiles.id)
|
|
||||||
- text
|
|
||||||
- language
|
|
||||||
- audio_path
|
|
||||||
- duration (seconds)
|
|
||||||
- seed (optional)
|
|
||||||
- created_at
|
|
||||||
|
|
||||||
-- Projects (ready for future)
|
|
||||||
projects
|
|
||||||
- id (PK, UUID)
|
|
||||||
- name
|
|
||||||
- data (JSON)
|
|
||||||
- created_at
|
|
||||||
- updated_at
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 UI Components Status
|
|
||||||
|
|
||||||
| Component | Status | Features |
|
|
||||||
|-----------|--------|----------|
|
|
||||||
| **ProfileList** | ✅ Complete | List, create, empty state |
|
|
||||||
| **ProfileCard** | ✅ Complete | Display profile info |
|
|
||||||
| **ProfileForm** | ✅ Complete | Create/edit dialog |
|
|
||||||
| **ProfileDetail** | ✅ Complete | View samples, add samples |
|
|
||||||
| **SampleUpload** | ✅ Complete | File upload + transcription |
|
|
||||||
| **GenerationForm** | ✅ Complete | Full generation form |
|
|
||||||
| **HistoryTable** | ✅ Complete | Table, search, pagination, play/download |
|
|
||||||
| **ConnectionForm** | ✅ Complete | Server URL input |
|
|
||||||
| **ServerStatus** | ✅ Complete | Health check display |
|
|
||||||
| **AudioStudio** | 📦 Placeholder | Timeline editor (future) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔌 API Endpoints
|
|
||||||
|
|
||||||
### **Profiles**
|
|
||||||
- `POST /profiles` - Create profile
|
|
||||||
- `GET /profiles` - List all profiles
|
|
||||||
- `GET /profiles/{id}` - Get profile
|
|
||||||
- `PUT /profiles/{id}` - Update profile
|
|
||||||
- `DELETE /profiles/{id}` - Delete profile
|
|
||||||
- `POST /profiles/{id}/samples` - Add sample
|
|
||||||
- `GET /profiles/{id}/samples` - List samples
|
|
||||||
- `DELETE /profiles/samples/{id}` - Delete sample
|
|
||||||
|
|
||||||
### **Generation**
|
|
||||||
- `POST /generate` - Generate speech
|
|
||||||
|
|
||||||
### **History**
|
|
||||||
- `GET /history` - List generations (with filters)
|
|
||||||
- `GET /history/{id}` - Get generation
|
|
||||||
- `DELETE /history/{id}` - Delete generation
|
|
||||||
- `GET /history/stats` - Get statistics
|
|
||||||
|
|
||||||
### **Transcription**
|
|
||||||
- `POST /transcribe` - Transcribe audio
|
|
||||||
|
|
||||||
### **Audio**
|
|
||||||
- `GET /audio/{id}` - Serve audio file
|
|
||||||
|
|
||||||
### **Health**
|
|
||||||
- `GET /health` - Health check with model status
|
|
||||||
|
|
||||||
### **Model Management**
|
|
||||||
- `POST /models/load` - Load TTS model
|
|
||||||
- `POST /models/unload` - Unload TTS model
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 What's Next (Planned Features)
|
|
||||||
|
|
||||||
### **Phase 2: Advanced Features**
|
|
||||||
- [ ] Multi-reference voice combination UI
|
|
||||||
- [ ] Batch generation (multiple variations)
|
|
||||||
- [ ] Advanced audio normalization
|
|
||||||
- [ ] Export options (MP3, OGG, etc.)
|
|
||||||
- [ ] M3GAN voice effect
|
|
||||||
|
|
||||||
### **Phase 3: Audio Studio**
|
|
||||||
- [ ] Timeline-based audio editor
|
|
||||||
- [ ] Word-level timestamps
|
|
||||||
- [ ] Project system (save/load sessions)
|
|
||||||
- [ ] Audio effects and filters
|
|
||||||
- [ ] Multi-track editing
|
|
||||||
|
|
||||||
### **Phase 4: Voice Design**
|
|
||||||
- [ ] Text-to-voice (no reference needed)
|
|
||||||
- [ ] Preset voices with style control
|
|
||||||
- [ ] Conversation mode (multi-speaker)
|
|
||||||
- [ ] Custom audio effects library
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Code Quality Standards
|
|
||||||
|
|
||||||
- ✅ **Type safety**: TypeScript strict mode, Pydantic models
|
|
||||||
- ✅ **Modular architecture**: No files over 500 lines
|
|
||||||
- ✅ **Error handling**: Comprehensive error messages
|
|
||||||
- ✅ **Caching**: Voice prompt caching for performance
|
|
||||||
- ✅ **Database**: SQLAlchemy ORM with proper relationships
|
|
||||||
- ✅ **API design**: RESTful with OpenAPI schema
|
|
||||||
- ✅ **UI/UX**: Responsive, accessible, loading states
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Testing Status
|
|
||||||
|
|
||||||
- ✅ **Manual testing**: Voice generation working end-to-end
|
|
||||||
- 📦 **Unit tests**: Not yet implemented
|
|
||||||
- 📦 **Integration tests**: Not yet implemented
|
|
||||||
- 📦 **E2E tests**: Not yet implemented
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 Dependencies
|
|
||||||
|
|
||||||
### **Backend**
|
|
||||||
- FastAPI - Web framework
|
|
||||||
- SQLAlchemy - ORM
|
|
||||||
- Pydantic - Validation
|
|
||||||
- Qwen3-TTS - Voice cloning model
|
|
||||||
- Whisper - Speech recognition
|
|
||||||
- librosa - Audio processing
|
|
||||||
- soundfile - Audio I/O
|
|
||||||
- PyTorch - ML framework
|
|
||||||
|
|
||||||
### **Frontend**
|
|
||||||
- React 18 - UI framework
|
|
||||||
- TypeScript - Type safety
|
|
||||||
- React Query - Server state
|
|
||||||
- Zustand - Client state
|
|
||||||
- React Hook Form - Forms
|
|
||||||
- Zod - Schema validation
|
|
||||||
- Tailwind CSS - Styling
|
|
||||||
- shadcn/ui - Components
|
|
||||||
- Lucide React - Icons
|
|
||||||
|
|
||||||
### **Desktop**
|
|
||||||
- Tauri v2 - Desktop framework
|
|
||||||
- Rust - System backend
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Current Capabilities Summary
|
|
||||||
|
|
||||||
✅ **Working End-to-End:**
|
|
||||||
1. Create voice profiles with audio samples
|
|
||||||
2. Generate speech from text using cloned voices
|
|
||||||
3. View and manage generation history
|
|
||||||
4. Play and download generated audio
|
|
||||||
5. Search and filter history
|
|
||||||
6. Connect to local or remote backend
|
|
||||||
7. Automatic model downloading
|
|
||||||
8. Voice prompt caching for speed
|
|
||||||
|
|
||||||
🎉 **You just successfully generated voice from the Tauri app!**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Key Files Reference
|
|
||||||
|
|
||||||
### **Backend Core**
|
|
||||||
- `backend/main.py` - FastAPI app and routes
|
|
||||||
- `backend/tts.py` - Qwen3-TTS model wrapper
|
|
||||||
- `backend/profiles.py` - Profile business logic
|
|
||||||
- `backend/history.py` - History business logic
|
|
||||||
- `backend/database.py` - Database models
|
|
||||||
|
|
||||||
### **Frontend Core**
|
|
||||||
- `app/src/App.tsx` - Main app component
|
|
||||||
- `app/src/lib/api/client.ts` - API client
|
|
||||||
- `app/src/lib/hooks/` - React Query hooks
|
|
||||||
- `app/src/stores/` - Zustand stores
|
|
||||||
|
|
||||||
### **Tauri**
|
|
||||||
- `tauri/src-tauri/src/main.rs` - Rust backend
|
|
||||||
- `tauri/src/main.tsx` - React entry point
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 Development Workflow
|
|
||||||
|
|
||||||
1. **Start backend**: `bun run dev:server` (or via Tauri)
|
|
||||||
2. **Start frontend**: `bun run dev` (Tauri) or `bun run dev:web` (web)
|
|
||||||
3. **Generate API client**: `bun run generate:api` (after backend changes)
|
|
||||||
4. **Build server binary**: `bun run build:server` (for Tauri bundling)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Ready to build more features! 🚀**
|
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Voicebox — Local TTS Server with Web UI (CPU)
|
||||||
|
# 3-stage build: Frontend → Python deps → Runtime
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# === Stage 1: Build frontend ===
|
||||||
|
FROM oven/bun:1 AS frontend
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copy workspace config and frontend source
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
COPY app/ ./app/
|
||||||
|
COPY web/ ./web/
|
||||||
|
|
||||||
|
# Strip workspaces not needed for web build, and fix trailing comma
|
||||||
|
RUN sed -i '/"tauri"/d; /"landing"/d' package.json && \
|
||||||
|
sed -i -z 's/,\n ]/\n ]/' package.json
|
||||||
|
RUN bun install --no-save
|
||||||
|
# Build frontend (skip tsc — upstream has pre-existing type errors)
|
||||||
|
RUN cd web && bunx --bun vite build
|
||||||
|
|
||||||
|
|
||||||
|
# === Stage 2: Build Python dependencies ===
|
||||||
|
FROM python:3.11-slim AS backend-builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||||
|
RUN pip install --no-cache-dir --prefix=/install \
|
||||||
|
git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||||
|
|
||||||
|
|
||||||
|
# === Stage 3: Runtime ===
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Create non-root user for security
|
||||||
|
RUN groupadd -r voicebox && \
|
||||||
|
useradd -r -g voicebox -m -s /bin/bash voicebox
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install only runtime system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ffmpeg \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy installed Python packages from builder stage
|
||||||
|
COPY --from=backend-builder /install /usr/local
|
||||||
|
|
||||||
|
# Copy backend application code
|
||||||
|
COPY --chown=voicebox:voicebox backend/ /app/backend/
|
||||||
|
|
||||||
|
# Copy built frontend from frontend stage
|
||||||
|
COPY --from=frontend --chown=voicebox:voicebox /build/web/dist /app/frontend/
|
||||||
|
|
||||||
|
# Create data directories owned by non-root user
|
||||||
|
RUN mkdir -p /app/data/generations /app/data/profiles /app/data/cache \
|
||||||
|
&& chown -R voicebox:voicebox /app/data
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER voicebox
|
||||||
|
|
||||||
|
# Expose the API port
|
||||||
|
EXPOSE 17493
|
||||||
|
|
||||||
|
# Health check — auto-restart if the server hangs
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s \
|
||||||
|
CMD curl -f http://localhost:17493/health || exit 1
|
||||||
|
|
||||||
|
# Start the FastAPI server
|
||||||
|
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "17493"]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Voicebox Contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# Voicebox Makefile
|
||||||
|
# Unix-only (macOS/Linux). Windows users should use WSL.
|
||||||
|
|
||||||
|
SHELL := /bin/bash
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
# Directories
|
||||||
|
BACKEND_DIR := backend
|
||||||
|
TAURI_DIR := tauri
|
||||||
|
WEB_DIR := web
|
||||||
|
APP_DIR := app
|
||||||
|
|
||||||
|
# Python (prefer 3.12, fallback to 3.13, then python3)
|
||||||
|
PYTHON := $(shell command -v python3.12 2>/dev/null || command -v python3.13 2>/dev/null || echo python3)
|
||||||
|
VENV := $(CURDIR)/$(BACKEND_DIR)/venv
|
||||||
|
VENV_BIN := $(VENV)/bin
|
||||||
|
PIP := $(VENV_BIN)/pip
|
||||||
|
PYTHON_VENV := $(VENV_BIN)/python
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
BLUE := \033[0;34m
|
||||||
|
GREEN := \033[0;32m
|
||||||
|
YELLOW := \033[0;33m
|
||||||
|
NC := \033[0m # No Color
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help: ## Show this help message
|
||||||
|
@echo -e "$(BLUE)Voicebox$(NC) - Development Commands"
|
||||||
|
@echo ""
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
|
||||||
|
awk 'BEGIN {FS = ":.*?## "}; {printf " $(GREEN)%-20s$(NC) %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SETUP
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
.PHONY: setup setup-js setup-python setup-rust
|
||||||
|
|
||||||
|
setup: setup-js setup-python ## Full project setup (all dependencies)
|
||||||
|
@echo -e "$(GREEN)✓ Setup complete!$(NC)"
|
||||||
|
@echo -e " Run $(YELLOW)make dev$(NC) to start development servers"
|
||||||
|
|
||||||
|
setup-js: ## Install JavaScript dependencies (bun)
|
||||||
|
@echo -e "$(BLUE)Installing JavaScript dependencies...$(NC)"
|
||||||
|
bun install
|
||||||
|
|
||||||
|
setup-python: $(VENV)/bin/activate ## Set up Python virtual environment and dependencies
|
||||||
|
@echo -e "$(BLUE)Installing Python dependencies...$(NC)"
|
||||||
|
$(PIP) install --upgrade pip
|
||||||
|
$(PIP) install -r $(BACKEND_DIR)/requirements.txt
|
||||||
|
$(PIP) install --no-deps chatterbox-tts
|
||||||
|
@if [ "$$(uname -m)" = "arm64" ] && [ "$$(uname)" = "Darwin" ]; then \
|
||||||
|
echo -e "$(BLUE)Detected Apple Silicon - installing MLX dependencies...$(NC)"; \
|
||||||
|
$(PIP) install -r $(BACKEND_DIR)/requirements-mlx.txt; \
|
||||||
|
echo -e "$(GREEN)✓ MLX backend enabled (native Metal acceleration)$(NC)"; \
|
||||||
|
fi
|
||||||
|
$(PIP) install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||||
|
@echo -e "$(GREEN)✓ Python environment ready$(NC)"
|
||||||
|
|
||||||
|
$(VENV)/bin/activate:
|
||||||
|
@echo -e "$(BLUE)Creating Python virtual environment...$(NC)"
|
||||||
|
@PY_MINOR=$$($(PYTHON) -c "import sys; print(sys.version_info[1])"); \
|
||||||
|
if [ "$$PY_MINOR" -gt 13 ]; then \
|
||||||
|
echo -e "$(YELLOW)Warning: Python 3.$$PY_MINOR detected. ML packages may not be compatible.$(NC)"; \
|
||||||
|
echo -e "$(YELLOW)Recommended: Use Python 3.12 or 3.13 (brew install [email protected])$(NC)"; \
|
||||||
|
fi
|
||||||
|
$(PYTHON) -m venv $(VENV)
|
||||||
|
|
||||||
|
setup-rust: ## Install Rust toolchain (if not present)
|
||||||
|
@command -v rustc >/dev/null 2>&1 || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DEVELOPMENT
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
.PHONY: dev dev-backend dev-frontend dev-web kill-dev
|
||||||
|
|
||||||
|
dev: ## Start backend + desktop app (parallel)
|
||||||
|
@echo -e "$(BLUE)Starting development servers...$(NC)"
|
||||||
|
@echo -e "$(YELLOW)Note: If Tauri fails, run 'make build-server' first or use separate terminals$(NC)"
|
||||||
|
@trap 'kill 0' EXIT; \
|
||||||
|
$(MAKE) dev-backend & \
|
||||||
|
sleep 2 && if [ "$$(uname)" = "Linux" ] && lspci 2>/dev/null | grep -qi nvidia; then \
|
||||||
|
WEBKIT_DISABLE_DMABUF_RENDERER=1 $(MAKE) dev-frontend; \
|
||||||
|
else \
|
||||||
|
$(MAKE) dev-frontend; \
|
||||||
|
fi & \
|
||||||
|
wait
|
||||||
|
|
||||||
|
dev-backend: ## Start FastAPI backend server
|
||||||
|
@echo -e "$(BLUE)Starting backend server on http://localhost:17493$(NC)"
|
||||||
|
$(VENV_BIN)/uvicorn backend.main:app --reload --port 17493
|
||||||
|
|
||||||
|
dev-frontend: ## Start Tauri desktop app
|
||||||
|
@echo -e "$(BLUE)Starting Tauri desktop app...$(NC)"
|
||||||
|
bun run dev
|
||||||
|
|
||||||
|
dev-web: ## Start backend + web app (parallel)
|
||||||
|
@echo -e "$(BLUE)Starting web development servers...$(NC)"
|
||||||
|
@trap 'kill 0' EXIT; \
|
||||||
|
$(MAKE) dev-backend & \
|
||||||
|
sleep 2 && cd $(WEB_DIR) && bun run dev & \
|
||||||
|
wait
|
||||||
|
|
||||||
|
kill-dev: ## Kill all development processes
|
||||||
|
@echo -e "$(YELLOW)Killing development processes...$(NC)"
|
||||||
|
-pkill -f "uvicorn main:app" 2>/dev/null || true
|
||||||
|
-pkill -f "vite" 2>/dev/null || true
|
||||||
|
@echo -e "$(GREEN)✓ Processes killed$(NC)"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BUILD
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
.PHONY: build build-server build-tauri build-web
|
||||||
|
|
||||||
|
build: build-server build-tauri ## Build everything (server binary + desktop app)
|
||||||
|
@echo -e "$(GREEN)✓ Build complete!$(NC)"
|
||||||
|
|
||||||
|
build-server: ## Build Python server binary
|
||||||
|
@echo -e "$(BLUE)Building server binary...$(NC)"
|
||||||
|
PATH="$(VENV_BIN):$$PATH" ./scripts/build-server.sh
|
||||||
|
|
||||||
|
build-tauri: ## Build Tauri desktop app
|
||||||
|
@echo -e "$(BLUE)Building Tauri desktop app...$(NC)"
|
||||||
|
cd $(TAURI_DIR) && bun run tauri build
|
||||||
|
|
||||||
|
build-web: ## Build web app
|
||||||
|
@echo -e "$(BLUE)Building web app...$(NC)"
|
||||||
|
cd $(WEB_DIR) && bun run build
|
||||||
|
@echo -e "$(GREEN)✓ Web build output in $(WEB_DIR)/dist/$(NC)"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DATABASE & API
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
.PHONY: db-init db-reset generate-api
|
||||||
|
|
||||||
|
db-init: $(VENV)/bin/activate ## Initialize SQLite database
|
||||||
|
@echo -e "$(BLUE)Initializing database...$(NC)"
|
||||||
|
cd $(BACKEND_DIR) && $(PYTHON_VENV) -c "from database import init_db; init_db()"
|
||||||
|
@echo -e "$(GREEN)✓ Database created at $(BACKEND_DIR)/data/voicebox.db$(NC)"
|
||||||
|
|
||||||
|
db-reset: ## Reset database (delete and reinitialize)
|
||||||
|
@echo -e "$(YELLOW)Resetting database...$(NC)"
|
||||||
|
rm -f $(BACKEND_DIR)/data/voicebox.db
|
||||||
|
$(MAKE) db-init
|
||||||
|
|
||||||
|
generate-api: ## Generate TypeScript API client from OpenAPI schema
|
||||||
|
@echo -e "$(BLUE)Generating API client...$(NC)"
|
||||||
|
@echo -e "$(YELLOW)Note: Backend must be running (make dev-backend)$(NC)"
|
||||||
|
./scripts/generate-api.sh
|
||||||
|
@echo -e "$(GREEN)✓ API client generated in $(APP_DIR)/src/lib/api/$(NC)"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CODE QUALITY
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
.PHONY: lint format typecheck check
|
||||||
|
|
||||||
|
lint: ## Run linter (Biome)
|
||||||
|
@echo -e "$(BLUE)Linting...$(NC)"
|
||||||
|
bun run lint
|
||||||
|
|
||||||
|
format: ## Format code (Biome)
|
||||||
|
@echo -e "$(BLUE)Formatting...$(NC)"
|
||||||
|
bun run format
|
||||||
|
|
||||||
|
typecheck: ## Run TypeScript type checking
|
||||||
|
@echo -e "$(BLUE)Type checking...$(NC)"
|
||||||
|
bun run tsc --noEmit
|
||||||
|
|
||||||
|
check: ## Run all checks (Biome lint + format + type check)
|
||||||
|
@echo -e "$(BLUE)Running all checks...$(NC)"
|
||||||
|
bun run check
|
||||||
|
@echo -e "$(GREEN)✓ All checks passed$(NC)"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# TESTING
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
.PHONY: test test-backend test-frontend
|
||||||
|
|
||||||
|
test: test-backend test-frontend ## Run all tests
|
||||||
|
@echo -e "$(GREEN)✓ All tests passed$(NC)"
|
||||||
|
|
||||||
|
test-backend: ## Run Python backend tests (requires pytest)
|
||||||
|
@echo -e "$(BLUE)Running backend tests...$(NC)"
|
||||||
|
@if [ -f "$(VENV_BIN)/pytest" ]; then \
|
||||||
|
cd $(BACKEND_DIR) && $(VENV_BIN)/pytest -v; \
|
||||||
|
else \
|
||||||
|
echo -e "$(YELLOW)pytest not installed. Run: $(PIP) install pytest$(NC)"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
test-frontend: ## Run frontend tests (requires test script in package.json)
|
||||||
|
@echo -e "$(BLUE)Running frontend tests...$(NC)"
|
||||||
|
@if bun run test --help >/dev/null 2>&1; then \
|
||||||
|
bun run test; \
|
||||||
|
else \
|
||||||
|
echo -e "$(YELLOW)No test script configured$(NC)"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LOGS & DEBUGGING
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
.PHONY: logs docs
|
||||||
|
|
||||||
|
logs: ## Tail backend logs
|
||||||
|
@echo -e "$(BLUE)Tailing logs (Ctrl+C to stop)...$(NC)"
|
||||||
|
tail -f $(BACKEND_DIR)/logs/*.log 2>/dev/null || echo "No log files found"
|
||||||
|
|
||||||
|
docs: ## Open API documentation (backend must be running)
|
||||||
|
@echo -e "$(BLUE)Opening API docs...$(NC)"
|
||||||
|
open http://localhost:17493/docs 2>/dev/null || xdg-open http://localhost:17493/docs
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CLEAN
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
.PHONY: clean clean-python clean-build clean-all
|
||||||
|
|
||||||
|
clean: ## Clean build artifacts
|
||||||
|
@echo -e "$(BLUE)Cleaning build artifacts...$(NC)"
|
||||||
|
rm -rf $(TAURI_DIR)/src-tauri/target/release
|
||||||
|
rm -rf $(WEB_DIR)/dist
|
||||||
|
rm -rf $(APP_DIR)/dist
|
||||||
|
@echo -e "$(GREEN)✓ Build artifacts cleaned$(NC)"
|
||||||
|
|
||||||
|
clean-python: ## Clean Python cache and virtual environment
|
||||||
|
@echo -e "$(BLUE)Cleaning Python files...$(NC)"
|
||||||
|
rm -rf $(VENV)
|
||||||
|
find $(BACKEND_DIR) -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
find $(BACKEND_DIR) -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||||
|
@echo -e "$(GREEN)✓ Python environment cleaned$(NC)"
|
||||||
|
|
||||||
|
clean-build: ## Clean Rust/Tauri build cache
|
||||||
|
@echo -e "$(BLUE)Cleaning Rust build cache...$(NC)"
|
||||||
|
cd $(TAURI_DIR)/src-tauri && cargo clean
|
||||||
|
@echo -e "$(GREEN)✓ Rust cache cleaned$(NC)"
|
||||||
|
|
||||||
|
clean-all: clean clean-python clean-build ## Nuclear clean (everything)
|
||||||
|
@echo -e "$(BLUE)Cleaning node_modules...$(NC)"
|
||||||
|
rm -rf node_modules
|
||||||
|
rm -rf $(APP_DIR)/node_modules
|
||||||
|
rm -rf $(TAURI_DIR)/node_modules
|
||||||
|
rm -rf $(WEB_DIR)/node_modules
|
||||||
|
@echo -e "$(GREEN)✓ Full clean complete$(NC)"
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Voicebox Offline Mode Fix
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
Voicebox crashes when generating speech if HuggingFace is unreachable, even when models are fully cached locally.
|
||||||
|
|
||||||
|
**Root Cause:**
|
||||||
|
- Voicebox downloads `mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16` (MLX optimized version)
|
||||||
|
- But `mlx_audio.tts.load()` tries to fetch `config.json` from original repo `Qwen/Qwen3-TTS-12Hz-1.7B-Base`
|
||||||
|
- This network request fails → server crashes with `RemoteDisconnected`
|
||||||
|
|
||||||
|
**Related Issues:**
|
||||||
|
- Issue #150: "Internet connection required, even though models are downloaded?"
|
||||||
|
- Issue #151: "API Stability Issues: Model Loading Hangs and Server Crashes"
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
Two-part fix:
|
||||||
|
|
||||||
|
### 1. Monkey-patch huggingface_hub (`backend/utils/hf_offline_patch.py`)
|
||||||
|
- Intercepts cache lookup functions
|
||||||
|
- Forces offline mode early (before mlx_audio imports)
|
||||||
|
- Adds debug logging for cache hits/misses
|
||||||
|
|
||||||
|
### 2. Symlink original repo to MLX version (`ensure_original_qwen_config_cached()`)
|
||||||
|
- When original `Qwen/Qwen3-TTS-12Hz-1.7B-Base` cache doesn't exist
|
||||||
|
- But MLX `mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16` does exist
|
||||||
|
- Creates a symlink so cache lookups succeed
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
- `backend/backends/mlx_backend.py` - Added patch imports at top
|
||||||
|
- `backend/utils/hf_offline_patch.py` - New patch module
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
To test this fix:
|
||||||
|
1. Build Voicebox from source: `make build`
|
||||||
|
2. Disconnect from internet
|
||||||
|
3. Try generating speech
|
||||||
|
4. Should work without network requests
|
||||||
|
|
||||||
|
## Build Instructions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Build the app
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Or build just the server
|
||||||
|
make build-server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- The patch is applied automatically when `mlx_backend.py` is imported
|
||||||
|
- Set `VOICEBOX_OFFLINE_PATCH=0` to disable the patch
|
||||||
|
- The symlink approach works because the config.json is compatible between versions
|
||||||
|
|
||||||
|
---
|
||||||
|
*Patch contributed by community*
|
||||||
@@ -1,409 +1,288 @@
|
|||||||
# voicebox
|
<p align="center">
|
||||||
|
<img src=".github/assets/icon-dark.webp" alt="Voicebox" width="120" height="120" />
|
||||||
|
</p>
|
||||||
|
|
||||||
A production-quality desktop app for Qwen3-TTS voice cloning and generation.
|
<h1 align="center">Voicebox</h1>
|
||||||
|
|
||||||
**Domain:** voicebox.sh
|
<p align="center">
|
||||||
|
<strong>The open-source voice synthesis studio.</strong><br/>
|
||||||
|
Clone voices. Generate speech. Build voice-powered apps.<br/>
|
||||||
|
All running locally on your machine.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/jamiepine/voicebox/releases">
|
||||||
|
<img src="https://img.shields.io/github/downloads/jamiepine/voicebox/total?style=flat&color=blue" alt="Downloads" />
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/jamiepine/voicebox/releases/latest">
|
||||||
|
<img src="https://img.shields.io/github/v/release/jamiepine/voicebox?style=flat" alt="Release" />
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/jamiepine/voicebox/stargazers">
|
||||||
|
<img src="https://img.shields.io/github/stars/jamiepine/voicebox?style=flat" alt="Stars" />
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/jamiepine/voicebox/blob/main/LICENSE">
|
||||||
|
<img src="https://img.shields.io/github/license/jamiepine/voicebox?style=flat" alt="License" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://voicebox.sh">voicebox.sh</a> •
|
||||||
|
<a href="#download">Download</a> •
|
||||||
|
<a href="#features">Features</a> •
|
||||||
|
<a href="#api">API</a> •
|
||||||
|
<a href="#roadmap">Roadmap</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://voicebox.sh">
|
||||||
|
<img src="landing/public/assets/app-screenshot-1.webp" alt="Voicebox App Screenshot" width="800" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<em>Click the image above to watch the demo video on <a href="https://voicebox.sh">voicebox.sh</a></em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="landing/public/assets/app-screenshot-2.webp" alt="Voicebox Screenshot 2" width="800" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="landing/public/assets/app-screenshot-3.webp" alt="Voicebox Screenshot 3" width="800" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
## What is Voicebox?
|
||||||
|
|
||||||
|
Voicebox is a **local-first voice cloning studio** with DAW-like features for professional voice synthesis. Think of it as a **local, free and open-source alternative to ElevenLabs** — download models, clone voices, and generate speech entirely on your machine.
|
||||||
|
|
||||||
|
Unlike cloud services that lock your voice data behind subscriptions, Voicebox gives you:
|
||||||
|
|
||||||
|
- **Complete privacy** — models and voice data stay on your machine
|
||||||
|
- **Professional tools** — multi-track timeline editor, audio trimming, conversation mixing
|
||||||
|
- **Model flexibility** — currently powered by Qwen3-TTS, with support for XTTS, Bark, and other models coming soon
|
||||||
|
- **API-first** — use the desktop app or integrate voice synthesis into your own projects
|
||||||
|
- **Native performance** — built with Tauri (Rust), not Electron
|
||||||
|
- **Super fast on Mac** — MLX backend with native Metal acceleration for 4-5x faster inference on Apple Silicon
|
||||||
|
|
||||||
|
Download a voice model, clone any voice from a few seconds of audio, and compose multi-voice projects with studio-grade editing tools. No Python install required, no cloud dependency, no limits.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Vision
|
## Download
|
||||||
|
|
||||||
Qwen3-TTS is a breakthrough model from Alibaba that achieves near-perfect voice cloning. The existing implementations (Voice-Clone-Studio, mimic, etc.) are either feature-rich but architecturally messy, or well-structured but limited in scope.
|
Voicebox is available now for macOS and Windows.
|
||||||
|
|
||||||
voicebox aims to build the definitive Qwen3-TTS application by combining the best patterns from existing projects while avoiding their architectural mistakes.
|
| Platform | Download |
|
||||||
|
|----------|----------|
|
||||||
|
| macOS (Apple Silicon) | [Voicebox_aarch64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/latest/download/Voicebox_aarch64.app.tar.gz) |
|
||||||
|
| macOS (Intel) | [Voicebox_x64.app.tar.gz](https://github.com/jamiepine/voicebox/releases/latest/download/Voicebox_x64.app.tar.gz) |
|
||||||
|
| Windows (MSI) | [Latest Windows MSI](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||||
|
| Windows (Setup) | [Latest Windows Setup](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||||
|
|
||||||
## Design Principles
|
> **Linux** — Pre-built binaries are not yet available. Linux users can compile from source, see [Development](#development) below.
|
||||||
|
|
||||||
1. **Clean architecture from day one** - No monolithic files, proper separation of concerns
|
---
|
||||||
2. **Desktop-first experience** - Native feel via Tauri, not a web app in disguise
|
|
||||||
3. **Production code quality** - Type safety, modularity, maintainability
|
|
||||||
4. **Performance and UX** - Smart caching, async operations, responsive UI
|
|
||||||
5. **Extensible design** - Easy to add new models, effects, and features
|
|
||||||
6. **Flexible deployment** - Run backend locally or connect to remote GPU machine with one click
|
|
||||||
|
|
||||||
## Technology Stack
|
## Features
|
||||||
|
|
||||||
### Backend (Python)
|
### Voice Cloning with Qwen3-TTS
|
||||||
- **FastAPI** - Async REST API
|
|
||||||
- **SQLAlchemy** - Database ORM with migrations
|
|
||||||
- **Pydantic** - Request/response validation
|
|
||||||
- **Qwen3-TTS** - Voice cloning model
|
|
||||||
- **Whisper** - Speech-to-text transcription
|
|
||||||
- **librosa + soundfile** - Audio processing
|
|
||||||
|
|
||||||
### Frontend (Tauri + TypeScript)
|
Powered by Alibaba's **Qwen3-TTS** — a breakthrough model that achieves near-perfect voice cloning from just a few seconds of audio.
|
||||||
- **Tauri** - Native desktop framework
|
|
||||||
- **React** - UI framework
|
|
||||||
- **TypeScript** - Type safety throughout
|
|
||||||
- **Bun** - Fast package manager and JavaScript runtime
|
|
||||||
- **React Query** - Server state management and API calls
|
|
||||||
- **OpenAPI (generated)** - Type-safe API client from FastAPI schema
|
|
||||||
- **Tailwind CSS** - Styling
|
|
||||||
- **Zustand** - Client-side state management
|
|
||||||
- **WaveSurfer.js** - Audio visualization
|
|
||||||
|
|
||||||
### Database
|
- **Instant cloning** — Upload a sample, get a voice profile
|
||||||
- **SQLite** - Local storage
|
- **High fidelity** — Natural prosody, emotion, and cadence
|
||||||
- **Alembic** - Schema migrations
|
- **Multi-language** — English, Chinese, and more coming
|
||||||
|
- **Lightning fast on Mac** — MLX backend leverages Apple Silicon's Neural Engine for super-fast generation
|
||||||
|
|
||||||
## Server/Client Mode
|
### Voice Profile Management
|
||||||
|
|
||||||
voicebox supports flexible deployment for users with multiple machines:
|
- **Create profiles** from audio files or record directly in-app
|
||||||
|
- **Import/Export** profiles to share or back up
|
||||||
|
- **Multi-sample support** — combine multiple samples for higher quality cloning
|
||||||
|
- **Organize** with descriptions and language tags
|
||||||
|
|
||||||
### Local Mode (Default)
|
### Speech Generation
|
||||||
- Backend runs locally alongside the Tauri app
|
|
||||||
- Best for users with GPU on their primary machine
|
|
||||||
|
|
||||||
### Remote Mode (One-Click Setup)
|
- **Text-to-speech** with any cloned voice
|
||||||
- **Use case:** Your laptop doesn't have a GPU, but your desktop does
|
- **Batch generation** for long-form content
|
||||||
- **Server:** Run voicebox on GPU machine, click "Start Server"
|
- **Smart caching** — regenerate instantly with voice prompt caching
|
||||||
- Starts FastAPI backend on local network
|
|
||||||
- Shows connection URL (e.g., `http://192.168.1.100:8000`)
|
|
||||||
- **Client:** Run voicebox on laptop, enter server URL
|
|
||||||
- Connects to remote backend
|
|
||||||
- Full UI functionality, inference happens on GPU machine
|
|
||||||
- **Security:** Local network only for now (no internet exposure)
|
|
||||||
|
|
||||||
### How It Works
|
### Stories Editor
|
||||||
```
|
|
||||||
┌─────────────────┐ ┌─────────────────┐
|
Create multi-voice narratives, podcasts, and conversations with a timeline-based editor.
|
||||||
│ Laptop │ │ Desktop │
|
|
||||||
│ (Client) │ │ (Server) │
|
- **Multi-track composition** — arrange multiple voice tracks in a single project
|
||||||
│ │ │ │
|
- **Inline audio editing** — trim and split clips directly in the timeline
|
||||||
│ Tauri App ────────────────▶ FastAPI │
|
- **Auto-playback** — preview stories with synchronized playhead
|
||||||
│ React UI │ HTTP │ Qwen3-TTS │
|
- **Voice mixing** — build conversations with multiple participants
|
||||||
│ │ │ SQLite │
|
|
||||||
│ │ │ CUDA/GPU │
|
### Recording & Transcription
|
||||||
└─────────────────┘ └─────────────────┘
|
|
||||||
|
- **In-app recording** with waveform visualization
|
||||||
|
- **System audio capture** — record desktop audio on macOS and Windows
|
||||||
|
- **Automatic transcription** powered by Whisper
|
||||||
|
- **Export recordings** in multiple formats
|
||||||
|
|
||||||
|
### Generation History
|
||||||
|
|
||||||
|
- **Full history** of all generated audio
|
||||||
|
- **Search & filter** by voice, text, or date
|
||||||
|
- **Re-generate** any past generation with one click
|
||||||
|
|
||||||
|
### Flexible Deployment
|
||||||
|
|
||||||
|
- **Local mode** — Everything runs on your machine
|
||||||
|
- **Remote mode** — Connect to a GPU server on your network
|
||||||
|
- **One-click server** — Turn any machine into a Voicebox server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Voicebox exposes a full REST API, so you can integrate voice synthesis into your own apps.
|
||||||
|
|
||||||
|
For the current local app and development workflow, the backend is typically available at `http://localhost:17493`.
|
||||||
|
If you launch the backend manually with a different host or port, use that address instead.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate speech
|
||||||
|
curl -X POST http://localhost:17493/generate \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"text": "Hello world", "profile_id": "abc123", "language": "en"}'
|
||||||
|
|
||||||
|
# List voice profiles
|
||||||
|
curl http://localhost:17493/profiles
|
||||||
|
|
||||||
|
# Create a profile
|
||||||
|
curl -X POST http://localhost:17493/profiles \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "My Voice", "language": "en"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
**Benefits:**
|
**Use cases:**
|
||||||
- Use powerful GPU machine from lightweight laptop
|
|
||||||
- No complex setup - just click "Start Server"
|
|
||||||
- All data (history, profiles) lives on server
|
|
||||||
- Client is just a UI - no local storage needed in remote mode
|
|
||||||
|
|
||||||
## Core Features
|
- Game dialogue systems
|
||||||
|
- Podcast/video production pipelines
|
||||||
|
- Accessibility tools
|
||||||
|
- Voice assistants
|
||||||
|
- Content creation automation
|
||||||
|
|
||||||
### Phase 1 (MVP)
|
Full API documentation is available at `http://localhost:17493/docs` in the default local workflow, or at `/docs` on whatever server address you configured.
|
||||||
- Voice profile management
|
|
||||||
- Single-reference voice cloning
|
|
||||||
- Generation history with search
|
|
||||||
- Basic audio playback and preview
|
|
||||||
- Server/client mode (local network)
|
|
||||||
- One-click server startup
|
|
||||||
|
|
||||||
### Phase 2
|
---
|
||||||
- Multi-reference voice combination
|
|
||||||
- Batch variation generation
|
|
||||||
- Advanced audio normalization
|
|
||||||
- Export options and formats
|
|
||||||
|
|
||||||
### Phase 3
|
## Tech Stack
|
||||||
- Audio studio with timeline editing
|
|
||||||
- Word-level timestamps
|
|
||||||
- Project system (save/load sessions)
|
|
||||||
- Export options
|
|
||||||
|
|
||||||
### Phase 4
|
| Layer | Technology |
|
||||||
- Voice design (text-to-voice)
|
|-------|------------|
|
||||||
- Preset voices with style control
|
| Desktop App | Tauri (Rust) |
|
||||||
- Conversation mode (multi-speaker)
|
| Frontend | React, TypeScript, Tailwind CSS |
|
||||||
- Custom audio effects
|
| State | Zustand, React Query |
|
||||||
|
| Backend | FastAPI (Python) |
|
||||||
|
| Voice Model | Qwen3-TTS (PyTorch or MLX) |
|
||||||
|
| Transcription | Whisper (PyTorch or MLX) |
|
||||||
|
| Inference Engine | MLX (Apple Silicon) / PyTorch (Windows/Linux/Intel) |
|
||||||
|
| Database | SQLite |
|
||||||
|
| Audio | WaveSurfer.js, librosa |
|
||||||
|
|
||||||
## Key Differentiators
|
**Why this stack?**
|
||||||
|
|
||||||
What makes voicebox better than existing implementations:
|
- **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
|
||||||
|
|
||||||
1. **Clean codebase** - Modular architecture, no 2,000+ line files
|
---
|
||||||
2. **Type safety end-to-end** - OpenAPI-generated TypeScript client, Pydantic backend, React Query
|
|
||||||
3. **Smart caching** - Voice prompt caching for instant re-generation
|
|
||||||
4. **Desktop UX** - Native performance, keyboard shortcuts, native dialogs
|
|
||||||
5. **Server/client mode** - One-click remote GPU access from any device
|
|
||||||
6. **Multi-reference** - Combine voice samples for higher quality
|
|
||||||
7. **Audio studio** - Timeline-based editing with word-level precision
|
|
||||||
8. **Production patterns** - Cross-platform, graceful degradation, error recovery
|
|
||||||
9. **Database-backed** - Searchable history, project persistence
|
|
||||||
10. **Extensible** - Clean plugin system for models and features
|
|
||||||
|
|
||||||
## Architecture Overview
|
## Roadmap
|
||||||
|
|
||||||
|
Voicebox is the beginning of something bigger. Here's what's coming:
|
||||||
|
|
||||||
|
### Coming Soon
|
||||||
|
|
||||||
|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Real-time Synthesis** | Stream audio as it generates, word by word |
|
||||||
|
| **Conversation Mode** | Multi-speaker dialogues with automatic turn-taking |
|
||||||
|
| **Voice Effects** | Pitch shift, reverb, M3GAN-style effects |
|
||||||
|
| **Timeline Editor** | Audio studio with word-level precision editing |
|
||||||
|
| **More Models** | XTTS, Bark, and other open-source voice models |
|
||||||
|
|
||||||
|
### Future Vision
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed setup and contribution guidelines.
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/jamiepine/voicebox.git
|
||||||
|
cd voicebox
|
||||||
|
|
||||||
|
just setup # creates Python venv, installs all deps
|
||||||
|
just dev # starts backend + desktop app
|
||||||
|
```
|
||||||
|
|
||||||
|
Install [just](https://github.com/casey/just): `brew install just` or `cargo install just`. Run `just --list` to see all commands.
|
||||||
|
|
||||||
|
Also available via Makefile: `make setup && make dev` (run `make help` for all commands).
|
||||||
|
|
||||||
|
**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org), [XCode on macOS](https://developer.apple.com/xcode/), [Tauri Prerequisites](https://v2.tauri.app/start/prerequisites/).
|
||||||
|
|
||||||
|
**Performance:**
|
||||||
|
- **Apple Silicon (M1/M2/M3)**: Uses MLX backend with native Metal acceleration for 4-5x faster inference
|
||||||
|
- **Windows/Linux/Intel Mac**: Uses PyTorch backend (CUDA GPU recommended, CPU supported but slower)
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
voicebox/
|
voicebox/
|
||||||
├── app/ # Shared React frontend (used by web & desktop)
|
├── app/ # Shared React frontend
|
||||||
│ ├── src/
|
├── tauri/ # Desktop app (Tauri + Rust)
|
||||||
│ │ ├── components/ # React components
|
├── web/ # Web deployment
|
||||||
│ │ │ ├── VoiceProfiles/
|
├── backend/ # Python FastAPI server
|
||||||
│ │ │ ├── Generation/
|
├── landing/ # Marketing website
|
||||||
│ │ │ ├── AudioStudio/
|
└── scripts/ # Build & release scripts
|
||||||
│ │ │ ├── History/
|
|
||||||
│ │ │ └── ServerSettings/
|
|
||||||
│ │ ├── lib/
|
|
||||||
│ │ │ ├── api/ # Generated OpenAPI client
|
|
||||||
│ │ │ ├── hooks/ # React Query hooks
|
|
||||||
│ │ │ └── utils/
|
|
||||||
│ │ ├── types/
|
|
||||||
│ │ └── App.tsx
|
|
||||||
│ ├── package.json
|
|
||||||
│ └── vite.config.ts
|
|
||||||
│
|
|
||||||
├── tauri/ # Tauri desktop app (thin wrapper)
|
|
||||||
│ ├── src/
|
|
||||||
│ │ └── main.tsx # Entry point, imports from ../app
|
|
||||||
│ ├── src-tauri/ # Rust backend
|
|
||||||
│ │ ├── src/
|
|
||||||
│ │ │ └── main.rs # Sidecar management, IPC
|
|
||||||
│ │ ├── binaries/ # Bundled Python server
|
|
||||||
│ │ │ └── voicebox-server-{platform}
|
|
||||||
│ │ ├── Cargo.toml
|
|
||||||
│ │ └── tauri.conf.json
|
|
||||||
│ └── package.json
|
|
||||||
│
|
|
||||||
├── web/ # Web deployment (thin wrapper)
|
|
||||||
│ ├── src/
|
|
||||||
│ │ └── main.tsx # Entry point, imports from ../app
|
|
||||||
│ ├── package.json
|
|
||||||
│ └── vite.config.ts
|
|
||||||
│
|
|
||||||
├── backend/ # Python FastAPI server
|
|
||||||
│ ├── main.py # FastAPI app + server mode
|
|
||||||
│ ├── models.py # Pydantic models
|
|
||||||
│ ├── tts.py # TTS inference
|
|
||||||
│ ├── transcribe.py # Whisper ASR
|
|
||||||
│ ├── profiles.py # Voice profiles
|
|
||||||
│ ├── history.py # Generation history
|
|
||||||
│ ├── studio.py # Audio editing
|
|
||||||
│ ├── database.py # SQLite ORM
|
|
||||||
│ ├── utils/
|
|
||||||
│ │ ├── audio.py # Audio processing
|
|
||||||
│ │ ├── cache.py # Prompt caching
|
|
||||||
│ │ └── validation.py
|
|
||||||
│ ├── requirements.txt
|
|
||||||
│ └── build_binary.py # PyInstaller build script
|
|
||||||
│
|
|
||||||
├── scripts/
|
|
||||||
│ ├── build-server.sh # Build Python binary for all platforms
|
|
||||||
│ └── generate-api.sh # Generate OpenAPI client
|
|
||||||
│
|
|
||||||
├── data/ # User data
|
|
||||||
│ ├── profiles/
|
|
||||||
│ ├── generations/
|
|
||||||
│ ├── projects/
|
|
||||||
│ └── voicebox.db
|
|
||||||
│
|
|
||||||
├── package.json # Root workspace config
|
|
||||||
└── docs/
|
|
||||||
├── ANALYSIS.md # Analysis of existing projects
|
|
||||||
├── TAURI_PLAN.md # Tauri app structure and bundling strategy
|
|
||||||
└── ARCHITECTURE.md # Detailed architecture docs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key architectural decisions:**
|
---
|
||||||
- **Shared frontend** - `app/` contains all React code, used by both desktop and web
|
|
||||||
- **Thin wrappers** - `tauri/` and `web/` just configure build tools and entry points
|
|
||||||
- **Bundled backend** - Python server packaged as sidecar binary with PyInstaller
|
|
||||||
- **Type-safe API** - OpenAPI schema generated from FastAPI, TypeScript client auto-generated
|
|
||||||
|
|
||||||
See [TAURI_PLAN.md](./docs/TAURI_PLAN.md) for detailed bundling strategy.
|
## Contributing
|
||||||
|
|
||||||
## Lessons from Existing Projects
|
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||||
|
|
||||||
voicebox learns from five existing Qwen3-TTS implementations:
|
1. Fork the repo
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Submit a PR
|
||||||
|
|
||||||
### voice (Rust CLI)
|
## Security
|
||||||
- ✅ Clean Rust/Python IPC pattern
|
|
||||||
- ✅ M3GAN voice effect
|
|
||||||
- ✅ Voice profile abstraction
|
|
||||||
- ❌ No concurrent requests
|
|
||||||
- ❌ No generation history
|
|
||||||
|
|
||||||
### Voice-Clone-Studio
|
Found a security vulnerability? Please report it responsibly. See [SECURITY.md](SECURITY.md) for details.
|
||||||
- ✅ Brilliant voice prompt caching
|
|
||||||
- ✅ Feature-rich (voice design, presets, conversations)
|
|
||||||
- ✅ VRAM-efficient model management
|
|
||||||
- ❌ 2,815-line single file
|
|
||||||
- ❌ Global state everywhere
|
|
||||||
|
|
||||||
### Qwen3-TTS_server
|
---
|
||||||
- ✅ Clean modular structure
|
|
||||||
- ✅ FastAPI REST API design
|
|
||||||
- ✅ Health endpoint for monitoring
|
|
||||||
- ❌ No authentication or rate limiting
|
|
||||||
- ❌ No caching or streaming
|
|
||||||
- ❌ No OpenAPI client generation
|
|
||||||
|
|
||||||
### mimic
|
|
||||||
- ✅ Excellent backend architecture (async, modular)
|
|
||||||
- ✅ Audio studio with timeline
|
|
||||||
- ✅ Database-backed history
|
|
||||||
- ✅ Multi-sample voice profiles
|
|
||||||
- ❌ 2,794-line app.js frontend
|
|
||||||
- ❌ Global state in UI
|
|
||||||
|
|
||||||
### qwen3-tts-enhanced
|
|
||||||
- ✅ Multi-reference combination
|
|
||||||
- ✅ Cross-platform graceful degradation
|
|
||||||
- ✅ Audio validation
|
|
||||||
- ✅ Production error handling
|
|
||||||
- ❌ Still monolithic (1,892 lines)
|
|
||||||
- ❌ No API layer
|
|
||||||
|
|
||||||
See [ANALYSIS.md](./docs/ANALYSIS.md) for detailed breakdown of each project.
|
|
||||||
|
|
||||||
## Development Roadmap
|
|
||||||
|
|
||||||
### Week 1: Foundation
|
|
||||||
- Project structure setup
|
|
||||||
- Backend skeleton (FastAPI + SQLite)
|
|
||||||
- OpenAPI schema generation
|
|
||||||
- Frontend skeleton (Tauri + React)
|
|
||||||
- TypeScript client generation from OpenAPI
|
|
||||||
- React Query setup
|
|
||||||
- Basic voice profile CRUD
|
|
||||||
- Server mode implementation
|
|
||||||
- Client connection UI
|
|
||||||
|
|
||||||
### Week 2: Core Features
|
|
||||||
- TTS integration
|
|
||||||
- Voice cloning pipeline
|
|
||||||
- Voice prompt caching
|
|
||||||
- Generation history
|
|
||||||
|
|
||||||
### Week 3: UX Polish
|
|
||||||
- Audio playback and preview
|
|
||||||
- Profile management UI
|
|
||||||
- History search and filters
|
|
||||||
- Error handling and validation
|
|
||||||
|
|
||||||
### Week 4: Advanced Features
|
|
||||||
- Multi-reference combination
|
|
||||||
- Batch generation
|
|
||||||
- Audio normalization
|
|
||||||
- M3GAN effect
|
|
||||||
|
|
||||||
### Week 5+: Studio Features
|
|
||||||
- Timeline editor
|
|
||||||
- Word-level timestamps
|
|
||||||
- Project system
|
|
||||||
- Export pipeline
|
|
||||||
|
|
||||||
## Technical Decisions
|
|
||||||
|
|
||||||
### Why Tauri over Electron?
|
|
||||||
- Smaller bundle size (Rust vs. Node.js)
|
|
||||||
- Better performance (native vs. V8)
|
|
||||||
- Lower memory usage
|
|
||||||
- Rust for system-level operations
|
|
||||||
|
|
||||||
### Why FastAPI over Flask?
|
|
||||||
- Native async/await support
|
|
||||||
- Automatic OpenAPI schema generation
|
|
||||||
- Pydantic validation built-in
|
|
||||||
- Better performance
|
|
||||||
|
|
||||||
### Why OpenAPI + React Query?
|
|
||||||
- **Type safety end-to-end** - FastAPI generates OpenAPI schema, we generate TypeScript client
|
|
||||||
- **No manual API code** - Client generated from `openapi.json` using openapi-typescript-codegen
|
|
||||||
- **Automatic caching** - React Query handles request deduplication and background refetching
|
|
||||||
- **Optimistic updates** - Update UI immediately, rollback on error
|
|
||||||
- **DevX** - Full autocomplete and type checking for all API calls
|
|
||||||
|
|
||||||
**Example workflow:**
|
|
||||||
```bash
|
|
||||||
# Backend generates OpenAPI schema
|
|
||||||
python backend/main.py --openapi > openapi.json
|
|
||||||
|
|
||||||
# Frontend generates TypeScript client
|
|
||||||
bun run generate-client
|
|
||||||
|
|
||||||
# Use type-safe hooks in React
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { ProfilesService } from '@/lib/api';
|
|
||||||
|
|
||||||
const { data: profiles } = useQuery({
|
|
||||||
queryKey: ['profiles'],
|
|
||||||
queryFn: () => ProfilesService.listProfiles()
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Why Bun over npm/yarn/pnpm?
|
|
||||||
- **Speed** - 20-30x faster than npm for install operations
|
|
||||||
- **Drop-in replacement** - Compatible with npm ecosystem, no migration needed
|
|
||||||
- **Built-in tooling** - Bundler, test runner, and package manager in one
|
|
||||||
- **Performance** - Faster script execution than Node.js
|
|
||||||
- **Developer experience** - Better error messages, workspaces support
|
|
||||||
|
|
||||||
### Why SQLite over file-based storage?
|
|
||||||
- Full-text search
|
|
||||||
- Transactions and integrity
|
|
||||||
- Migrations via Alembic
|
|
||||||
- Easy to backup/restore
|
|
||||||
|
|
||||||
### Why React over Vue/Svelte?
|
|
||||||
- Larger ecosystem
|
|
||||||
- Better TypeScript support
|
|
||||||
- Familiar to most developers
|
|
||||||
- Mature tooling
|
|
||||||
|
|
||||||
### Why bundle Python server with PyInstaller?
|
|
||||||
- **No Python installation required** - Users don't need Python on their system
|
|
||||||
- **Consistent environment** - Exact dependencies bundled, no version conflicts
|
|
||||||
- **Single-click install** - One installer includes everything
|
|
||||||
- **Tauri sidecar pattern** - Rust spawns/manages Python process lifecycle
|
|
||||||
- **Platform-specific binaries** - PyInstaller creates native executables for each platform
|
|
||||||
|
|
||||||
**Tradeoffs:**
|
|
||||||
- Larger bundle size (~500MB with models vs ~50MB without backend)
|
|
||||||
- Need separate build for each platform (macOS Intel/ARM, Windows, Linux)
|
|
||||||
- First launch slower (model loading time)
|
|
||||||
|
|
||||||
**Alternative considered:** Require users to install Python and run `pip install` - rejected for poor UX
|
|
||||||
|
|
||||||
### Why no Docker initially?
|
|
||||||
- Desktop app, not server deployment
|
|
||||||
- Users install locally
|
|
||||||
- Can add later for server mode
|
|
||||||
|
|
||||||
## Performance Targets
|
|
||||||
|
|
||||||
- **First generation:** < 10 seconds (cold start)
|
|
||||||
- **Cached generation:** < 2 seconds (warm start)
|
|
||||||
- **UI responsiveness:** 60 FPS at all times
|
|
||||||
- **Memory usage:** < 4GB VRAM for small models
|
|
||||||
- **Startup time:** < 3 seconds to UI
|
|
||||||
- **Database queries:** < 100ms for history search
|
|
||||||
|
|
||||||
## Quality Standards
|
|
||||||
|
|
||||||
- **No files over 500 lines** (except auto-generated)
|
|
||||||
- **Type hints on all Python functions**
|
|
||||||
- **TypeScript strict mode enabled**
|
|
||||||
- **OpenAPI client auto-generated from schema**
|
|
||||||
- **ESLint + Prettier for frontend**
|
|
||||||
- **Black + isort for backend**
|
|
||||||
- **All user-facing errors have context**
|
|
||||||
- **No global mutable state**
|
|
||||||
- **React Query for all server state**
|
|
||||||
|
|
||||||
## Project Status
|
|
||||||
|
|
||||||
**Current phase:** Planning and analysis
|
|
||||||
|
|
||||||
**Documentation:**
|
|
||||||
- [ANALYSIS.md](./docs/ANALYSIS.md) - Comprehensive analysis of existing implementations
|
|
||||||
- [TAURI_PLAN.md](./docs/TAURI_PLAN.md) - Tauri app architecture and Python server bundling strategy
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
TBD
|
MIT License — see [LICENSE](LICENSE) for details.
|
||||||
|
|
||||||
## Credits
|
---
|
||||||
|
|
||||||
Built by analyzing and learning from:
|
<p align="center">
|
||||||
- voice (Rust CLI)
|
<a href="https://voicebox.sh">voicebox.sh</a>
|
||||||
- Voice-Clone-Studio
|
</p>
|
||||||
- Qwen3-TTS_server
|
|
||||||
- mimic
|
|
||||||
- qwen3-tts-enhanced
|
|
||||||
|
|
||||||
Powered by Alibaba's Qwen3-TTS model.
|
|
||||||
|
|||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
We release patches for security vulnerabilities. Which versions are eligible for receiving such patches depends on the CVSS v3.0 Rating:
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
| ------- | ------------------ |
|
||||||
|
| 0.1.x | :white_check_mark: |
|
||||||
|
| < 0.1 | :x: |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
If you discover a security vulnerability, please report it responsibly:
|
||||||
|
|
||||||
|
1. **Do not** open a public GitHub issue
|
||||||
|
2. Email security details to: [[email protected]](mailto:[email protected])
|
||||||
|
3. Include:
|
||||||
|
- Description of the vulnerability
|
||||||
|
- Steps to reproduce
|
||||||
|
- Potential impact
|
||||||
|
- Suggested fix (if any)
|
||||||
|
|
||||||
|
We will:
|
||||||
|
- Acknowledge receipt within 48 hours
|
||||||
|
- Provide a timeline for addressing the issue
|
||||||
|
- Keep you informed of progress
|
||||||
|
- Credit you in the security advisory (if desired)
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
### For Users
|
||||||
|
|
||||||
|
- **Keep Voicebox updated** - Updates include security patches
|
||||||
|
- **Verify downloads** - Only download from official releases
|
||||||
|
- **Local processing** - Voice data stays on your machine
|
||||||
|
- **Network security** - Use HTTPS when connecting to remote servers
|
||||||
|
|
||||||
|
### For Developers
|
||||||
|
|
||||||
|
- **Dependencies** - Keep all dependencies up to date
|
||||||
|
- **Code review** - All PRs require review before merging
|
||||||
|
- **Secrets** - Never commit API keys or signing keys
|
||||||
|
- **Signing** - All releases are cryptographically signed
|
||||||
|
|
||||||
|
## Known Security Considerations
|
||||||
|
|
||||||
|
### Local Processing
|
||||||
|
|
||||||
|
Voicebox processes all audio locally by default. Your voice data never leaves your machine unless you explicitly enable remote server mode.
|
||||||
|
|
||||||
|
### Remote Server Mode
|
||||||
|
|
||||||
|
When connecting to a remote server:
|
||||||
|
- Ensure the server is on a trusted network
|
||||||
|
- Use HTTPS for remote connections
|
||||||
|
- Verify server identity before connecting
|
||||||
|
|
||||||
|
### Auto-Updates
|
||||||
|
|
||||||
|
- Updates are cryptographically signed
|
||||||
|
- Signature verification happens before installation
|
||||||
|
- Only HTTPS endpoints are allowed
|
||||||
|
|
||||||
|
### Python Server
|
||||||
|
|
||||||
|
The embedded Python server:
|
||||||
|
- Runs locally by default (localhost only)
|
||||||
|
- Can be configured for remote access
|
||||||
|
- Uses standard FastAPI security practices
|
||||||
|
|
||||||
|
## Disclosure Timeline
|
||||||
|
|
||||||
|
- **Day 0**: Vulnerability reported
|
||||||
|
- **Day 1-2**: Initial assessment and acknowledgment
|
||||||
|
- **Day 3-7**: Investigation and fix development
|
||||||
|
- **Day 8-14**: Testing and release preparation
|
||||||
|
- **Day 15+**: Public disclosure (if applicable)
|
||||||
|
|
||||||
|
Timeline may vary based on severity and complexity.
|
||||||
|
|
||||||
|
## Security Updates
|
||||||
|
|
||||||
|
Security updates will be:
|
||||||
|
- Released as patch versions (e.g., 0.1.1)
|
||||||
|
- Documented in CHANGELOG.md
|
||||||
|
- Announced via GitHub releases
|
||||||
|
- Automatically delivered via auto-updater
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Thank you for helping keep Voicebox secure! 🔒
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
# voicebox Setup Guide
|
|
||||||
|
|
||||||
Quick start guide for setting up the voicebox development environment.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- **Bun** - Fast JavaScript runtime and package manager
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://bun.sh/install | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Python 3.11+** - For backend development
|
|
||||||
```bash
|
|
||||||
python --version # Should be 3.11 or higher
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Rust** - For Tauri desktop app (installed automatically by Tauri CLI)
|
|
||||||
```bash
|
|
||||||
rustc --version # Check if installed
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Node.js 18+** (optional) - Fallback if Bun is not available
|
|
||||||
|
|
||||||
## Initial Setup
|
|
||||||
|
|
||||||
### 1. Install Dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install all workspace dependencies
|
|
||||||
bun install
|
|
||||||
```
|
|
||||||
|
|
||||||
This will install dependencies for:
|
|
||||||
- `app/` - Shared React frontend
|
|
||||||
- `tauri/` - Tauri desktop wrapper
|
|
||||||
- `web/` - Web deployment wrapper
|
|
||||||
|
|
||||||
### 2. Setup Backend
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
|
|
||||||
# Create virtual environment
|
|
||||||
python -m venv venv
|
|
||||||
|
|
||||||
# Activate virtual environment
|
|
||||||
source venv/bin/activate # On macOS/Linux
|
|
||||||
# or
|
|
||||||
venv\Scripts\activate # On Windows
|
|
||||||
|
|
||||||
# Install Python dependencies
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Initialize Database
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
python -c "from database import init_db; init_db()"
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates the SQLite database at `data/voicebox.db`.
|
|
||||||
|
|
||||||
### 4. Install Qwen3-TTS (Optional)
|
|
||||||
|
|
||||||
The Qwen3-TTS models are automatically downloaded from HuggingFace Hub on first use. However, you need to install the `qwen_tts` package:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install git+https://github.com/QwenLM/Qwen3-TTS.git
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** Models (~2-4GB) will be automatically downloaded on first generation. This may take a few minutes depending on your internet connection.
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Start Backend Server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
source venv/bin/activate # Activate venv if not already active
|
|
||||||
uvicorn main:app --reload --port 8000
|
|
||||||
```
|
|
||||||
|
|
||||||
Backend will be available at `http://localhost:8000`
|
|
||||||
|
|
||||||
### Start Tauri Desktop App
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From project root
|
|
||||||
bun run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Or manually:
|
|
||||||
```bash
|
|
||||||
cd tauri
|
|
||||||
bun run tauri dev
|
|
||||||
```
|
|
||||||
|
|
||||||
This will:
|
|
||||||
1. Start Vite dev server on port 5173
|
|
||||||
2. Launch Tauri window pointing to localhost:5173
|
|
||||||
3. Enable hot reload
|
|
||||||
|
|
||||||
### Start Web App
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From project root
|
|
||||||
bun run dev:web
|
|
||||||
```
|
|
||||||
|
|
||||||
Or manually:
|
|
||||||
```bash
|
|
||||||
cd web
|
|
||||||
bun run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Web app will be available at `http://localhost:5174` (or next available port)
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
### Build Python Server Binary
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/build-server.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates a platform-specific binary in `tauri/src-tauri/binaries/`
|
|
||||||
|
|
||||||
### Build Tauri Desktop App
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd tauri
|
|
||||||
bun run tauri build
|
|
||||||
```
|
|
||||||
|
|
||||||
Creates platform-specific installers:
|
|
||||||
- macOS: `.app`, `.dmg`
|
|
||||||
- Windows: `.exe`, `.msi`
|
|
||||||
- Linux: `.deb`, `.AppImage`
|
|
||||||
|
|
||||||
### Build Web App
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd web
|
|
||||||
bun run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Output in `web/dist/`
|
|
||||||
|
|
||||||
## Generate OpenAPI Client
|
|
||||||
|
|
||||||
After starting the backend server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/generate-api.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
This will:
|
|
||||||
1. Download OpenAPI schema from backend
|
|
||||||
2. Generate TypeScript client in `app/src/lib/api/`
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
voicebox/
|
|
||||||
├── app/ # Shared React frontend
|
|
||||||
├── tauri/ # Tauri desktop wrapper
|
|
||||||
├── web/ # Web deployment wrapper
|
|
||||||
├── backend/ # Python FastAPI server
|
|
||||||
├── scripts/ # Build and utility scripts
|
|
||||||
├── data/ # User data (gitignored)
|
|
||||||
└── docs/ # Documentation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Backend won't start
|
|
||||||
- Check Python version: `python --version` (needs 3.11+)
|
|
||||||
- Ensure virtual environment is activated
|
|
||||||
- Install dependencies: `pip install -r requirements.txt`
|
|
||||||
|
|
||||||
### Tauri build fails
|
|
||||||
- Ensure Rust is installed: `rustc --version`
|
|
||||||
- Install Tauri CLI: `bunx @tauri-apps/cli install`
|
|
||||||
- Check `tauri/src-tauri/Cargo.toml` for correct dependencies
|
|
||||||
|
|
||||||
### OpenAPI client generation fails
|
|
||||||
- Ensure backend is running on port 8000
|
|
||||||
- Check `curl http://localhost:8000/openapi.json` returns valid JSON
|
|
||||||
- Install openapi-typescript-codegen: `bun add -d openapi-typescript-codegen`
|
|
||||||
|
|
||||||
## Model Downloads
|
|
||||||
|
|
||||||
Models are automatically downloaded from HuggingFace Hub on first use:
|
|
||||||
- **Whisper** (transcription): Auto-downloads on first transcription
|
|
||||||
- **Qwen3-TTS** (voice cloning): Auto-downloads on first generation
|
|
||||||
|
|
||||||
First-time usage will be slower due to model downloads, but subsequent runs will use cached models.
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. ✅ TTS model loading implemented in `backend/tts.py`
|
|
||||||
2. ✅ API routes implemented in `backend/main.py`
|
|
||||||
3. Build React components in `app/src/components/`
|
|
||||||
4. Connect frontend to backend via generated API client
|
|
||||||
|
|
||||||
See [README.md](./README.md) for architecture details and [docs/](./docs/) for detailed documentation.
|
|
||||||
+9
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/app",
|
"name": "@voicebox/app",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -13,6 +13,9 @@
|
|||||||
"check": "biome check --write src"
|
"check": "biome check --write src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-avatar": "^1.1.0",
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
@@ -30,7 +33,10 @@
|
|||||||
"@radix-ui/react-toast": "^1.2.1",
|
"@radix-ui/react-toast": "^1.2.1",
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@tanstack/react-query-devtools": "^5.0.0",
|
"@tanstack/react-query-devtools": "^5.0.0",
|
||||||
|
"@tanstack/react-router": "^1.157.16",
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.0.0",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
@@ -38,9 +44,11 @@
|
|||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"framer-motion": "^12.29.0",
|
"framer-motion": "^12.29.0",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
|
"motion": "^12.29.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"react-hook-form": "^7.53.0",
|
"react-hook-form": "^7.53.0",
|
||||||
|
"react-sound-visualizer": "^1.4.0",
|
||||||
"tailwind-merge": "^2.5.4",
|
"tailwind-merge": "^2.5.4",
|
||||||
"wavesurfer.js": "^7.0.0",
|
"wavesurfer.js": "^7.0.0",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
|
|||||||
+121
-82
@@ -1,33 +1,78 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { RouterProvider } from '@tanstack/react-router';
|
||||||
import { GenerationForm } from '@/components/Generation/GenerationForm';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { HistoryTable } from '@/components/History/HistoryTable';
|
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||||
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
import ShinyText from '@/components/ShinyText';
|
||||||
import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
|
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||||
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||||
import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
|
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import { Toaster } from '@/components/ui/toaster';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
import { Sidebar } from '@/components/Sidebar';
|
import { router } from '@/router';
|
||||||
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
import { UpdateNotification } from '@/components/UpdateNotification';
|
|
||||||
import { isTauri, startServer, setupWindowCloseHandler } from '@/lib/tauri';
|
|
||||||
|
|
||||||
// Track if server is starting to prevent duplicate starts
|
const LOADING_MESSAGES = [
|
||||||
let serverStarting = false;
|
'Warming up tensors...',
|
||||||
|
'Calibrating synthesizer engine...',
|
||||||
|
'Initializing voice models...',
|
||||||
|
'Loading neural networks...',
|
||||||
|
'Preparing audio pipelines...',
|
||||||
|
'Optimizing waveform generators...',
|
||||||
|
'Tuning frequency analyzers...',
|
||||||
|
'Building voice embeddings...',
|
||||||
|
'Configuring text-to-speech cores...',
|
||||||
|
'Syncing audio buffers...',
|
||||||
|
'Establishing model connections...',
|
||||||
|
'Preprocessing training data...',
|
||||||
|
'Validating voice samples...',
|
||||||
|
'Compiling inference engines...',
|
||||||
|
'Mapping phoneme sequences...',
|
||||||
|
'Aligning prosody parameters...',
|
||||||
|
'Activating speech synthesis...',
|
||||||
|
'Fine-tuning acoustic models...',
|
||||||
|
'Preparing voice cloning matrices...',
|
||||||
|
'Initializing Qwen TTS framework...',
|
||||||
|
];
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [activeTab, setActiveTab] = useState('main');
|
const platform = usePlatform();
|
||||||
const [serverReady, setServerReady] = useState(false);
|
const [serverReady, setServerReady] = useState(false);
|
||||||
|
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
||||||
|
const serverStartingRef = useRef(false);
|
||||||
|
|
||||||
|
// Automatically check for app updates on startup and show toast notifications
|
||||||
|
useAutoUpdater({ checkOnMount: true, showToast: true });
|
||||||
|
|
||||||
|
// Sync stored setting to Rust on startup
|
||||||
|
useEffect(() => {
|
||||||
|
if (platform.metadata.isTauri) {
|
||||||
|
const keepRunning = useServerStore.getState().keepServerRunningOnClose;
|
||||||
|
platform.lifecycle.setKeepServerRunning(keepRunning).catch((error) => {
|
||||||
|
console.error('Failed to sync initial setting to Rust:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Empty dependency array - platform is stable from context, only run once
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.metadata.isTauri, platform.lifecycle]);
|
||||||
|
|
||||||
|
// Setup lifecycle callbacks
|
||||||
|
useEffect(() => {
|
||||||
|
platform.lifecycle.onServerReady = () => {
|
||||||
|
setServerReady(true);
|
||||||
|
};
|
||||||
|
// Empty dependency array - platform is stable from context, only run once
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.lifecycle]);
|
||||||
|
|
||||||
// Setup window close handler and auto-start server when running in Tauri (production only)
|
// Setup window close handler and auto-start server when running in Tauri (production only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTauri()) {
|
if (!platform.metadata.isTauri) {
|
||||||
|
setServerReady(true); // Web assumes server is running
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup window close handler to check setting and stop server if needed
|
// Setup window close handler to check setting and stop server if needed
|
||||||
// This works in both dev and prod, but will only stop server if it was started by the app
|
// This works in both dev and prod, but will only stop server if it was started by the app
|
||||||
setupWindowCloseHandler().catch((error) => {
|
platform.lifecycle.setupWindowCloseHandler().catch((error) => {
|
||||||
console.error('Failed to setup window close handler:', error);
|
console.error('Failed to setup window close handler:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,16 +88,21 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-start server in production
|
// Auto-start server in production
|
||||||
if (serverStarting) {
|
if (serverStartingRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
serverStarting = 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})`);
|
||||||
|
|
||||||
startServer(false)
|
platform.lifecycle
|
||||||
.then(() => {
|
.startServer(isRemote, customModelsDir)
|
||||||
console.log('Server is ready');
|
.then((serverUrl) => {
|
||||||
|
console.log('Server is ready at:', serverUrl);
|
||||||
|
// Update the server URL in the store with the dynamically assigned port
|
||||||
|
useServerStore.getState().setServerUrl(serverUrl);
|
||||||
setServerReady(true);
|
setServerReady(true);
|
||||||
// Mark that we started the server (so we know to stop it on close)
|
// Mark that we started the server (so we know to stop it on close)
|
||||||
// @ts-expect-error - adding property to window
|
// @ts-expect-error - adding property to window
|
||||||
@@ -60,7 +110,7 @@ function App() {
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to auto-start server:', error);
|
console.error('Failed to auto-start server:', error);
|
||||||
serverStarting = false;
|
serverStartingRef.current = false;
|
||||||
// @ts-expect-error - adding property to window
|
// @ts-expect-error - adding property to window
|
||||||
window.__voiceboxServerStartedByApp = false;
|
window.__voiceboxServerStartedByApp = false;
|
||||||
});
|
});
|
||||||
@@ -69,72 +119,61 @@ function App() {
|
|||||||
// Note: Window close is handled separately in Tauri Rust code
|
// Note: Window close is handled separately in Tauri Rust code
|
||||||
return () => {
|
return () => {
|
||||||
// Window close event handles server shutdown based on setting
|
// Window close event handles server shutdown based on setting
|
||||||
serverStarting = false;
|
serverStartingRef.current = false;
|
||||||
};
|
};
|
||||||
}, []);
|
// Empty dependency array - platform is stable from context, only run once
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.metadata.isTauri, platform.lifecycle]);
|
||||||
|
|
||||||
|
// Cycle through loading messages every 3 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (!platform.metadata.isTauri || serverReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setLoadingMessageIndex((prev) => (prev + 1) % LOADING_MESSAGES.length);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [serverReady, platform.metadata.isTauri]);
|
||||||
|
|
||||||
// Show loading screen while server is starting in Tauri
|
// Show loading screen while server is starting in Tauri
|
||||||
if (isTauri() && !serverReady) {
|
if (platform.metadata.isTauri && !serverReady) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
<div
|
||||||
<div className="text-center space-y-4">
|
className={cn(
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
'min-h-screen bg-background flex items-center justify-center',
|
||||||
<p className="text-muted-foreground">Starting server...</p>
|
TOP_SAFE_AREA_PADDING,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TitleBarDragRegion />
|
||||||
|
<div className="text-center space-y-6">
|
||||||
|
<div className="flex justify-center relative">
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-48 h-48 rounded-full bg-accent/20 blur-3xl" />
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
src={voiceboxLogo}
|
||||||
|
alt="Voicebox"
|
||||||
|
className="w-48 h-48 object-contain animate-fade-in-scale relative z-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="animate-fade-in-delayed">
|
||||||
|
<ShinyText
|
||||||
|
text={LOADING_MESSAGES[loadingMessageIndex]}
|
||||||
|
className="text-lg font-medium text-muted-foreground"
|
||||||
|
speed={2}
|
||||||
|
color="hsl(var(--muted-foreground))"
|
||||||
|
shineColor="hsl(var(--foreground))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <RouterProvider router={router} />;
|
||||||
<div className="h-screen bg-background flex flex-col overflow-hidden">
|
|
||||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
|
||||||
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} />
|
|
||||||
|
|
||||||
<main className="flex-1 ml-20 overflow-hidden flex flex-col">
|
|
||||||
<div className="container mx-auto px-8 py-8 max-w-[1800px] h-full overflow-hidden flex flex-col">
|
|
||||||
<UpdateNotification />
|
|
||||||
|
|
||||||
{activeTab === 'settings' ? (
|
|
||||||
<div className="space-y-4 overflow-y-auto">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<ConnectionForm />
|
|
||||||
<ServerStatus />
|
|
||||||
</div>
|
|
||||||
{isTauri() && <UpdateStatus />}
|
|
||||||
<ModelManagement />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Main view: Profiles top left, Generator bottom left, History right
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0 overflow-hidden">
|
|
||||||
{/* Left Column */}
|
|
||||||
<div className="flex flex-col gap-6 min-h-0 overflow-y-auto pb-32">
|
|
||||||
{/* Profiles - Top Left */}
|
|
||||||
<div className="shrink-0 flex flex-col">
|
|
||||||
<ProfileList />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Generator - Bottom Left */}
|
|
||||||
<div className="shrink-0">
|
|
||||||
<GenerationForm />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column - History */}
|
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
|
||||||
<HistoryTable />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Audio Player - always visible except on settings */}
|
|
||||||
{activeTab !== 'settings' && <AudioPlayer />}
|
|
||||||
|
|
||||||
<Toaster />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useRouterState } from '@tanstack/react-router';
|
||||||
|
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||||
|
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
|
||||||
|
import { StoryTrackEditor } from '@/components/StoriesTab/StoryTrackEditor';
|
||||||
|
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
import { useStory } from '@/lib/hooks/useStories';
|
||||||
|
|
||||||
|
interface AppFrameProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppFrame({ children }: AppFrameProps) {
|
||||||
|
const routerState = useRouterState();
|
||||||
|
const isStoriesRoute = routerState.location.pathname === '/stories';
|
||||||
|
|
||||||
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
|
const { data: story } = useStory(selectedStoryId);
|
||||||
|
|
||||||
|
// Show track editor when on stories route with a selected story that has items
|
||||||
|
const showTrackEditor = isStoriesRoute && selectedStoryId && story && story.items.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('h-screen bg-background flex flex-col overflow-hidden', TOP_SAFE_AREA_PADDING)}>
|
||||||
|
<TitleBarDragRegion />
|
||||||
|
{children}
|
||||||
|
{showTrackEditor ? (
|
||||||
|
<StoryTrackEditor storyId={story.id} items={story.items} />
|
||||||
|
) : (
|
||||||
|
<AudioPlayer />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,30 +1,76 @@
|
|||||||
import { Pause, Play, Repeat, Volume2, VolumeX } from 'lucide-react';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-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 { formatAudioDuration } from '@/lib/utils/audio';
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
|
import { debug } from '@/lib/utils/debug';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
export function AudioPlayer() {
|
export function AudioPlayer() {
|
||||||
|
const platform = usePlatform();
|
||||||
|
const volumeLabelId = useId();
|
||||||
const {
|
const {
|
||||||
audioUrl,
|
audioUrl,
|
||||||
|
audioId,
|
||||||
|
profileId,
|
||||||
title,
|
title,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
currentTime,
|
currentTime,
|
||||||
duration,
|
duration,
|
||||||
volume,
|
volume,
|
||||||
isLooping,
|
isLooping,
|
||||||
|
shouldRestart,
|
||||||
setIsPlaying,
|
setIsPlaying,
|
||||||
setCurrentTime,
|
setCurrentTime,
|
||||||
setDuration,
|
setDuration,
|
||||||
setVolume,
|
setVolume,
|
||||||
toggleLoop,
|
toggleLoop,
|
||||||
|
clearRestartFlag,
|
||||||
|
reset,
|
||||||
} = usePlayerStore();
|
} = usePlayerStore();
|
||||||
|
|
||||||
|
// Check if profile has assigned channels (for native audio routing)
|
||||||
|
const { data: profileChannels } = useQuery({
|
||||||
|
queryKey: ['profile-channels', profileId],
|
||||||
|
queryFn: () => {
|
||||||
|
if (!profileId) return { channel_ids: [] };
|
||||||
|
return apiClient.getProfileChannels(profileId);
|
||||||
|
},
|
||||||
|
enabled: !!profileId && platform.metadata.isTauri,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: channels } = useQuery({
|
||||||
|
queryKey: ['channels'],
|
||||||
|
queryFn: () => apiClient.listChannels(),
|
||||||
|
enabled: !!profileChannels && profileChannels.channel_ids.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine if we should use native playback
|
||||||
|
const useNativePlayback = useMemo(() => {
|
||||||
|
if (!platform.metadata.isTauri || !profileChannels || !channels) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedChannels = channels.filter((ch) => profileChannels.channel_ids.includes(ch.id));
|
||||||
|
|
||||||
|
// Use native playback if any assigned channel has non-default devices
|
||||||
|
const shouldUseNative = assignedChannels.some(
|
||||||
|
(ch) => ch.device_ids.length > 0 && !ch.is_default,
|
||||||
|
);
|
||||||
|
|
||||||
|
return shouldUseNative;
|
||||||
|
}, [profileChannels, channels, profileId]);
|
||||||
|
|
||||||
const waveformRef = useRef<HTMLDivElement>(null);
|
const waveformRef = useRef<HTMLDivElement>(null);
|
||||||
const wavesurferRef = useRef<WaveSurfer | null>(null);
|
const wavesurferRef = useRef<WaveSurfer | null>(null);
|
||||||
const loadingRef = useRef(false);
|
const loadingRef = useRef(false);
|
||||||
|
const previousAudioIdRef = useRef<string | null>(null);
|
||||||
|
const hasInitializedRef = useRef(false);
|
||||||
|
const isUsingNativePlaybackRef = useRef(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -36,11 +82,11 @@ export function AudioPlayer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (wavesurferRef.current) {
|
if (wavesurferRef.current) {
|
||||||
console.log('WaveSurfer already initialized, skipping');
|
debug.log('WaveSurfer already initialized, skipping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Creating NEW WaveSurfer instance');
|
debug.log('Creating NEW WaveSurfer instance');
|
||||||
|
|
||||||
// Wait for container to be properly rendered
|
// Wait for container to be properly rendered
|
||||||
const initWaveSurfer = () => {
|
const initWaveSurfer = () => {
|
||||||
@@ -66,7 +112,7 @@ export function AudioPlayer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Initializing WaveSurfer...', {
|
debug.log('Initializing WaveSurfer...', {
|
||||||
container,
|
container,
|
||||||
width: rect.width,
|
width: rect.width,
|
||||||
height: rect.height,
|
height: rect.height,
|
||||||
@@ -99,9 +145,9 @@ export function AudioPlayer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
wavesurferRef.current = wavesurfer;
|
wavesurferRef.current = wavesurfer;
|
||||||
console.log('WaveSurfer created successfully');
|
debug.log('WaveSurfer created successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create WaveSurfer:', error);
|
debug.error('Failed to create WaveSurfer:', error);
|
||||||
setError(
|
setError(
|
||||||
`Failed to initialize waveform: ${error instanceof Error ? error.message : String(error)}`,
|
`Failed to initialize waveform: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
);
|
);
|
||||||
@@ -111,53 +157,259 @@ 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update store when duration is loaded
|
// Update store when duration is loaded
|
||||||
wavesurfer.on('ready', () => {
|
wavesurfer.on('ready', async () => {
|
||||||
const dur = wavesurfer.getDuration();
|
const dur = wavesurfer.getDuration();
|
||||||
setDuration(dur);
|
setDuration(dur);
|
||||||
loadingRef.current = false;
|
loadingRef.current = false;
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
console.log('Audio ready, duration:', dur);
|
debug.log('Audio ready, duration:', dur);
|
||||||
console.log('Waveform should be visible now');
|
debug.log('Waveform should be visible now');
|
||||||
|
|
||||||
// Ensure volume is set
|
// Ensure volume is set
|
||||||
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
|
// 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();
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
if (mediaElement) {
|
if (mediaElement && !isUsingNativePlaybackRef.current) {
|
||||||
mediaElement.volume = currentVolume;
|
mediaElement.volume = currentVolume;
|
||||||
mediaElement.muted = false;
|
mediaElement.muted = false;
|
||||||
console.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
|
debug.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-play when ready
|
// Auto-play when ready - check if we should use native playback
|
||||||
// Use a small delay to ensure audio element is fully ready
|
// Get current values from the store and queries at runtime (not captured closure values)
|
||||||
setTimeout(() => {
|
const currentAudioUrl = usePlayerStore.getState().audioUrl;
|
||||||
wavesurfer.play().catch((error) => {
|
const currentProfileId = usePlayerStore.getState().profileId;
|
||||||
console.error('Failed to autoplay:', error);
|
|
||||||
// Don't show error for autoplay failures (browser restrictions)
|
debug.log('Auto-play check - capturing runtime values...');
|
||||||
});
|
|
||||||
}, 100);
|
// Fetch profile channels at runtime (not using captured value)
|
||||||
|
let runtimeProfileChannels = null;
|
||||||
|
let runtimeChannels = null;
|
||||||
|
|
||||||
|
if (platform.metadata.isTauri && currentProfileId) {
|
||||||
|
try {
|
||||||
|
runtimeProfileChannels = await apiClient.getProfileChannels(currentProfileId);
|
||||||
|
debug.log('Runtime profileChannels:', runtimeProfileChannels);
|
||||||
|
|
||||||
|
if (runtimeProfileChannels && runtimeProfileChannels.channel_ids.length > 0) {
|
||||||
|
runtimeChannels = await apiClient.listChannels();
|
||||||
|
debug.log('Runtime channels:', runtimeChannels);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debug.error('Failed to fetch runtime channel data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug.log('Auto-play check:', {
|
||||||
|
isTauri: platform.metadata.isTauri,
|
||||||
|
currentAudioUrl,
|
||||||
|
currentProfileId,
|
||||||
|
hasProfileChannels: !!runtimeProfileChannels,
|
||||||
|
hasChannels: !!runtimeChannels,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
platform.metadata.isTauri &&
|
||||||
|
currentAudioUrl &&
|
||||||
|
currentProfileId &&
|
||||||
|
runtimeProfileChannels &&
|
||||||
|
runtimeChannels
|
||||||
|
) {
|
||||||
|
debug.log('Attempting native audio playback...');
|
||||||
|
|
||||||
|
// Stop any existing native playback first
|
||||||
|
if (isUsingNativePlaybackRef.current) {
|
||||||
|
try {
|
||||||
|
platform.audio.stopPlayback();
|
||||||
|
debug.log('Stopped existing native playback before starting new one');
|
||||||
|
} catch (error) {
|
||||||
|
debug.error('Failed to stop existing playback:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Collect all device IDs from assigned channels
|
||||||
|
const assignedChannels = runtimeChannels.filter((ch: any) =>
|
||||||
|
runtimeProfileChannels.channel_ids.includes(ch.id),
|
||||||
|
);
|
||||||
|
debug.log('Assigned channels for playback:', assignedChannels);
|
||||||
|
|
||||||
|
// Check if any assigned channel has non-default devices
|
||||||
|
const shouldUseNative = assignedChannels.some(
|
||||||
|
(ch: any) => ch.device_ids.length > 0 && !ch.is_default,
|
||||||
|
);
|
||||||
|
debug.log('Should use native playback:', shouldUseNative);
|
||||||
|
|
||||||
|
if (!shouldUseNative) {
|
||||||
|
debug.log('No custom devices assigned, falling back to WaveSurfer');
|
||||||
|
// Reset native playback flag and unmute WaveSurfer
|
||||||
|
isUsingNativePlaybackRef.current = false;
|
||||||
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
|
if (mediaElement) {
|
||||||
|
const currentVolume = usePlayerStore.getState().volume;
|
||||||
|
mediaElement.volume = currentVolume;
|
||||||
|
mediaElement.muted = false;
|
||||||
|
debug.log(
|
||||||
|
'WaveSurfer unmuted for normal playback - volume:',
|
||||||
|
mediaElement.volume,
|
||||||
|
'muted:',
|
||||||
|
mediaElement.muted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const deviceIds = assignedChannels.flatMap((ch: any) => ch.device_ids);
|
||||||
|
debug.log('Device IDs to play to:', deviceIds);
|
||||||
|
|
||||||
|
if (deviceIds.length > 0) {
|
||||||
|
debug.log('Fetching audio data from:', currentAudioUrl);
|
||||||
|
// Fetch audio data
|
||||||
|
const response = await fetch(currentAudioUrl);
|
||||||
|
const audioData = new Uint8Array(await response.arrayBuffer());
|
||||||
|
debug.log('Audio data size:', audioData.length);
|
||||||
|
|
||||||
|
// Play via native audio
|
||||||
|
debug.log('Invoking play_audio_to_devices...');
|
||||||
|
try {
|
||||||
|
await platform.audio.playToDevices(audioData, deviceIds);
|
||||||
|
debug.log('play_audio_to_devices completed successfully');
|
||||||
|
|
||||||
|
// Mark that we're using native playback
|
||||||
|
isUsingNativePlaybackRef.current = true;
|
||||||
|
|
||||||
|
// Mute WaveSurfer's audio element to prevent UI audio output
|
||||||
|
// Keep WaveSurfer running for visualization
|
||||||
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
|
if (mediaElement) {
|
||||||
|
mediaElement.volume = 0;
|
||||||
|
mediaElement.muted = true;
|
||||||
|
debug.log(
|
||||||
|
'WaveSurfer muted for native playback - volume:',
|
||||||
|
mediaElement.volume,
|
||||||
|
'muted:',
|
||||||
|
mediaElement.muted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start WaveSurfer playback for visualization (muted)
|
||||||
|
wavesurfer.play().catch((error) => {
|
||||||
|
debug.error('Failed to start WaveSurfer visualization:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsPlaying(true);
|
||||||
|
debug.log('Auto-playing via native audio routing - SUCCESS');
|
||||||
|
return;
|
||||||
|
} catch (invokeError) {
|
||||||
|
debug.error('play_audio_to_devices invoke failed:', invokeError);
|
||||||
|
throw invokeError;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debug.log('No device IDs found, falling back to WaveSurfer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debug.error(
|
||||||
|
'Native playback failed during auto-play, falling back to WaveSurfer:',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
// Reset native playback flag and unmute WaveSurfer
|
||||||
|
isUsingNativePlaybackRef.current = false;
|
||||||
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
|
if (mediaElement) {
|
||||||
|
const currentVolume = usePlayerStore.getState().volume;
|
||||||
|
mediaElement.volume = currentVolume;
|
||||||
|
mediaElement.muted = false;
|
||||||
|
debug.log(
|
||||||
|
'WaveSurfer unmuted after native playback failure - volume:',
|
||||||
|
mediaElement.volume,
|
||||||
|
'muted:',
|
||||||
|
mediaElement.muted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Fall through to WaveSurfer playback
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debug.log('Not using native playback, using WaveSurfer');
|
||||||
|
// Reset native playback flag and unmute WaveSurfer
|
||||||
|
isUsingNativePlaybackRef.current = false;
|
||||||
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
|
if (mediaElement) {
|
||||||
|
const currentVolume = usePlayerStore.getState().volume;
|
||||||
|
mediaElement.volume = currentVolume;
|
||||||
|
mediaElement.muted = false;
|
||||||
|
debug.log(
|
||||||
|
'WaveSurfer unmuted for normal playback - volume:',
|
||||||
|
mediaElement.volume,
|
||||||
|
'muted:',
|
||||||
|
mediaElement.muted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only auto-play if shouldAutoPlay flag is set (user explicitly clicked to play)
|
||||||
|
const shouldAutoPlayNow = usePlayerStore.getState().shouldAutoPlay;
|
||||||
|
if (shouldAutoPlayNow) {
|
||||||
|
// Clear the flag first
|
||||||
|
usePlayerStore.getState().clearAutoPlayFlag();
|
||||||
|
|
||||||
|
// Use a small delay to ensure audio element is fully ready
|
||||||
|
setTimeout(() => {
|
||||||
|
wavesurfer.play().catch((error) => {
|
||||||
|
debug.error('Failed to autoplay:', error);
|
||||||
|
// Don't show error for autoplay failures (browser restrictions)
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
debug.log('Skipping auto-play - shouldAutoPlay is false');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle play/pause
|
// Handle play/pause
|
||||||
wavesurfer.on('play', () => {
|
wavesurfer.on('play', () => {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
// Ensure audio element is not muted when playing
|
// Ensure audio element volume is set correctly
|
||||||
const mediaElement = wavesurfer.getMediaElement();
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
if (mediaElement) {
|
if (mediaElement) {
|
||||||
mediaElement.muted = false;
|
// Double-check: if using native playback, keep WaveSurfer muted
|
||||||
const currentVolume = usePlayerStore.getState().volume;
|
// Otherwise, ensure it's unmuted
|
||||||
mediaElement.volume = currentVolume;
|
if (isUsingNativePlaybackRef.current) {
|
||||||
console.log('Playing - volume:', mediaElement.volume, 'muted:', mediaElement.muted);
|
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));
|
||||||
@@ -169,12 +421,17 @@ export function AudioPlayer() {
|
|||||||
wavesurfer.play();
|
wavesurfer.play();
|
||||||
} else {
|
} else {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
|
// Trigger finish callback if set
|
||||||
|
const onFinish = usePlayerStore.getState().onFinish;
|
||||||
|
if (onFinish) {
|
||||||
|
onFinish();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle errors
|
// Handle errors
|
||||||
wavesurfer.on('error', (error) => {
|
wavesurfer.on('error', (error) => {
|
||||||
console.error('WaveSurfer error:', error);
|
debug.error('WaveSurfer error:', error);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setError(`Audio error: ${error instanceof Error ? error.message : String(error)}`);
|
setError(`Audio error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
});
|
});
|
||||||
@@ -189,7 +446,7 @@ export function AudioPlayer() {
|
|||||||
|
|
||||||
// Load audio immediately if audioUrl is already set
|
// Load audio immediately if audioUrl is already set
|
||||||
if (audioUrl) {
|
if (audioUrl) {
|
||||||
console.log('WaveSurfer ready, loading audio:', audioUrl);
|
debug.log('WaveSurfer ready, loading audio:', audioUrl);
|
||||||
loadingRef.current = true;
|
loadingRef.current = true;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
// Stop any current playback before loading new audio
|
// Stop any current playback before loading new audio
|
||||||
@@ -199,11 +456,11 @@ export function AudioPlayer() {
|
|||||||
wavesurfer
|
wavesurfer
|
||||||
.load(audioUrl)
|
.load(audioUrl)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('Audio loaded into WaveSurfer');
|
debug.log('Audio loaded into WaveSurfer');
|
||||||
loadingRef.current = false;
|
loadingRef.current = false;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to load audio into WaveSurfer:', error);
|
debug.error('Failed to load audio into WaveSurfer:', error);
|
||||||
loadingRef.current = false;
|
loadingRef.current = false;
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setError(
|
setError(
|
||||||
@@ -228,12 +485,12 @@ export function AudioPlayer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log('Cleaning up WaveSurfer initialization effect');
|
debug.log('Cleaning up WaveSurfer initialization effect');
|
||||||
if (rafId1) cancelAnimationFrame(rafId1);
|
if (rafId1) cancelAnimationFrame(rafId1);
|
||||||
if (rafId2) cancelAnimationFrame(rafId2);
|
if (rafId2) cancelAnimationFrame(rafId2);
|
||||||
if (timeoutId) clearTimeout(timeoutId);
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
if (wavesurferRef.current) {
|
if (wavesurferRef.current) {
|
||||||
console.log('Destroying WaveSurfer instance');
|
debug.log('Destroying WaveSurfer instance');
|
||||||
try {
|
try {
|
||||||
const mediaElement = wavesurferRef.current.getMediaElement();
|
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||||
if (mediaElement) {
|
if (mediaElement) {
|
||||||
@@ -242,7 +499,7 @@ export function AudioPlayer() {
|
|||||||
}
|
}
|
||||||
wavesurferRef.current.destroy();
|
wavesurferRef.current.destroy();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error destroying WaveSurfer:', error);
|
debug.error('Error destroying WaveSurfer:', error);
|
||||||
}
|
}
|
||||||
wavesurferRef.current = null;
|
wavesurferRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -263,36 +520,59 @@ export function AudioPlayer() {
|
|||||||
setDuration(0);
|
setDuration(0);
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
// Reset native playback flag
|
||||||
|
isUsingNativePlaybackRef.current = false;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop native playback if it was active
|
||||||
|
if (isUsingNativePlaybackRef.current && platform.metadata.isTauri) {
|
||||||
|
try {
|
||||||
|
platform.audio.stopPlayback();
|
||||||
|
debug.log('Stopped native audio playback');
|
||||||
|
} catch (error) {
|
||||||
|
debug.error('Failed to stop native playback:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset native playback flag when loading new audio
|
||||||
|
// Also unmute WaveSurfer if it was muted
|
||||||
|
if (isUsingNativePlaybackRef.current) {
|
||||||
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
|
if (mediaElement) {
|
||||||
|
mediaElement.muted = false;
|
||||||
|
mediaElement.volume = usePlayerStore.getState().volume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isUsingNativePlaybackRef.current = false;
|
||||||
|
|
||||||
// CRITICAL: Force stop any current playback and cancel any pending loads
|
// CRITICAL: Force stop any current playback and cancel any pending loads
|
||||||
// This must happen BEFORE any early returns
|
// This must happen BEFORE any early returns
|
||||||
console.log('Audio URL changed to:', audioUrl);
|
debug.log('Audio URL changed to:', audioUrl);
|
||||||
|
|
||||||
// COMPLETELY stop and destroy the current audio
|
// COMPLETELY stop and destroy the current audio
|
||||||
try {
|
try {
|
||||||
// First pause if playing
|
// First pause if playing
|
||||||
if (wavesurfer.isPlaying()) {
|
if (wavesurfer.isPlaying()) {
|
||||||
console.log('Pausing current playback');
|
debug.log('Pausing current playback');
|
||||||
wavesurfer.pause();
|
wavesurfer.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop the media element explicitly
|
// Stop the media element explicitly
|
||||||
const mediaElement = wavesurfer.getMediaElement();
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
if (mediaElement) {
|
if (mediaElement) {
|
||||||
console.log('Stopping media element');
|
debug.log('Stopping media element');
|
||||||
mediaElement.pause();
|
mediaElement.pause();
|
||||||
mediaElement.currentTime = 0;
|
mediaElement.currentTime = 0;
|
||||||
mediaElement.src = '';
|
mediaElement.src = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use empty() to completely destroy the waveform and media element
|
// Use empty() to completely destroy the waveform and media element
|
||||||
console.log('Calling wavesurfer.empty() to destroy audio');
|
debug.log('Calling wavesurfer.empty() to destroy audio');
|
||||||
wavesurfer.empty();
|
wavesurfer.empty();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error stopping previous audio:', error);
|
debug.error('Error stopping previous audio:', error);
|
||||||
// Continue anyway to load new audio
|
// Continue anyway to load new audio
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,16 +587,16 @@ export function AudioPlayer() {
|
|||||||
setDuration(0);
|
setDuration(0);
|
||||||
|
|
||||||
// Load new audio
|
// Load new audio
|
||||||
console.log('Starting new audio load for:', audioUrl);
|
debug.log('Starting new audio load for:', audioUrl);
|
||||||
wavesurfer
|
wavesurfer
|
||||||
.load(audioUrl)
|
.load(audioUrl)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('Audio load promise resolved');
|
debug.log('Audio load promise resolved');
|
||||||
// Don't set loading to false here - wait for 'ready' event
|
// Don't set loading to false here - wait for 'ready' event
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to load audio:', error);
|
debug.error('Failed to load audio:', error);
|
||||||
console.error('Audio URL:', audioUrl);
|
debug.error('Audio URL:', audioUrl);
|
||||||
loadingRef.current = false;
|
loadingRef.current = false;
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setError(`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`);
|
setError(`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
@@ -331,7 +611,7 @@ export function AudioPlayer() {
|
|||||||
if (isPlaying && wavesurferRef.current.isPlaying() === false) {
|
if (isPlaying && wavesurferRef.current.isPlaying() === false) {
|
||||||
// Only auto-play if audio is ready
|
// Only auto-play if audio is ready
|
||||||
wavesurferRef.current.play().catch((error) => {
|
wavesurferRef.current.play().catch((error) => {
|
||||||
console.error('Failed to play:', error);
|
debug.error('Failed to play:', error);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
});
|
});
|
||||||
@@ -347,33 +627,173 @@ export function AudioPlayer() {
|
|||||||
// Also ensure the underlying audio element volume is set
|
// Also ensure the underlying audio element volume is set
|
||||||
const mediaElement = wavesurferRef.current.getMediaElement();
|
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||||
if (mediaElement) {
|
if (mediaElement) {
|
||||||
mediaElement.volume = volume;
|
// If using native playback, keep WaveSurfer muted regardless of volume setting
|
||||||
mediaElement.muted = volume === 0;
|
if (isUsingNativePlaybackRef.current) {
|
||||||
console.log('Volume synced:', volume, 'muted:', mediaElement.muted);
|
mediaElement.volume = 0;
|
||||||
|
mediaElement.muted = true;
|
||||||
|
debug.log('Volume sync: Using native playback, keeping WaveSurfer muted');
|
||||||
|
} else {
|
||||||
|
mediaElement.volume = volume;
|
||||||
|
mediaElement.muted = volume === 0;
|
||||||
|
debug.log('Volume synced:', volume, 'muted:', mediaElement.muted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [volume]);
|
}, [volume]);
|
||||||
|
|
||||||
|
// Mark as initialized when audio is ready, reset when audioId changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (duration > 0 && audioId) {
|
||||||
|
hasInitializedRef.current = true;
|
||||||
|
}
|
||||||
|
// Reset initialization flag when audioId changes to a new audio
|
||||||
|
if (audioId !== previousAudioIdRef.current && previousAudioIdRef.current !== null) {
|
||||||
|
hasInitializedRef.current = false;
|
||||||
|
}
|
||||||
|
if (audioId !== null) {
|
||||||
|
previousAudioIdRef.current = audioId;
|
||||||
|
}
|
||||||
|
}, [duration, audioId]);
|
||||||
|
|
||||||
|
// Handle restart flag - when history item is clicked again, restart from beginning
|
||||||
|
useEffect(() => {
|
||||||
|
const wavesurfer = wavesurferRef.current;
|
||||||
|
if (!wavesurfer || !shouldRestart || duration === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset to beginning and play
|
||||||
|
debug.log('Restarting current audio from beginning');
|
||||||
|
wavesurfer.seekTo(0);
|
||||||
|
wavesurfer.play().catch((error) => {
|
||||||
|
debug.error('Failed to play after restart:', error);
|
||||||
|
setIsPlaying(false);
|
||||||
|
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the restart flag
|
||||||
|
clearRestartFlag();
|
||||||
|
}, [shouldRestart, duration, setIsPlaying, clearRestartFlag]);
|
||||||
|
|
||||||
|
// Handle shouldAutoPlay flag - for story mode auto-advance
|
||||||
|
const shouldAutoPlay = usePlayerStore((state) => state.shouldAutoPlay);
|
||||||
|
const clearAutoPlayFlag = usePlayerStore((state) => state.clearAutoPlayFlag);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wavesurfer = wavesurferRef.current;
|
||||||
|
if (!wavesurfer || !shouldAutoPlay || duration === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-play the newly loaded audio
|
||||||
|
debug.log('Auto-playing next track in story mode');
|
||||||
|
wavesurfer.seekTo(0);
|
||||||
|
wavesurfer.play().catch((error) => {
|
||||||
|
debug.error('Failed to auto-play:', error);
|
||||||
|
setIsPlaying(false);
|
||||||
|
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the auto-play flag
|
||||||
|
clearAutoPlayFlag();
|
||||||
|
}, [shouldAutoPlay, duration, setIsPlaying, clearAutoPlayFlag]);
|
||||||
|
|
||||||
// Handle loop - WaveSurfer handles this via the 'finish' event
|
// Handle loop - WaveSurfer handles this via the 'finish' event
|
||||||
|
|
||||||
const handlePlayPause = () => {
|
const handlePlayPause = async () => {
|
||||||
|
// Standard WaveSurfer playback (works for both normal and native playback modes)
|
||||||
|
// When using native playback, WaveSurfer is muted but still controls visualization
|
||||||
if (!wavesurferRef.current) {
|
if (!wavesurferRef.current) {
|
||||||
console.error('WaveSurfer not initialized');
|
debug.error('WaveSurfer not initialized');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if audio is loaded
|
// Check if audio is loaded
|
||||||
if (duration === 0 && !isLoading) {
|
if (duration === 0 && !isLoading) {
|
||||||
console.error('Audio not loaded yet');
|
debug.error('Audio not loaded yet');
|
||||||
setError('Audio not loaded. Please wait...');
|
setError('Audio not loaded. Please wait...');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If using native playback
|
||||||
|
if (useNativePlayback && audioUrl && profileChannels && channels) {
|
||||||
|
if (isPlaying) {
|
||||||
|
// Pause: stop native playback and pause WaveSurfer visualization
|
||||||
|
try {
|
||||||
|
platform.audio.stopPlayback();
|
||||||
|
debug.log('Stopped native audio playback');
|
||||||
|
} catch (error) {
|
||||||
|
debug.error('Failed to stop native playback:', error);
|
||||||
|
}
|
||||||
|
wavesurferRef.current.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play: trigger native playback
|
||||||
|
try {
|
||||||
|
// Stop any existing native playback first
|
||||||
|
try {
|
||||||
|
platform.audio.stopPlayback();
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore errors when stopping (might not be playing)
|
||||||
|
debug.log('No existing playback to stop');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all device IDs from assigned channels
|
||||||
|
const assignedChannels = channels.filter((ch) =>
|
||||||
|
profileChannels.channel_ids.includes(ch.id),
|
||||||
|
);
|
||||||
|
const deviceIds = assignedChannels.flatMap((ch) => ch.device_ids);
|
||||||
|
|
||||||
|
if (deviceIds.length > 0) {
|
||||||
|
// Fetch audio data
|
||||||
|
const response = await fetch(audioUrl);
|
||||||
|
const audioData = new Uint8Array(await response.arrayBuffer());
|
||||||
|
|
||||||
|
// Play via native audio
|
||||||
|
await platform.audio.playToDevices(audioData, deviceIds);
|
||||||
|
|
||||||
|
// Mark that we're using native playback
|
||||||
|
isUsingNativePlaybackRef.current = true;
|
||||||
|
|
||||||
|
// Mute WaveSurfer and start it for visualization
|
||||||
|
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||||
|
if (mediaElement) {
|
||||||
|
mediaElement.volume = 0;
|
||||||
|
mediaElement.muted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start WaveSurfer for visualization (muted)
|
||||||
|
wavesurferRef.current.play().catch((error) => {
|
||||||
|
debug.error('Failed to start WaveSurfer visualization:', error);
|
||||||
|
setIsPlaying(false);
|
||||||
|
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debug.error('Native playback failed, falling back to WaveSurfer:', error);
|
||||||
|
// Fall through to WaveSurfer playback
|
||||||
|
isUsingNativePlaybackRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard WaveSurfer playback (or fallback from native playback failure)
|
||||||
if (wavesurferRef.current.isPlaying()) {
|
if (wavesurferRef.current.isPlaying()) {
|
||||||
wavesurferRef.current.pause();
|
wavesurferRef.current.pause();
|
||||||
} else {
|
} else {
|
||||||
|
// Ensure WaveSurfer is not muted if not using native playback
|
||||||
|
if (!isUsingNativePlaybackRef.current) {
|
||||||
|
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||||
|
if (mediaElement) {
|
||||||
|
mediaElement.muted = false;
|
||||||
|
mediaElement.volume = volume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
wavesurferRef.current.play().catch((error) => {
|
wavesurferRef.current.play().catch((error) => {
|
||||||
console.error('Failed to play:', error);
|
debug.error('Failed to play:', error);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
});
|
});
|
||||||
@@ -390,6 +810,24 @@ export function AudioPlayer() {
|
|||||||
setVolume(value[0] / 100);
|
setVolume(value[0] / 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
// Stop any native playback
|
||||||
|
if (isUsingNativePlaybackRef.current && platform.metadata.isTauri) {
|
||||||
|
try {
|
||||||
|
platform.audio.stopPlayback();
|
||||||
|
} catch (error) {
|
||||||
|
debug.error('Failed to stop native playback:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Stop WaveSurfer
|
||||||
|
if (wavesurferRef.current) {
|
||||||
|
wavesurferRef.current.pause();
|
||||||
|
wavesurferRef.current.seekTo(0);
|
||||||
|
}
|
||||||
|
// Reset player state
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
// Don't render if no audio
|
// Don't render if no audio
|
||||||
if (!audioUrl) {
|
if (!audioUrl) {
|
||||||
return null;
|
return null;
|
||||||
@@ -407,6 +845,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>
|
||||||
@@ -421,6 +862,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 && (
|
||||||
@@ -438,7 +881,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 */}
|
||||||
@@ -448,28 +893,51 @@ 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>
|
||||||
|
|
||||||
|
{/* Close Button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="shrink-0"
|
||||||
|
title="Close player"
|
||||||
|
aria-label="Close player"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,675 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
|
interface AudioDevice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
is_default: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioTab() {
|
||||||
|
const platform = usePlatform();
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
const [editingChannel, setEditingChannel] = useState<string | null>(null);
|
||||||
|
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||||
|
const isPlayerVisible = !!audioUrl;
|
||||||
|
|
||||||
|
const { data: channels, isLoading: channelsLoading } = useQuery({
|
||||||
|
queryKey: ['channels'],
|
||||||
|
queryFn: () => apiClient.listChannels(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: devices, isLoading: devicesLoading } = useQuery({
|
||||||
|
queryKey: ['audio-devices'],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!platform.metadata.isTauri) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await platform.audio.listOutputDevices();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to list audio devices:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: platform.metadata.isTauri,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: profiles } = useQuery({
|
||||||
|
queryKey: ['profiles'],
|
||||||
|
queryFn: () => apiClient.listProfiles(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createChannel = useMutation({
|
||||||
|
mutationFn: (data: { name: string; device_ids: string[] }) => apiClient.createChannel(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['channels'] });
|
||||||
|
setCreateDialogOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateChannel = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
channelId,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
channelId: string;
|
||||||
|
data: { name?: string; device_ids?: string[] };
|
||||||
|
}) => apiClient.updateChannel(channelId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['channels'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
|
||||||
|
setEditingChannel(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteChannel = useMutation({
|
||||||
|
mutationFn: (channelId: string) => apiClient.deleteChannel(channelId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['channels'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: channelVoices } = useQuery({
|
||||||
|
queryKey: ['channel-voices', editingChannel],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!editingChannel) return { profile_ids: [] };
|
||||||
|
return apiClient.getChannelVoices(editingChannel);
|
||||||
|
},
|
||||||
|
enabled: !!editingChannel,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setChannelVoices = useMutation({
|
||||||
|
mutationFn: ({ channelId, profileIds }: { channelId: string; profileIds: string[] }) =>
|
||||||
|
apiClient.setChannelVoices(channelId, profileIds),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['channel-voices'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (channelsLoading || devicesLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-muted-foreground">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChannelDelete = async (e, channelId) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (await confirm('Delete this channel?')) {
|
||||||
|
deleteChannel.mutate(channelId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const allChannels = channels || [];
|
||||||
|
const allDevices = devices || [];
|
||||||
|
const selectedChannel = selectedChannelId
|
||||||
|
? allChannels.find((c) => c.id === selectedChannelId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-6 shrink-0">
|
||||||
|
<h2 className="text-2xl font-bold">Audio Channels</h2>
|
||||||
|
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
New Channel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0">
|
||||||
|
{/* Left Column - Channels */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col min-h-0 overflow-y-auto',
|
||||||
|
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{allChannels.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
||||||
|
<Speaker className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
No audio channels yet. Create your first channel to route voices to specific
|
||||||
|
devices.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Create Channel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{allChannels.map((channel) => {
|
||||||
|
const isSelected = selectedChannelId === channel.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={channel.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'group border rounded-lg p-4 transition-colors cursor-pointer text-left w-full',
|
||||||
|
isSelected && 'ring-2 ring-primary bg-primary/5 border-primary',
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedChannelId(isSelected ? null : channel.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||||
|
<Speaker className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<h3 className="font-semibold text-base truncate">{channel.name}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2.5 ml-10">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||||
|
Output Devices
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{channel.device_ids.length > 0
|
||||||
|
? channel.device_ids.map((deviceId) => {
|
||||||
|
const device = allDevices.find((d) => d.id === deviceId);
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={deviceId}
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs font-normal"
|
||||||
|
>
|
||||||
|
{device?.name || deviceId}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: (() => {
|
||||||
|
const defaultDevice = allDevices.find((d) => d.is_default);
|
||||||
|
return defaultDevice ? (
|
||||||
|
<Badge variant="outline" className="text-xs font-normal">
|
||||||
|
{defaultDevice.name}
|
||||||
|
</Badge>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||||
|
Assigned Voices
|
||||||
|
</div>
|
||||||
|
<ChannelVoicesList channelId={channel.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!channel.is_default && (
|
||||||
|
<div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditingChannel(channel.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={(e) => handleChannelDelete(e, channel.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Available Devices */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col min-h-0 overflow-y-auto',
|
||||||
|
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="shrink-0 mb-4">
|
||||||
|
<h3 className="text-lg font-semibold">Available Devices</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{selectedChannelId
|
||||||
|
? selectedChannel?.is_default
|
||||||
|
? 'Default channel uses system default device'
|
||||||
|
: 'Click devices to add or remove them from the selected channel'
|
||||||
|
: 'Select a channel to assign devices'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{allDevices.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{allDevices.map((device) => {
|
||||||
|
const isConnected =
|
||||||
|
selectedChannelId &&
|
||||||
|
selectedChannel &&
|
||||||
|
(selectedChannel.device_ids.length === 0
|
||||||
|
? device.is_default
|
||||||
|
: selectedChannel.device_ids.includes(device.id));
|
||||||
|
const canToggle =
|
||||||
|
selectedChannelId && selectedChannel && !selectedChannel.is_default;
|
||||||
|
|
||||||
|
const handleDeviceClick = () => {
|
||||||
|
if (!canToggle || !selectedChannel) return;
|
||||||
|
|
||||||
|
const currentDeviceIds = selectedChannel.device_ids;
|
||||||
|
const newDeviceIds = isConnected
|
||||||
|
? currentDeviceIds.filter((id) => id !== device.id)
|
||||||
|
: [...currentDeviceIds, device.id];
|
||||||
|
|
||||||
|
updateChannel.mutate({
|
||||||
|
channelId: selectedChannelId,
|
||||||
|
data: { device_ids: newDeviceIds },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={device.id}
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeviceClick}
|
||||||
|
disabled={!canToggle}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 text-sm p-3 rounded-lg border transition-colors text-left w-full',
|
||||||
|
isConnected
|
||||||
|
? 'bg-primary/10 border-primary ring-1 ring-primary/20'
|
||||||
|
: 'hover:bg-muted/50',
|
||||||
|
!canToggle && 'cursor-default opacity-60',
|
||||||
|
canToggle && 'cursor-pointer',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{canToggle ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 rounded border-2 flex items-center justify-center shrink-0',
|
||||||
|
isConnected ? 'bg-accent border-accent' : 'border-muted-foreground/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isConnected && <Check className="h-3 w-3 text-accent-foreground" />}
|
||||||
|
</div>
|
||||||
|
) : device.is_default ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-primary shrink-0" />
|
||||||
|
) : null}
|
||||||
|
<span className={cn('truncate flex-1', device.is_default && 'font-medium')}>
|
||||||
|
{device.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
||||||
|
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<p className="text-muted-foreground text-center">
|
||||||
|
{platform.metadata.isTauri
|
||||||
|
? 'No audio devices found'
|
||||||
|
: 'Audio device selection requires Tauri'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Channel Dialog */}
|
||||||
|
<CreateChannelDialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onOpenChange={setCreateDialogOpen}
|
||||||
|
devices={devices || []}
|
||||||
|
onCreate={(name, deviceIds) => {
|
||||||
|
createChannel.mutate({ name, device_ids: deviceIds });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit Channel Dialog */}
|
||||||
|
{editingChannel &&
|
||||||
|
(() => {
|
||||||
|
const channel = channels?.find((c) => c.id === editingChannel);
|
||||||
|
return channel ? (
|
||||||
|
<EditChannelDialog
|
||||||
|
open={!!editingChannel}
|
||||||
|
onOpenChange={(open) => !open && setEditingChannel(null)}
|
||||||
|
channel={channel}
|
||||||
|
devices={devices || []}
|
||||||
|
profiles={profiles || []}
|
||||||
|
channelVoices={channelVoices?.profile_ids || []}
|
||||||
|
onUpdate={(name, deviceIds) => {
|
||||||
|
updateChannel.mutate({
|
||||||
|
channelId: editingChannel,
|
||||||
|
data: { name, device_ids: deviceIds },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onSetVoices={(profileIds) => {
|
||||||
|
setChannelVoices.mutate({
|
||||||
|
channelId: editingChannel,
|
||||||
|
profileIds,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChannelVoicesList({ channelId }: { channelId: string }) {
|
||||||
|
const { data: voices } = useQuery({
|
||||||
|
queryKey: ['channel-voices', channelId],
|
||||||
|
queryFn: () => apiClient.getChannelVoices(channelId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: profiles } = useQuery({
|
||||||
|
queryKey: ['profiles'],
|
||||||
|
queryFn: () => apiClient.listProfiles(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const voiceNames =
|
||||||
|
voices?.profile_ids.map((id) => profiles?.find((p) => p.id === id)?.name).filter(Boolean) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{voiceNames.length > 0 ? (
|
||||||
|
voiceNames.map((name) => (
|
||||||
|
<Badge key={name} variant="outline" className="text-xs font-normal">
|
||||||
|
{name}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">No voices assigned</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateChannelDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
devices: AudioDevice[];
|
||||||
|
onCreate: (name: string, deviceIds: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateChannelDialogProps) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (name.trim()) {
|
||||||
|
onCreate(name.trim(), selectedDevices);
|
||||||
|
setName('');
|
||||||
|
setSelectedDevices([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Audio Channel</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new audio channel (bus) to route voices to specific output devices.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="channel-name">Channel Name</Label>
|
||||||
|
<Input
|
||||||
|
id="channel-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g., Virtual Cable, Broadcast"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Output Devices</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedDevices[0] || ''}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value && !selectedDevices.includes(value)) {
|
||||||
|
setSelectedDevices([...selectedDevices, value]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select device" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{devices.map((device) => (
|
||||||
|
<SelectItem key={device.id} value={device.id}>
|
||||||
|
{device.name} {device.is_default && '(default)'}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{selectedDevices.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{selectedDevices.map((deviceId) => {
|
||||||
|
const device = devices.find((d) => d.id === deviceId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={deviceId}
|
||||||
|
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
|
||||||
|
>
|
||||||
|
<span>{device?.name || deviceId}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={!name.trim()}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditChannelDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
channel: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
device_ids: string[];
|
||||||
|
};
|
||||||
|
devices: AudioDevice[];
|
||||||
|
profiles: Array<{ id: string; name: string }>;
|
||||||
|
channelVoices: string[];
|
||||||
|
onUpdate: (name: string, deviceIds: string[]) => void;
|
||||||
|
onSetVoices: (profileIds: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditChannelDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
channel,
|
||||||
|
devices,
|
||||||
|
profiles,
|
||||||
|
channelVoices,
|
||||||
|
onUpdate,
|
||||||
|
onSetVoices,
|
||||||
|
}: EditChannelDialogProps) {
|
||||||
|
const [name, setName] = useState(channel.name);
|
||||||
|
const [selectedDevices, setSelectedDevices] = useState<string[]>(channel.device_ids);
|
||||||
|
const [selectedVoices, setSelectedVoices] = useState<string[]>(channelVoices);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (name.trim()) {
|
||||||
|
onUpdate(name.trim(), selectedDevices);
|
||||||
|
onSetVoices(selectedVoices);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Channel</DialogTitle>
|
||||||
|
<DialogDescription>Update channel settings and voice assignments.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-channel-name">Channel Name</Label>
|
||||||
|
<Input id="edit-channel-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Output Devices</Label>
|
||||||
|
<Select
|
||||||
|
value=""
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value && !selectedDevices.includes(value)) {
|
||||||
|
setSelectedDevices([...selectedDevices, value]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Add device" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{devices.map((device) => (
|
||||||
|
<SelectItem key={device.id} value={device.id}>
|
||||||
|
{device.name} {device.is_default && '(default)'}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{selectedDevices.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{selectedDevices.map((deviceId) => {
|
||||||
|
const device = devices.find((d) => d.id === deviceId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={deviceId}
|
||||||
|
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
|
||||||
|
>
|
||||||
|
<span>{device?.name || deviceId}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Assigned Voices</Label>
|
||||||
|
<Select
|
||||||
|
value=""
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value && !selectedVoices.includes(value)) {
|
||||||
|
setSelectedVoices([...selectedVoices, value]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Add voice" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{profiles.map((profile) => (
|
||||||
|
<SelectItem key={profile.id} value={profile.id}>
|
||||||
|
{profile.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{selectedVoices.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{selectedVoices.map((profileId) => {
|
||||||
|
const profile = profiles.find((p) => p.id === profileId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={profileId}
|
||||||
|
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
|
||||||
|
>
|
||||||
|
<span>{profile?.name || profileId}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setSelectedVoices(selectedVoices.filter((id) => id !== profileId))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={!name.trim()}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,332 @@
|
|||||||
|
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 { 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);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveAsNew() {
|
||||||
|
await handleSaveNew();
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
# Voice generation components
|
|
||||||
@@ -0,0 +1,519 @@
|
|||||||
|
import { useMatchRoute } from '@tanstack/react-router';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { Loader2, SlidersHorizontal, Sparkles } from 'lucide-react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import type { EffectConfig } from '@/lib/api/types';
|
||||||
|
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
|
||||||
|
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
||||||
|
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
|
||||||
|
import { useStory } from '@/lib/hooks/useStories';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
import { ParalinguisticInput } from './ParalinguisticInput';
|
||||||
|
|
||||||
|
interface FloatingGenerateBoxProps {
|
||||||
|
isPlayerOpen?: boolean;
|
||||||
|
showVoiceSelector?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloatingGenerateBox({
|
||||||
|
isPlayerOpen = false,
|
||||||
|
showVoiceSelector = false,
|
||||||
|
}: FloatingGenerateBoxProps) {
|
||||||
|
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||||
|
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
|
||||||
|
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
||||||
|
const { data: profiles } = useProfiles();
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [isInstructMode, setIsInstructMode] = useState(false);
|
||||||
|
const [effectsChain, setEffectsChain] = useState<EffectConfig[]>([]);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const matchRoute = useMatchRoute();
|
||||||
|
const isStoriesRoute = matchRoute({ to: '/stories' });
|
||||||
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
|
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||||
|
const { data: currentStory } = useStory(selectedStoryId);
|
||||||
|
const addPendingStoryAdd = useGenerationStore((s) => s.addPendingStoryAdd);
|
||||||
|
|
||||||
|
// Calculate if track editor is visible (on stories route with items)
|
||||||
|
const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0;
|
||||||
|
|
||||||
|
const { form, handleSubmit, isPending } = useGenerationForm({
|
||||||
|
onSuccess: async (generationId) => {
|
||||||
|
setIsExpanded(false);
|
||||||
|
// Defer the story add until TTS completes — useGenerationProgress handles it
|
||||||
|
if (isStoriesRoute && selectedStoryId && generationId) {
|
||||||
|
addPendingStoryAdd(generationId, selectedStoryId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getEffectsChain: () => (effectsChain.length > 0 ? effectsChain : undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click away handler to collapse the box
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
|
||||||
|
// Don't collapse if clicking inside the container
|
||||||
|
if (containerRef.current?.contains(target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't collapse if clicking on a Select dropdown (which renders in a portal)
|
||||||
|
if (
|
||||||
|
target.closest('[role="listbox"]') ||
|
||||||
|
target.closest('[data-radix-popper-content-wrapper]')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsExpanded(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isExpanded]);
|
||||||
|
|
||||||
|
// Set first voice as default if none selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedProfileId && profiles && profiles.length > 0) {
|
||||||
|
setSelectedProfileId(profiles[0].id);
|
||||||
|
}
|
||||||
|
}, [selectedProfileId, profiles, setSelectedProfileId]);
|
||||||
|
|
||||||
|
// Sync generation form language with selected profile's language
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedProfile?.language) {
|
||||||
|
form.setValue('language', selectedProfile.language as LanguageCode);
|
||||||
|
}
|
||||||
|
}, [selectedProfile, form]);
|
||||||
|
|
||||||
|
// Auto-resize textarea based on content (only when expanded)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isExpanded) {
|
||||||
|
// Reset textarea height after collapse animation completes
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (textarea) {
|
||||||
|
textarea.style.height = '32px';
|
||||||
|
textarea.style.overflowY = 'hidden';
|
||||||
|
}
|
||||||
|
}, 200); // Wait for animation to complete
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const adjustHeight = () => {
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
const scrollHeight = textarea.scrollHeight;
|
||||||
|
const minHeight = 100; // Expanded minimum
|
||||||
|
const maxHeight = 300; // Max height in pixels
|
||||||
|
const targetHeight = Math.max(minHeight, Math.min(scrollHeight, maxHeight));
|
||||||
|
textarea.style.height = `${targetHeight}px`;
|
||||||
|
|
||||||
|
// Show scrollbar if content exceeds max height
|
||||||
|
if (scrollHeight > maxHeight) {
|
||||||
|
textarea.style.overflowY = 'auto';
|
||||||
|
} else {
|
||||||
|
textarea.style.overflowY = 'hidden';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Small delay to let framer animation complete
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
adjustHeight();
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
// Adjust on mount and when value changes
|
||||||
|
adjustHeight();
|
||||||
|
|
||||||
|
// Watch for input changes
|
||||||
|
textarea.addEventListener('input', adjustHeight);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
textarea.removeEventListener('input', adjustHeight);
|
||||||
|
};
|
||||||
|
}, [isExpanded]);
|
||||||
|
|
||||||
|
async function onSubmit(data: Parameters<typeof handleSubmit>[0]) {
|
||||||
|
await handleSubmit(data, selectedProfileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn(
|
||||||
|
'fixed right-auto',
|
||||||
|
isStoriesRoute
|
||||||
|
? // Position aligned with story list: after sidebar + padding, width 360px
|
||||||
|
'left-[calc(5rem+2rem)] w-[360px]'
|
||||||
|
: 'left-[calc(5rem+2rem)] right-8 lg:right-auto lg:w-[calc((100%-5rem-4rem)/2-1rem)]',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
// On stories route: offset by track editor height when visible
|
||||||
|
// On other routes: offset by audio player height when visible
|
||||||
|
bottom: hasTrackEditor
|
||||||
|
? `${trackEditorHeight + 24}px`
|
||||||
|
: isPlayerOpen
|
||||||
|
? 'calc(7rem + 1.5rem)'
|
||||||
|
: '1.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[2rem] shadow-2xl hover:bg-background/40 hover:border-accent/20 transition-all duration-300 p-3"
|
||||||
|
transition={{ duration: 0.6, ease: 'easeInOut' }}
|
||||||
|
>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<motion.div
|
||||||
|
className={cn('flex-1', isExpanded && 'mr-12')}
|
||||||
|
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
{/* Text field - hidden when in instruct mode */}
|
||||||
|
<div style={{ display: isInstructMode ? 'none' : 'block' }}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="text"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
height: isExpanded ? 'auto' : '32px',
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
{form.watch('engine') === 'chatterbox_turbo' ? (
|
||||||
|
<ParalinguisticInput
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
placeholder={
|
||||||
|
isStoriesRoute && currentStory
|
||||||
|
? `Generate speech for "${currentStory.name}"... (type / for effects)`
|
||||||
|
: selectedProfile
|
||||||
|
? `Type / for effects like [laugh], [sigh]...`
|
||||||
|
: 'Select a voice profile above...'
|
||||||
|
}
|
||||||
|
className="px-3 py-2 resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm w-full"
|
||||||
|
style={{
|
||||||
|
minHeight: isExpanded ? '100px' : '32px',
|
||||||
|
maxHeight: '300px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
disabled={!selectedProfileId}
|
||||||
|
onClick={() => setIsExpanded(true)}
|
||||||
|
onFocus={() => setIsExpanded(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Textarea
|
||||||
|
{...field}
|
||||||
|
ref={(node: HTMLTextAreaElement | null) => {
|
||||||
|
// Store ref for auto-resize (only for active field)
|
||||||
|
if (!isInstructMode) {
|
||||||
|
textareaRef.current = node;
|
||||||
|
}
|
||||||
|
// Forward ref to react-hook-form
|
||||||
|
if (typeof field.ref === 'function') {
|
||||||
|
field.ref(node);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
isStoriesRoute && currentStory
|
||||||
|
? `Generate speech for "${currentStory.name}"...`
|
||||||
|
: selectedProfile
|
||||||
|
? `Generate speech using ${selectedProfile.name}...`
|
||||||
|
: 'Select a voice profile above...'
|
||||||
|
}
|
||||||
|
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full"
|
||||||
|
style={{
|
||||||
|
minHeight: isExpanded ? '100px' : '32px',
|
||||||
|
maxHeight: '300px',
|
||||||
|
}}
|
||||||
|
disabled={!selectedProfileId}
|
||||||
|
onClick={() => setIsExpanded(true)}
|
||||||
|
onFocus={() => setIsExpanded(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage className="text-xs" />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Instruct field - hidden when in text mode */}
|
||||||
|
<div style={{ display: isInstructMode ? 'block' : 'none' }}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="instruct"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
height: isExpanded ? 'auto' : '32px',
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
{...field}
|
||||||
|
ref={(node: HTMLTextAreaElement | null) => {
|
||||||
|
// Store ref for auto-resize (only for active field)
|
||||||
|
if (isInstructMode) {
|
||||||
|
textareaRef.current = node;
|
||||||
|
}
|
||||||
|
// Forward ref to react-hook-form
|
||||||
|
if (typeof field.ref === 'function') {
|
||||||
|
field.ref(node);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="e.g. very happy and excited"
|
||||||
|
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full"
|
||||||
|
style={{
|
||||||
|
minHeight: isExpanded ? '100px' : '32px',
|
||||||
|
maxHeight: '300px',
|
||||||
|
}}
|
||||||
|
disabled={!selectedProfileId}
|
||||||
|
onClick={() => setIsExpanded(true)}
|
||||||
|
onFocus={() => setIsExpanded(true)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage className="text-xs" />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<div className="group relative">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending || !selectedProfileId}
|
||||||
|
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200"
|
||||||
|
size="icon"
|
||||||
|
aria-label={
|
||||||
|
isPending
|
||||||
|
? 'Generating...'
|
||||||
|
: !selectedProfileId
|
||||||
|
? 'Select a voice profile first'
|
||||||
|
: 'Generate speech'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
|
||||||
|
{isPending
|
||||||
|
? 'Generating...'
|
||||||
|
: !selectedProfileId
|
||||||
|
? 'Select a voice profile first'
|
||||||
|
: 'Generate speech'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<AnimatePresence>
|
||||||
|
{isExpanded && form.watch('engine') === 'qwen' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="absolute top-0 right-[calc(100%+0.5rem)]"
|
||||||
|
>
|
||||||
|
<div className="group relative">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsInstructMode(!isInstructMode)}
|
||||||
|
className={cn(
|
||||||
|
'h-10 w-10 rounded-full transition-all duration-200',
|
||||||
|
isInstructMode
|
||||||
|
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
|
||||||
|
: effectsChain.length > 0
|
||||||
|
? 'bg-accent/50 text-accent-foreground border border-accent/50 hover:bg-accent/70'
|
||||||
|
: 'bg-card border border-border hover:bg-background/50',
|
||||||
|
)}
|
||||||
|
aria-label={
|
||||||
|
isInstructMode ? 'Fine tune instructions, on' : 'Fine tune instructions'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
|
||||||
|
Fine tune instructions & effects
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Effects chain editor panel - shown alongside instruct */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isExpanded && isInstructMode && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden mt-2"
|
||||||
|
>
|
||||||
|
<div className="border-t border-border/50 pt-2 pb-1">
|
||||||
|
<EffectsChainEditor value={effectsChain} onChange={setEffectsChain} compact />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||||
|
className=" mt-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{showVoiceSelector && (
|
||||||
|
<div className="flex-1">
|
||||||
|
<Select
|
||||||
|
value={selectedProfileId || ''}
|
||||||
|
onValueChange={(value) => setSelectedProfileId(value || null)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all w-full">
|
||||||
|
<SelectValue placeholder="Select a voice..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{profiles?.map((profile) => (
|
||||||
|
<SelectItem key={profile.id} value={profile.id} className="text-xs">
|
||||||
|
{profile.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="language"
|
||||||
|
render={({ field }) => {
|
||||||
|
const engineLangs = getLanguageOptionsForEngine(
|
||||||
|
form.watch('engine') || 'qwen',
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<FormItem className="flex-1 space-y-0">
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{engineLangs.map((lang) => (
|
||||||
|
<SelectItem key={lang.value} value={lang.value} className="text-xs">
|
||||||
|
{lang.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage className="text-xs" />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormItem className="flex-1 space-y-0">
|
||||||
|
<Select
|
||||||
|
value={
|
||||||
|
form.watch('engine') === 'luxtts'
|
||||||
|
? 'luxtts'
|
||||||
|
: form.watch('engine') === 'chatterbox'
|
||||||
|
? 'chatterbox'
|
||||||
|
: form.watch('engine') === 'chatterbox_turbo'
|
||||||
|
? 'chatterbox_turbo'
|
||||||
|
: `qwen:${form.watch('modelSize') || '1.7B'}`
|
||||||
|
}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === 'luxtts') {
|
||||||
|
form.setValue('engine', 'luxtts');
|
||||||
|
form.setValue('language', 'en');
|
||||||
|
} else if (value === 'chatterbox') {
|
||||||
|
form.setValue('engine', 'chatterbox');
|
||||||
|
} else if (value === 'chatterbox_turbo') {
|
||||||
|
form.setValue('engine', 'chatterbox_turbo');
|
||||||
|
form.setValue('language', 'en');
|
||||||
|
} else {
|
||||||
|
const [, modelSize] = value.split(':');
|
||||||
|
form.setValue('engine', 'qwen');
|
||||||
|
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="qwen:1.7B" className="text-xs text-muted-foreground">
|
||||||
|
Qwen3-TTS 1.7B
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="qwen:0.6B" className="text-xs text-muted-foreground">
|
||||||
|
Qwen3-TTS 0.6B
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="luxtts" className="text-xs text-muted-foreground">
|
||||||
|
LuxTTS
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="chatterbox" className="text-xs text-muted-foreground">
|
||||||
|
Chatterbox
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
value="chatterbox_turbo"
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Chatterbox Turbo
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormItem>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Loader2, Mic } from 'lucide-react';
|
import { Loader2, Mic } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import * as z from 'zod';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
@@ -23,71 +19,20 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { getLanguageOptionsForEngine } from '@/lib/constants/languages';
|
||||||
import { useGeneration } from '@/lib/hooks/useGeneration';
|
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
||||||
import { useProfile } from '@/lib/hooks/useProfiles';
|
import { useProfile } from '@/lib/hooks/useProfiles';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
import { ParalinguisticInput } from './ParalinguisticInput';
|
||||||
const generationSchema = z.object({
|
|
||||||
text: z.string().min(1, 'Text is required').max(5000),
|
|
||||||
language: z.enum(['en', 'zh']),
|
|
||||||
seed: z.number().int().optional(),
|
|
||||||
modelSize: z.enum(['1.7B', '0.6B']).optional(),
|
|
||||||
instruct: z.string().max(500).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type GenerationFormValues = z.infer<typeof generationSchema>;
|
|
||||||
|
|
||||||
export function GenerationForm() {
|
export function GenerationForm() {
|
||||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||||
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
||||||
const generation = useGeneration();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const form = useForm<GenerationFormValues>({
|
const { form, handleSubmit, isPending } = useGenerationForm();
|
||||||
resolver: zodResolver(generationSchema),
|
|
||||||
defaultValues: {
|
|
||||||
text: '',
|
|
||||||
language: 'en',
|
|
||||||
seed: undefined,
|
|
||||||
modelSize: '1.7B',
|
|
||||||
instruct: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function onSubmit(data: GenerationFormValues) {
|
async function onSubmit(data: Parameters<typeof handleSubmit>[0]) {
|
||||||
if (!selectedProfileId) {
|
await handleSubmit(data, selectedProfileId);
|
||||||
toast({
|
|
||||||
title: 'No profile selected',
|
|
||||||
description: 'Please select a voice profile from the cards above.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await generation.mutateAsync({
|
|
||||||
profile_id: selectedProfileId,
|
|
||||||
text: data.text,
|
|
||||||
language: data.language,
|
|
||||||
seed: data.seed,
|
|
||||||
model_size: data.modelSize,
|
|
||||||
instruct: data.instruct || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Generation complete!',
|
|
||||||
description: `Audio generated (${result.duration.toFixed(2)}s)`,
|
|
||||||
});
|
|
||||||
|
|
||||||
form.reset();
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Generation failed',
|
|
||||||
description: error instanceof Error ? error.message : 'Failed to generate audio',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -104,7 +49,7 @@ export function GenerationForm() {
|
|||||||
<div className="mt-2 p-3 border rounded-md bg-muted/50 flex items-center gap-2">
|
<div className="mt-2 p-3 border rounded-md bg-muted/50 flex items-center gap-2">
|
||||||
<Mic className="h-4 w-4 text-muted-foreground" />
|
<Mic className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="font-medium">{selectedProfile.name}</span>
|
<span className="font-medium">{selectedProfile.name}</span>
|
||||||
<Badge variant="outline">{selectedProfile.language}</Badge>
|
<span className="text-sm text-muted-foreground">{selectedProfile.language}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 p-3 border border-dashed rounded-md text-sm text-muted-foreground">
|
<div className="mt-2 p-3 border border-dashed rounded-md text-sm text-muted-foreground">
|
||||||
@@ -120,84 +65,134 @@ export function GenerationForm() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Text to Speak</FormLabel>
|
<FormLabel>Text to Speak</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
{form.watch('engine') === 'chatterbox_turbo' ? (
|
||||||
placeholder="Enter the text you want to generate..."
|
<ParalinguisticInput
|
||||||
className="min-h-[150px]"
|
value={field.value}
|
||||||
{...field}
|
onChange={field.onChange}
|
||||||
/>
|
placeholder="Enter text... type / for effects like [laugh], [sigh]"
|
||||||
</FormControl>
|
className="min-h-[150px] rounded-md border border-input bg-background px-3 py-2"
|
||||||
<FormDescription>Max 5000 characters</FormDescription>
|
/>
|
||||||
<FormMessage />
|
) : (
|
||||||
</FormItem>
|
<Textarea
|
||||||
)}
|
placeholder="Enter the text you want to generate..."
|
||||||
/>
|
className="min-h-[150px]"
|
||||||
|
{...field}
|
||||||
<FormField
|
/>
|
||||||
control={form.control}
|
)}
|
||||||
name="instruct"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Delivery Instructions (optional)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
placeholder="e.g. Speak slowly with emphasis, Warm and friendly tone, Professional and authoritative..."
|
|
||||||
className="min-h-[80px]"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Natural language instructions to control speech delivery (tone, emotion, pace).
|
{form.watch('engine') === 'chatterbox_turbo'
|
||||||
Max 500 characters
|
? 'Max 5000 characters. Type / to insert sound effects.'
|
||||||
|
: 'Max 5000 characters'}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
{form.watch('engine') === 'qwen' && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="language"
|
name="instruct"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Language</FormLabel>
|
<FormLabel>Delivery Instructions (optional)</FormLabel>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<FormControl>
|
||||||
<FormControl>
|
<Textarea
|
||||||
<SelectTrigger>
|
placeholder="e.g. Speak slowly with emphasis, Warm and friendly tone, Professional and authoritative..."
|
||||||
<SelectValue />
|
className="min-h-[80px]"
|
||||||
</SelectTrigger>
|
{...field}
|
||||||
</FormControl>
|
/>
|
||||||
<SelectContent>
|
</FormControl>
|
||||||
<SelectItem value="en">English</SelectItem>
|
<FormDescription>
|
||||||
<SelectItem value="zh">Chinese</SelectItem>
|
Natural language instructions to control speech delivery (tone, emotion,
|
||||||
</SelectContent>
|
pace). Max 500 characters
|
||||||
</Select>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Model</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={
|
||||||
|
form.watch('engine') === 'luxtts'
|
||||||
|
? 'luxtts'
|
||||||
|
: form.watch('engine') === 'chatterbox'
|
||||||
|
? 'chatterbox'
|
||||||
|
: form.watch('engine') === 'chatterbox_turbo'
|
||||||
|
? 'chatterbox_turbo'
|
||||||
|
: `qwen:${form.watch('modelSize') || '1.7B'}`
|
||||||
|
}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === 'luxtts') {
|
||||||
|
form.setValue('engine', 'luxtts');
|
||||||
|
form.setValue('language', 'en');
|
||||||
|
} else if (value === 'chatterbox') {
|
||||||
|
form.setValue('engine', 'chatterbox');
|
||||||
|
} else if (value === 'chatterbox_turbo') {
|
||||||
|
form.setValue('engine', 'chatterbox_turbo');
|
||||||
|
form.setValue('language', 'en');
|
||||||
|
} else {
|
||||||
|
const [, modelSize] = value.split(':');
|
||||||
|
form.setValue('engine', 'qwen');
|
||||||
|
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="qwen:1.7B">Qwen3-TTS 1.7B</SelectItem>
|
||||||
|
<SelectItem value="qwen:0.6B">Qwen3-TTS 0.6B</SelectItem>
|
||||||
|
<SelectItem value="luxtts">LuxTTS</SelectItem>
|
||||||
|
<SelectItem value="chatterbox">Chatterbox</SelectItem>
|
||||||
|
<SelectItem value="chatterbox_turbo">Chatterbox Turbo</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
{form.watch('engine') === 'luxtts'
|
||||||
|
? 'Fast, English-focused'
|
||||||
|
: form.watch('engine') === 'chatterbox'
|
||||||
|
? '23 languages, incl. Hebrew'
|
||||||
|
: form.watch('engine') === 'chatterbox_turbo'
|
||||||
|
? 'English, [laugh] [cough] tags'
|
||||||
|
: 'Multi-language, two sizes'}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="modelSize"
|
name="language"
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
<FormItem>
|
const engineLangs = getLanguageOptionsForEngine(form.watch('engine') || 'qwen');
|
||||||
<FormLabel>Model Size</FormLabel>
|
return (
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>Language</FormLabel>
|
||||||
<SelectTrigger>
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
<SelectValue />
|
<FormControl>
|
||||||
</SelectTrigger>
|
<SelectTrigger>
|
||||||
</FormControl>
|
<SelectValue />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
<SelectItem value="1.7B">Qwen TTS 1.7B (Higher Quality)</SelectItem>
|
</FormControl>
|
||||||
<SelectItem value="0.6B">Qwen TTS 0.6B (Faster)</SelectItem>
|
<SelectContent>
|
||||||
</SelectContent>
|
{engineLangs.map((lang) => (
|
||||||
</Select>
|
<SelectItem key={lang.value} value={lang.value}>
|
||||||
<FormDescription>Larger models produce better quality</FormDescription>
|
{lang.label}
|
||||||
<FormMessage />
|
</SelectItem>
|
||||||
</FormItem>
|
))}
|
||||||
)}
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@@ -223,12 +218,8 @@ export function GenerationForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button type="submit" className="w-full" disabled={isPending || !selectedProfileId}>
|
||||||
type="submit"
|
{isPending ? (
|
||||||
className="w-full"
|
|
||||||
disabled={generation.isPending || !selectedProfileId}
|
|
||||||
>
|
|
||||||
{generation.isPending ? (
|
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Generating...
|
Generating...
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
/**
|
||||||
|
* ParalinguisticInput — a contentEditable rich text input that renders
|
||||||
|
* Chatterbox Turbo paralinguistic tags (e.g. [laugh]) as inline badges.
|
||||||
|
*
|
||||||
|
* Trigger: typing "/" opens an autocomplete dropdown.
|
||||||
|
* Paste: pasting text with [tag] patterns auto-converts to badges.
|
||||||
|
* Output: serializes badges back to plain [tag] text for the API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
// ── Tag definitions ─────────────────────────────────────────────────
|
||||||
|
const PARALINGUISTIC_TAGS = [
|
||||||
|
{ tag: '[laugh]', label: 'laugh', emoji: '\u{1F602}' },
|
||||||
|
{ tag: '[chuckle]', label: 'chuckle', emoji: '\u{1F60F}' },
|
||||||
|
{ tag: '[gasp]', label: 'gasp', emoji: '\u{1F62E}' },
|
||||||
|
{ tag: '[cough]', label: 'cough', emoji: '\u{1F637}' },
|
||||||
|
{ tag: '[sigh]', label: 'sigh', emoji: '\u{1F614}' },
|
||||||
|
{ tag: '[groan]', label: 'groan', emoji: '\u{1F629}' },
|
||||||
|
{ tag: '[sniff]', label: 'sniff', emoji: '\u{1F443}' },
|
||||||
|
{ tag: '[shush]', label: 'shush', emoji: '\u{1F92B}' },
|
||||||
|
{ tag: '[clear throat]', label: 'clear throat', emoji: '\u{1F64A}' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TAG_REGEX = /\[(laugh|chuckle|gasp|cough|sigh|groan|sniff|shush|clear throat)\]/gi;
|
||||||
|
|
||||||
|
// Data attribute used to identify badge spans in the DOM
|
||||||
|
const BADGE_ATTR = 'data-ptag';
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Build an inline badge <span> for a tag. */
|
||||||
|
function makeBadgeHTML(tag: string): string {
|
||||||
|
const entry = PARALINGUISTIC_TAGS.find((t) => t.tag.toLowerCase() === tag.toLowerCase());
|
||||||
|
const label = entry?.label ?? tag.replace(/[[\]]/g, '');
|
||||||
|
const emoji = entry?.emoji ?? '';
|
||||||
|
// Non-editable inline badge. Zero-width spaces around it let the
|
||||||
|
// caret sit on either side so the user can type before/after.
|
||||||
|
return `\u200B<span ${BADGE_ATTR}="${tag}" contenteditable="false" class="ptag-badge">${emoji ? `${emoji}\u00A0` : ''}${label}</span>\u200B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert plain text with [tag] patterns into HTML with badge spans. */
|
||||||
|
function textToHTML(text: string): string {
|
||||||
|
// Escape HTML entities first
|
||||||
|
const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
// Replace tag patterns with badge HTML
|
||||||
|
return escaped.replace(TAG_REGEX, (match) => makeBadgeHTML(match));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialize the contentEditable innerHTML back to plain text with [tag] syntax. */
|
||||||
|
function htmlToText(container: HTMLElement): string {
|
||||||
|
let result = '';
|
||||||
|
for (const node of container.childNodes) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
// Strip zero-width spaces we added around badges
|
||||||
|
result += (node.textContent ?? '').replace(/\u200B/g, '');
|
||||||
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
if (el.hasAttribute(BADGE_ATTR)) {
|
||||||
|
result += el.getAttribute(BADGE_ATTR) ?? '';
|
||||||
|
} else if (el.tagName === 'BR') {
|
||||||
|
result += '\n';
|
||||||
|
} else {
|
||||||
|
// Recurse for nested elements (e.g. spans from paste)
|
||||||
|
result += htmlToText(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the text content from the current caret position back to the last
|
||||||
|
* whitespace or start of container, to detect the "/" trigger. */
|
||||||
|
function getWordBeforeCaret(_container: HTMLElement): { word: string; range: Range | null } {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel || sel.rangeCount === 0) return { word: '', range: null };
|
||||||
|
const range = sel.getRangeAt(0).cloneRange();
|
||||||
|
range.collapse(true);
|
||||||
|
|
||||||
|
// Walk backwards from caret through the text node
|
||||||
|
const textNode = range.startContainer;
|
||||||
|
if (textNode.nodeType !== Node.TEXT_NODE) return { word: '', range: null };
|
||||||
|
const text = textNode.textContent ?? '';
|
||||||
|
const offset = range.startOffset;
|
||||||
|
|
||||||
|
let start = offset;
|
||||||
|
while (
|
||||||
|
start > 0 &&
|
||||||
|
text[start - 1] !== ' ' &&
|
||||||
|
text[start - 1] !== '\n' &&
|
||||||
|
text[start - 1] !== '\u00A0'
|
||||||
|
) {
|
||||||
|
start--;
|
||||||
|
}
|
||||||
|
|
||||||
|
const word = text.slice(start, offset);
|
||||||
|
const wordRange = document.createRange();
|
||||||
|
wordRange.setStart(textNode, start);
|
||||||
|
wordRange.setEnd(textNode, offset);
|
||||||
|
|
||||||
|
return { word, range: wordRange };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ParalinguisticInputProps {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
onClick?: () => void;
|
||||||
|
onFocus?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParalinguisticInputRef {
|
||||||
|
focus: () => void;
|
||||||
|
element: HTMLDivElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParalinguisticInput = forwardRef<ParalinguisticInputRef, ParalinguisticInputProps>(
|
||||||
|
function ParalinguisticInput(
|
||||||
|
{ value, onChange, placeholder, disabled, className, style, onClick, onFocus },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
const [menuFilter, setMenuFilter] = useState('');
|
||||||
|
const [menuIndex, setMenuIndex] = useState(0);
|
||||||
|
const [menuPosition, setMenuPosition] = useState<{ bottom: number; left: number }>({
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
});
|
||||||
|
const triggerRangeRef = useRef<Range | null>(null);
|
||||||
|
const lastSerializedRef = useRef<string>('');
|
||||||
|
const isComposingRef = useRef(false);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
focus: () => editorRef.current?.focus(),
|
||||||
|
element: editorRef.current,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Filtered tag list for the autocomplete menu
|
||||||
|
const filteredTags = PARALINGUISTIC_TAGS.filter((t) =>
|
||||||
|
t.label.toLowerCase().includes(menuFilter.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Sync external value → editor ──────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const el = editorRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
// Only update DOM if the external value differs from what we last emitted
|
||||||
|
if (value !== undefined && value !== lastSerializedRef.current) {
|
||||||
|
lastSerializedRef.current = value;
|
||||||
|
el.innerHTML = value ? textToHTML(value) : '';
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// ── Emit plain-text value on input ────────────────────────────
|
||||||
|
const emitChange = useCallback(() => {
|
||||||
|
const el = editorRef.current;
|
||||||
|
if (!el || !onChange) return;
|
||||||
|
const text = htmlToText(el);
|
||||||
|
lastSerializedRef.current = text;
|
||||||
|
onChange(text);
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
// ── Insert a tag badge at the caret ───────────────────────────
|
||||||
|
const insertTag = useCallback(
|
||||||
|
(tag: string) => {
|
||||||
|
const el = editorRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
// Delete the /filter text
|
||||||
|
const wordRange = triggerRangeRef.current;
|
||||||
|
if (wordRange) {
|
||||||
|
wordRange.deleteContents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert badge HTML
|
||||||
|
const temp = document.createElement('span');
|
||||||
|
temp.innerHTML = makeBadgeHTML(tag);
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
let lastNode: Node | null = null;
|
||||||
|
while (temp.firstChild) {
|
||||||
|
lastNode = frag.appendChild(temp.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel && sel.rangeCount > 0) {
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(frag);
|
||||||
|
|
||||||
|
// Move caret after the badge
|
||||||
|
if (lastNode) {
|
||||||
|
const newRange = document.createRange();
|
||||||
|
newRange.setStartAfter(lastNode);
|
||||||
|
newRange.collapse(true);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(newRange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowMenu(false);
|
||||||
|
setMenuFilter('');
|
||||||
|
emitChange();
|
||||||
|
el.focus();
|
||||||
|
},
|
||||||
|
[emitChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Handle keydown for autocomplete navigation ────────────────
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (showMenu) {
|
||||||
|
if (filteredTags.length === 0) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowMenu(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setMenuIndex((i) => (i + 1) % filteredTags.length);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setMenuIndex((i) => (i - 1 + filteredTags.length) % filteredTags.length);
|
||||||
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (filteredTags[menuIndex]) {
|
||||||
|
insertTag(filteredTags[menuIndex].tag);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowMenu(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Prevent Enter from creating <div> blocks in contentEditable
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
// Let the form handle submit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showMenu, filteredTags, menuIndex, insertTag],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Handle input (check for / trigger) ────────────────────────
|
||||||
|
const handleInput = useCallback(() => {
|
||||||
|
if (isComposingRef.current) return;
|
||||||
|
const el = editorRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const { word, range } = getWordBeforeCaret(el);
|
||||||
|
|
||||||
|
if (word.startsWith('/')) {
|
||||||
|
const filter = word.slice(1); // strip the /
|
||||||
|
setMenuFilter(filter);
|
||||||
|
setMenuIndex(0);
|
||||||
|
triggerRangeRef.current = range;
|
||||||
|
|
||||||
|
// Position the menu above the caret using viewport coords (portalled)
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel && sel.rangeCount > 0) {
|
||||||
|
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
||||||
|
setMenuPosition({
|
||||||
|
bottom: window.innerHeight - rect.top + 4,
|
||||||
|
left: rect.left,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowMenu(true);
|
||||||
|
} else {
|
||||||
|
setShowMenu(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
emitChange();
|
||||||
|
}, [emitChange]);
|
||||||
|
|
||||||
|
// ── Handle paste — convert [tag] patterns to badges ───────────
|
||||||
|
const handlePaste = useCallback(
|
||||||
|
(e: React.ClipboardEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const text = e.clipboardData.getData('text/plain');
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
const el = editorRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const html = textToHTML(text);
|
||||||
|
|
||||||
|
// Insert at caret
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel && sel.rangeCount > 0) {
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
range.deleteContents();
|
||||||
|
const temp = document.createElement('div');
|
||||||
|
temp.innerHTML = html;
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
let lastNode: Node | null = null;
|
||||||
|
while (temp.firstChild) {
|
||||||
|
lastNode = frag.appendChild(temp.firstChild);
|
||||||
|
}
|
||||||
|
range.insertNode(frag);
|
||||||
|
if (lastNode) {
|
||||||
|
const newRange = document.createRange();
|
||||||
|
newRange.setStartAfter(lastNode);
|
||||||
|
newRange.collapse(true);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(newRange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitChange();
|
||||||
|
},
|
||||||
|
[emitChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Show placeholder ──────────────────────────────────────────
|
||||||
|
const isEmpty = !value || value.trim() === '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Placeholder */}
|
||||||
|
{isEmpty && placeholder && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 text-sm text-muted-foreground/60 px-3 py-2 select-none"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Editable area */}
|
||||||
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
contentEditable={!disabled}
|
||||||
|
suppressContentEditableWarning
|
||||||
|
role={disabled ? undefined : 'textbox'}
|
||||||
|
aria-multiline={disabled ? undefined : true}
|
||||||
|
aria-placeholder={placeholder}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
className={cn(
|
||||||
|
'min-h-[32px] text-sm whitespace-pre-wrap break-words outline-none',
|
||||||
|
'[&_.ptag-badge]:inline-flex [&_.ptag-badge]:items-center [&_.ptag-badge]:rounded-full',
|
||||||
|
'[&_.ptag-badge]:bg-accent/20 [&_.ptag-badge]:text-accent [&_.ptag-badge]:border [&_.ptag-badge]:border-accent/30',
|
||||||
|
'[&_.ptag-badge]:px-2 [&_.ptag-badge]:py-0 [&_.ptag-badge]:text-xs [&_.ptag-badge]:font-medium',
|
||||||
|
'[&_.ptag-badge]:mx-0.5 [&_.ptag-badge]:select-none [&_.ptag-badge]:cursor-default',
|
||||||
|
'[&_.ptag-badge]:align-baseline',
|
||||||
|
disabled && 'opacity-50 cursor-not-allowed',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
onInput={!disabled ? handleInput : undefined}
|
||||||
|
onKeyDown={!disabled ? handleKeyDown : undefined}
|
||||||
|
onPaste={!disabled ? handlePaste : undefined}
|
||||||
|
onClick={!disabled ? onClick : undefined}
|
||||||
|
onFocus={!disabled ? onFocus : undefined}
|
||||||
|
onBlur={() => {
|
||||||
|
setShowMenu(false);
|
||||||
|
triggerRangeRef.current = null;
|
||||||
|
}}
|
||||||
|
onCompositionStart={() => {
|
||||||
|
isComposingRef.current = true;
|
||||||
|
}}
|
||||||
|
onCompositionEnd={() => {
|
||||||
|
isComposingRef.current = false;
|
||||||
|
handleInput();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Autocomplete dropdown — portalled to body, positioned above the caret */}
|
||||||
|
{showMenu &&
|
||||||
|
filteredTags.length > 0 &&
|
||||||
|
createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 4 }}
|
||||||
|
transition={{ duration: 0.12 }}
|
||||||
|
className="fixed z-[9999] min-w-[200px] max-h-[280px] overflow-y-auto rounded-lg border border-border bg-popover shadow-lg"
|
||||||
|
style={{
|
||||||
|
bottom: menuPosition.bottom,
|
||||||
|
left: menuPosition.left,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredTags.map((t, i) => (
|
||||||
|
<button
|
||||||
|
key={t.tag}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left transition-colors',
|
||||||
|
i === menuIndex
|
||||||
|
? 'bg-accent/20 text-accent-foreground'
|
||||||
|
: 'text-popover-foreground hover:bg-muted/50',
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault(); // Keep focus in editor
|
||||||
|
insertTag(t.tag);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setMenuIndex(i)}
|
||||||
|
>
|
||||||
|
<span className="text-base leading-none">{t.emoji}</span>
|
||||||
|
<span>{t.label}</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground font-mono">{t.tag}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1 +0,0 @@
|
|||||||
# Generation history components
|
|
||||||
@@ -1,7 +1,31 @@
|
|||||||
import { Download, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import {
|
||||||
|
AlignCenter,
|
||||||
|
AudioLines,
|
||||||
|
AudioWaveform,
|
||||||
|
Download,
|
||||||
|
FileArchive,
|
||||||
|
Loader2,
|
||||||
|
MoreHorizontal,
|
||||||
|
Play,
|
||||||
|
RotateCcw,
|
||||||
|
Star,
|
||||||
|
Trash2,
|
||||||
|
Wand2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import Loader from 'react-loaders';
|
||||||
|
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -9,167 +33,759 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {
|
import {
|
||||||
Table,
|
Select,
|
||||||
TableBody,
|
SelectContent,
|
||||||
TableCell,
|
SelectItem,
|
||||||
TableHead,
|
SelectTrigger,
|
||||||
TableHeader,
|
SelectValue,
|
||||||
TableRow,
|
} from '@/components/ui/select';
|
||||||
} from '@/components/ui/table';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useDeleteGeneration, useHistory } from '@/lib/hooks/useHistory';
|
import type { EffectConfig, GenerationVersionResponse, HistoryResponse } from '@/lib/api/types';
|
||||||
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
|
import {
|
||||||
|
useDeleteGeneration,
|
||||||
|
useExportGeneration,
|
||||||
|
useExportGenerationAudio,
|
||||||
|
useHistory,
|
||||||
|
useImportGeneration,
|
||||||
|
} 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)
|
||||||
|
// This is the new alternate history view with fixed height rows
|
||||||
|
|
||||||
|
// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS WITH INFINITE SCROLL
|
||||||
export function HistoryTable() {
|
export function HistoryTable() {
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
|
const [allHistory, setAllHistory] = useState<HistoryResponse[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [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 queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: historyData, isLoading } = useHistory({
|
const {
|
||||||
|
data: historyData,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
} = useHistory({
|
||||||
limit,
|
limit,
|
||||||
offset: page * limit,
|
offset: page * limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteGeneration = useDeleteGeneration();
|
const deleteGeneration = useDeleteGeneration();
|
||||||
const setAudio = usePlayerStore((state) => state.setAudio);
|
const exportGeneration = useExportGeneration();
|
||||||
|
const exportGenerationAudio = useExportGenerationAudio();
|
||||||
|
const importGeneration = useImportGeneration();
|
||||||
|
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
|
||||||
|
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
||||||
|
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
|
||||||
const currentAudioId = usePlayerStore((state) => state.audioId);
|
const currentAudioId = usePlayerStore((state) => state.audioId);
|
||||||
const isPlaying = usePlayerStore((state) => state.isPlaying);
|
const isPlaying = usePlayerStore((state) => state.isPlaying);
|
||||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||||
const isPlayerVisible = !!audioUrl;
|
const isPlayerVisible = !!audioUrl;
|
||||||
|
|
||||||
const handlePlay = (audioId: string, text: string) => {
|
// Update accumulated history when new data arrives
|
||||||
const audioUrl = apiClient.getAudioUrl(audioId);
|
useEffect(() => {
|
||||||
// If clicking the same audio that's playing, it will be handled by the player
|
if (historyData?.items) {
|
||||||
setAudio(audioUrl, audioId, text.substring(0, 50));
|
setTotal(historyData.total);
|
||||||
|
if (page === 0) {
|
||||||
|
// Reset to first page
|
||||||
|
setAllHistory(historyData.items);
|
||||||
|
} else {
|
||||||
|
// Append new items, avoiding duplicates
|
||||||
|
setAllHistory((prev) => {
|
||||||
|
const existingIds = new Set(prev.map((item) => item.id));
|
||||||
|
const newItems = historyData.items.filter((item) => !existingIds.has(item.id));
|
||||||
|
return [...prev, ...newItems];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [historyData, page]);
|
||||||
|
|
||||||
|
// Reset to page 0 when deletions or imports occur
|
||||||
|
useEffect(() => {
|
||||||
|
if (deleteGeneration.isSuccess || importGeneration.isSuccess) {
|
||||||
|
setPage(0);
|
||||||
|
setAllHistory([]);
|
||||||
|
}
|
||||||
|
}, [deleteGeneration.isSuccess, importGeneration.isSuccess]);
|
||||||
|
|
||||||
|
// Intersection Observer for infinite scroll
|
||||||
|
useEffect(() => {
|
||||||
|
const loadMoreEl = loadMoreRef.current;
|
||||||
|
if (!loadMoreEl) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const target = entries[0];
|
||||||
|
if (target.isIntersecting && !isFetching && allHistory.length < total) {
|
||||||
|
setPage((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: scrollRef.current,
|
||||||
|
rootMargin: '100px',
|
||||||
|
threshold: 0.1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(loadMoreEl);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [isFetching, allHistory.length, total]);
|
||||||
|
|
||||||
|
// Track scroll position for gradient effect
|
||||||
|
useEffect(() => {
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (!scrollEl) return;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
setIsScrolled(scrollEl.scrollTop > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollEl.addEventListener('scroll', handleScroll);
|
||||||
|
return () => scrollEl.removeEventListener('scroll', handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePlay = (audioId: string, text: string, profileId: string) => {
|
||||||
|
// If clicking the same audio, restart it from the beginning
|
||||||
|
if (currentAudioId === audioId) {
|
||||||
|
restartCurrentAudio();
|
||||||
|
} else {
|
||||||
|
// Otherwise, load the new audio and auto-play it
|
||||||
|
const audioUrl = apiClient.getAudioUrl(audioId);
|
||||||
|
setAudioWithAutoPlay(audioUrl, audioId, profileId, text.substring(0, 50));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = (audioId: string, text: string) => {
|
const handleDownloadAudio = (generationId: string, text: string) => {
|
||||||
const audioUrl = apiClient.getAudioUrl(audioId);
|
exportGenerationAudio.mutate(
|
||||||
const filename = `${text.substring(0, 30).replace(/[^a-z0-9]/gi, '_')}.wav`;
|
{ generationId, text },
|
||||||
const link = document.createElement('a');
|
{
|
||||||
link.href = audioUrl;
|
onError: (error) => {
|
||||||
link.download = filename;
|
toast({
|
||||||
document.body.appendChild(link);
|
title: 'Failed to download audio',
|
||||||
link.click();
|
description: error.message,
|
||||||
document.body.removeChild(link);
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
const handleExportPackage = (generationId: string, text: string) => {
|
||||||
|
exportGeneration.mutate(
|
||||||
|
{ generationId, text },
|
||||||
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to export generation',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (generationId: string, profileName: string) => {
|
||||||
|
setGenerationToDelete({ id: generationId, name: profileName });
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
if (generationToDelete) {
|
||||||
|
deleteGeneration.mutate(generationToDelete.id);
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setGenerationToDelete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = () => {
|
||||||
|
if (selectedFile) {
|
||||||
|
importGeneration.mutate(selectedFile, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setImportDialogOpen(false);
|
||||||
|
setSelectedFile(null);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
toast({
|
||||||
|
title: 'Generation imported',
|
||||||
|
description: data.message || 'Generation imported successfully',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to import generation',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading && page === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-muted-foreground">Loading history...</div>
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const history = historyData?.items || [];
|
const history = allHistory;
|
||||||
const total = historyData?.total || 0;
|
const hasMore = allHistory.length < total;
|
||||||
const hasMore = history.length === limit && (page + 1) * limit < total;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0 relative">
|
||||||
{history.length === 0 ? (
|
{history.length === 0 ? (
|
||||||
<div className="text-center py-12 text-muted-foreground flex-1 flex items-center justify-center">
|
<div className="text-center py-12 px-5 border-2 border-dashed mb-5 border-muted rounded-md text-muted-foreground flex-1 flex items-center justify-center">
|
||||||
No generation history yet. Generate your first audio to see it here.
|
No voice generations, yet...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{isScrolled && (
|
||||||
|
<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
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 min-h-0 overflow-y-auto border rounded-md overflow-x-hidden',
|
'flex-1 min-h-0 overflow-y-auto space-y-2 pb-4',
|
||||||
isPlayerVisible && 'max-h-[calc(100vh-220px)]',
|
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Table className="w-full table-fixed">
|
{history.map((gen) => {
|
||||||
<TableHeader className="sticky top-0 bg-background z-10">
|
const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying;
|
||||||
<TableRow>
|
const isGenerating = gen.status === 'generating';
|
||||||
<TableHead className="w-[38%]">Input</TableHead>
|
const isFailed = gen.status === 'failed';
|
||||||
<TableHead className="w-[13%]">Voice</TableHead>
|
const isPlayable = !isGenerating && !isFailed;
|
||||||
<TableHead className="w-[9%]">Lang</TableHead>
|
const hasVersions = gen.versions && gen.versions.length > 1;
|
||||||
<TableHead className="w-[9%]">Length</TableHead>
|
const isVersionsExpanded = expandedVersionsId === gen.id;
|
||||||
<TableHead className="w-[13%]">Date</TableHead>
|
return (
|
||||||
<TableHead className="w-[8%] text-right"></TableHead>
|
<div
|
||||||
</TableRow>
|
key={gen.id}
|
||||||
</TableHeader>
|
className={cn(
|
||||||
<TableBody>
|
'border rounded-md bg-card transition-colors text-left w-full',
|
||||||
{history.map((gen) => {
|
isCurrentlyPlaying && 'bg-muted/70',
|
||||||
const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying;
|
)}
|
||||||
return (
|
>
|
||||||
<TableRow
|
{/* Main row */}
|
||||||
key={gen.id}
|
<div
|
||||||
className={cn(isCurrentlyPlaying && 'bg-muted/50', 'cursor-pointer')}
|
role={isPlayable ? 'button' : undefined}
|
||||||
onClick={() => handlePlay(gen.id, gen.text)}
|
tabIndex={isPlayable ? 0 : undefined}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Status icon */}
|
||||||
|
<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">
|
||||||
|
{isGenerating ? (
|
||||||
|
<span className="text-accent">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()}
|
||||||
>
|
>
|
||||||
<TableCell className="truncate">{gen.text}</TableCell>
|
<Button
|
||||||
<TableCell className="truncate">{gen.profile_name}</TableCell>
|
variant="ghost"
|
||||||
<TableCell>
|
size="icon"
|
||||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
className={cn(
|
||||||
{gen.language}
|
'h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground',
|
||||||
</Badge>
|
gen.is_favorited && 'text-accent hover:text-accent',
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="text-sm">{formatDuration(gen.duration)}</TableCell>
|
aria-label={gen.is_favorited ? 'Unfavorite' : 'Favorite'}
|
||||||
<TableCell className="text-xs text-muted-foreground/60">
|
onClick={() => handleToggleFavorite(gen.id)}
|
||||||
{formatDate(gen.created_at)}
|
>
|
||||||
</TableCell>
|
<Star
|
||||||
<TableCell className="text-right">
|
className="h-2 w-2"
|
||||||
<div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
|
fill={gen.is_favorited ? 'currentColor' : 'none'}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
{hasVersions && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
'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)}
|
||||||
|
>
|
||||||
|
<AudioLines className="h-2 w-2" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFailed ? (
|
||||||
|
<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)}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-2 w-2" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 rounded-full"
|
className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
|
||||||
aria-label="Actions"
|
aria-label="Actions"
|
||||||
|
disabled={isGenerating}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
<MoreHorizontal className="h-2 w-2" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => handlePlay(gen.id, gen.text)}>
|
<DropdownMenuItem
|
||||||
|
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
|
||||||
|
>
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<Play className="mr-2 h-4 w-4" />
|
||||||
Play
|
Play
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => handleDownload(gen.id, gen.text)}>
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDownloadAudio(gen.id, gen.text)}
|
||||||
|
disabled={exportGenerationAudio.isPending}
|
||||||
|
>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
Download
|
Export Audio
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => deleteGeneration.mutate(gen.id)}
|
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}
|
disabled={deleteGeneration.isPending}
|
||||||
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>
|
</>
|
||||||
</TableCell>
|
)}
|
||||||
</TableRow>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center mt-4 shrink-0">
|
{/* Expandable versions panel */}
|
||||||
<Button
|
<AnimatePresence>
|
||||||
variant="outline"
|
{isVersionsExpanded && gen.versions && (
|
||||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
<motion.div
|
||||||
disabled={page === 0}
|
initial={{ height: 0, opacity: 0 }}
|
||||||
>
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
Previous
|
exit={{ height: 0, opacity: 0 }}
|
||||||
</Button>
|
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||||
<div className="text-sm text-muted-foreground">
|
className="overflow-hidden"
|
||||||
Page {page + 1} • {total} total
|
>
|
||||||
</div>
|
<div className="border-t border-border/50">
|
||||||
<Button variant="outline" onClick={() => setPage((p) => p + 1)} disabled={!hasMore}>
|
<div className="divide-y divide-border/40">
|
||||||
Next
|
{gen.versions.map((v) => {
|
||||||
</Button>
|
// 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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Load more trigger element */}
|
||||||
|
{hasMore && (
|
||||||
|
<div ref={loadMoreRef} className="flex items-center justify-center py-4">
|
||||||
|
{isFetching && <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* End of list indicator */}
|
||||||
|
{!hasMore && history.length > 0 && (
|
||||||
|
<div className="text-center py-4 text-xs text-muted-foreground">
|
||||||
|
You've reached the end
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Generation</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete this generation from "{generationToDelete?.name}"?
|
||||||
|
This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setGenerationToDelete(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
disabled={deleteGeneration.isPending}
|
||||||
|
>
|
||||||
|
{deleteGeneration.isPending ? 'Deleting...' : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import Generation</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Import the generation from "{selectedFile?.name}". This will add it to your history.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setImportDialogOpen(false);
|
||||||
|
setSelectedFile(null);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleImportConfirm}
|
||||||
|
disabled={importGeneration.isPending || !selectedFile}
|
||||||
|
>
|
||||||
|
{importGeneration.isPending ? 'Importing...' : 'Import'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { Sparkles, Upload } from 'lucide-react';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
||||||
|
import { HistoryTable } from '@/components/History/HistoryTable';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
|
||||||
|
|
||||||
|
import { useImportProfile } from '@/lib/hooks/useProfiles';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
|
||||||
|
export function MainEditor() {
|
||||||
|
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||||
|
const isPlayerVisible = !!audioUrl;
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||||
|
const importProfile = useImportProfile();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const handleImportClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
if (!file.name.endsWith('.voicebox.zip')) {
|
||||||
|
toast({
|
||||||
|
title: 'Invalid file type',
|
||||||
|
description: 'Please select a valid .voicebox.zip file',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedFile(file);
|
||||||
|
setImportDialogOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportConfirm = () => {
|
||||||
|
if (selectedFile) {
|
||||||
|
importProfile.mutate(selectedFile, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setImportDialogOpen(false);
|
||||||
|
setSelectedFile(null);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
toast({
|
||||||
|
title: 'Profile imported',
|
||||||
|
description: 'Voice profile imported successfully',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to import profile',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Main view: Profiles top left, Generator bottom left, History right
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-6 h-full min-h-0 overflow-hidden relative">
|
||||||
|
{/* Left Column */}
|
||||||
|
<div className="flex flex-col min-h-0 overflow-hidden relative lg:overflow-hidden">
|
||||||
|
{/* Scroll Mask - Always visible, behind content */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />
|
||||||
|
|
||||||
|
{/* Fixed Header */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 z-10">
|
||||||
|
<div className="flex items-center justify-between mb-4 px-1">
|
||||||
|
<h2 className="text-2xl font-bold">Voicebox</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={handleImportClick}>
|
||||||
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
|
Import Voice
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".voicebox.zip"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<Button onClick={() => setDialogOpen(true)}>
|
||||||
|
<Sparkles className="mr-2 h-4 w-4" />
|
||||||
|
Create Voice
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className={cn('flex-1 min-h-0 overflow-y-auto pt-14 pb-4', isPlayerVisible && 'lg:pb-32')}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="shrink-0 flex flex-col">
|
||||||
|
<ProfileList />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider - single column only */}
|
||||||
|
{/* <div className="border-t border-border -my-3 lg:hidden" /> */}
|
||||||
|
|
||||||
|
{/* Right Column - History */}
|
||||||
|
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||||
|
<HistoryTable />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Generate Box */}
|
||||||
|
<FloatingGenerateBox isPlayerOpen={!!audioUrl} />
|
||||||
|
|
||||||
|
{/* Import Dialog */}
|
||||||
|
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import Profile</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Import the profile from "{selectedFile?.name}". This will create a new profile with
|
||||||
|
all samples.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setImportDialogOpen(false);
|
||||||
|
setSelectedFile(null);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleImportConfirm}
|
||||||
|
disabled={importProfile.isPending || !selectedFile}
|
||||||
|
>
|
||||||
|
{importProfile.isPending ? 'Importing...' : 'Import'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
|
||||||
|
|
||||||
|
export function ModelsTab() {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<ModelManagement />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
# Server settings and connection components
|
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { Loader2, XCircle } from 'lucide-react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
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,8 +17,9 @@ 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 { useServerHealth } from '@/lib/hooks/useServer';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
const connectionSchema = z.object({
|
const connectionSchema = z.object({
|
||||||
@@ -25,11 +29,15 @@ const connectionSchema = z.object({
|
|||||||
type ConnectionFormValues = z.infer<typeof connectionSchema>;
|
type ConnectionFormValues = z.infer<typeof connectionSchema>;
|
||||||
|
|
||||||
export function ConnectionForm() {
|
export function ConnectionForm() {
|
||||||
|
const platform = usePlatform();
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
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),
|
||||||
@@ -47,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}`,
|
||||||
@@ -55,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>
|
||||||
@@ -69,7 +77,7 @@ export function ConnectionForm() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Server URL</FormLabel>
|
<FormLabel>Server URL</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="http://localhost:8000" {...field} />
|
<Input placeholder="http://127.0.0.1:17493" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Enter the URL of your voicebox backend server</FormDescription>
|
<FormDescription>Enter the URL of your voicebox backend server</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -81,13 +89,48 @@ 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);
|
||||||
|
platform.lifecycle.setKeepServerRunning(checked).catch((error) => {
|
||||||
|
console.error('Failed to sync setting to Rust:', error);
|
||||||
|
});
|
||||||
toast({
|
toast({
|
||||||
title: 'Setting updated',
|
title: 'Setting updated',
|
||||||
description: checked
|
description: checked
|
||||||
@@ -110,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,364 @@
|
|||||||
|
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 */}
|
||||||
|
{cudaDownloading && downloadProgress && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span>{downloadProgress.filename || 'Downloading CUDA backend...'}</span>
|
||||||
|
</div>
|
||||||
|
{downloadProgress.total > 0 && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{downloadProgress.progress.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{downloadProgress.total > 0 && (
|
||||||
|
<>
|
||||||
|
<Progress value={downloadProgress.progress} className="h-2" />
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{formatBytes(downloadProgress.current)} /{' '}
|
||||||
|
{formatBytes(downloadProgress.total)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Restart in progress */}
|
||||||
|
{restartPhase !== 'idle' && (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-primary/5 border">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-sm">
|
||||||
|
{restartPhase === 'stopping' && 'Stopping server...'}
|
||||||
|
{restartPhase === 'waiting' && 'Restarting server...'}
|
||||||
|
{restartPhase === 'ready' && 'Server restarted successfully!'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{restartPhase === 'idle' && !cudaDownloading && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Not downloaded yet - show download button */}
|
||||||
|
{!cudaAvailable && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Download the CUDA backend (~2.4 GB) for NVIDIA GPU acceleration. Requires an
|
||||||
|
NVIDIA GPU with CUDA support.
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleDownload} className="w-full" size="sm">
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Download CUDA Backend
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Downloaded but not active - show switch button */}
|
||||||
|
{cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
CUDA backend is downloaded and ready. Restart the server to enable GPU
|
||||||
|
acceleration.
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleRestart} className="w-full" size="sm">
|
||||||
|
<RotateCw className="h-4 w-4 mr-2" />
|
||||||
|
Switch to CUDA Backend
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Currently active - show switch back to CPU */}
|
||||||
|
{isCurrentlyCuda && platform.metadata.isTauri && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Running with CUDA GPU acceleration. Switch back to CPU if needed (you can
|
||||||
|
re-download later).
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={handleSwitchToCpu}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<RotateCw className="h-4 w-4 mr-2" />
|
||||||
|
Switch to CPU Backend
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete option when downloaded (and not active) */}
|
||||||
|
{cudaAvailable && !isCurrentlyCuda && (
|
||||||
|
<Button
|
||||||
|
onClick={handleDelete}
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full text-muted-foreground hover:text-destructive"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Remove CUDA Backend
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,30 @@
|
|||||||
|
import { Loader2, XCircle } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import type { ModelProgress as ModelProgressType } from '@/lib/api/types';
|
import type { ModelProgress as ModelProgressType } from '@/lib/api/types';
|
||||||
import { Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
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 [isSubscribed, setIsSubscribed] = useState(false);
|
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverUrl || isSubscribed) return;
|
// IMPORTANT: Only connect to SSE when this specific model is downloading
|
||||||
|
// Opening SSE connections for all models exhausts HTTP/1.1 connection limits (6 per origin)
|
||||||
|
// which causes other fetches (like the download trigger) to be queued/blocked
|
||||||
|
if (!serverUrl || !isDownloading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[ModelProgress] Connecting SSE for ${modelName}`);
|
||||||
|
|
||||||
// Subscribe to progress updates via Server-Sent Events
|
// Subscribe to progress updates via Server-Sent Events
|
||||||
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
||||||
@@ -28,8 +36,8 @@ 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();
|
||||||
setIsSubscribed(false);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing progress event:', error);
|
console.error('Error parsing progress event:', error);
|
||||||
@@ -37,18 +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();
|
||||||
setIsSubscribed(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setIsSubscribed(true);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
console.log(`[ModelProgress] Cleanup - closing SSE for ${modelName}`);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
setIsSubscribed(false);
|
|
||||||
};
|
};
|
||||||
}, [serverUrl, modelName, isSubscribed]);
|
}, [serverUrl, modelName, isDownloading]);
|
||||||
|
|
||||||
// Don't render if no progress or if complete/error and some time has passed
|
// Don't render if no progress or if complete/error and some time has passed
|
||||||
if (
|
if (
|
||||||
@@ -63,13 +68,11 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
|||||||
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]}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusIcon = () => {
|
const getStatusIcon = () => {
|
||||||
switch (progress.status) {
|
switch (progress.status) {
|
||||||
case 'complete':
|
|
||||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
|
||||||
case 'error':
|
case 'error':
|
||||||
return <XCircle className="h-4 w-4 text-destructive" />;
|
return <XCircle className="h-4 w-4 text-destructive" />;
|
||||||
case 'downloading':
|
case 'downloading':
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
import { Loader2, XCircle } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { 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" />
|
||||||
@@ -43,7 +32,6 @@ export function ServerStatus() {
|
|||||||
) : health ? (
|
) : health ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
|
||||||
<span className="text-sm">Connected</span>
|
<span className="text-sm">Connected</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { AlertCircle, Download, RefreshCw } from 'lucide-react';
|
||||||
import { RefreshCw, Download, CheckCircle2, AlertCircle } from 'lucide-react';
|
import { useEffect, useState } from 'react';
|
||||||
|
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 { Badge } from '@/components/ui/badge';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||||
import { getVersion } from '@tauri-apps/api/app';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
export function UpdateStatus() {
|
export function UpdateStatus() {
|
||||||
const { status, checkForUpdates, downloadAndInstall } = useAutoUpdater(false);
|
const platform = usePlatform();
|
||||||
|
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(() => {
|
||||||
getVersion()
|
platform.metadata
|
||||||
|
.getVersion()
|
||||||
.then(setCurrentVersion)
|
.then(setCurrentVersion)
|
||||||
.catch(() => setCurrentVersion('0.1.0'));
|
.catch(() => setCurrentVersion('Unknown'));
|
||||||
}, []);
|
}, [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>
|
||||||
@@ -26,74 +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.installing}
|
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.installing && (
|
|
||||||
<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" />
|
|
||||||
Install 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 gap-2 text-sm">
|
<AlertCircle className="h-4 w-4" />
|
||||||
<Download className="h-4 w-4" />
|
{status.error}
|
||||||
Downloading update...
|
</div>
|
||||||
</div>
|
)}
|
||||||
<Progress />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status.installing && (
|
{status.available && !status.downloading && !status.readyToInstall && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3 p-4 border rounded-lg bg-primary/5">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center justify-between">
|
||||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
<div>
|
||||||
Installing update...
|
<div className="font-semibold">Update Available</div>
|
||||||
</div>
|
<div className="text-sm text-muted-foreground">Version {status.version}</div>
|
||||||
<div className="text-xs text-muted-foreground">App will restart automatically</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.available && !status.checking && !status.error && status.checking === false && (
|
{status.downloading && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="space-y-2">
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
<div className="flex items-center justify-between text-sm">
|
||||||
You're up to date
|
<div className="flex items-center gap-2">
|
||||||
</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">
|
||||||
|
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 && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
You're up to date
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
||||||
|
import { GenerationSettings } from '@/components/ServerSettings/GenerationSettings';
|
||||||
|
import { GpuAcceleration } from '@/components/ServerSettings/GpuAcceleration';
|
||||||
|
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
||||||
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
|
export function ServerTab() {
|
||||||
|
const platform = usePlatform();
|
||||||
|
const isPlayerVisible = !!usePlayerStore((state) => state.audioUrl);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('overflow-y-auto flex flex-col', isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<ConnectionForm />
|
||||||
|
<GenerationSettings />
|
||||||
|
{platform.metadata.isTauri && <GpuAcceleration />}
|
||||||
|
{platform.metadata.isTauri && <UpdateStatus />}
|
||||||
|
</div>
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Created by{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/jamiepine"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-accent hover:underline"
|
||||||
|
>
|
||||||
|
Jamie Pine
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { motion, useAnimationFrame, useMotionValue, useTransform } from 'motion/react';
|
||||||
|
import type React from 'react';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface ShinyTextProps {
|
||||||
|
text: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
speed?: number;
|
||||||
|
className?: string;
|
||||||
|
color?: string;
|
||||||
|
shineColor?: string;
|
||||||
|
spread?: number;
|
||||||
|
yoyo?: boolean;
|
||||||
|
pauseOnHover?: boolean;
|
||||||
|
direction?: 'left' | 'right';
|
||||||
|
delay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShinyText: React.FC<ShinyTextProps> = ({
|
||||||
|
text,
|
||||||
|
disabled = false,
|
||||||
|
speed = 2,
|
||||||
|
className = '',
|
||||||
|
color = '#b5b5b5',
|
||||||
|
shineColor = '#ffffff',
|
||||||
|
spread = 120,
|
||||||
|
yoyo = false,
|
||||||
|
pauseOnHover = false,
|
||||||
|
direction = 'left',
|
||||||
|
delay = 0,
|
||||||
|
}) => {
|
||||||
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
const progress = useMotionValue(0);
|
||||||
|
const elapsedRef = useRef(0);
|
||||||
|
const lastTimeRef = useRef<number | null>(null);
|
||||||
|
const directionRef = useRef(direction === 'left' ? 1 : -1);
|
||||||
|
|
||||||
|
const animationDuration = speed * 1000;
|
||||||
|
const delayDuration = delay * 1000;
|
||||||
|
|
||||||
|
useAnimationFrame((time) => {
|
||||||
|
if (disabled || isPaused) {
|
||||||
|
lastTimeRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastTimeRef.current === null) {
|
||||||
|
lastTimeRef.current = time;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaTime = time - lastTimeRef.current;
|
||||||
|
lastTimeRef.current = time;
|
||||||
|
|
||||||
|
elapsedRef.current += deltaTime;
|
||||||
|
|
||||||
|
// Animation goes from 0 to 100
|
||||||
|
if (yoyo) {
|
||||||
|
const cycleDuration = animationDuration + delayDuration;
|
||||||
|
const fullCycle = cycleDuration * 2;
|
||||||
|
const cycleTime = elapsedRef.current % fullCycle;
|
||||||
|
|
||||||
|
if (cycleTime < animationDuration) {
|
||||||
|
// Forward animation: 0 -> 100
|
||||||
|
const p = (cycleTime / animationDuration) * 100;
|
||||||
|
progress.set(directionRef.current === 1 ? p : 100 - p);
|
||||||
|
} else if (cycleTime < cycleDuration) {
|
||||||
|
// Delay at end
|
||||||
|
progress.set(directionRef.current === 1 ? 100 : 0);
|
||||||
|
} else if (cycleTime < cycleDuration + animationDuration) {
|
||||||
|
// Reverse animation: 100 -> 0
|
||||||
|
const reverseTime = cycleTime - cycleDuration;
|
||||||
|
const p = 100 - (reverseTime / animationDuration) * 100;
|
||||||
|
progress.set(directionRef.current === 1 ? p : 100 - p);
|
||||||
|
} else {
|
||||||
|
// Delay at start
|
||||||
|
progress.set(directionRef.current === 1 ? 0 : 100);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const cycleDuration = animationDuration + delayDuration;
|
||||||
|
const cycleTime = elapsedRef.current % cycleDuration;
|
||||||
|
|
||||||
|
if (cycleTime < animationDuration) {
|
||||||
|
// Animation phase: 0 -> 100
|
||||||
|
const p = (cycleTime / animationDuration) * 100;
|
||||||
|
progress.set(directionRef.current === 1 ? p : 100 - p);
|
||||||
|
} else {
|
||||||
|
// Delay phase - hold at end (shine off-screen)
|
||||||
|
progress.set(directionRef.current === 1 ? 100 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
directionRef.current = direction === 'left' ? 1 : -1;
|
||||||
|
elapsedRef.current = 0;
|
||||||
|
progress.set(0);
|
||||||
|
// eslint-d, progress.setisable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [direction]);
|
||||||
|
|
||||||
|
// Transform: p=0 -> 150% (shine off right), p=100 -> -50% (shine off left)
|
||||||
|
const backgroundPosition = useTransform(progress, (p) => `${150 - p * 2}% center`);
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
if (pauseOnHover) setIsPaused(true);
|
||||||
|
}, [pauseOnHover]);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
if (pauseOnHover) setIsPaused(false);
|
||||||
|
}, [pauseOnHover]);
|
||||||
|
|
||||||
|
const gradientStyle: React.CSSProperties = {
|
||||||
|
backgroundImage: `linear-gradient(${spread}deg, ${color} 0%, ${color} 35%, ${shineColor} 50%, ${color} 65%, ${color} 100%)`,
|
||||||
|
backgroundSize: '200% auto',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
backgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.span
|
||||||
|
className={`inline-block ${className}`}
|
||||||
|
style={{ ...gradientStyle, backgroundPosition }}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</motion.span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShinyText;
|
||||||
|
// plugins: [],
|
||||||
|
// };
|
||||||
@@ -1,26 +1,45 @@
|
|||||||
import { Home, Settings } from 'lucide-react';
|
import { Link, useMatchRoute } from '@tanstack/react-router';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { AudioLines, Box, Mic, Server, Speaker, Volume2, Wand2 } from 'lucide-react';
|
||||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import { version } from '../../package.json';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
activeTab: string;
|
isMacOS?: boolean;
|
||||||
onTabChange: (tab: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'main', icon: Home, label: 'Main' },
|
{ id: 'main', path: '/', icon: Volume2, label: 'Generate' },
|
||||||
{ id: 'settings', icon: Settings, label: 'Settings' },
|
{ id: 'stories', path: '/stories', icon: AudioLines, label: 'Stories' },
|
||||||
|
{ 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: 'models', path: '/models', icon: Box, label: 'Models' },
|
||||||
|
{ id: 'server', path: '/server', icon: Server, label: 'Server' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar({ activeTab, onTabChange }: SidebarProps) {
|
export function Sidebar({ isMacOS }: SidebarProps) {
|
||||||
|
const matchRoute = useMatchRoute();
|
||||||
|
const isPlayerOpen = !!usePlayerStore((s) => s.audioUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed left-0 top-0 h-full w-20 bg-sidebar border-r border-border flex flex-col items-center py-6 gap-6">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed left-0 top-0 h-full w-20 bg-sidebar border-r border-border flex flex-col items-center py-6 gap-6',
|
||||||
|
isMacOS && 'pt-14',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<img
|
<img
|
||||||
src={voiceboxLogo}
|
src={voiceboxLogo}
|
||||||
alt="Voicebox"
|
alt="Voicebox"
|
||||||
className="w-12 h-12 object-contain"
|
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>
|
||||||
|
|
||||||
@@ -28,26 +47,46 @@ export function Sidebar({ activeTab, onTabChange }: SidebarProps) {
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
const isActive = activeTab === tab.id;
|
// For index route, use exact match; for others, use default matching
|
||||||
|
const isActive =
|
||||||
|
tab.path === '/' ? matchRoute({ to: '/', exact: true }) : matchRoute({ to: tab.path });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Link
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
type="button"
|
to={tab.path}
|
||||||
onClick={() => onTabChange(tab.id)}
|
|
||||||
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-accent hover:text-accent-foreground',
|
isActive
|
||||||
isActive ? 'bg-accent text-accent-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 && (
|
||||||
</button>
|
<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) / 0.5)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Icon className="h-5 w-5 relative z-10" />
|
||||||
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Version */}
|
||||||
|
<div
|
||||||
|
className="mt-auto text-[10px] text-muted-foreground/50 transition-all duration-300"
|
||||||
|
style={{ paddingBottom: isPlayerOpen ? '7rem' : undefined }}
|
||||||
|
>
|
||||||
|
v{version}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import { StoryContent } from './StoryContent';
|
||||||
|
import { StoryList } from './StoryList';
|
||||||
|
|
||||||
|
export function StoriesTab() {
|
||||||
|
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||||
|
{/* Main content area */}
|
||||||
|
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden relative">
|
||||||
|
{/* Left Column - Story List */}
|
||||||
|
<div className="flex flex-col min-h-0 overflow-hidden w-full max-w-[360px] shrink-0">
|
||||||
|
<StoryList />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Story Content */}
|
||||||
|
<div className="flex flex-col min-h-0 overflow-hidden flex-1">
|
||||||
|
<StoryContent />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Generate Box - position is managed via storyStore.trackEditorHeight */}
|
||||||
|
<FloatingGenerateBox showVoiceSelector isPlayerOpen={!!audioUrl} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { GripVertical, Mic, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import type { StoryItemDetail } from '@/lib/api/types';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
|
interface StoryChatItemProps {
|
||||||
|
item: StoryItemDetail;
|
||||||
|
storyId: string;
|
||||||
|
index: number;
|
||||||
|
onRemove: () => void;
|
||||||
|
currentTimeMs: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
dragHandleProps?: React.HTMLAttributes<HTMLButtonElement>;
|
||||||
|
isDragging?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StoryChatItem({
|
||||||
|
item,
|
||||||
|
onRemove,
|
||||||
|
currentTimeMs,
|
||||||
|
isPlaying,
|
||||||
|
dragHandleProps,
|
||||||
|
isDragging,
|
||||||
|
}: StoryChatItemProps) {
|
||||||
|
const seek = useStoryStore((state) => state.seek);
|
||||||
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
|
const [avatarError, setAvatarError] = useState(false);
|
||||||
|
|
||||||
|
const avatarUrl = `${serverUrl}/profiles/${item.profile_id}/avatar`;
|
||||||
|
|
||||||
|
// Check if this item is currently playing based on timecode
|
||||||
|
const itemStartMs = item.start_time_ms;
|
||||||
|
const itemEndMs = item.start_time_ms + item.duration * 1000;
|
||||||
|
const isCurrentlyPlaying = isPlaying && currentTimeMs >= itemStartMs && currentTimeMs < itemEndMs;
|
||||||
|
|
||||||
|
const handlePlay = () => {
|
||||||
|
// Seek to the start of this item
|
||||||
|
seek(itemStartMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (ms: number): string => {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
const milliseconds = Math.floor((ms % 1000) / 100);
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-3 p-4 rounded-lg border transition-colors',
|
||||||
|
isCurrentlyPlaying && 'bg-muted/70 border-primary',
|
||||||
|
!isCurrentlyPlaying && 'hover:bg-muted/50',
|
||||||
|
isDragging && 'opacity-50 shadow-lg',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Drag Handle */}
|
||||||
|
{dragHandleProps && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 cursor-grab active:cursor-grabbing touch-none text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
{...dragHandleProps}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Voice Avatar */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center overflow-hidden">
|
||||||
|
{!avatarError ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={`${item.profile_name} avatar`}
|
||||||
|
className={cn(
|
||||||
|
'h-full w-full object-cover transition-all duration-200',
|
||||||
|
!isCurrentlyPlaying && 'grayscale'
|
||||||
|
)}
|
||||||
|
onError={() => setAvatarError(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Mic className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="font-medium text-sm">{item.profile_name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{item.language}</span>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums ml-auto">
|
||||||
|
{formatTime(itemStartMs)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={item.text}
|
||||||
|
className="flex-1 resize-none text-sm text-muted-foreground select-text bg-card cursor-text"
|
||||||
|
readOnly
|
||||||
|
onDoubleClick={handlePlay}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Actions">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={handlePlay}>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Play from here
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onRemove} className="text-destructive focus:text-destructive">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Remove from Story
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sortable wrapper component
|
||||||
|
export function SortableStoryChatItem(props: Omit<StoryChatItemProps, 'dragHandleProps' | 'isDragging'>) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: props.item.generation_id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} {...attributes}>
|
||||||
|
<StoryChatItem
|
||||||
|
{...props}
|
||||||
|
dragHandleProps={listeners}
|
||||||
|
isDragging={isDragging}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
type DragEndEvent,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { Download, Plus } from 'lucide-react';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import Loader from 'react-loaders';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { useHistory } from '@/lib/hooks/useHistory';
|
||||||
|
import {
|
||||||
|
useAddStoryItem,
|
||||||
|
useExportStoryAudio,
|
||||||
|
useRemoveStoryItem,
|
||||||
|
useReorderStoryItems,
|
||||||
|
useStory,
|
||||||
|
} from '@/lib/hooks/useStories';
|
||||||
|
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
import { SortableStoryChatItem } from './StoryChatItem';
|
||||||
|
|
||||||
|
export function StoryContent() {
|
||||||
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
|
const { data: story, isLoading } = useStory(selectedStoryId);
|
||||||
|
const removeItem = useRemoveStoryItem();
|
||||||
|
const reorderItems = useReorderStoryItems();
|
||||||
|
const exportAudio = useExportStoryAudio();
|
||||||
|
const addStoryItem = useAddStoryItem();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
|
||||||
|
|
||||||
|
// Add generation popover state
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||||
|
const { data: historyData } = useHistory();
|
||||||
|
|
||||||
|
// Filter generations not in story and matching search
|
||||||
|
const availableGenerations = useMemo(() => {
|
||||||
|
if (!historyData?.items || !story) return [];
|
||||||
|
const storyGenerationIds = new Set(story.items.map((i) => i.generation_id));
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return historyData.items.filter(
|
||||||
|
(gen) =>
|
||||||
|
gen.status === 'completed' &&
|
||||||
|
!storyGenerationIds.has(gen.id) &&
|
||||||
|
(gen.text.toLowerCase().includes(query) || gen.profile_name.toLowerCase().includes(query)),
|
||||||
|
);
|
||||||
|
}, [historyData, story, searchQuery]);
|
||||||
|
|
||||||
|
// Get track editor height from store for dynamic padding
|
||||||
|
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||||
|
|
||||||
|
// Track editor is shown when story has items
|
||||||
|
const hasBottomBar = story && story.items.length > 0;
|
||||||
|
|
||||||
|
// Calculate dynamic bottom padding: track editor + gap
|
||||||
|
const bottomPadding = hasBottomBar ? trackEditorHeight + 24 : 0;
|
||||||
|
|
||||||
|
// Drag and drop sensors
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Playback state (for auto-scroll and item highlighting)
|
||||||
|
const isPlaying = useStoryStore((state) => state.isPlaying);
|
||||||
|
const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
|
||||||
|
const playbackStoryId = useStoryStore((state) => state.playbackStoryId);
|
||||||
|
|
||||||
|
// Refs for auto-scrolling to playing item
|
||||||
|
const itemRefsMap = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
|
const lastScrolledItemRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Use playback hook
|
||||||
|
useStoryPlayback(story?.items);
|
||||||
|
|
||||||
|
// Sort items by start_time_ms
|
||||||
|
const sortedItems = useMemo(() => {
|
||||||
|
if (!story?.items) return [];
|
||||||
|
return [...story.items].sort((a, b) => a.start_time_ms - b.start_time_ms);
|
||||||
|
}, [story?.items]);
|
||||||
|
|
||||||
|
// Find the currently playing item based on timecode
|
||||||
|
const currentlyPlayingItemId = useMemo(() => {
|
||||||
|
if (!isPlaying || playbackStoryId !== story?.id || !sortedItems.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const playingItem = sortedItems.find((item) => {
|
||||||
|
const itemStart = item.start_time_ms;
|
||||||
|
const itemEnd = item.start_time_ms + item.duration * 1000;
|
||||||
|
return currentTimeMs >= itemStart && currentTimeMs < itemEnd;
|
||||||
|
});
|
||||||
|
return playingItem?.generation_id ?? null;
|
||||||
|
}, [isPlaying, playbackStoryId, story?.id, sortedItems, currentTimeMs]);
|
||||||
|
|
||||||
|
// Auto-scroll to the currently playing item
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentlyPlayingItemId || currentlyPlayingItemId === lastScrolledItemRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = itemRefsMap.current.get(currentlyPlayingItemId);
|
||||||
|
if (element && scrollRef.current) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
lastScrolledItemRef.current = currentlyPlayingItemId;
|
||||||
|
}
|
||||||
|
}, [currentlyPlayingItemId]);
|
||||||
|
|
||||||
|
// Reset last scrolled item when playback stops
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying) {
|
||||||
|
lastScrolledItemRef.current = null;
|
||||||
|
}
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
const handleRemoveItem = (itemId: string) => {
|
||||||
|
if (!story) return;
|
||||||
|
|
||||||
|
removeItem.mutate(
|
||||||
|
{
|
||||||
|
storyId: story.id,
|
||||||
|
itemId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to remove item',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (!story || !over || active.id === over.id) return;
|
||||||
|
|
||||||
|
const oldIndex = sortedItems.findIndex((item) => item.generation_id === active.id);
|
||||||
|
const newIndex = sortedItems.findIndex((item) => item.generation_id === over.id);
|
||||||
|
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return;
|
||||||
|
|
||||||
|
// Calculate the new order
|
||||||
|
const newOrder = arrayMove(sortedItems, oldIndex, newIndex);
|
||||||
|
const generationIds = newOrder.map((item) => item.generation_id);
|
||||||
|
|
||||||
|
// Send reorder request to backend
|
||||||
|
reorderItems.mutate(
|
||||||
|
{
|
||||||
|
storyId: story.id,
|
||||||
|
data: { generation_ids: generationIds },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to reorder items',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportAudio = () => {
|
||||||
|
if (!story) return;
|
||||||
|
|
||||||
|
exportAudio.mutate(
|
||||||
|
{
|
||||||
|
storyId: story.id,
|
||||||
|
storyName: story.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to export audio',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddGeneration = (generationId: string) => {
|
||||||
|
if (!story) return;
|
||||||
|
|
||||||
|
addStoryItem.mutate(
|
||||||
|
{
|
||||||
|
storyId: story.id,
|
||||||
|
data: { generation_id: generationId },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsAddOpen(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to add generation',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!selectedStoryId) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-lg font-medium mb-2">Select a story</p>
|
||||||
|
<p className="text-sm">Choose a story from the list to view its content</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-muted-foreground">Loading story...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!story) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-lg font-medium mb-2">Story not found</p>
|
||||||
|
<p className="text-sm">The selected story could not be loaded</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4 px-1">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold">{story.name}</h2>
|
||||||
|
{story.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 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}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80 p-0" align="end">
|
||||||
|
<div className="p-2 border-b">
|
||||||
|
<Input
|
||||||
|
placeholder="Search by name or transcript..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-60 overflow-y-auto">
|
||||||
|
{availableGenerations.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
{searchQuery ? 'No matching generations found' : 'No available generations'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
availableGenerations.map((gen) => (
|
||||||
|
<button
|
||||||
|
key={gen.id}
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left px-3 py-2 hover:bg-muted transition-colors border-b last:border-b-0"
|
||||||
|
onClick={() => handleAddGeneration(gen.id)}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm">{gen.profile_name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{gen.text.length > 50 ? `${gen.text.substring(0, 50)}...` : gen.text}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
{story.items.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleExportAudio}
|
||||||
|
disabled={exportAudio.isPending}
|
||||||
|
>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Export Audio
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex-1 min-h-0 overflow-y-auto space-y-3"
|
||||||
|
style={{ paddingBottom: bottomPadding > 0 ? `${bottomPadding}px` : undefined }}
|
||||||
|
>
|
||||||
|
{sortedItems.length === 0 ? (
|
||||||
|
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-md text-muted-foreground">
|
||||||
|
<p className="text-sm">No items in this story</p>
|
||||||
|
<p className="text-xs mt-2">Generate speech using the box below to add items</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={sortedItems.map((item) => item.generation_id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sortedItems.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) {
|
||||||
|
itemRefsMap.current.set(item.generation_id, el);
|
||||||
|
} else {
|
||||||
|
itemRefsMap.current.delete(item.generation_id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SortableStoryChatItem
|
||||||
|
item={item}
|
||||||
|
storyId={story.id}
|
||||||
|
index={index}
|
||||||
|
onRemove={() => handleRemoveItem(item.id)}
|
||||||
|
currentTimeMs={currentTimeMs}
|
||||||
|
isPlaying={isPlaying && playbackStoryId === story.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import {
|
||||||
|
useCreateStory,
|
||||||
|
useDeleteStory,
|
||||||
|
useStories,
|
||||||
|
useStory,
|
||||||
|
useUpdateStory,
|
||||||
|
} from '@/lib/hooks/useStories';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { formatDate } from '@/lib/utils/format';
|
||||||
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
|
||||||
|
export function StoryList() {
|
||||||
|
const { data: stories, isLoading } = useStories();
|
||||||
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
|
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
|
||||||
|
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||||
|
const { data: selectedStory } = useStory(selectedStoryId);
|
||||||
|
const createStory = useCreateStory();
|
||||||
|
const updateStory = useUpdateStory();
|
||||||
|
const deleteStory = useDeleteStory();
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [editingStory, setEditingStory] = useState<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [deletingStoryId, setDeletingStoryId] = useState<string | null>(null);
|
||||||
|
const [newStoryName, setNewStoryName] = useState('');
|
||||||
|
const [newStoryDescription, setNewStoryDescription] = useState('');
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// 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 = () => {
|
||||||
|
if (!newStoryName.trim()) {
|
||||||
|
toast({
|
||||||
|
title: 'Name required',
|
||||||
|
description: 'Please enter a story name',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createStory.mutate(
|
||||||
|
{
|
||||||
|
name: newStoryName.trim(),
|
||||||
|
description: newStoryDescription.trim() || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (story) => {
|
||||||
|
setSelectedStoryId(story.id);
|
||||||
|
setCreateDialogOpen(false);
|
||||||
|
setNewStoryName('');
|
||||||
|
setNewStoryDescription('');
|
||||||
|
toast({
|
||||||
|
title: 'Story created',
|
||||||
|
description: `"${story.name}" has been created`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to create story',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditClick = (story: { id: string; name: string; description?: string }) => {
|
||||||
|
setEditingStory(story);
|
||||||
|
setNewStoryName(story.name);
|
||||||
|
setNewStoryDescription(story.description || '');
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateStory = () => {
|
||||||
|
if (!editingStory || !newStoryName.trim()) {
|
||||||
|
toast({
|
||||||
|
title: 'Name required',
|
||||||
|
description: 'Please enter a story name',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStory.mutate(
|
||||||
|
{
|
||||||
|
storyId: editingStory.id,
|
||||||
|
data: {
|
||||||
|
name: newStoryName.trim(),
|
||||||
|
description: newStoryDescription.trim() || undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setEditDialogOpen(false);
|
||||||
|
setEditingStory(null);
|
||||||
|
setNewStoryName('');
|
||||||
|
setNewStoryDescription('');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to update story',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (storyId: string) => {
|
||||||
|
setDeletingStoryId(storyId);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
if (!deletingStoryId) return;
|
||||||
|
|
||||||
|
deleteStory.mutate(deletingStoryId, {
|
||||||
|
onSuccess: () => {
|
||||||
|
// Clear selection if deleting the currently selected story
|
||||||
|
if (selectedStoryId === deletingStoryId) {
|
||||||
|
setSelectedStoryId(null);
|
||||||
|
}
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setDeletingStoryId(null);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to delete story',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-muted-foreground">Loading stories...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storyList = stories || [];
|
||||||
|
const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full 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 */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 z-20">
|
||||||
|
<div className="flex items-center justify-between mb-4 px-1">
|
||||||
|
<h2 className="text-2xl font-bold">Stories</h2>
|
||||||
|
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New Story
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Story List */}
|
||||||
|
<div
|
||||||
|
className="flex-1 overflow-y-auto pt-14 relative z-0"
|
||||||
|
style={{ paddingBottom: hasTrackEditor ? `${trackEditorHeight + 140}px` : '170px' }}
|
||||||
|
>
|
||||||
|
{storyList.length === 0 ? (
|
||||||
|
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
||||||
|
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||||
|
<p className="text-sm">No stories yet</p>
|
||||||
|
<p className="text-xs mt-2">Create your first story to get started</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{storyList.map((story) => (
|
||||||
|
<div
|
||||||
|
key={story.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className={cn(
|
||||||
|
'px-5 py-3 rounded-lg transition-colors group flex items-center cursor-pointer',
|
||||||
|
selectedStoryId === story.id ? 'bg-muted' : 'hover:bg-muted/50',
|
||||||
|
)}
|
||||||
|
aria-label={`Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}`}
|
||||||
|
aria-pressed={selectedStoryId === story.id}
|
||||||
|
onClick={() => setSelectedStoryId(story.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.target !== e.currentTarget) return;
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedStoryId(story.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2 w-full min-w-0">
|
||||||
|
<div className="flex-1 min-w-0 text-left overflow-hidden">
|
||||||
|
<h3 className="text-sm font-medium truncate">{story.name}</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{story.item_count} {story.item_count === 1 ? 'item' : 'items'}
|
||||||
|
</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{formatDate(story.updated_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={`Actions for ${story.name}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => handleEditClick(story)}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDeleteClick(story.id)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Story Dialog */}
|
||||||
|
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Story</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new story to organize your voice generations into conversations.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="story-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="story-name"
|
||||||
|
placeholder="My Story"
|
||||||
|
value={newStoryName}
|
||||||
|
onChange={(e) => setNewStoryName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleCreateStory();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="story-description">Description (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="story-description"
|
||||||
|
placeholder="A conversation between..."
|
||||||
|
value={newStoryDescription}
|
||||||
|
onChange={(e) => setNewStoryDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreateStory} disabled={createStory.isPending}>
|
||||||
|
{createStory.isPending ? 'Creating...' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit Story Dialog */}
|
||||||
|
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Story</DialogTitle>
|
||||||
|
<DialogDescription>Update the story name and description.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-story-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-story-name"
|
||||||
|
placeholder="My Story"
|
||||||
|
value={newStoryName}
|
||||||
|
onChange={(e) => setNewStoryName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleUpdateStory();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-story-description">Description (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="edit-story-description"
|
||||||
|
placeholder="A conversation between..."
|
||||||
|
value={newStoryDescription}
|
||||||
|
onChange={(e) => setNewStoryDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleUpdateStory} disabled={updateStory.isPending}>
|
||||||
|
{updateStory.isPending ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Story Confirmation Dialog */}
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete the story and all its items. This action cannot be
|
||||||
|
undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction asChild>
|
||||||
|
<Button
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
disabled={deleteStory.isPending}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{deleteStory.isPending ? 'Deleting...' : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
|||||||
|
const isWindows = navigator.userAgent.includes('Windows');
|
||||||
|
|
||||||
|
export function TitleBarDragRegion() {
|
||||||
|
if (isWindows) return null;
|
||||||
|
|
||||||
|
return <div data-tauri-drag-region className="fixed top-0 left-0 right-0 h-12 z-[9999]" />;
|
||||||
|
}
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { useAutoUpdater } from '../hooks/useAutoUpdater';
|
|
||||||
import { Button } from './ui/button';
|
|
||||||
import { Card } from './ui/card';
|
|
||||||
import { Progress } from './ui/progress';
|
|
||||||
|
|
||||||
export function UpdateNotification() {
|
|
||||||
const { status, checkForUpdates, downloadAndInstall } = useAutoUpdater(true);
|
|
||||||
|
|
||||||
if (status.error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!status.available && !status.checking) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.checking) {
|
|
||||||
return (
|
|
||||||
<Card className="p-4 mb-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full" />
|
|
||||||
<span className="text-sm">Checking for updates...</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.available) {
|
|
||||||
return (
|
|
||||||
<Card className="p-4 mb-4 border-primary">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold">Update Available</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Version {status.version} is ready to install
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{status.downloading && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm">Downloading update...</p>
|
|
||||||
<Progress />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status.installing && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm">Installing update...</p>
|
|
||||||
<p className="text-xs text-muted-foreground">App will restart automatically</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!status.downloading && !status.installing && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={downloadAndInstall} size="sm">
|
|
||||||
Install Now
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => window.location.reload()} variant="outline" size="sm">
|
|
||||||
Later
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# Voice profile management components
|
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { Mic, Pause, Play, Square } from 'lucide-react';
|
||||||
|
import { memo, useEffect, useState } from 'react';
|
||||||
|
import { Visualizer } from 'react-sound-visualizer';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||||
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
|
|
||||||
|
const MemoizedWaveform = memo(function MemoizedWaveform({
|
||||||
|
audioStream,
|
||||||
|
}: {
|
||||||
|
audioStream: MediaStream;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 pointer-events-none flex items-center justify-center opacity-30">
|
||||||
|
<Visualizer audio={audioStream} autoStart strokeColor="#b39a3d">
|
||||||
|
{({ canvasRef }) => (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={500}
|
||||||
|
height={150}
|
||||||
|
className="w-full h-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Visualizer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface AudioSampleRecordingProps {
|
||||||
|
file: File | null | undefined;
|
||||||
|
isRecording: boolean;
|
||||||
|
duration: number;
|
||||||
|
onStart: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onTranscribe: () => void;
|
||||||
|
onPlayPause: () => void;
|
||||||
|
isPlaying: boolean;
|
||||||
|
isTranscribing?: boolean;
|
||||||
|
showWaveform?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioSampleRecording({
|
||||||
|
file,
|
||||||
|
isRecording,
|
||||||
|
duration,
|
||||||
|
onStart,
|
||||||
|
onStop,
|
||||||
|
onCancel,
|
||||||
|
onTranscribe,
|
||||||
|
onPlayPause,
|
||||||
|
isPlaying,
|
||||||
|
isTranscribing = false,
|
||||||
|
showWaveform = true,
|
||||||
|
}: AudioSampleRecordingProps) {
|
||||||
|
const [audioStream, setAudioStream] = useState<MediaStream | null>(null);
|
||||||
|
|
||||||
|
// Request microphone access when component mounts
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showWaveform) return;
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return;
|
||||||
|
|
||||||
|
let stream: MediaStream | null = null;
|
||||||
|
|
||||||
|
navigator.mediaDevices
|
||||||
|
.getUserMedia({ audio: true, video: false })
|
||||||
|
.then((s) => {
|
||||||
|
stream = s;
|
||||||
|
setAudioStream(s);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('Could not access microphone for visualization:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach((track) => {
|
||||||
|
track.stop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [showWaveform]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!isRecording && !file && (
|
||||||
|
<div className="relative flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px] overflow-hidden">
|
||||||
|
{showWaveform && audioStream && (
|
||||||
|
<MemoizedWaveform audioStream={audioStream} />
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onStart}
|
||||||
|
size="lg"
|
||||||
|
className="relative z-10 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Mic className="h-5 w-5" />
|
||||||
|
Start Recording
|
||||||
|
</Button>
|
||||||
|
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
||||||
|
Click to start recording. Maximum duration: 30 seconds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRecording && (
|
||||||
|
<div className="relative flex flex-col items-center justify-center gap-4 p-4 border-2 border-accent rounded-lg bg-accent/5 min-h-[180px] overflow-hidden">
|
||||||
|
{showWaveform && audioStream && (
|
||||||
|
<MemoizedWaveform audioStream={audioStream} />
|
||||||
|
)}
|
||||||
|
<div className="relative z-10 flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-accent animate-pulse" />
|
||||||
|
<span className="text-lg font-mono font-semibold">
|
||||||
|
{formatAudioDuration(duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onStop}
|
||||||
|
className="relative z-10 flex items-center gap-2 bg-accent text-accent-foreground hover:bg-accent/90"
|
||||||
|
>
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
Stop Recording
|
||||||
|
</Button>
|
||||||
|
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
||||||
|
{formatAudioDuration(30 - duration)} remaining
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{file && !isRecording && (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mic className="h-5 w-5 text-primary" />
|
||||||
|
<span className="font-medium">Recording complete</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onPlayPause}
|
||||||
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onTranscribe}
|
||||||
|
disabled={isTranscribing}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Mic className="h-4 w-4" />
|
||||||
|
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
Record Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { Mic, Monitor, Pause, Play, Square } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||||
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
|
|
||||||
|
interface AudioSampleSystemProps {
|
||||||
|
file: File | null | undefined;
|
||||||
|
isRecording: boolean;
|
||||||
|
duration: number;
|
||||||
|
onStart: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onTranscribe: () => void;
|
||||||
|
onPlayPause: () => void;
|
||||||
|
isPlaying: boolean;
|
||||||
|
isTranscribing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioSampleSystem({
|
||||||
|
file,
|
||||||
|
isRecording,
|
||||||
|
duration,
|
||||||
|
onStart,
|
||||||
|
onStop,
|
||||||
|
onCancel,
|
||||||
|
onTranscribe,
|
||||||
|
onPlayPause,
|
||||||
|
isPlaying,
|
||||||
|
isTranscribing = false,
|
||||||
|
}: AudioSampleSystemProps) {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!isRecording && !file && (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
||||||
|
<Button type="button" onClick={onStart} size="lg" className="flex items-center gap-2">
|
||||||
|
<Monitor className="h-5 w-5" />
|
||||||
|
Start Capture
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Capture audio from your system. Maximum duration: 30 seconds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRecording && (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-destructive rounded-lg bg-destructive/5 min-h-[180px]">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
|
||||||
|
<span className="text-lg font-mono font-semibold">
|
||||||
|
{formatAudioDuration(duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onStop}
|
||||||
|
variant="destructive"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
Stop Capture
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
{formatAudioDuration(30 - duration)} remaining
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{file && !isRecording && (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Monitor className="h-5 w-5 text-primary" />
|
||||||
|
<span className="font-medium">Capture complete</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onPlayPause}
|
||||||
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onTranscribe}
|
||||||
|
disabled={isTranscribing}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Mic className="h-4 w-4" />
|
||||||
|
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
Capture Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { Mic, Pause, Play, Upload } from 'lucide-react';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||||
|
|
||||||
|
interface AudioSampleUploadProps {
|
||||||
|
file: File | null | undefined;
|
||||||
|
onFileChange: (file: File | undefined) => void;
|
||||||
|
onTranscribe: () => void;
|
||||||
|
onPlayPause: () => void;
|
||||||
|
isPlaying: boolean;
|
||||||
|
isValidating?: boolean;
|
||||||
|
isTranscribing?: boolean;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
fieldName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioSampleUpload({
|
||||||
|
file,
|
||||||
|
onFileChange,
|
||||||
|
onTranscribe,
|
||||||
|
onPlayPause,
|
||||||
|
isPlaying,
|
||||||
|
isValidating = false,
|
||||||
|
isTranscribing = false,
|
||||||
|
isDisabled = false,
|
||||||
|
fieldName,
|
||||||
|
}: AudioSampleUploadProps) {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="audio/*"
|
||||||
|
name={fieldName}
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={(e) => {
|
||||||
|
const selectedFile = e.target.files?.[0];
|
||||||
|
if (selectedFile) {
|
||||||
|
onFileChange(selectedFile);
|
||||||
|
} else {
|
||||||
|
onFileChange(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
const droppedFile = e.dataTransfer.files?.[0];
|
||||||
|
if (droppedFile?.type.startsWith('audio/')) {
|
||||||
|
onFileChange(droppedFile);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`flex flex-col items-center justify-center gap-4 p-4 border-2 rounded-lg transition-colors min-h-[180px] ${
|
||||||
|
file
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: isDragging
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-dashed border-muted-foreground/25 hover:border-muted-foreground/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!file ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Upload className="h-5 w-5" />
|
||||||
|
Choose File
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Click to choose a file or drag and drop. Maximum duration: 30 seconds.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Upload className="h-5 w-5 text-primary" />
|
||||||
|
<span className="font-medium">File uploaded</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onPlayPause}
|
||||||
|
disabled={isValidating}
|
||||||
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onTranscribe}
|
||||||
|
disabled={isTranscribing || isValidating || isDisabled}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Mic className="h-4 w-4" />
|
||||||
|
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
onFileChange(undefined);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Edit, Eye, 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';
|
||||||
@@ -13,19 +13,19 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||||
import { useDeleteProfile } from '@/lib/hooks/useProfiles';
|
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
import { ProfileDetail } from './ProfileDetail';
|
|
||||||
|
|
||||||
interface ProfileCardProps {
|
interface ProfileCardProps {
|
||||||
profile: VoiceProfileResponse;
|
profile: VoiceProfileResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileCard({ profile }: ProfileCardProps) {
|
export function ProfileCard({ profile }: ProfileCardProps) {
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
|
||||||
const deleteProfile = useDeleteProfile();
|
const deleteProfile = useDeleteProfile();
|
||||||
|
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);
|
||||||
@@ -52,20 +52,40 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
|||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExport = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
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">
|
|
||||||
<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>
|
||||||
@@ -73,19 +93,20 @@ 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
|
||||||
icon={Eye}
|
icon={Download}
|
||||||
onClick={(e) => {
|
onClick={handleExport}
|
||||||
e.stopPropagation();
|
disabled={exportProfile.isPending}
|
||||||
setDetailOpen(true);
|
aria-label="Export profile"
|
||||||
}}
|
|
||||||
aria-label="View details"
|
|
||||||
/>
|
/>
|
||||||
<CircleButton
|
<CircleButton
|
||||||
icon={Edit}
|
icon={Edit}
|
||||||
@@ -105,8 +126,6 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<ProfileDetail profileId={profile.id} open={detailOpen} onOpenChange={setDetailOpen} />
|
|
||||||
|
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { useProfile } from '@/lib/hooks/useProfiles';
|
|
||||||
import { formatDate } from '@/lib/utils/format';
|
|
||||||
import { SampleList } from './SampleList';
|
|
||||||
|
|
||||||
interface ProfileDetailProps {
|
|
||||||
profileId: string;
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProfileDetail({ profileId, open, onOpenChange }: ProfileDetailProps) {
|
|
||||||
const { data: profile, isLoading } = useProfile(profileId);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<div className="text-muted-foreground">Loading profile...</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!profile) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{profile.name}</DialogTitle>
|
|
||||||
<DialogDescription>Manage samples and view profile details</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{profile.description && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium mb-1">Description</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">{profile.description}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Badge variant="outline">{profile.language}</Badge>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Created {formatDate(profile.created_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<SampleList profileId={profileId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -11,11 +11,7 @@ export function ProfileList() {
|
|||||||
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return null;
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<div className="text-muted-foreground">Loading profiles...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -30,14 +26,6 @@ export function ProfileList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center justify-between mb-4 shrink-0">
|
|
||||||
<h2 className="text-2xl font-bold">Voicebox</h2>
|
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
|
||||||
<Sparkles className="mr-2 h-4 w-4" />
|
|
||||||
New Profile
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
{allProfiles.length === 0 ? (
|
{allProfiles.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -48,14 +36,16 @@ export function ProfileList() {
|
|||||||
</p>
|
</p>
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
<Button onClick={() => setDialogOpen(true)}>
|
||||||
<Sparkles className="mr-2 h-4 w-4" />
|
<Sparkles className="mr-2 h-4 w-4" />
|
||||||
Create Profile
|
Create Voice
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 grid-cols-3 auto-rows-auto p-1">
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,145 @@
|
|||||||
import { Plus, Trash2, Play } from 'lucide-react';
|
import { Check, Edit, Pause, Play, Plus, Trash2, Volume2, X } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { CircleButton } from '@/components/ui/circle-button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { useDeleteSample, useProfileSamples } from '@/lib/hooks/useProfiles';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useDeleteSample, useProfileSamples, useUpdateSample } from '@/lib/hooks/useProfiles';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { SampleUpload } from './SampleUpload';
|
import { SampleUpload } from './SampleUpload';
|
||||||
|
|
||||||
|
interface MiniSamplePlayerProps {
|
||||||
|
audioUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = new Audio(audioUrl);
|
||||||
|
audioRef.current = audio;
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
setDuration(audio.duration);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
setCurrentTime(audio.currentTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnded = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlay = () => setIsPlaying(true);
|
||||||
|
const handlePause = () => setIsPlaying(false);
|
||||||
|
|
||||||
|
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
audio.addEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
audio.addEventListener('ended', handleEnded);
|
||||||
|
audio.addEventListener('play', handlePlay);
|
||||||
|
audio.addEventListener('pause', handlePause);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.pause();
|
||||||
|
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
audio.removeEventListener('ended', handleEnded);
|
||||||
|
audio.removeEventListener('play', handlePlay);
|
||||||
|
audio.removeEventListener('pause', handlePause);
|
||||||
|
audio.src = '';
|
||||||
|
};
|
||||||
|
}, [audioUrl]);
|
||||||
|
|
||||||
|
const handlePlayPause = () => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
} else {
|
||||||
|
audioRef.current.play();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeek = (value: number[]) => {
|
||||||
|
if (!audioRef.current || duration === 0) return;
|
||||||
|
const progress = value[0] / 100;
|
||||||
|
audioRef.current.currentTime = progress * duration;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.currentTime = 0;
|
||||||
|
}
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t bg-muted/30 px-3 py-2 mt-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
disabled={isLoading}
|
||||||
|
aria-label={isPlaying ? 'Pause sample' : 'Play sample'}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5 ml-0.5" />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||||
|
<Slider
|
||||||
|
value={duration > 0 ? [(currentTime / duration) * 100] : [0]}
|
||||||
|
onValueChange={handleSeek}
|
||||||
|
max={100}
|
||||||
|
step={0.1}
|
||||||
|
className="flex-1"
|
||||||
|
aria-label="Sample playback position"
|
||||||
|
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 min-w-[70px]">
|
||||||
|
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="font-mono">{formatAudioDuration(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={handleStop}
|
||||||
|
title="Stop"
|
||||||
|
aria-label="Stop playback"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface SampleListProps {
|
interface SampleListProps {
|
||||||
profileId: string;
|
profileId: string;
|
||||||
}
|
}
|
||||||
@@ -14,22 +147,62 @@ interface SampleListProps {
|
|||||||
export function SampleList({ profileId }: SampleListProps) {
|
export function SampleList({ profileId }: SampleListProps) {
|
||||||
const { data: samples, isLoading } = useProfileSamples(profileId);
|
const { data: samples, isLoading } = useProfileSamples(profileId);
|
||||||
const deleteSample = useDeleteSample();
|
const deleteSample = useDeleteSample();
|
||||||
const [uploadOpen, setUploadOpen] = useState(false);
|
const updateSample = useUpdateSample();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
const setAudio = usePlayerStore((state) => state.setAudio);
|
const [editingSampleId, setEditingSampleId] = useState<string | null>(null);
|
||||||
const currentAudioId = usePlayerStore((state) => state.audioId);
|
const [editedText, setEditedText] = useState<string>('');
|
||||||
const isPlaying = usePlayerStore((state) => state.isPlaying);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [sampleToDelete, setSampleToDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleDelete = (sampleId: string) => {
|
const handleDeleteClick = (sampleId: string) => {
|
||||||
if (confirm('Are you sure you want to delete this sample?')) {
|
setSampleToDelete(sampleId);
|
||||||
deleteSample.mutate(sampleId);
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
if (sampleToDelete) {
|
||||||
|
deleteSample.mutate(sampleToDelete);
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setSampleToDelete(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePlay = (audioPath: string, referenceText: string, sampleId: string) => {
|
const handleStartEdit = (sampleId: string, currentText: string) => {
|
||||||
const audioUrl = `${serverUrl}${audioPath}`;
|
setEditingSampleId(sampleId);
|
||||||
setAudio(audioUrl, sampleId, referenceText.substring(0, 50));
|
setEditedText(currentText);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditingSampleId(null);
|
||||||
|
setEditedText('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async (sampleId: string) => {
|
||||||
|
if (!editedText.trim()) {
|
||||||
|
toast({
|
||||||
|
title: 'Invalid text',
|
||||||
|
description: 'Reference text cannot be empty.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateSample.mutateAsync({ sampleId, referenceText: editedText.trim() });
|
||||||
|
toast({
|
||||||
|
title: 'Sample updated',
|
||||||
|
description: 'Reference text has been updated successfully.',
|
||||||
|
});
|
||||||
|
setEditingSampleId(null);
|
||||||
|
setEditedText('');
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Update failed',
|
||||||
|
description: error instanceof Error ? error.message : 'Failed to update sample',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -37,55 +210,152 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 pt-4">
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-lg font-semibold">Audio Samples</h3>
|
|
||||||
<Button size="sm" onClick={() => setUploadOpen(true)}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Add Sample
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{samples && samples.length === 0 ? (
|
{samples && samples.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground py-4">
|
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
|
||||||
No samples yet. Add your first audio sample.
|
<Volume2 className="h-8 w-8 text-muted-foreground/50 mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">No samples yet</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||||
|
Add your first audio sample to get started
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{samples?.map((sample) => (
|
{samples?.map((sample, index) => {
|
||||||
<div
|
const isEditing = editingSampleId === sample.id;
|
||||||
key={sample.id}
|
|
||||||
className="flex items-center justify-between p-3 border rounded-lg"
|
return (
|
||||||
>
|
<div
|
||||||
<div className="flex-1">
|
key={sample.id}
|
||||||
<p className="text-sm font-medium">{sample.reference_text}</p>
|
className={cn(
|
||||||
<p className="text-xs text-muted-foreground mt-1">{sample.audio_path}</p>
|
'group relative rounded-lg border bg-card transition-all duration-200',
|
||||||
|
isEditing ? 'ring-2 ring-primary/20' : 'hover:border-primary/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isEditing ? (
|
||||||
|
/* Edit Mode */
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
|
||||||
|
<Edit className="h-3 w-3" />
|
||||||
|
<span>Editing transcription</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={editedText}
|
||||||
|
onChange={(e) => setEditedText(e.target.value)}
|
||||||
|
className="min-h-[100px] text-sm resize-none"
|
||||||
|
placeholder="Enter reference text..."
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
disabled={updateSample.isPending}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 mr-1" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSaveEdit(sample.id)}
|
||||||
|
disabled={updateSample.isPending}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4 mr-1" />
|
||||||
|
{updateSample.isPending ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* View Mode */}
|
||||||
|
<div className="flex items-center gap-3 p-3 h-[72px]">
|
||||||
|
{/* Text Content */}
|
||||||
|
<div className="flex-1 min-w-0 py-0.5">
|
||||||
|
<p className="text-sm font-medium line-clamp-2 leading-snug">
|
||||||
|
{sample.reference_text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<CircleButton
|
||||||
|
icon={Edit}
|
||||||
|
title="Edit transcription"
|
||||||
|
onClick={() => handleStartEdit(sample.id, sample.reference_text)}
|
||||||
|
/>
|
||||||
|
<CircleButton
|
||||||
|
icon={Trash2}
|
||||||
|
title="Delete sample"
|
||||||
|
onClick={() => handleDeleteClick(sample.id)}
|
||||||
|
disabled={deleteSample.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sample Number Badge */}
|
||||||
|
<div className="absolute top-1 right-2 text-[10px] text-muted-foreground/50 font-medium">
|
||||||
|
#{index + 1}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mini Player - Always visible */}
|
||||||
|
<MiniSamplePlayer audioUrl={apiClient.getSampleUrl(sample.id)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
);
|
||||||
<Button
|
})}
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handlePlay(sample.audio_path, sample.reference_text, sample.id)}
|
|
||||||
className={currentAudioId === sample.id && isPlaying ? 'text-primary' : ''}
|
|
||||||
>
|
|
||||||
<Play className="h-4 w-4 mr-1" />
|
|
||||||
Play
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDelete(sample.id)}
|
|
||||||
disabled={deleteSample.isPending}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setUploadOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Sample
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground text-center px-2">
|
||||||
|
Note: A single 30-second sample is the sweet spot. Quality may decrease with multiple
|
||||||
|
samples. In a future update samples might be interchangeable and tagged for varying styles
|
||||||
|
of the same voice.
|
||||||
|
</p>
|
||||||
|
|
||||||
<SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} />
|
<SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} />
|
||||||
|
|
||||||
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Sample</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete this audio sample? This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setSampleToDelete(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
disabled={deleteSample.isPending}
|
||||||
|
>
|
||||||
|
{deleteSample.isPending ? 'Deleting...' : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Mic, Monitor, Upload } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -13,21 +14,23 @@ import {
|
|||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
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 { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
|
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
|
||||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
|
||||||
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
||||||
import { Mic, Square, Upload } from 'lucide-react';
|
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
|
||||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
||||||
|
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import { AudioSampleRecording } from './AudioSampleRecording';
|
||||||
|
import { AudioSampleSystem } from './AudioSampleSystem';
|
||||||
|
import { AudioSampleUpload } from './AudioSampleUpload';
|
||||||
|
|
||||||
const sampleSchema = z.object({
|
const sampleSchema = z.object({
|
||||||
file: z.instanceof(File, { message: 'Please select an audio file' }),
|
file: z.instanceof(File, { message: 'Please select an audio file' }),
|
||||||
@@ -46,11 +49,13 @@ interface SampleUploadProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProps) {
|
export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProps) {
|
||||||
|
const platform = usePlatform();
|
||||||
const addSample = useAddSample();
|
const addSample = useAddSample();
|
||||||
const transcribe = useTranscription();
|
const transcribe = useTranscription();
|
||||||
const { data: profile } = useProfile(profileId);
|
const { data: profile } = useProfile(profileId);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [mode, setMode] = useState<'upload' | 'record'>('upload');
|
const [mode, setMode] = useState<'upload' | 'record' | 'system'>('upload');
|
||||||
|
const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer();
|
||||||
|
|
||||||
const form = useForm<SampleFormValues>({
|
const form = useForm<SampleFormValues>({
|
||||||
resolver: zodResolver(sampleSchema),
|
resolver: zodResolver(sampleSchema),
|
||||||
@@ -69,12 +74,16 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
stopRecording,
|
stopRecording,
|
||||||
cancelRecording,
|
cancelRecording,
|
||||||
} = useAudioRecording({
|
} = useAudioRecording({
|
||||||
maxDurationSeconds: 30,
|
maxDurationSeconds: 29,
|
||||||
onRecordingComplete: (blob) => {
|
onRecordingComplete: (blob, recordedDuration) => {
|
||||||
// Convert blob to File object
|
// Convert blob to File object
|
||||||
const file = new File([blob], `recording-${Date.now()}.webm`, {
|
const file = new File([blob], `recording-${Date.now()}.webm`, {
|
||||||
type: blob.type || 'audio/webm',
|
type: blob.type || 'audio/webm',
|
||||||
});
|
}) as File & { recordedDuration?: number };
|
||||||
|
// Store the actual recorded duration to bypass metadata reading issues on Windows
|
||||||
|
if (recordedDuration !== undefined) {
|
||||||
|
file.recordedDuration = recordedDuration;
|
||||||
|
}
|
||||||
form.setValue('file', file, { shouldValidate: true });
|
form.setValue('file', file, { shouldValidate: true });
|
||||||
toast({
|
toast({
|
||||||
title: 'Recording complete',
|
title: 'Recording complete',
|
||||||
@@ -83,6 +92,33 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
isRecording: isSystemRecording,
|
||||||
|
duration: systemDuration,
|
||||||
|
error: systemRecordingError,
|
||||||
|
isSupported: isSystemAudioSupported,
|
||||||
|
startRecording: startSystemRecording,
|
||||||
|
stopRecording: stopSystemRecording,
|
||||||
|
cancelRecording: cancelSystemRecording,
|
||||||
|
} = useSystemAudioCapture({
|
||||||
|
maxDurationSeconds: 29,
|
||||||
|
onRecordingComplete: (blob, recordedDuration) => {
|
||||||
|
// Convert blob to File object
|
||||||
|
const file = new File([blob], `system-audio-${Date.now()}.wav`, {
|
||||||
|
type: blob.type || 'audio/wav',
|
||||||
|
}) as File & { recordedDuration?: number };
|
||||||
|
// Store the actual recorded duration to bypass metadata reading issues on Windows
|
||||||
|
if (recordedDuration !== undefined) {
|
||||||
|
file.recordedDuration = recordedDuration;
|
||||||
|
}
|
||||||
|
form.setValue('file', file, { shouldValidate: true });
|
||||||
|
toast({
|
||||||
|
title: 'System audio captured',
|
||||||
|
description: 'Audio has been captured successfully.',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Show recording errors
|
// Show recording errors
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (recordingError) {
|
if (recordingError) {
|
||||||
@@ -94,6 +130,17 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
}
|
}
|
||||||
}, [recordingError, toast]);
|
}, [recordingError, toast]);
|
||||||
|
|
||||||
|
// Show system audio recording errors
|
||||||
|
useEffect(() => {
|
||||||
|
if (systemRecordingError) {
|
||||||
|
toast({
|
||||||
|
title: 'System audio capture error',
|
||||||
|
description: systemRecordingError,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [systemRecordingError, toast]);
|
||||||
|
|
||||||
async function handleTranscribe() {
|
async function handleTranscribe() {
|
||||||
const file = form.getValues('file');
|
const file = form.getValues('file');
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -110,11 +157,6 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
const result = await transcribe.mutateAsync({ file, language });
|
const result = await transcribe.mutateAsync({ file, language });
|
||||||
|
|
||||||
form.setValue('referenceText', result.text, { shouldValidate: true });
|
form.setValue('referenceText', result.text, { shouldValidate: true });
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Transcription complete',
|
|
||||||
description: 'Audio has been transcribed successfully.',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Transcription failed',
|
title: 'Transcription failed',
|
||||||
@@ -154,14 +196,27 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
cancelRecording();
|
cancelRecording();
|
||||||
}
|
}
|
||||||
|
if (isSystemRecording) {
|
||||||
|
cancelSystemRecording();
|
||||||
|
}
|
||||||
|
cleanupAudio();
|
||||||
}
|
}
|
||||||
onOpenChange(newOpen);
|
onOpenChange(newOpen);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCancelRecording() {
|
function handleCancelRecording() {
|
||||||
cancelRecording();
|
if (mode === 'record') {
|
||||||
// Reset file field by clearing the input
|
cancelRecording();
|
||||||
|
} else if (mode === 'system') {
|
||||||
|
cancelSystemRecording();
|
||||||
|
}
|
||||||
form.resetField('file');
|
form.resetField('file');
|
||||||
|
cleanupAudio();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayPause() {
|
||||||
|
const file = form.getValues('file');
|
||||||
|
playPause(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -176,58 +231,40 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<Tabs value={mode} onValueChange={(v) => setMode(v as 'upload' | 'record')}>
|
<Tabs value={mode} onValueChange={(v) => setMode(v as 'upload' | 'record' | 'system')}>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList
|
||||||
|
className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||||
|
>
|
||||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||||
<Upload className="h-4 w-4" />
|
<Upload className="h-4 w-4 shrink-0" />
|
||||||
Upload
|
Upload
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="record" className="flex items-center gap-2">
|
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||||
<Mic className="h-4 w-4" />
|
<Mic className="h-4 w-4 shrink-0" />
|
||||||
Record
|
Record
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||||
|
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||||
|
<Monitor className="h-4 w-4 shrink-0" />
|
||||||
|
System Audio
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="upload" className="space-y-4">
|
<TabsContent value="upload" className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="file"
|
name="file"
|
||||||
render={({ field: { onChange, value, ...field } }) => (
|
render={({ field: { onChange, name } }) => (
|
||||||
<FormItem>
|
<AudioSampleUpload
|
||||||
<FormLabel>Audio File</FormLabel>
|
file={selectedFile}
|
||||||
<FormControl>
|
onFileChange={onChange}
|
||||||
<div className="flex items-center gap-2">
|
onTranscribe={handleTranscribe}
|
||||||
<Input
|
onPlayPause={handlePlayPause}
|
||||||
type="file"
|
isPlaying={isPlaying}
|
||||||
accept="audio/*"
|
isTranscribing={transcribe.isPending}
|
||||||
onChange={(e) => {
|
fieldName={name}
|
||||||
const file = e.target.files?.[0];
|
/>
|
||||||
if (file) {
|
|
||||||
onChange(file);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
{selectedFile && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleTranscribe}
|
|
||||||
disabled={transcribe.isPending}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Mic className="h-4 w-4" />
|
|
||||||
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Supported formats: WAV, MP3, M4A. Click "Transcribe" to automatically
|
|
||||||
extract text from the audio.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -237,94 +274,44 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="file"
|
name="file"
|
||||||
render={() => (
|
render={() => (
|
||||||
<FormItem>
|
<AudioSampleRecording
|
||||||
<FormLabel>Record Audio</FormLabel>
|
file={selectedFile}
|
||||||
<FormControl>
|
isRecording={isRecording}
|
||||||
<div className="space-y-4">
|
duration={duration}
|
||||||
{!isRecording && !selectedFile && (
|
onStart={startRecording}
|
||||||
<div className="flex flex-col items-center gap-4 p-6 border-2 border-dashed rounded-lg">
|
onStop={stopRecording}
|
||||||
<Button
|
onCancel={handleCancelRecording}
|
||||||
type="button"
|
onTranscribe={handleTranscribe}
|
||||||
onClick={startRecording}
|
onPlayPause={handlePlayPause}
|
||||||
size="lg"
|
isPlaying={isPlaying}
|
||||||
className="flex items-center gap-2"
|
isTranscribing={transcribe.isPending}
|
||||||
>
|
/>
|
||||||
<Mic className="h-5 w-5" />
|
|
||||||
Start Recording
|
|
||||||
</Button>
|
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
|
||||||
Click to start recording. Maximum duration: 30 seconds.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isRecording && (
|
|
||||||
<div className="flex flex-col items-center gap-4 p-6 border-2 border-destructive rounded-lg bg-destructive/5">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
|
|
||||||
<span className="text-lg font-mono font-semibold">
|
|
||||||
{formatAudioDuration(duration)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={stopRecording}
|
|
||||||
variant="destructive"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Square className="h-4 w-4" />
|
|
||||||
Stop Recording
|
|
||||||
</Button>
|
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
|
||||||
Recording in progress... ({formatAudioDuration(30 - duration)}{' '}
|
|
||||||
remaining)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedFile && !isRecording && (
|
|
||||||
<div className="flex flex-col items-center gap-4 p-6 border-2 border-primary rounded-lg bg-primary/5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Mic className="h-5 w-5 text-primary" />
|
|
||||||
<span className="font-medium">Recording complete</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
File: {selectedFile.name}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleTranscribe}
|
|
||||||
disabled={transcribe.isPending}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Mic className="h-4 w-4" />
|
|
||||||
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleCancelRecording}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
Record Again
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Record audio directly from your microphone. Maximum duration is 30 seconds.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||||
|
<TabsContent value="system" className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="file"
|
||||||
|
render={() => (
|
||||||
|
<AudioSampleSystem
|
||||||
|
file={selectedFile}
|
||||||
|
isRecording={isSystemRecording}
|
||||||
|
duration={systemDuration}
|
||||||
|
onStart={startSystemRecording}
|
||||||
|
onStop={stopSystemRecording}
|
||||||
|
onCancel={handleCancelRecording}
|
||||||
|
onTranscribe={handleTranscribe}
|
||||||
|
onPlayPause={handlePlayPause}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
isTranscribing={transcribe.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@@ -340,9 +327,6 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
|
||||||
This should match exactly what is spoken in the audio file.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Mic, Plus, Search, Sparkles } from 'lucide-react';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
import { MultiSelect } from '@/components/ui/multi-select';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||||
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
|
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
import { VoiceInspector } from './VoiceInspector';
|
||||||
|
|
||||||
|
export function VoicesTab() {
|
||||||
|
const { data: profiles, isLoading } = useProfiles();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||||
|
const selectedVoiceId = useUIStore((state) => state.selectedVoiceId);
|
||||||
|
const setSelectedVoiceId = useUIStore((state) => state.setSelectedVoiceId);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||||
|
const isPlayerVisible = !!audioUrl;
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const filteredProfiles = useMemo(() => {
|
||||||
|
if (!profiles) return [];
|
||||||
|
if (!search.trim()) return profiles;
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return profiles.filter(
|
||||||
|
(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);
|
||||||
|
}
|
||||||
|
// Clear selection if selected profile was deleted
|
||||||
|
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
|
||||||
|
const { data: channelAssignments } = useQuery({
|
||||||
|
queryKey: ['profile-channels'],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!profiles) return {};
|
||||||
|
const assignments: Record<string, string[]> = {};
|
||||||
|
for (const profile of profiles) {
|
||||||
|
try {
|
||||||
|
const result = await apiClient.getProfileChannels(profile.id);
|
||||||
|
assignments[profile.id] = result.channel_ids;
|
||||||
|
} catch {
|
||||||
|
assignments[profile.id] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return assignments;
|
||||||
|
},
|
||||||
|
enabled: !!profiles,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all channels
|
||||||
|
const { data: channels } = useQuery({
|
||||||
|
queryKey: ['channels'],
|
||||||
|
queryFn: () => apiClient.listChannels(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChannelChange = async (profileId: string, channelIds: string[]) => {
|
||||||
|
try {
|
||||||
|
await apiClient.setProfileChannels(profileId, channelIds);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update channels:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-muted-foreground">Loading voices...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex gap-0 overflow-hidden -mx-8">
|
||||||
|
{/* Left: Table */}
|
||||||
|
<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 */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 z-20 pl-8 pr-8">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Voices</h1>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="relative w-[240px]">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Right: Inspector */}
|
||||||
|
{selectedVoiceId && (
|
||||||
|
<div className="w-[340px] shrink-0 border-l border-t rounded-tl-xl bg-muted/30">
|
||||||
|
<VoiceInspector key={selectedVoiceId} profileId={selectedVoiceId} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProfileForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VoiceRowProps {
|
||||||
|
profile: VoiceProfileResponse;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
channelIds: string[];
|
||||||
|
channels: Array<{ id: string; name: string; is_default: boolean }>;
|
||||||
|
onChannelChange: (channelIds: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VoiceRow({
|
||||||
|
profile,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
channelIds,
|
||||||
|
channels,
|
||||||
|
onChannelChange,
|
||||||
|
}: VoiceRowProps) {
|
||||||
|
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 (
|
||||||
|
<TableRow
|
||||||
|
className={cn('cursor-pointer', isSelected ? 'bg-muted/50' : 'hover:bg-muted/50')}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex w-full min-w-0 items-center gap-2">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
||||||
|
{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 className="min-w-0">
|
||||||
|
<div className="font-medium truncate">{profile.name}</div>
|
||||||
|
{profile.description && (
|
||||||
|
<div className="text-sm text-muted-foreground truncate">{profile.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{profile.language}</TableCell>
|
||||||
|
<TableCell>{profile.generation_count}</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()}>
|
||||||
|
<MultiSelect
|
||||||
|
options={channels.map((ch) => ({
|
||||||
|
value: ch.id,
|
||||||
|
label: `${ch.name}${ch.is_default ? ' (Default)' : ''}`,
|
||||||
|
}))}
|
||||||
|
value={channelIds}
|
||||||
|
onChange={onChannelChange}
|
||||||
|
placeholder="Select channels..."
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell />
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { buttonVariants } from './button';
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root;
|
||||||
|
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||||
|
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
));
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||||
|
);
|
||||||
|
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||||
|
));
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
};
|
||||||
@@ -1,33 +1,41 @@
|
|||||||
|
import { Check } from 'lucide-react';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
export interface CheckboxProps {
|
||||||
|
checked?: boolean;
|
||||||
onCheckedChange?: (checked: boolean) => void;
|
onCheckedChange?: (checked: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||||
({ className, onCheckedChange, ...props }, ref) => {
|
({ checked = false, onCheckedChange, disabled = false, className, id, ...props }, ref) => {
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (onCheckedChange) {
|
|
||||||
onCheckedChange(e.target.checked);
|
|
||||||
}
|
|
||||||
// Call original onChange if provided
|
|
||||||
if (props.onChange) {
|
|
||||||
props.onChange(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<button
|
||||||
type="checkbox"
|
type="button"
|
||||||
|
ref={ref}
|
||||||
|
id={id}
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled && onCheckedChange) {
|
||||||
|
onCheckedChange(!checked);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
'h-4 w-4 rounded border-2 flex items-center justify-center shrink-0 transition-colors',
|
||||||
|
checked ? 'bg-accent border-accent' : 'border-muted-foreground/30',
|
||||||
|
disabled && 'opacity-50 cursor-not-allowed',
|
||||||
|
!disabled && 'cursor-pointer',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
|
||||||
onChange={handleChange}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{checked && <Check className="h-3 w-3 text-accent-foreground" />}
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ export interface CircleButtonProps extends React.ButtonHTMLAttributes<HTMLButton
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CircleButton = React.forwardRef<HTMLButtonElement, CircleButtonProps>(
|
const CircleButton = React.forwardRef<HTMLButtonElement, CircleButtonProps>(
|
||||||
({ className, icon: Icon, ...props }, ref) => {
|
({ className, icon: Icon, type = 'button', ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-7 w-7 rounded-full flex items-center justify-center flex-shrink-0',
|
'h-7 w-7 rounded-full flex items-center justify-center flex-shrink-0',
|
||||||
'hover:bg-muted transition-colors',
|
'hover:bg-muted transition-colors',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ChevronDown, Check } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
|
|
||||||
|
export interface MultiSelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultiSelectProps {
|
||||||
|
options: MultiSelectOption[];
|
||||||
|
value: string[];
|
||||||
|
onChange: (value: string[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MultiSelectCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-xs outline-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
));
|
||||||
|
MultiSelectCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
|
export function MultiSelect({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Select...',
|
||||||
|
className,
|
||||||
|
}: MultiSelectProps) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const handleSelect = (optionValue: string) => {
|
||||||
|
const newValue = value.includes(optionValue)
|
||||||
|
? value.filter((v) => v !== optionValue)
|
||||||
|
: [...value, optionValue];
|
||||||
|
onChange(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayText =
|
||||||
|
value.length === 0
|
||||||
|
? placeholder
|
||||||
|
: value.length === 1
|
||||||
|
? options.find((opt) => opt.value === value[0])?.label || placeholder
|
||||||
|
: `${value.length} selected`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 w-full items-center justify-between rounded-full border border-border bg-card px-3 py-2 text-xs ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 hover:bg-background/50 transition-all',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="line-clamp-1">{displayText}</span>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="max-h-96 overflow-auto"
|
||||||
|
align="start"
|
||||||
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<MultiSelectCheckboxItem
|
||||||
|
key={option.value}
|
||||||
|
checked={value.includes(option.value)}
|
||||||
|
onSelect={() => handleSelect(option.value)}
|
||||||
|
onCheckedChange={() => handleSelect(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</MultiSelectCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root;
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
));
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent };
|
||||||
@@ -12,7 +12,7 @@ const Progress = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ProgressPrimitive.Indicator
|
<ProgressPrimitive.Indicator
|
||||||
className="h-full w-full flex-1 bg-primary transition-all"
|
className="h-full w-full flex-1 bg-accent transition-all"
|
||||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
/>
|
/>
|
||||||
</ProgressPrimitive.Root>
|
</ProgressPrimitive.Root>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function Toaster() {
|
|||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
{toasts.map(({ id, title, description, action, ...props }) => (
|
{toasts.map(({ id, title, description, action, ...props }) => (
|
||||||
<Toast key={id} {...props}>
|
<Toast key={id} {...props}>
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1 flex-1 min-w-0">
|
||||||
{title && <ToastTitle>{title}</ToastTitle>}
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
{description && <ToastDescription>{description}</ToastDescription>}
|
{description && <ToastDescription>{description}</ToastDescription>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
interface Window {
|
||||||
|
__voiceboxServerStartedByApp?: boolean;
|
||||||
|
}
|
||||||
@@ -1,112 +1,52 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { check, type Update } from '@tauri-apps/plugin-updater';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
import { relaunch } from '@tauri-apps/plugin-process';
|
import type { UpdateStatus } from '@/platform/types';
|
||||||
|
|
||||||
export interface UpdateStatus {
|
// Re-export UpdateStatus for backwards compatibility
|
||||||
checking: boolean;
|
export type { UpdateStatus };
|
||||||
available: boolean;
|
|
||||||
version?: string;
|
|
||||||
downloading: boolean;
|
|
||||||
installing: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isTauri = () => {
|
|
||||||
return '__TAURI_INTERNALS__' in window;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useAutoUpdater(checkOnMount = false) {
|
export function useAutoUpdater(checkOnMount = false) {
|
||||||
const [status, setStatus] = useState<UpdateStatus>({
|
const platform = usePlatform();
|
||||||
checking: false,
|
const [status, setStatus] = useState<UpdateStatus>(platform.updater.getStatus());
|
||||||
available: false,
|
const hasCheckedRef = useRef(false);
|
||||||
downloading: false,
|
|
||||||
installing: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [update, setUpdate] = useState<Update | null>(null);
|
// Subscribe to updater status changes
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = platform.updater.subscribe((newStatus) => {
|
||||||
|
setStatus(newStatus);
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
// Empty dependency array - platform is stable from context
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.subscribe]);
|
||||||
|
|
||||||
const checkForUpdates = async () => {
|
const checkForUpdates = useCallback(async () => {
|
||||||
if (!isTauri()) {
|
await platform.updater.checkForUpdates();
|
||||||
return;
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}
|
}, [platform.updater.checkForUpdates]);
|
||||||
|
|
||||||
try {
|
const downloadAndInstall = useCallback(async () => {
|
||||||
setStatus((prev) => ({ ...prev, checking: true, error: undefined }));
|
await platform.updater.downloadAndInstall();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.downloadAndInstall]);
|
||||||
|
|
||||||
const foundUpdate = await check();
|
const restartAndInstall = useCallback(async () => {
|
||||||
|
await platform.updater.restartAndInstall();
|
||||||
if (foundUpdate?.available) {
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
setUpdate(foundUpdate);
|
}, [platform.updater.restartAndInstall]);
|
||||||
setStatus({
|
|
||||||
checking: false,
|
|
||||||
available: true,
|
|
||||||
version: foundUpdate.version,
|
|
||||||
downloading: false,
|
|
||||||
installing: false,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setStatus({
|
|
||||||
checking: false,
|
|
||||||
available: false,
|
|
||||||
downloading: false,
|
|
||||||
installing: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setStatus({
|
|
||||||
checking: false,
|
|
||||||
available: false,
|
|
||||||
downloading: false,
|
|
||||||
installing: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to check for updates',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadAndInstall = async () => {
|
|
||||||
if (!update || !isTauri()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setStatus((prev) => ({ ...prev, downloading: true, error: undefined }));
|
|
||||||
|
|
||||||
await update.downloadAndInstall((event) => {
|
|
||||||
switch (event.event) {
|
|
||||||
case 'Started':
|
|
||||||
setStatus((prev) => ({ ...prev, downloading: true }));
|
|
||||||
break;
|
|
||||||
case 'Progress':
|
|
||||||
console.log(`Downloaded ${event.data.chunkLength} bytes`);
|
|
||||||
break;
|
|
||||||
case 'Finished':
|
|
||||||
setStatus((prev) => ({
|
|
||||||
...prev,
|
|
||||||
downloading: false,
|
|
||||||
installing: true,
|
|
||||||
}));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await relaunch();
|
|
||||||
} catch (error) {
|
|
||||||
setStatus((prev) => ({
|
|
||||||
...prev,
|
|
||||||
downloading: false,
|
|
||||||
installing: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to install update',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (checkOnMount && isTauri()) {
|
if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) {
|
||||||
|
hasCheckedRef.current = true;
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
}
|
}
|
||||||
}, [checkOnMount]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.metadata.isTauricheckOnMountcheckForUpdates]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
downloadAndInstall,
|
downloadAndInstall,
|
||||||
|
restartAndInstall,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { Download, RefreshCw } from 'lucide-react';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { ToastAction } from '@/components/ui/toast';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import type { UpdateStatus } from '@/platform/types';
|
||||||
|
|
||||||
|
// Re-export UpdateStatus for backwards compatibility
|
||||||
|
export type { UpdateStatus };
|
||||||
|
|
||||||
|
interface UseAutoUpdaterOptions {
|
||||||
|
checkOnMount?: boolean;
|
||||||
|
showToast?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutoUpdater(options: boolean | UseAutoUpdaterOptions = false) {
|
||||||
|
// Support both old boolean API and new options object
|
||||||
|
const { checkOnMount, showToast } =
|
||||||
|
typeof options === 'boolean'
|
||||||
|
? { checkOnMount: options, showToast: false }
|
||||||
|
: { checkOnMount: options.checkOnMount ?? false, showToast: options.showToast ?? false };
|
||||||
|
|
||||||
|
const platform = usePlatform();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [status, setStatus] = useState<UpdateStatus>(platform.updater.getStatus());
|
||||||
|
const hasCheckedRef = useRef(false);
|
||||||
|
const toastIdRef = useRef<string | null>(null);
|
||||||
|
const toastUpdateRef = useRef<
|
||||||
|
| ((props: {
|
||||||
|
title?: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
duration?: number;
|
||||||
|
variant?: 'default' | 'destructive';
|
||||||
|
open?: boolean;
|
||||||
|
action?: React.ReactElement<typeof ToastAction>;
|
||||||
|
}) => void)
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
// Subscribe to updater status changes
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = platform.updater.subscribe((newStatus) => {
|
||||||
|
setStatus(newStatus);
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
// Empty dependency array - platform is stable from context
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.subscribe]);
|
||||||
|
|
||||||
|
const checkForUpdates = useCallback(async () => {
|
||||||
|
await platform.updater.checkForUpdates();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.checkForUpdates]);
|
||||||
|
|
||||||
|
const downloadAndInstall = useCallback(async () => {
|
||||||
|
await platform.updater.downloadAndInstall();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.downloadAndInstall]);
|
||||||
|
|
||||||
|
const restartAndInstall = useCallback(async () => {
|
||||||
|
await platform.updater.restartAndInstall();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.restartAndInstall]);
|
||||||
|
|
||||||
|
// Check for updates on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) {
|
||||||
|
hasCheckedRef.current = true;
|
||||||
|
checkForUpdates().catch((error) => {
|
||||||
|
console.error('Auto update check failed:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Empty dependency array - only run once on mount
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.metadata.isTauricheckOnMountcheckForUpdates]);
|
||||||
|
|
||||||
|
// Show toast when update is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!showToast ||
|
||||||
|
!status.available ||
|
||||||
|
status.downloading ||
|
||||||
|
status.readyToInstall ||
|
||||||
|
toastIdRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateNow = async () => {
|
||||||
|
await downloadAndInstall();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toastResult = toast({
|
||||||
|
title: 'Update Available',
|
||||||
|
description: `Version ${status.version} is ready to download.`,
|
||||||
|
duration: Infinity,
|
||||||
|
action: (
|
||||||
|
<ToastAction altText="Update now" onClick={handleUpdateNow}>
|
||||||
|
Update Now
|
||||||
|
</ToastAction>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
toastIdRef.current = toastResult.id;
|
||||||
|
// Type assertion needed because update function has broader type than our ref
|
||||||
|
toastUpdateRef.current = toastResult.update as typeof toastUpdateRef.current;
|
||||||
|
}, [
|
||||||
|
showToast,
|
||||||
|
status.available,
|
||||||
|
status.downloading,
|
||||||
|
status.readyToInstall,
|
||||||
|
status.version,
|
||||||
|
downloadAndInstall,
|
||||||
|
toast,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update toast when downloading
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showToast || !status.downloading || !toastIdRef.current || !toastUpdateRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressPercent = status.downloadProgress || 0;
|
||||||
|
const progressText =
|
||||||
|
status.downloadedBytes !== undefined &&
|
||||||
|
status.totalBytes !== undefined &&
|
||||||
|
status.totalBytes > 0
|
||||||
|
? `${(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB / ${(status.totalBytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
toastUpdateRef.current({
|
||||||
|
title: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Download className="h-4 w-4 animate-pulse" />
|
||||||
|
<span>Downloading Update</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
description: (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm">Version {status.version}</div>
|
||||||
|
{progressPercent > 0 && (
|
||||||
|
<>
|
||||||
|
<Progress value={progressPercent} className="h-2" />
|
||||||
|
{progressText && <div className="text-xs text-muted-foreground">{progressText}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
duration: Infinity,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
showToast,
|
||||||
|
status.downloading,
|
||||||
|
status.downloadProgress,
|
||||||
|
status.downloadedBytes,
|
||||||
|
status.totalBytes,
|
||||||
|
status.version,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update toast when ready to install
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showToast || !status.readyToInstall || !toastIdRef.current || !toastUpdateRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestartNow = async () => {
|
||||||
|
await restartAndInstall();
|
||||||
|
};
|
||||||
|
|
||||||
|
toastUpdateRef.current({
|
||||||
|
title: 'Update Ready',
|
||||||
|
description: `Version ${status.version} has been downloaded and is ready to install.`,
|
||||||
|
duration: Infinity,
|
||||||
|
action: (
|
||||||
|
<ToastAction altText="Restart now" onClick={handleRestartNow}>
|
||||||
|
<RefreshCw className="h-3 w-3 mr-1" />
|
||||||
|
Restart Now
|
||||||
|
</ToastAction>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [showToast, status.readyToInstall, status.version, restartAndInstall]);
|
||||||
|
|
||||||
|
// Handle errors in toast
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showToast || !status.error || !toastIdRef.current || !toastUpdateRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toastUpdateRef.current({
|
||||||
|
title: 'Update Failed',
|
||||||
|
description: status.error,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toastIdRef.current = null;
|
||||||
|
toastUpdateRef.current = null;
|
||||||
|
}, 5000);
|
||||||
|
}, [showToast, status.error]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
checkForUpdates,
|
||||||
|
downloadAndInstall,
|
||||||
|
restartAndInstall,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -126,3 +127,47 @@
|
|||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInScale {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-scale {
|
||||||
|
animation: fadeInScale 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-delayed {
|
||||||
|
animation: fadeIn 0.5s ease-out 0.15s forwards;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+539
-10
@@ -1,17 +1,37 @@
|
|||||||
|
import type { LanguageCode } from '@/lib/constants/languages';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
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,
|
||||||
|
ModelStatusListResponse,
|
||||||
|
ProfileSampleResponse,
|
||||||
|
StoryCreate,
|
||||||
|
StoryDetailResponse,
|
||||||
|
StoryItemBatchUpdate,
|
||||||
|
StoryItemCreate,
|
||||||
|
StoryItemDetail,
|
||||||
|
StoryItemMove,
|
||||||
|
StoryItemReorder,
|
||||||
|
StoryItemSplit,
|
||||||
|
StoryItemTrim,
|
||||||
|
StoryItemVersionUpdate,
|
||||||
|
StoryResponse,
|
||||||
|
TranscriptionResponse,
|
||||||
|
VoiceProfileCreate,
|
||||||
|
VoiceProfileResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
@@ -109,6 +129,76 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateProfileSample(
|
||||||
|
sampleId: string,
|
||||||
|
referenceText: string,
|
||||||
|
): Promise<ProfileSampleResponse> {
|
||||||
|
return this.request<ProfileSampleResponse>(`/profiles/samples/${sampleId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ reference_text: referenceText }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportProfile(profileId: string): Promise<Blob> {
|
||||||
|
const url = `${this.getBaseUrl()}/profiles/${profileId}/export`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
async importProfile(file: File): Promise<VoiceProfileResponse> {
|
||||||
|
const url = `${this.getBaseUrl()}/profiles/import`;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({
|
||||||
|
detail: response.statusText,
|
||||||
|
}));
|
||||||
|
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadAvatar(profileId: string, file: File): Promise<VoiceProfileResponse> {
|
||||||
|
const url = `${this.getBaseUrl()}/profiles/${profileId}/avatar`;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({
|
||||||
|
detail: response.statusText,
|
||||||
|
}));
|
||||||
|
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAvatar(profileId: string): Promise<void> {
|
||||||
|
await this.request<void>(`/profiles/${profileId}/avatar`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Generation
|
// Generation
|
||||||
async generateSpeech(data: GenerationRequest): Promise<GenerationResponse> {
|
async generateSpeech(data: GenerationRequest): Promise<GenerationResponse> {
|
||||||
return this.request<GenerationResponse>('/generate', {
|
return this.request<GenerationResponse>('/generate', {
|
||||||
@@ -117,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();
|
||||||
@@ -141,13 +249,76 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async exportGeneration(generationId: string): Promise<Blob> {
|
||||||
|
const url = `${this.getBaseUrl()}/history/${generationId}/export`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({
|
||||||
|
detail: response.statusText,
|
||||||
|
}));
|
||||||
|
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportGenerationAudio(generationId: string): Promise<Blob> {
|
||||||
|
const url = `${this.getBaseUrl()}/history/${generationId}/export-audio`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({
|
||||||
|
detail: response.statusText,
|
||||||
|
}));
|
||||||
|
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
async importGeneration(file: File): Promise<{
|
||||||
|
id: string;
|
||||||
|
profile_id: string;
|
||||||
|
profile_name: string;
|
||||||
|
text: string;
|
||||||
|
message: string;
|
||||||
|
}> {
|
||||||
|
const url = `${this.getBaseUrl()}/history/import`;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({
|
||||||
|
detail: response.statusText,
|
||||||
|
}));
|
||||||
|
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSampleUrl(sampleId: string): string {
|
||||||
|
return `${this.getBaseUrl()}/samples/${sampleId}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Transcription
|
// Transcription
|
||||||
async transcribeAudio(file: File, language?: 'en' | 'zh'): Promise<TranscriptionResponse> {
|
async transcribeAudio(file: File, language?: LanguageCode): Promise<TranscriptionResponse> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
if (language) {
|
if (language) {
|
||||||
@@ -175,12 +346,370 @@ 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',
|
||||||
|
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
|
||||||
|
});
|
||||||
|
console.log('[API] triggerModelDownload response:', result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteModel(modelName: string): Promise<{ message: string }> {
|
||||||
|
return this.request<{ message: string }>(`/models/${modelName}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
method: 'POST',
|
||||||
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
|
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Task Management
|
||||||
|
async getActiveTasks(): Promise<ActiveTasksResponse> {
|
||||||
|
return this.request<ActiveTasksResponse>('/tasks/active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearAllTasks(): Promise<{ message: string }> {
|
||||||
|
return this.request<{ message: string }>('/tasks/clear', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio Channels
|
||||||
|
async listChannels(): Promise<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
is_default: boolean;
|
||||||
|
device_ids: string[];
|
||||||
|
created_at: string;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
return this.request('/channels');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createChannel(data: { name: string; device_ids: string[] }): Promise<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
is_default: boolean;
|
||||||
|
device_ids: string[];
|
||||||
|
created_at: string;
|
||||||
|
}> {
|
||||||
|
return this.request('/channels', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateChannel(
|
||||||
|
channelId: string,
|
||||||
|
data: {
|
||||||
|
name?: string;
|
||||||
|
device_ids?: string[];
|
||||||
|
},
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
is_default: boolean;
|
||||||
|
device_ids: string[];
|
||||||
|
created_at: string;
|
||||||
|
}> {
|
||||||
|
return this.request(`/channels/${channelId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteChannel(channelId: string): Promise<{ message: string }> {
|
||||||
|
return this.request(`/channels/${channelId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChannelVoices(channelId: string): Promise<{ profile_ids: string[] }> {
|
||||||
|
return this.request(`/channels/${channelId}/voices`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setChannelVoices(channelId: string, profileIds: string[]): Promise<{ message: string }> {
|
||||||
|
return this.request(`/channels/${channelId}/voices`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ profile_ids: profileIds }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProfileChannels(profileId: string): Promise<{ channel_ids: string[] }> {
|
||||||
|
return this.request(`/profiles/${profileId}/channels`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setProfileChannels(profileId: string, channelIds: string[]): Promise<{ message: string }> {
|
||||||
|
return this.request(`/profiles/${profileId}/channels`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ channel_ids: channelIds }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// CUDA Backend Management
|
||||||
|
async getCudaStatus(): Promise<CudaStatus> {
|
||||||
|
return this.request<CudaStatus>('/backend/cuda-status');
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadCudaBackend(): Promise<{ message: string; progress_key: string }> {
|
||||||
|
return this.request<{ message: string; progress_key: string }>('/backend/download-cuda', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCudaBackend(): Promise<{ message: string }> {
|
||||||
|
return this.request<{ message: string }>('/backend/cuda', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stories
|
||||||
|
async listStories(): Promise<StoryResponse[]> {
|
||||||
|
return this.request<StoryResponse[]>('/stories');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createStory(data: StoryCreate): Promise<StoryResponse> {
|
||||||
|
return this.request<StoryResponse>('/stories', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStory(storyId: string): Promise<StoryDetailResponse> {
|
||||||
|
return this.request<StoryDetailResponse>(`/stories/${storyId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStory(storyId: string, data: StoryCreate): Promise<StoryResponse> {
|
||||||
|
return this.request<StoryResponse>(`/stories/${storyId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteStory(storyId: string): Promise<void> {
|
||||||
|
await this.request<void>(`/stories/${storyId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addStoryItem(storyId: string, data: StoryItemCreate): Promise<StoryItemDetail> {
|
||||||
|
return this.request<StoryItemDetail>(`/stories/${storyId}/items`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeStoryItem(storyId: string, itemId: string): Promise<void> {
|
||||||
|
await this.request<void>(`/stories/${storyId}/items/${itemId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStoryItemTimes(storyId: string, data: StoryItemBatchUpdate): Promise<void> {
|
||||||
|
await this.request<void>(`/stories/${storyId}/items/times`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async reorderStoryItems(storyId: string, data: StoryItemReorder): Promise<StoryItemDetail[]> {
|
||||||
|
return this.request<StoryItemDetail[]>(`/stories/${storyId}/items/reorder`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveStoryItem(
|
||||||
|
storyId: string,
|
||||||
|
itemId: string,
|
||||||
|
data: StoryItemMove,
|
||||||
|
): Promise<StoryItemDetail> {
|
||||||
|
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/move`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async trimStoryItem(
|
||||||
|
storyId: string,
|
||||||
|
itemId: string,
|
||||||
|
data: StoryItemTrim,
|
||||||
|
): Promise<StoryItemDetail> {
|
||||||
|
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/trim`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async splitStoryItem(
|
||||||
|
storyId: string,
|
||||||
|
itemId: string,
|
||||||
|
data: StoryItemSplit,
|
||||||
|
): Promise<StoryItemDetail[]> {
|
||||||
|
return this.request<StoryItemDetail[]>(`/stories/${storyId}/items/${itemId}/split`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async duplicateStoryItem(storyId: string, itemId: string): Promise<StoryItemDetail> {
|
||||||
|
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/duplicate`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async 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> {
|
||||||
|
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({
|
||||||
|
detail: response.statusText,
|
||||||
|
}));
|
||||||
|
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
};
|
};
|
||||||
|
|||||||
+238
-5
@@ -1,9 +1,10 @@
|
|||||||
// API Types matching backend Pydantic models
|
// API Types matching backend Pydantic models
|
||||||
|
import type { LanguageCode } from '@/lib/constants/languages';
|
||||||
|
|
||||||
export interface VoiceProfileCreate {
|
export interface VoiceProfileCreate {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
language: 'en' | 'zh';
|
language: LanguageCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VoiceProfileResponse {
|
export interface VoiceProfileResponse {
|
||||||
@@ -11,6 +12,10 @@ export interface VoiceProfileResponse {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
language: string;
|
language: 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;
|
||||||
}
|
}
|
||||||
@@ -26,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: 'en' | 'zh';
|
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 {
|
||||||
@@ -39,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: '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 {
|
||||||
@@ -54,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 {
|
||||||
@@ -62,7 +100,7 @@ export interface HistoryListResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TranscriptionRequest {
|
export interface TranscriptionRequest {
|
||||||
language?: 'en' | 'zh';
|
language?: LanguageCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TranscriptionResponse {
|
export interface TranscriptionResponse {
|
||||||
@@ -76,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 {
|
||||||
@@ -93,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[];
|
||||||
}
|
}
|
||||||
@@ -105,3 +183,158 @@ export interface ModelStatusListResponse {
|
|||||||
export interface ModelDownloadRequest {
|
export interface ModelDownloadRequest {
|
||||||
model_name: string;
|
model_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ActiveDownloadTask {
|
||||||
|
model_name: string;
|
||||||
|
status: string;
|
||||||
|
started_at: string;
|
||||||
|
error?: string;
|
||||||
|
progress?: number; // 0-100 percentage
|
||||||
|
current?: number; // bytes downloaded
|
||||||
|
total?: number; // total bytes
|
||||||
|
filename?: string; // current file being downloaded
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveGenerationTask {
|
||||||
|
task_id: string;
|
||||||
|
profile_id: string;
|
||||||
|
text_preview: string;
|
||||||
|
started_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveTasksResponse {
|
||||||
|
downloads: ActiveDownloadTask[];
|
||||||
|
generations: ActiveGenerationTask[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryCreate {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
item_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemDetail {
|
||||||
|
id: string;
|
||||||
|
story_id: string;
|
||||||
|
generation_id: string;
|
||||||
|
version_id?: string;
|
||||||
|
start_time_ms: number;
|
||||||
|
track: number;
|
||||||
|
trim_start_ms: number;
|
||||||
|
trim_end_ms: number;
|
||||||
|
created_at: string;
|
||||||
|
profile_id: string;
|
||||||
|
profile_name: string;
|
||||||
|
text: string;
|
||||||
|
language: string;
|
||||||
|
audio_path: string;
|
||||||
|
duration: number;
|
||||||
|
seed?: number;
|
||||||
|
instruct?: string;
|
||||||
|
generation_created_at: string;
|
||||||
|
versions?: GenerationVersionResponse[];
|
||||||
|
active_version_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemVersionUpdate {
|
||||||
|
version_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryDetailResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
items: StoryItemDetail[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemCreate {
|
||||||
|
generation_id: string;
|
||||||
|
start_time_ms?: number;
|
||||||
|
track?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemUpdateTime {
|
||||||
|
generation_id: string;
|
||||||
|
start_time_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemBatchUpdate {
|
||||||
|
updates: StoryItemUpdateTime[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemReorder {
|
||||||
|
generation_ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemMove {
|
||||||
|
start_time_ms: number;
|
||||||
|
track: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemTrim {
|
||||||
|
trim_start_ms: number;
|
||||||
|
trim_end_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoryItemSplit {
|
||||||
|
split_time_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Supported languages for voice generation, per engine.
|
||||||
|
*
|
||||||
|
* Qwen3-TTS supports 10 languages.
|
||||||
|
* LuxTTS is English-only.
|
||||||
|
* Chatterbox Multilingual supports 23 languages.
|
||||||
|
* Chatterbox Turbo is English-only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** All languages that any engine supports. */
|
||||||
|
export const ALL_LANGUAGES = {
|
||||||
|
ar: 'Arabic',
|
||||||
|
da: 'Danish',
|
||||||
|
de: 'German',
|
||||||
|
el: 'Greek',
|
||||||
|
en: 'English',
|
||||||
|
es: 'Spanish',
|
||||||
|
fi: 'Finnish',
|
||||||
|
fr: 'French',
|
||||||
|
he: 'Hebrew',
|
||||||
|
hi: 'Hindi',
|
||||||
|
it: 'Italian',
|
||||||
|
ja: 'Japanese',
|
||||||
|
ko: 'Korean',
|
||||||
|
ms: 'Malay',
|
||||||
|
nl: 'Dutch',
|
||||||
|
no: 'Norwegian',
|
||||||
|
pl: 'Polish',
|
||||||
|
pt: 'Portuguese',
|
||||||
|
ru: 'Russian',
|
||||||
|
sv: 'Swedish',
|
||||||
|
sw: 'Swahili',
|
||||||
|
tr: 'Turkish',
|
||||||
|
zh: 'Chinese',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type LanguageCode = keyof typeof ALL_LANGUAGES;
|
||||||
|
|
||||||
|
/** Per-engine supported language codes. */
|
||||||
|
export const ENGINE_LANGUAGES: Record<string, readonly LanguageCode[]> = {
|
||||||
|
qwen: ['zh', 'en', 'ja', 'ko', 'de', 'fr', 'ru', 'pt', 'es', 'it'],
|
||||||
|
luxtts: ['en'],
|
||||||
|
chatterbox: [
|
||||||
|
'ar',
|
||||||
|
'da',
|
||||||
|
'de',
|
||||||
|
'el',
|
||||||
|
'en',
|
||||||
|
'es',
|
||||||
|
'fi',
|
||||||
|
'fr',
|
||||||
|
'he',
|
||||||
|
'hi',
|
||||||
|
'it',
|
||||||
|
'ja',
|
||||||
|
'ko',
|
||||||
|
'ms',
|
||||||
|
'nl',
|
||||||
|
'no',
|
||||||
|
'pl',
|
||||||
|
'pt',
|
||||||
|
'ru',
|
||||||
|
'sv',
|
||||||
|
'sw',
|
||||||
|
'tr',
|
||||||
|
'zh',
|
||||||
|
],
|
||||||
|
chatterbox_turbo: ['en'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Helper: get language options for a given engine. */
|
||||||
|
export function getLanguageOptionsForEngine(engine: string) {
|
||||||
|
const codes = ENGINE_LANGUAGES[engine] ?? ENGINE_LANGUAGES.qwen;
|
||||||
|
return codes.map((code) => ({
|
||||||
|
value: code,
|
||||||
|
label: ALL_LANGUAGES[code],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Backwards-compatible exports used elsewhere ──────────────────────
|
||||||
|
export const SUPPORTED_LANGUAGES = ALL_LANGUAGES;
|
||||||
|
export const LANGUAGE_CODES = Object.keys(ALL_LANGUAGES) as LanguageCode[];
|
||||||
|
export const LANGUAGE_OPTIONS = LANGUAGE_CODES.map((code) => ({
|
||||||
|
value: code,
|
||||||
|
label: ALL_LANGUAGES[code],
|
||||||
|
}));
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* 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 = isWindows ? 'pt-8' : 'pt-12';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom safe area padding - height of the audio player
|
||||||
|
* Corresponds to Tailwind's pb-32 (8rem / 128px)
|
||||||
|
*/
|
||||||
|
export const BOTTOM_SAFE_AREA_PADDING = 'pb-32';
|
||||||
@@ -1 +0,0 @@
|
|||||||
# React Query hooks will be placed here
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
|
||||||
|
export function useAudioPlayer() {
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const playPause = (file: File | null | undefined) => {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (audioRef.current) {
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
setIsPlaying(false);
|
||||||
|
} else {
|
||||||
|
audioRef.current.play();
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const audio = new Audio(URL.createObjectURL(file));
|
||||||
|
audioRef.current = audio;
|
||||||
|
|
||||||
|
audio.addEventListener('ended', () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
if (audioRef.current) {
|
||||||
|
URL.revokeObjectURL(audioRef.current.src);
|
||||||
|
}
|
||||||
|
audioRef.current = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
audio.addEventListener('error', () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
toast({
|
||||||
|
title: 'Playback error',
|
||||||
|
description: 'Failed to play audio file',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
if (audioRef.current) {
|
||||||
|
URL.revokeObjectURL(audioRef.current.src);
|
||||||
|
}
|
||||||
|
audioRef.current = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
audio.play();
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
if (audioRef.current.src.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(audioRef.current.src);
|
||||||
|
}
|
||||||
|
audioRef.current = null;
|
||||||
|
}
|
||||||
|
setIsPlaying(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPlaying,
|
||||||
|
playPause,
|
||||||
|
cleanup,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { isTauri } from '@/lib/tauri';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import { convertToWav } from '@/lib/utils/audio';
|
||||||
|
|
||||||
interface UseAudioRecordingOptions {
|
interface UseAudioRecordingOptions {
|
||||||
maxDurationSeconds?: number;
|
maxDurationSeconds?: number;
|
||||||
onRecordingComplete?: (blob: Blob) => void;
|
onRecordingComplete?: (blob: Blob, duration?: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAudioRecording({
|
export function useAudioRecording({
|
||||||
maxDurationSeconds = 30,
|
maxDurationSeconds = 29,
|
||||||
onRecordingComplete,
|
onRecordingComplete,
|
||||||
}: UseAudioRecordingOptions = {}) {
|
}: UseAudioRecordingOptions = {}) {
|
||||||
|
const platform = usePlatform();
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -18,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
|
||||||
@@ -39,15 +43,14 @@ export function useAudioRecording({
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
const isTauriEnv = isTauri();
|
|
||||||
console.error('MediaDevices check:', {
|
console.error('MediaDevices check:', {
|
||||||
hasNavigator: typeof navigator !== 'undefined',
|
hasNavigator: typeof navigator !== 'undefined',
|
||||||
hasMediaDevices: !!navigator?.mediaDevices,
|
hasMediaDevices: !!navigator?.mediaDevices,
|
||||||
hasGetUserMedia: !!navigator?.mediaDevices?.getUserMedia,
|
hasGetUserMedia: !!navigator?.mediaDevices?.getUserMedia,
|
||||||
isTauri: isTauriEnv,
|
isTauri: platform.metadata.isTauri,
|
||||||
});
|
});
|
||||||
|
|
||||||
const errorMsg = isTauriEnv
|
const errorMsg = platform.metadata.isTauri
|
||||||
? 'Microphone access is not available. Please ensure:\n1. The app has microphone permissions in System Settings (macOS: System Settings > Privacy & Security > Microphone)\n2. You restart the app after granting permissions\n3. You are using Tauri v2 with a webview that supports getUserMedia'
|
? 'Microphone access is not available. Please ensure:\n1. The app has microphone permissions in System Settings (macOS: System Settings > Privacy & Security > Microphone)\n2. You restart the app after granting permissions\n3. You are using Tauri v2 with a webview that supports getUserMedia'
|
||||||
: 'Microphone access is not available. Please ensure you are using a secure context (HTTPS or localhost) and that your browser has microphone permissions enabled.';
|
: 'Microphone access is not available. Please ensure you are using a secure context (HTTPS or localhost) and that your browser has microphone permissions enabled.';
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
@@ -85,15 +88,35 @@ export function useAudioRecording({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
mediaRecorder.onstop = () => {
|
mediaRecorder.onstop = async () => {
|
||||||
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
// Snapshot the cancellation flag and recorded duration immediately —
|
||||||
onRecordingComplete?.(blob);
|
// 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;
|
||||||
|
|
||||||
// Stop all tracks
|
const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||||
|
|
||||||
|
// Stop all tracks now that we have the data
|
||||||
streamRef.current?.getTracks().forEach((track) => {
|
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) => {
|
||||||
@@ -149,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import * as z from 'zod';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { EffectConfig } from '@/lib/api/types';
|
||||||
|
import { LANGUAGE_CODES, type LanguageCode } from '@/lib/constants/languages';
|
||||||
|
import { useGeneration } from '@/lib/hooks/useGeneration';
|
||||||
|
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
|
const generationSchema = z.object({
|
||||||
|
text: z.string().min(1, '').max(50000),
|
||||||
|
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
||||||
|
seed: z.number().int().optional(),
|
||||||
|
modelSize: z.enum(['1.7B', '0.6B']).optional(),
|
||||||
|
instruct: z.string().max(500).optional(),
|
||||||
|
engine: z.enum(['qwen', 'luxtts', 'chatterbox', 'chatterbox_turbo']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GenerationFormValues = z.infer<typeof generationSchema>;
|
||||||
|
|
||||||
|
interface UseGenerationFormOptions {
|
||||||
|
onSuccess?: (generationId: string) => void;
|
||||||
|
defaultValues?: Partial<GenerationFormValues>;
|
||||||
|
getEffectsChain?: () => EffectConfig[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const generation = useGeneration();
|
||||||
|
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
|
||||||
|
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
|
||||||
|
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
|
||||||
|
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
|
||||||
|
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
|
||||||
|
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useModelDownloadToast({
|
||||||
|
modelName: downloadingModelName || '',
|
||||||
|
displayName: downloadingDisplayName || '',
|
||||||
|
enabled: !!downloadingModelName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<GenerationFormValues>({
|
||||||
|
resolver: zodResolver(generationSchema),
|
||||||
|
defaultValues: {
|
||||||
|
text: '',
|
||||||
|
language: 'en',
|
||||||
|
seed: undefined,
|
||||||
|
modelSize: '1.7B',
|
||||||
|
instruct: '',
|
||||||
|
engine: 'qwen',
|
||||||
|
...options.defaultValues,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSubmit(
|
||||||
|
data: GenerationFormValues,
|
||||||
|
selectedProfileId: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!selectedProfileId) {
|
||||||
|
toast({
|
||||||
|
title: 'No profile selected',
|
||||||
|
description: 'Please select a voice profile from the cards above.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const engine = data.engine || 'qwen';
|
||||||
|
const modelName =
|
||||||
|
engine === 'luxtts'
|
||||||
|
? 'luxtts'
|
||||||
|
: engine === 'chatterbox'
|
||||||
|
? 'chatterbox-tts'
|
||||||
|
: engine === 'chatterbox_turbo'
|
||||||
|
? 'chatterbox-turbo'
|
||||||
|
: `qwen-tts-${data.modelSize}`;
|
||||||
|
const displayName =
|
||||||
|
engine === 'luxtts'
|
||||||
|
? 'LuxTTS'
|
||||||
|
: engine === 'chatterbox'
|
||||||
|
? 'Chatterbox TTS'
|
||||||
|
: engine === 'chatterbox_turbo'
|
||||||
|
? 'Chatterbox Turbo'
|
||||||
|
: data.modelSize === '1.7B'
|
||||||
|
? 'Qwen TTS 1.7B'
|
||||||
|
: 'Qwen TTS 0.6B';
|
||||||
|
|
||||||
|
// Check if model needs downloading
|
||||||
|
try {
|
||||||
|
const modelStatus = await apiClient.getModelStatus();
|
||||||
|
const model = modelStatus.models.find((m) => m.model_name === modelName);
|
||||||
|
|
||||||
|
if (model && !model.downloaded) {
|
||||||
|
setDownloadingModelName(modelName);
|
||||||
|
setDownloadingDisplayName(displayName);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check model status:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isQwen = engine === 'qwen';
|
||||||
|
const effectsChain = options.getEffectsChain?.();
|
||||||
|
// This now returns immediately with status="generating"
|
||||||
|
const result = await generation.mutateAsync({
|
||||||
|
profile_id: selectedProfileId,
|
||||||
|
text: data.text,
|
||||||
|
language: data.language,
|
||||||
|
seed: data.seed,
|
||||||
|
model_size: isQwen ? data.modelSize : undefined,
|
||||||
|
engine,
|
||||||
|
instruct: isQwen ? data.instruct || undefined : undefined,
|
||||||
|
max_chunk_chars: maxChunkChars,
|
||||||
|
crossfade_ms: crossfadeMs,
|
||||||
|
normalize: normalizeAudio,
|
||||||
|
effects_chain: effectsChain?.length ? effectsChain : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track this generation for SSE status updates
|
||||||
|
addPendingGeneration(result.id);
|
||||||
|
|
||||||
|
// Reset form immediately — user can start typing again
|
||||||
|
form.reset({
|
||||||
|
text: '',
|
||||||
|
language: data.language,
|
||||||
|
seed: undefined,
|
||||||
|
modelSize: data.modelSize,
|
||||||
|
instruct: '',
|
||||||
|
engine: data.engine,
|
||||||
|
});
|
||||||
|
options.onSuccess?.(result.id);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Generation failed',
|
||||||
|
description: error instanceof Error ? error.message : 'Failed to generate audio',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDownloadingModelName(null);
|
||||||
|
setDownloadingDisplayName(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
handleSubmit,
|
||||||
|
isPending: generation.isPending,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
|
interface GenerationStatusEvent {
|
||||||
|
id: string;
|
||||||
|
status: 'generating' | 'completed' | 'failed' | 'not_found';
|
||||||
|
duration?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to SSE for all pending generations. When a generation completes,
|
||||||
|
* invalidates the history query, removes it from pending, and auto-plays
|
||||||
|
* if the player is idle.
|
||||||
|
*/
|
||||||
|
export function useGenerationProgress() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const pendingIds = useGenerationStore((s) => s.pendingGenerationIds);
|
||||||
|
const removePendingGeneration = useGenerationStore((s) => s.removePendingGeneration);
|
||||||
|
const removePendingStoryAdd = useGenerationStore((s) => s.removePendingStoryAdd);
|
||||||
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||||
|
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
|
||||||
|
const autoplayOnGenerate = useServerStore((s) => s.autoplayOnGenerate);
|
||||||
|
|
||||||
|
// Keep refs to avoid stale closures in EventSource handlers
|
||||||
|
const isPlayingRef = useRef(isPlaying);
|
||||||
|
const autoplayRef = useRef(autoplayOnGenerate);
|
||||||
|
isPlayingRef.current = isPlaying;
|
||||||
|
autoplayRef.current = autoplayOnGenerate;
|
||||||
|
|
||||||
|
// Track active EventSource instances
|
||||||
|
const eventSourcesRef = useRef<Map<string, EventSource>>(new Map());
|
||||||
|
|
||||||
|
// Unmount-only cleanup — close all SSE connections when the hook is torn down
|
||||||
|
useEffect(() => {
|
||||||
|
const sources = eventSourcesRef.current;
|
||||||
|
return () => {
|
||||||
|
for (const source of sources.values()) {
|
||||||
|
source.close();
|
||||||
|
}
|
||||||
|
sources.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentSources = eventSourcesRef.current;
|
||||||
|
|
||||||
|
// Close SSE connections for IDs no longer pending
|
||||||
|
for (const [id, source] of currentSources.entries()) {
|
||||||
|
if (!pendingIds.has(id)) {
|
||||||
|
source.close();
|
||||||
|
currentSources.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open SSE connections for new pending IDs
|
||||||
|
for (const id of pendingIds) {
|
||||||
|
if (currentSources.has(id)) continue;
|
||||||
|
|
||||||
|
const url = apiClient.getGenerationStatusUrl(id);
|
||||||
|
const source = new EventSource(url);
|
||||||
|
|
||||||
|
source.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data: GenerationStatusEvent = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
source.close();
|
||||||
|
currentSources.delete(id);
|
||||||
|
removePendingGeneration(id);
|
||||||
|
|
||||||
|
// Refresh history to pick up the completed generation
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
|
||||||
|
// If this generation was queued for a story, add it now
|
||||||
|
const storyId = removePendingStoryAdd(id);
|
||||||
|
if (storyId) {
|
||||||
|
apiClient
|
||||||
|
.addStoryItem(storyId, { generation_id: id })
|
||||||
|
.then(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', storyId] });
|
||||||
|
toast({
|
||||||
|
title: 'Added to story',
|
||||||
|
description: data.duration
|
||||||
|
? `Audio generated (${data.duration.toFixed(2)}s) and added to story`
|
||||||
|
: 'Audio generated and added to story',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast({
|
||||||
|
title: 'Generation complete',
|
||||||
|
description: 'Audio generated but failed to add to story',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// toast({
|
||||||
|
// title: 'Generation complete!',
|
||||||
|
// description: data.duration
|
||||||
|
// ? `Audio generated (${data.duration.toFixed(2)}s)`
|
||||||
|
// : 'Audio generated',
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-play if enabled and nothing is currently playing
|
||||||
|
if (autoplayRef.current && !isPlayingRef.current) {
|
||||||
|
const genAudioUrl = apiClient.getAudioUrl(id);
|
||||||
|
setAudioWithAutoPlay(genAudioUrl, id, '', '');
|
||||||
|
}
|
||||||
|
} else if (data.status === 'failed' || data.status === 'not_found') {
|
||||||
|
source.close();
|
||||||
|
currentSources.delete(id);
|
||||||
|
removePendingGeneration(id);
|
||||||
|
removePendingStoryAdd(id);
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: data.status === 'not_found' ? 'Generation not found' : 'Generation failed',
|
||||||
|
description: data.error || 'An error occurred during generation',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors from heartbeats etc
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
source.onerror = () => {
|
||||||
|
// EventSource auto-reconnects, but if we get repeated errors
|
||||||
|
// just clean up
|
||||||
|
source.close();
|
||||||
|
currentSources.delete(id);
|
||||||
|
removePendingGeneration(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
currentSources.set(id, source);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
pendingIds,
|
||||||
|
removePendingGeneration,
|
||||||
|
removePendingStoryAdd,
|
||||||
|
queryClient,
|
||||||
|
toast,
|
||||||
|
setAudioWithAutoPlay,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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 { HistoryQuery } from '@/lib/api/types';
|
import type { HistoryQuery } from '@/lib/api/types';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
export function useHistory(query?: HistoryQuery) {
|
export function useHistory(query?: HistoryQuery) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -27,3 +28,66 @@ export function useDeleteGeneration() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useExportGeneration() {
|
||||||
|
const platform = usePlatform();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
|
||||||
|
const blob = await apiClient.exportGeneration(generationId);
|
||||||
|
|
||||||
|
// Create safe filename from text
|
||||||
|
const safeText = text
|
||||||
|
.substring(0, 30)
|
||||||
|
.replace(/[^a-z0-9]/gi, '-')
|
||||||
|
.toLowerCase();
|
||||||
|
const filename = `generation-${safeText}.voicebox.zip`;
|
||||||
|
|
||||||
|
await platform.filesystem.saveFile(filename, blob, [
|
||||||
|
{
|
||||||
|
name: 'Voicebox Generation',
|
||||||
|
extensions: ['zip'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return blob;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExportGenerationAudio() {
|
||||||
|
const platform = usePlatform();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
|
||||||
|
const blob = await apiClient.exportGenerationAudio(generationId);
|
||||||
|
|
||||||
|
// Create safe filename from text
|
||||||
|
const safeText = text
|
||||||
|
.substring(0, 30)
|
||||||
|
.replace(/[^a-z0-9]/gi, '-')
|
||||||
|
.toLowerCase();
|
||||||
|
const filename = `${safeText}.wav`;
|
||||||
|
|
||||||
|
await platform.filesystem.saveFile(filename, blob, [
|
||||||
|
{
|
||||||
|
name: 'Audio File',
|
||||||
|
extensions: ['wav'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return blob;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useImportGeneration() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (file: File) => apiClient.importGeneration(file),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import type { ModelProgress } from '@/lib/api/types';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
|
interface UseModelDownloadToastOptions {
|
||||||
|
modelName: string;
|
||||||
|
displayName: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
onComplete?: () => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to show and update a toast notification with model download progress.
|
||||||
|
* Subscribes to Server-Sent Events for real-time progress updates.
|
||||||
|
*/
|
||||||
|
export function useModelDownloadToast({
|
||||||
|
modelName,
|
||||||
|
displayName,
|
||||||
|
enabled = false,
|
||||||
|
onComplete,
|
||||||
|
onError,
|
||||||
|
}: UseModelDownloadToastOptions) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
|
const toastIdRef = useRef<string | null>(null);
|
||||||
|
// biome-ignore lint: Using any for toast update ref to handle complex toast types
|
||||||
|
const toastUpdateRef = useRef<any>(null);
|
||||||
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
|
||||||
|
const formatBytes = useCallback((bytes: number): string => {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[useModelDownloadToast] useEffect triggered', {
|
||||||
|
enabled,
|
||||||
|
serverUrl,
|
||||||
|
modelName,
|
||||||
|
displayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!enabled || !serverUrl || !modelName) {
|
||||||
|
console.log('[useModelDownloadToast] Not enabled, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[useModelDownloadToast] Creating toast and EventSource for:', modelName);
|
||||||
|
|
||||||
|
// Create initial toast
|
||||||
|
const toastResult = toast({
|
||||||
|
title: displayName,
|
||||||
|
description: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span>Connecting to download...</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
duration: Infinity, // Don't auto-dismiss, we'll handle it manually
|
||||||
|
});
|
||||||
|
toastIdRef.current = toastResult.id;
|
||||||
|
toastUpdateRef.current = toastResult.update;
|
||||||
|
|
||||||
|
// Subscribe to progress updates via Server-Sent Events
|
||||||
|
const eventSourceUrl = `${serverUrl}/models/progress/${modelName}`;
|
||||||
|
console.log('[useModelDownloadToast] Creating EventSource to:', eventSourceUrl);
|
||||||
|
const eventSource = new EventSource(eventSourceUrl);
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
console.log('[useModelDownloadToast] EventSource connection opened for:', modelName);
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
console.log('[useModelDownloadToast] Received SSE message:', event.data);
|
||||||
|
try {
|
||||||
|
const progress = JSON.parse(event.data) as ModelProgress;
|
||||||
|
|
||||||
|
// Update toast with progress
|
||||||
|
if (toastIdRef.current && toastUpdateRef.current) {
|
||||||
|
const progressPercent = progress.total > 0 ? progress.progress : 0;
|
||||||
|
const progressText =
|
||||||
|
progress.total > 0
|
||||||
|
? `${formatBytes(progress.current)} / ${formatBytes(progress.total)} (${progress.progress.toFixed(1)}%)`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Determine status icon and text
|
||||||
|
let statusIcon: React.ReactNode = null;
|
||||||
|
let statusText = 'Processing...';
|
||||||
|
|
||||||
|
switch (progress.status) {
|
||||||
|
case 'complete':
|
||||||
|
statusIcon = <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
||||||
|
statusText = 'Download complete';
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
statusIcon = <XCircle className="h-4 w-4 text-destructive" />;
|
||||||
|
statusText = 'Download failed. See Problems panel for details.';
|
||||||
|
break;
|
||||||
|
case 'downloading':
|
||||||
|
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
||||||
|
statusText = progress.filename || 'Downloading...';
|
||||||
|
break;
|
||||||
|
case 'extracting':
|
||||||
|
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
||||||
|
statusText = 'Extracting...';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
toastUpdateRef.current({
|
||||||
|
title: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{statusIcon}
|
||||||
|
<span>{displayName}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
description: (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm">{statusText}</div>
|
||||||
|
{progress.total > 0 && (
|
||||||
|
<>
|
||||||
|
<Progress value={progressPercent} className="h-2" />
|
||||||
|
<div className="text-xs text-muted-foreground">{progressText}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
duration: progress.status === 'complete' || progress.status === 'error' ? 5000 : Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close connection and dismiss toast on completion or error
|
||||||
|
// Also treat progress >= 100% as complete
|
||||||
|
const isComplete = progress.status === 'complete' || progress.progress >= 100;
|
||||||
|
const isError = progress.status === 'error';
|
||||||
|
|
||||||
|
if (isComplete || isError) {
|
||||||
|
console.log('[useModelDownloadToast] Download finished:', {
|
||||||
|
isComplete,
|
||||||
|
isError,
|
||||||
|
progress: progress.progress,
|
||||||
|
});
|
||||||
|
eventSource.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
|
||||||
|
// Update toast to show completion state before callbacks
|
||||||
|
if (isComplete && toastUpdateRef.current) {
|
||||||
|
toastUpdateRef.current({
|
||||||
|
title: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
|
<span>{displayName}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
description: 'Download complete',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call callbacks
|
||||||
|
if (isComplete && onComplete) {
|
||||||
|
console.log('[useModelDownloadToast] Download complete, calling onComplete callback');
|
||||||
|
onComplete();
|
||||||
|
} else if (isError && onError) {
|
||||||
|
console.log('[useModelDownloadToast] Download error, calling onError callback');
|
||||||
|
onError(progress.error || 'Unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing progress event:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('[useModelDownloadToast] SSE error for:', modelName, error);
|
||||||
|
console.log('[useModelDownloadToast] EventSource readyState:', eventSource.readyState);
|
||||||
|
eventSource.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
|
||||||
|
// Show error toast
|
||||||
|
if (toastIdRef.current && toastUpdateRef.current) {
|
||||||
|
toastUpdateRef.current({
|
||||||
|
title: displayName,
|
||||||
|
description: 'Failed to track download progress',
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
toastIdRef.current = null;
|
||||||
|
toastUpdateRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSourceRef.current = eventSource;
|
||||||
|
|
||||||
|
// Cleanup on unmount or when disabled
|
||||||
|
return () => {
|
||||||
|
console.log('[useModelDownloadToast] Cleanup - closing EventSource for:', modelName);
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
}
|
||||||
|
// Note: We don't dismiss the toast here as it might still be showing completion state
|
||||||
|
};
|
||||||
|
}, [enabled, serverUrl, modelName, displayName, toast, formatBytes, onComplete, onError]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isTracking: enabled && eventSourceRef.current !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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 { VoiceProfileCreate } from '@/lib/api/types';
|
import type { VoiceProfileCreate } from '@/lib/api/types';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
export function useProfiles() {
|
export function useProfiles() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -96,3 +97,85 @@ export function useDeleteSample() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUpdateSample() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ sampleId, referenceText }: { sampleId: string; referenceText: string }) =>
|
||||||
|
apiClient.updateProfileSample(sampleId, referenceText),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['profiles', data.profile_id, 'samples'],
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['profiles', data.profile_id],
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExportProfile() {
|
||||||
|
const platform = usePlatform();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (profileId: string) => {
|
||||||
|
const blob = await apiClient.exportProfile(profileId);
|
||||||
|
|
||||||
|
// Get profile name for filename
|
||||||
|
const profile = await apiClient.getProfile(profileId);
|
||||||
|
const safeName = profile.name.replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||||
|
const filename = `profile-${safeName}.voicebox.zip`;
|
||||||
|
|
||||||
|
await platform.filesystem.saveFile(filename, blob, [
|
||||||
|
{
|
||||||
|
name: 'Voicebox Profile',
|
||||||
|
extensions: ['zip'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return blob;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useImportProfile() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (file: File) => apiClient.importProfile(file),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadAvatar() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ profileId, file }: { profileId: string; file: File }) =>
|
||||||
|
apiClient.uploadAvatar(profileId, file),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['profiles', variables.profileId],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteAvatar() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (profileId: string) => apiClient.deleteAvatar(profileId),
|
||||||
|
onSuccess: (_, profileId) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['profiles', profileId],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { ActiveDownloadTask } from '@/lib/api/types';
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
|
|
||||||
|
// Polling interval in milliseconds
|
||||||
|
const POLL_INTERVAL = 30000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to monitor active tasks (downloads and generations).
|
||||||
|
* Polls the server periodically to catch downloads triggered from anywhere
|
||||||
|
* (transcription, generation, explicit download, etc.).
|
||||||
|
*
|
||||||
|
* Returns the active downloads so components can render download toasts.
|
||||||
|
*/
|
||||||
|
export function useRestoreActiveTasks() {
|
||||||
|
const [activeDownloads, setActiveDownloads] = useState<ActiveDownloadTask[]>([]);
|
||||||
|
const setActiveGenerationId = useGenerationStore((state) => state.setActiveGenerationId);
|
||||||
|
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
|
||||||
|
|
||||||
|
// Track which downloads we've seen to detect new ones
|
||||||
|
const seenDownloadsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const fetchActiveTasks = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const tasks = await apiClient.getActiveTasks();
|
||||||
|
|
||||||
|
// Restore pending generations (e.g., after page refresh)
|
||||||
|
if (tasks.generations.length > 0) {
|
||||||
|
setActiveGenerationId(tasks.generations[0].task_id);
|
||||||
|
for (const gen of tasks.generations) {
|
||||||
|
addPendingGeneration(gen.task_id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const currentId = useGenerationStore.getState().activeGenerationId;
|
||||||
|
if (currentId) {
|
||||||
|
setActiveGenerationId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update active downloads
|
||||||
|
// Keep track of all active downloads (including new ones)
|
||||||
|
const currentDownloadNames = new Set(tasks.downloads.map((d) => d.model_name));
|
||||||
|
|
||||||
|
// Remove completed downloads from our seen set
|
||||||
|
for (const name of seenDownloadsRef.current) {
|
||||||
|
if (!currentDownloadNames.has(name)) {
|
||||||
|
seenDownloadsRef.current.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new downloads to seen set
|
||||||
|
for (const download of tasks.downloads) {
|
||||||
|
seenDownloadsRef.current.add(download.model_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveDownloads(tasks.downloads);
|
||||||
|
} catch (error) {
|
||||||
|
// Silently fail - server might be temporarily unavailable
|
||||||
|
console.debug('Failed to fetch active tasks:', error);
|
||||||
|
}
|
||||||
|
}, [setActiveGenerationId, addPendingGeneration]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch immediately on mount
|
||||||
|
fetchActiveTasks();
|
||||||
|
|
||||||
|
// Poll for active tasks
|
||||||
|
const interval = setInterval(fetchActiveTasks, POLL_INTERVAL);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchActiveTasks]);
|
||||||
|
|
||||||
|
return activeDownloads;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map model names to display names for download toasts.
|
||||||
|
*/
|
||||||
|
export const MODEL_DISPLAY_NAMES: Record<string, string> = {
|
||||||
|
'qwen-tts-1.7B': 'Qwen TTS 1.7B',
|
||||||
|
'qwen-tts-0.6B': 'Qwen TTS 0.6B',
|
||||||
|
'whisper-base': 'Whisper Base',
|
||||||
|
'whisper-small': 'Whisper Small',
|
||||||
|
'whisper-medium': 'Whisper Medium',
|
||||||
|
'whisper-large': 'Whisper Large',
|
||||||
|
};
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type {
|
||||||
|
StoryCreate,
|
||||||
|
StoryItemBatchUpdate,
|
||||||
|
StoryItemCreate,
|
||||||
|
StoryItemMove,
|
||||||
|
StoryItemReorder,
|
||||||
|
StoryItemSplit,
|
||||||
|
StoryItemTrim,
|
||||||
|
StoryItemVersionUpdate,
|
||||||
|
} from '@/lib/api/types';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
|
export function useStories() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['stories'],
|
||||||
|
queryFn: () => apiClient.listStories(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStory(storyId: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['stories', storyId],
|
||||||
|
queryFn: () => apiClient.getStory(storyId!),
|
||||||
|
enabled: !!storyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateStory() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: StoryCreate) => apiClient.createStory(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateStory() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, data }: { storyId: string; data: StoryCreate }) =>
|
||||||
|
apiClient.updateStory(storyId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteStory() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (storyId: string) => apiClient.deleteStory(storyId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddStoryItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, data }: { storyId: string; data: StoryItemCreate }) =>
|
||||||
|
apiClient.addStoryItem(storyId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveStoryItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, itemId }: { storyId: string; itemId: string }) =>
|
||||||
|
apiClient.removeStoryItem(storyId, itemId),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateStoryItemTimes() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, data }: { storyId: string; data: StoryItemBatchUpdate }) =>
|
||||||
|
apiClient.updateStoryItemTimes(storyId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReorderStoryItems() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, data }: { storyId: string; data: StoryItemReorder }) =>
|
||||||
|
apiClient.reorderStoryItems(storyId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMoveStoryItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
storyId,
|
||||||
|
itemId,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
storyId: string;
|
||||||
|
itemId: string;
|
||||||
|
data: StoryItemMove;
|
||||||
|
}) => apiClient.moveStoryItem(storyId, itemId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTrimStoryItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
storyId,
|
||||||
|
itemId,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
storyId: string;
|
||||||
|
itemId: string;
|
||||||
|
data: StoryItemTrim;
|
||||||
|
}) => apiClient.trimStoryItem(storyId, itemId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSplitStoryItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
storyId,
|
||||||
|
itemId,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
storyId: string;
|
||||||
|
itemId: string;
|
||||||
|
data: StoryItemSplit;
|
||||||
|
}) => apiClient.splitStoryItem(storyId, itemId, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDuplicateStoryItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ storyId, itemId }: { storyId: string; itemId: string }) =>
|
||||||
|
apiClient.duplicateStoryItem(storyId, itemId),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function 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() {
|
||||||
|
const platform = usePlatform();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ storyId, storyName }: { storyId: string; storyName: string }) => {
|
||||||
|
const blob = await apiClient.exportStoryAudio(storyId);
|
||||||
|
|
||||||
|
// Create safe filename
|
||||||
|
const safeName = storyName
|
||||||
|
.substring(0, 50)
|
||||||
|
.replace(/[^a-z0-9]/gi, '-')
|
||||||
|
.toLowerCase();
|
||||||
|
const filename = `${safeName || 'story'}.wav`;
|
||||||
|
|
||||||
|
await platform.filesystem.saveFile(filename, blob, [
|
||||||
|
{
|
||||||
|
name: 'Audio File',
|
||||||
|
extensions: ['wav'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return blob;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { StoryItemDetail } from '@/lib/api/types';
|
||||||
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
|
||||||
|
interface ActiveSource {
|
||||||
|
source: AudioBufferSourceNode;
|
||||||
|
itemId: string;
|
||||||
|
generationId: string;
|
||||||
|
startTimeMs: number;
|
||||||
|
endTimeMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing timecode-based story playback using Web Audio API.
|
||||||
|
* Supports multiple simultaneous audio sources for overlapping clips on different tracks.
|
||||||
|
* Uses AudioContext for sample-accurate timing synchronization.
|
||||||
|
*/
|
||||||
|
export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
||||||
|
const isPlaying = useStoryStore((state) => state.isPlaying);
|
||||||
|
const playbackItems = useStoryStore((state) => state.playbackItems);
|
||||||
|
const playbackStartContextTime = useStoryStore((state) => state.playbackStartContextTime);
|
||||||
|
const playbackStartStoryTime = useStoryStore((state) => state.playbackStartStoryTime);
|
||||||
|
const setPlaybackTiming = useStoryStore((state) => state.setPlaybackTiming);
|
||||||
|
|
||||||
|
// AudioContext instance (created once)
|
||||||
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
// Master gain for volume control
|
||||||
|
const masterGainRef = useRef<GainNode | null>(null);
|
||||||
|
// Preloaded AudioBuffers by generation_id (audio file is shared between split clips)
|
||||||
|
const audioBuffersRef = useRef<Map<string, AudioBuffer>>(new Map());
|
||||||
|
// Currently playing AudioBufferSourceNodes by item.id (unique per clip)
|
||||||
|
const activeSourcesRef = useRef<Map<string, ActiveSource>>(new Map());
|
||||||
|
// Animation frame for syncing visual playhead
|
||||||
|
const animationFrameRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Get or create AudioContext and audio graph
|
||||||
|
const getAudioContext = useCallback(() => {
|
||||||
|
if (!audioContextRef.current) {
|
||||||
|
audioContextRef.current = new AudioContext();
|
||||||
|
console.log(
|
||||||
|
'[StoryPlayback] Created AudioContext, sample rate:',
|
||||||
|
audioContextRef.current.sampleRate,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create master gain node for volume control
|
||||||
|
masterGainRef.current = audioContextRef.current.createGain();
|
||||||
|
masterGainRef.current.gain.value = 1;
|
||||||
|
masterGainRef.current.connect(audioContextRef.current.destination);
|
||||||
|
}
|
||||||
|
// Resume context if suspended (browser autoplay policy)
|
||||||
|
if (audioContextRef.current.state === 'suspended') {
|
||||||
|
audioContextRef.current.resume().catch(() => {
|
||||||
|
// Ignore resume errors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return audioContextRef.current;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Stop a source by item id
|
||||||
|
const stopSource = useCallback((itemId: string) => {
|
||||||
|
const activeSource = activeSourcesRef.current.get(itemId);
|
||||||
|
if (activeSource) {
|
||||||
|
try {
|
||||||
|
activeSource.source.stop();
|
||||||
|
} catch {
|
||||||
|
// Source may have already stopped
|
||||||
|
}
|
||||||
|
activeSourcesRef.current.delete(itemId);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
useEffect(() => {
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
// Clear preloaded buffers when no items
|
||||||
|
audioBuffersRef.current.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentKeys = new Set(items.map(getAudioKey));
|
||||||
|
const audioContext = getAudioContext();
|
||||||
|
|
||||||
|
// Remove buffers for items that no longer exist
|
||||||
|
for (const [id] of audioBuffersRef.current) {
|
||||||
|
if (!currentKeys.has(id)) {
|
||||||
|
audioBuffersRef.current.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preload audio for new items
|
||||||
|
const preloadPromises: Promise<void>[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const key = getAudioKey(item);
|
||||||
|
if (!audioBuffersRef.current.has(key)) {
|
||||||
|
const audioUrl = getAudioUrlForItem(item);
|
||||||
|
console.log('[StoryPlayback] Preloading audio buffer:', key);
|
||||||
|
|
||||||
|
const preloadPromise = fetch(audioUrl)
|
||||||
|
.then((response) => response.arrayBuffer())
|
||||||
|
.then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer))
|
||||||
|
.then((audioBuffer) => {
|
||||||
|
audioBuffersRef.current.set(key, audioBuffer);
|
||||||
|
console.log(
|
||||||
|
'[StoryPlayback] Preloaded buffer:',
|
||||||
|
key,
|
||||||
|
'duration:',
|
||||||
|
audioBuffer.duration,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[StoryPlayback] Failed to preload audio:', key, err);
|
||||||
|
});
|
||||||
|
|
||||||
|
preloadPromises.push(preloadPromise);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all(preloadPromises).then(() => {
|
||||||
|
console.log('[StoryPlayback] Preloaded', audioBuffersRef.current.size, 'audio buffers');
|
||||||
|
});
|
||||||
|
}, [items, getAudioContext]);
|
||||||
|
|
||||||
|
// Cleanup AudioContext on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
// Stop all sources
|
||||||
|
for (const [itemId] of activeSourcesRef.current) {
|
||||||
|
stopSource(itemId);
|
||||||
|
}
|
||||||
|
activeSourcesRef.current.clear();
|
||||||
|
|
||||||
|
// Clean up audio graph
|
||||||
|
if (masterGainRef.current) {
|
||||||
|
masterGainRef.current.disconnect();
|
||||||
|
masterGainRef.current = null;
|
||||||
|
}
|
||||||
|
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
|
||||||
|
audioContextRef.current.close().catch(() => {
|
||||||
|
// Ignore errors when closing
|
||||||
|
});
|
||||||
|
audioContextRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animationFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [stopSource]);
|
||||||
|
|
||||||
|
// Find ALL items that should be playing at a given story time
|
||||||
|
const findActiveItems = useCallback(
|
||||||
|
(storyTimeMs: number, itemList: StoryItemDetail[]): StoryItemDetail[] => {
|
||||||
|
return itemList.filter((item) => {
|
||||||
|
const itemStart = item.start_time_ms;
|
||||||
|
// Use effective duration (accounting for trims)
|
||||||
|
const trimStartMs = item.trim_start_ms || 0;
|
||||||
|
const trimEndMs = item.trim_end_ms || 0;
|
||||||
|
const effectiveDurationMs = item.duration * 1000 - trimStartMs - trimEndMs;
|
||||||
|
const itemEnd = item.start_time_ms + effectiveDurationMs;
|
||||||
|
return storyTimeMs >= itemStart && storyTimeMs < itemEnd;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert AudioContext time to story time (ms)
|
||||||
|
const contextTimeToStoryTime = useCallback(
|
||||||
|
(contextTime: number): number => {
|
||||||
|
if (playbackStartContextTime === null || playbackStartStoryTime === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const elapsedContextTime = contextTime - playbackStartContextTime;
|
||||||
|
return playbackStartStoryTime + elapsedContextTime * 1000;
|
||||||
|
},
|
||||||
|
[playbackStartContextTime, playbackStartStoryTime],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert story time (ms) to AudioContext time
|
||||||
|
const storyTimeToContextTime = useCallback(
|
||||||
|
(storyTimeMs: number): number => {
|
||||||
|
if (playbackStartContextTime === null || playbackStartStoryTime === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const elapsedStoryTime = (storyTimeMs - playbackStartStoryTime) / 1000;
|
||||||
|
return playbackStartContextTime + elapsedStoryTime;
|
||||||
|
},
|
||||||
|
[playbackStartContextTime, playbackStartStoryTime],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stop all sources
|
||||||
|
const stopAllSources = useCallback(() => {
|
||||||
|
console.log('[StoryPlayback] Stopping all sources');
|
||||||
|
for (const [itemId] of activeSourcesRef.current) {
|
||||||
|
stopSource(itemId);
|
||||||
|
}
|
||||||
|
activeSourcesRef.current.clear();
|
||||||
|
}, [stopSource]);
|
||||||
|
|
||||||
|
// Schedule playback for all items that should be playing
|
||||||
|
const schedulePlayback = useCallback(
|
||||||
|
(storyTimeMs: number, itemList: StoryItemDetail[]) => {
|
||||||
|
const audioContext = getAudioContext();
|
||||||
|
const currentContextTime = audioContext.currentTime;
|
||||||
|
|
||||||
|
// Find all items that should be playing
|
||||||
|
const shouldBePlaying = findActiveItems(storyTimeMs, itemList);
|
||||||
|
const shouldBePlayingIds = new Set(shouldBePlaying.map((item) => item.id));
|
||||||
|
|
||||||
|
// Stop sources that shouldn't be playing anymore
|
||||||
|
for (const [itemId] of activeSourcesRef.current) {
|
||||||
|
if (!shouldBePlayingIds.has(itemId)) {
|
||||||
|
stopSource(itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule new sources for items that should be playing
|
||||||
|
for (const item of shouldBePlaying) {
|
||||||
|
if (!activeSourcesRef.current.has(item.id)) {
|
||||||
|
const bufferKey = getAudioKey(item);
|
||||||
|
const buffer = audioBuffersRef.current.get(bufferKey);
|
||||||
|
if (!buffer) {
|
||||||
|
console.warn('[StoryPlayback] Buffer not loaded for:', bufferKey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate when this item should start in AudioContext time
|
||||||
|
const itemStartContextTime = storyTimeToContextTime(item.start_time_ms);
|
||||||
|
|
||||||
|
// Calculate effective duration and trim offsets
|
||||||
|
const trimStartSec = (item.trim_start_ms || 0) / 1000;
|
||||||
|
const trimEndSec = (item.trim_end_ms || 0) / 1000;
|
||||||
|
const effectiveDuration = item.duration - trimStartSec - trimEndSec;
|
||||||
|
const itemEndStoryTime = item.start_time_ms + effectiveDuration * 1000;
|
||||||
|
|
||||||
|
// Calculate offset into the buffer (if seeking mid-way)
|
||||||
|
// Offset is relative to the trimmed start of the clip
|
||||||
|
const offsetIntoEffectiveClip = Math.max(0, (storyTimeMs - item.start_time_ms) / 1000);
|
||||||
|
const offsetIntoBuffer = trimStartSec + offsetIntoEffectiveClip;
|
||||||
|
const duration = effectiveDuration - offsetIntoEffectiveClip;
|
||||||
|
|
||||||
|
// If the item should have already started, schedule it to start immediately
|
||||||
|
const startAtContextTime = Math.max(currentContextTime, itemStartContextTime);
|
||||||
|
|
||||||
|
console.log('[StoryPlayback] Scheduling source:', {
|
||||||
|
itemId: item.id,
|
||||||
|
generationId: item.generation_id,
|
||||||
|
storyTimeMs,
|
||||||
|
itemStart: item.start_time_ms,
|
||||||
|
offsetIntoBuffer,
|
||||||
|
startAtContextTime,
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
const source = audioContext.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.connect(masterGainRef.current || audioContext.destination);
|
||||||
|
|
||||||
|
const activeSource: ActiveSource = {
|
||||||
|
source,
|
||||||
|
itemId: item.id,
|
||||||
|
generationId: item.generation_id,
|
||||||
|
startTimeMs: item.start_time_ms,
|
||||||
|
endTimeMs: itemEndStoryTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
activeSourcesRef.current.set(item.id, activeSource);
|
||||||
|
|
||||||
|
// Schedule playback
|
||||||
|
source.start(startAtContextTime, offsetIntoBuffer, duration);
|
||||||
|
|
||||||
|
// Clean up when source ends
|
||||||
|
source.onended = () => {
|
||||||
|
console.log('[StoryPlayback] Source ended:', item.id);
|
||||||
|
activeSourcesRef.current.delete(item.id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getAudioContext, findActiveItems, storyTimeToContextTime, stopSource],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync visual playhead from AudioContext time
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying || playbackStartContextTime === null || playbackStartStoryTime === null) {
|
||||||
|
if (animationFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioContext = getAudioContext();
|
||||||
|
const itemList = playbackItems || [];
|
||||||
|
|
||||||
|
const syncPlayhead = () => {
|
||||||
|
if (!useStoryStore.getState().isPlaying) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentContextTime = audioContext.currentTime;
|
||||||
|
const currentStoryTime = contextTimeToStoryTime(currentContextTime);
|
||||||
|
const totalDuration = useStoryStore.getState().totalDurationMs;
|
||||||
|
|
||||||
|
// Update store with current story time
|
||||||
|
useStoryStore.setState({ currentTimeMs: Math.min(currentStoryTime, totalDuration) });
|
||||||
|
|
||||||
|
// Schedule any items that should be playing
|
||||||
|
schedulePlayback(currentStoryTime, itemList);
|
||||||
|
|
||||||
|
// Check if we've reached the end
|
||||||
|
if (currentStoryTime >= totalDuration) {
|
||||||
|
// Check if all sources have ended
|
||||||
|
if (activeSourcesRef.current.size === 0) {
|
||||||
|
console.log('[StoryPlayback] Reached end');
|
||||||
|
useStoryStore.getState().stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue sync loop
|
||||||
|
animationFrameRef.current = requestAnimationFrame(syncPlayhead);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial sync
|
||||||
|
const currentContextTime = audioContext.currentTime;
|
||||||
|
const currentStoryTime = contextTimeToStoryTime(currentContextTime);
|
||||||
|
schedulePlayback(currentStoryTime, itemList);
|
||||||
|
|
||||||
|
// Start sync loop
|
||||||
|
animationFrameRef.current = requestAnimationFrame(syncPlayhead);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
isPlaying,
|
||||||
|
playbackItems,
|
||||||
|
playbackStartContextTime,
|
||||||
|
playbackStartStoryTime,
|
||||||
|
getAudioContext,
|
||||||
|
contextTimeToStoryTime,
|
||||||
|
schedulePlayback,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Handle play/pause changes - stop sources when paused
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying) {
|
||||||
|
console.log('[StoryPlayback] Stopping playback');
|
||||||
|
stopAllSources();
|
||||||
|
}
|
||||||
|
}, [isPlaying, stopAllSources]);
|
||||||
|
|
||||||
|
// Handle seek - reset timing anchors when they become null (triggered by seek)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying || !playbackItems || playbackItems.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only run when timing anchors are null (after a seek)
|
||||||
|
if (playbackStartContextTime !== null && playbackStartStoryTime !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioContext = getAudioContext();
|
||||||
|
const currentContextTime = audioContext.currentTime;
|
||||||
|
const currentStoryTime = useStoryStore.getState().currentTimeMs;
|
||||||
|
|
||||||
|
console.log('[StoryPlayback] Setting timing anchors after seek:', {
|
||||||
|
contextTime: currentContextTime,
|
||||||
|
storyTime: currentStoryTime,
|
||||||
|
});
|
||||||
|
setPlaybackTiming(currentContextTime, currentStoryTime);
|
||||||
|
|
||||||
|
// Stop all existing sources and reschedule from new position
|
||||||
|
stopAllSources();
|
||||||
|
schedulePlayback(currentStoryTime, playbackItems);
|
||||||
|
}, [
|
||||||
|
isPlaying,
|
||||||
|
playbackItems,
|
||||||
|
playbackStartContextTime,
|
||||||
|
playbackStartStoryTime,
|
||||||
|
getAudioContext,
|
||||||
|
stopAllSources,
|
||||||
|
schedulePlayback,
|
||||||
|
setPlaybackTiming,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
|
interface UseSystemAudioCaptureOptions {
|
||||||
|
maxDurationSeconds?: number;
|
||||||
|
onRecordingComplete?: (blob: Blob, duration?: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for native system audio capture using Tauri commands.
|
||||||
|
* Uses ScreenCaptureKit on macOS and WASAPI loopback on Windows.
|
||||||
|
*/
|
||||||
|
export function useSystemAudioCapture({
|
||||||
|
maxDurationSeconds = 29,
|
||||||
|
onRecordingComplete,
|
||||||
|
}: UseSystemAudioCaptureOptions = {}) {
|
||||||
|
const platform = usePlatform();
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isSupported, setIsSupported] = useState(false);
|
||||||
|
const timerRef = useRef<number | null>(null);
|
||||||
|
const startTimeRef = useRef<number | null>(null);
|
||||||
|
const stopRecordingRef = useRef<(() => Promise<void>) | null>(null);
|
||||||
|
const isRecordingRef = useRef(false);
|
||||||
|
|
||||||
|
// Check if system audio capture is supported
|
||||||
|
useEffect(() => {
|
||||||
|
const supported = platform.audio.isSystemAudioSupported();
|
||||||
|
setIsSupported(supported);
|
||||||
|
}, [platform]);
|
||||||
|
|
||||||
|
const startRecording = useCallback(async () => {
|
||||||
|
if (!platform.metadata.isTauri) {
|
||||||
|
const errorMsg = 'System audio capture is only available in the desktop app.';
|
||||||
|
setError(errorMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSupported) {
|
||||||
|
const errorMsg = 'System audio capture is not supported on this platform.';
|
||||||
|
setError(errorMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
setDuration(0);
|
||||||
|
|
||||||
|
// Start native capture
|
||||||
|
await platform.audio.startSystemAudioCapture(maxDurationSeconds);
|
||||||
|
|
||||||
|
setIsRecording(true);
|
||||||
|
isRecordingRef.current = true;
|
||||||
|
startTimeRef.current = Date.now();
|
||||||
|
|
||||||
|
// Start timer
|
||||||
|
timerRef.current = window.setInterval(() => {
|
||||||
|
if (startTimeRef.current) {
|
||||||
|
const elapsed = (Date.now() - startTimeRef.current) / 1000;
|
||||||
|
setDuration(elapsed);
|
||||||
|
|
||||||
|
// Auto-stop at max duration
|
||||||
|
if (elapsed >= maxDurationSeconds && stopRecordingRef.current) {
|
||||||
|
void stopRecordingRef.current();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Failed to start system audio capture. Please check permissions.';
|
||||||
|
setError(errorMessage);
|
||||||
|
setIsRecording(false);
|
||||||
|
}
|
||||||
|
}, [maxDurationSeconds, isSupported, platform]);
|
||||||
|
|
||||||
|
const stopRecording = useCallback(async () => {
|
||||||
|
if (!isRecording || !platform.metadata.isTauri) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsRecording(false);
|
||||||
|
isRecordingRef.current = false;
|
||||||
|
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop capture and get Blob
|
||||||
|
const blob = await platform.audio.stopSystemAudioCapture();
|
||||||
|
|
||||||
|
// Pass the actual recorded duration
|
||||||
|
const recordedDuration = startTimeRef.current
|
||||||
|
? (Date.now() - startTimeRef.current) / 1000
|
||||||
|
: undefined;
|
||||||
|
onRecordingComplete?.(blob, recordedDuration);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Failed to stop system audio capture.';
|
||||||
|
setError(errorMessage);
|
||||||
|
}
|
||||||
|
}, [isRecording, onRecordingComplete, platform]);
|
||||||
|
|
||||||
|
// Store stopRecording in ref for use in timer
|
||||||
|
useEffect(() => {
|
||||||
|
stopRecordingRef.current = stopRecording;
|
||||||
|
}, [stopRecording]);
|
||||||
|
|
||||||
|
const cancelRecording = useCallback(async () => {
|
||||||
|
if (isRecordingRef.current) {
|
||||||
|
await stopRecording();
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRecording(false);
|
||||||
|
isRecordingRef.current = false;
|
||||||
|
setDuration(0);
|
||||||
|
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
}, [stopRecording]);
|
||||||
|
|
||||||
|
// Cleanup on unmount only
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
// Cancel recording on unmount if still recording
|
||||||
|
if (isRecordingRef.current && platform.metadata.isTauri) {
|
||||||
|
// Call stop directly without the callback to avoid stale closure
|
||||||
|
platform.audio.stopSystemAudioCapture().catch((err) => {
|
||||||
|
console.error('Error stopping audio capture on unmount:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: Only run on unmount
|
||||||
|
}, [platform]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRecording,
|
||||||
|
duration,
|
||||||
|
error,
|
||||||
|
isSupported,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
cancelRecording,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { LanguageCode } from '@/lib/constants/languages';
|
||||||
|
|
||||||
export function useTranscription() {
|
export function useTranscription() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ file, language }: { file: File; language?: 'en' | 'zh' }) =>
|
mutationFn: ({ file, language }: { file: File; language?: LanguageCode }) =>
|
||||||
apiClient.transcribeAudio(file, language),
|
apiClient.transcribeAudio(file, language),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tauri integration utilities
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
|
||||||
import { listen, emit } from '@tauri-apps/api/event';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if running in Tauri environment
|
|
||||||
*/
|
|
||||||
export function isTauri(): boolean {
|
|
||||||
return '__TAURI_INTERNALS__' in window;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the bundled Python server (Tauri only)
|
|
||||||
*/
|
|
||||||
export async function startServer(remote = false): Promise<string> {
|
|
||||||
if (!isTauri()) {
|
|
||||||
throw new Error('Not running in Tauri environment');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await invoke<string>('start_server', { remote });
|
|
||||||
console.log('Server started:', result);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to start server:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the bundled Python server (Tauri only)
|
|
||||||
*/
|
|
||||||
export async function stopServer(): Promise<void> {
|
|
||||||
if (!isTauri()) {
|
|
||||||
throw new Error('Not running in Tauri environment');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await invoke('stop_server');
|
|
||||||
console.log('Server stopped');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to stop server:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup window close handler to check setting and stop server if needed
|
|
||||||
*/
|
|
||||||
export async function setupWindowCloseHandler(): Promise<void> {
|
|
||||||
if (!isTauri()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Listen for window close request from Rust
|
|
||||||
await listen<null>('window-close-requested', async () => {
|
|
||||||
// Import store here to avoid circular dependency
|
|
||||||
const { useServerStore } = await import('@/stores/serverStore');
|
|
||||||
const keepRunning = useServerStore.getState().keepServerRunningOnClose;
|
|
||||||
|
|
||||||
// Check if server was started by this app instance
|
|
||||||
// In dev mode, serverStartedByApp will be false, so we won't try to stop a separately-run server
|
|
||||||
// We need to access the module-level variable - this is a bit hacky but works
|
|
||||||
// @ts-expect-error - accessing module-level variable from another module
|
|
||||||
const serverStartedByApp = window.__voiceboxServerStartedByApp ?? false;
|
|
||||||
|
|
||||||
if (!keepRunning && serverStartedByApp) {
|
|
||||||
// Stop server before closing (only if we started it)
|
|
||||||
try {
|
|
||||||
await stopServer();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to stop server on close:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit event back to Rust to allow close
|
|
||||||
await emit('window-close-allowed');
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to setup window close handler:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,3 +16,157 @@ export function formatAudioDuration(seconds: number): string {
|
|||||||
const secs = Math.floor(seconds % 60);
|
const secs = Math.floor(seconds % 60);
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audio duration from a File.
|
||||||
|
* If the file has a recordedDuration property (from recording hooks),
|
||||||
|
* use that instead of trying to read metadata. This fixes issues on Windows
|
||||||
|
* where WebM files from MediaRecorder don't have proper duration metadata.
|
||||||
|
*
|
||||||
|
* For uploaded files we use AudioContext.decodeAudioData which fully decodes
|
||||||
|
* the audio and returns the exact duration. This is more reliable than
|
||||||
|
* HTMLMediaElement.duration which can return incorrect large values for VBR
|
||||||
|
* MP3 files that lack a proper XING/VBRI header.
|
||||||
|
*/
|
||||||
|
export async function getAudioDuration(
|
||||||
|
file: File & { recordedDuration?: number },
|
||||||
|
): Promise<number> {
|
||||||
|
if (file.recordedDuration !== undefined && Number.isFinite(file.recordedDuration)) {
|
||||||
|
return file.recordedDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Web Audio API for accurate duration — avoids VBR MP3 metadata issues.
|
||||||
|
try {
|
||||||
|
const audioContext = new AudioContext();
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||||
|
return audioBuffer.duration;
|
||||||
|
} finally {
|
||||||
|
await audioContext.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback: read duration from the media element (less accurate but works for WAV).
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const audio = new Audio();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
audio.addEventListener('loadedmetadata', () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
||||||
|
resolve(audio.duration);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Audio file has invalid duration metadata'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
audio.addEventListener('error', () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error('Failed to load audio file'));
|
||||||
|
});
|
||||||
|
|
||||||
|
audio.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert any audio blob to WAV format using Web Audio API.
|
||||||
|
* This ensures compatibility without requiring ffmpeg on the backend.
|
||||||
|
*/
|
||||||
|
export async function convertToWav(audioBlob: Blob): Promise<Blob> {
|
||||||
|
// Create audio context
|
||||||
|
const audioContext = new AudioContext();
|
||||||
|
|
||||||
|
// Read blob as array buffer
|
||||||
|
const arrayBuffer = await audioBlob.arrayBuffer();
|
||||||
|
|
||||||
|
// Decode audio data
|
||||||
|
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||||
|
|
||||||
|
// Convert to WAV
|
||||||
|
const wavBlob = audioBufferToWav(audioBuffer);
|
||||||
|
|
||||||
|
// Close audio context to free resources
|
||||||
|
await audioContext.close();
|
||||||
|
|
||||||
|
return wavBlob;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert AudioBuffer to WAV blob.
|
||||||
|
*/
|
||||||
|
function audioBufferToWav(buffer: AudioBuffer): Blob {
|
||||||
|
const numberOfChannels = buffer.numberOfChannels;
|
||||||
|
const sampleRate = buffer.sampleRate;
|
||||||
|
const format = 1; // PCM
|
||||||
|
const bitDepth = 16;
|
||||||
|
|
||||||
|
const bytesPerSample = bitDepth / 8;
|
||||||
|
const blockAlign = numberOfChannels * bytesPerSample;
|
||||||
|
|
||||||
|
// Interleave channels
|
||||||
|
const interleaved = interleaveChannels(buffer);
|
||||||
|
|
||||||
|
// Create WAV file
|
||||||
|
const dataLength = interleaved.length * bytesPerSample;
|
||||||
|
const buffer2 = new ArrayBuffer(44 + dataLength);
|
||||||
|
const view = new DataView(buffer2);
|
||||||
|
|
||||||
|
// Write WAV header
|
||||||
|
writeString(view, 0, 'RIFF');
|
||||||
|
view.setUint32(4, 36 + dataLength, true);
|
||||||
|
writeString(view, 8, 'WAVE');
|
||||||
|
writeString(view, 12, 'fmt ');
|
||||||
|
view.setUint32(16, 16, true); // fmt chunk size
|
||||||
|
view.setUint16(20, format, true); // audio format (PCM)
|
||||||
|
view.setUint16(22, numberOfChannels, true);
|
||||||
|
view.setUint32(24, sampleRate, true);
|
||||||
|
view.setUint32(28, sampleRate * blockAlign, true); // byte rate
|
||||||
|
view.setUint16(32, blockAlign, true);
|
||||||
|
view.setUint16(34, bitDepth, true);
|
||||||
|
writeString(view, 36, 'data');
|
||||||
|
view.setUint32(40, dataLength, true);
|
||||||
|
|
||||||
|
// Write audio data
|
||||||
|
floatTo16BitPCM(view, 44, interleaved);
|
||||||
|
|
||||||
|
return new Blob([buffer2], { type: 'audio/wav' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interleave multiple channels into a single array.
|
||||||
|
*/
|
||||||
|
function interleaveChannels(buffer: AudioBuffer): Float32Array {
|
||||||
|
const numberOfChannels = buffer.numberOfChannels;
|
||||||
|
const length = buffer.length;
|
||||||
|
const interleaved = new Float32Array(length * numberOfChannels);
|
||||||
|
|
||||||
|
for (let channel = 0; channel < numberOfChannels; channel++) {
|
||||||
|
const channelData = buffer.getChannelData(channel);
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
interleaved[i * numberOfChannels + channel] = channelData[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return interleaved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write string to DataView.
|
||||||
|
*/
|
||||||
|
function writeString(view: DataView, offset: number, string: string): void {
|
||||||
|
for (let i = 0; i < string.length; i++) {
|
||||||
|
view.setUint8(offset + i, string.charCodeAt(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert float32 audio data to 16-bit PCM.
|
||||||
|
*/
|
||||||
|
function floatTo16BitPCM(view: DataView, offset: number, input: Float32Array): void {
|
||||||
|
for (let i = 0; i < input.length; i++, offset += 2) {
|
||||||
|
const s = Math.max(-1, Math.min(1, input[i]));
|
||||||
|
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user