mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-28 06:35:18 -07:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1622e08372 | ||
|
|
dab3344fb5 | ||
|
|
66cdb307e9 | ||
|
|
5d66970fe9 | ||
|
|
637e0b4585 | ||
|
|
d21954358d | ||
|
|
3f633f4a02 | ||
|
|
43241b85cf | ||
|
|
793e392e56 | ||
|
|
73d9b5d70e | ||
|
|
af7e9814db | ||
|
|
409ec2dbb1 | ||
|
|
3ffbdeed89 | ||
|
|
61dabe7382 | ||
|
|
732d35ca89 | ||
|
|
53b1e8868c | ||
|
|
f090759d8f | ||
|
|
4c4b3e5463 | ||
|
|
e4f3647f9a | ||
|
|
9b07a8480d | ||
|
|
be30a0ac6b | ||
|
|
595747c3d0 | ||
|
|
580179eba3 | ||
|
|
3b14f81741 | ||
|
|
6dd5bb2311 | ||
|
|
dcbdf3e89b | ||
|
|
942064912a | ||
|
|
a52ff7d950 | ||
|
|
ab10c26ce4 | ||
|
|
ce4269ffa5 | ||
|
|
ec0fb60197 | ||
|
|
ec9402c568 | ||
|
|
d89521559a | ||
|
|
80689ad8ce |
@@ -1,94 +0,0 @@
|
|||||||
---
|
|
||||||
name: draft-release-notes
|
|
||||||
description: Use this skill to draft or update the [Unreleased] section of CHANGELOG.md from the actual changes since the last tag. Run this at any point during development to keep a working copy of the release narrative. Does NOT bump versions or create tags.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Draft Release Notes
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Update the `[Unreleased]` section at the top of `CHANGELOG.md` with a narrative release story based on the real changes since the last tag. This is a **non-destructive working copy** — run it as many times as you want during development.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
1. **Identify the last release tag and gather changes.**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
LAST_TAG=$(git tag --list "v*" --sort=-v:refname | head -n 1)
|
|
||||||
echo "Last tag: $LAST_TAG"
|
|
||||||
```
|
|
||||||
|
|
||||||
Then collect raw material from three sources:
|
|
||||||
|
|
||||||
a. **Commit log since last tag:**
|
|
||||||
```bash
|
|
||||||
git log --oneline "$LAST_TAG"..HEAD
|
|
||||||
```
|
|
||||||
|
|
||||||
b. **GitHub-generated release notes preview** (PR titles, new contributors):
|
|
||||||
```bash
|
|
||||||
gh api repos/:owner/:repo/releases/generate-notes \
|
|
||||||
-f tag_name="vNEXT" \
|
|
||||||
-f target_commitish="$(git rev-parse HEAD)" \
|
|
||||||
-f previous_tag_name="$LAST_TAG" \
|
|
||||||
--jq '.body'
|
|
||||||
```
|
|
||||||
|
|
||||||
c. **Diff stat for theme analysis:**
|
|
||||||
```bash
|
|
||||||
git diff --stat "$LAST_TAG"..HEAD
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Draft the release narrative.**
|
|
||||||
|
|
||||||
Write markdown for the `[Unreleased]` section following the format below. Do not include the `## [Unreleased]` heading itself — just the body content.
|
|
||||||
|
|
||||||
3. **Update CHANGELOG.md.**
|
|
||||||
|
|
||||||
Replace everything between `## [Unreleased]` and the next `## [` heading with the new draft. Preserve the HTML comment header and all existing release sections below.
|
|
||||||
|
|
||||||
The `[Unreleased]` section must always exist and always be the first section after the header comments.
|
|
||||||
|
|
||||||
4. **Do NOT commit, tag, or bump versions.** Just leave the file modified in the working tree.
|
|
||||||
|
|
||||||
## Release Story Format
|
|
||||||
|
|
||||||
Structure the `[Unreleased]` section like this:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
<One strong opening paragraph: what this release is about and why it matters.
|
|
||||||
Tie it to concrete shipped changes. No vague hype.>
|
|
||||||
|
|
||||||
<One paragraph on major technical shifts, if applicable.>
|
|
||||||
|
|
||||||
### <Feature/Theme Group>
|
|
||||||
- Bullet points with specifics
|
|
||||||
- Reference PRs where available: ([#123](https://github.com/jamiepine/voicebox/pull/123))
|
|
||||||
|
|
||||||
### <Another Group>
|
|
||||||
- ...
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
- ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Style Guidelines
|
|
||||||
|
|
||||||
- **Factual and specific.** Every claim should trace to a real commit or PR.
|
|
||||||
- **Narrative over list.** Lead with paragraphs that tell the story, then support with bullets.
|
|
||||||
- **Group by theme, not by commit.** Cluster related changes under descriptive headings.
|
|
||||||
- **Reference PRs** where they exist, but don't fabricate them.
|
|
||||||
- **Skip trivial chores** (typo fixes, CI tweaks) unless they're the bulk of the release.
|
|
||||||
- **Match the voice of existing releases** — look at the v0.2.1 and v0.2.3 entries in CHANGELOG.md for tone reference.
|
|
||||||
|
|
||||||
## When There Are No Changes
|
|
||||||
|
|
||||||
If `git log "$LAST_TAG"..HEAD` is empty, leave the `[Unreleased]` section empty (just the heading) and tell the user there's nothing to draft.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- This skill only touches the `[Unreleased]` section. It never modifies stamped release sections.
|
|
||||||
- The agent can be asked to run this skill at any point — mid-feature, before a PR, or right before cutting a release.
|
|
||||||
- The `release-bump` skill depends on this draft being up to date before it finalizes.
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
---
|
|
||||||
name: release-bump
|
|
||||||
description: Use this skill to finalize a release. It stamps the [Unreleased] changelog section with a version and date, runs bumpversion to update all version files, and creates the release commit and tag. Only run this when you're ready to ship.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Release Bump
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Finalize the changelog draft, bump the version across all tracked files, and create a tagged release commit. After this skill runs, the repo has a clean release commit and tag ready to push.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- `gh` CLI installed and authenticated (`gh auth status`).
|
|
||||||
- `bumpversion` installed (`pip install bumpversion` or available in the project venv).
|
|
||||||
- The `[Unreleased]` section of `CHANGELOG.md` should already contain the release narrative. If it's empty or stale, run the `draft-release-notes` skill first.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
1. **Verify the working tree is clean** (except `CHANGELOG.md` which may have the draft).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git status --porcelain
|
|
||||||
```
|
|
||||||
|
|
||||||
Only `CHANGELOG.md` (and optionally `.agents/` files) should be modified. If there are other uncommitted changes, stop and ask the user to commit or stash them first.
|
|
||||||
|
|
||||||
2. **Determine the bump level.**
|
|
||||||
|
|
||||||
Ask the user if not specified: `patch`, `minor`, or `major`. Check the current version:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep '^current_version' .bumpversion.cfg
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Stamp the changelog.**
|
|
||||||
|
|
||||||
Read the current `[Unreleased]` content from `CHANGELOG.md`. Compute the new version (based on bump level and current version). Then:
|
|
||||||
|
|
||||||
a. Replace the `## [Unreleased]` section body with an empty placeholder.
|
|
||||||
b. Insert a new stamped section immediately after `## [Unreleased]`:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
## [X.Y.Z] - YYYY-MM-DD
|
|
||||||
|
|
||||||
<the content that was in [Unreleased]>
|
|
||||||
```
|
|
||||||
|
|
||||||
c. Update the reference links at the bottom of the file:
|
|
||||||
- Change the `[Unreleased]` link to compare against the new tag
|
|
||||||
- Add a new link for the new version
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
[Unreleased]: https://github.com/jamiepine/voicebox/compare/vX.Y.Z...HEAD
|
|
||||||
[X.Y.Z]: https://github.com/jamiepine/voicebox/compare/vPREVIOUS...vX.Y.Z
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Stage the changelog.**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add CHANGELOG.md
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Run bumpversion.**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bumpversion --allow-dirty <patch|minor|major>
|
|
||||||
```
|
|
||||||
|
|
||||||
The `--allow-dirty` flag is needed because `CHANGELOG.md` is already staged. bumpversion will:
|
|
||||||
- Update version strings in all tracked files (see `.bumpversion.cfg`)
|
|
||||||
- Create a commit with message `Bump version: X.Y.Z -> A.B.C`
|
|
||||||
- Create a tag `vA.B.C`
|
|
||||||
|
|
||||||
The staged `CHANGELOG.md` will be included in this commit automatically.
|
|
||||||
|
|
||||||
6. **Verify results.**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git show --name-only --stat HEAD
|
|
||||||
git tag --list "v*" --sort=-v:refname | head -n 5
|
|
||||||
```
|
|
||||||
|
|
||||||
Confirm the commit contains:
|
|
||||||
- `CHANGELOG.md`
|
|
||||||
- `.bumpversion.cfg`
|
|
||||||
- `tauri/src-tauri/tauri.conf.json`
|
|
||||||
- `tauri/src-tauri/Cargo.toml`
|
|
||||||
- `package.json`
|
|
||||||
- `app/package.json`
|
|
||||||
- `tauri/package.json`
|
|
||||||
- `landing/package.json`
|
|
||||||
- `web/package.json`
|
|
||||||
- `backend/__init__.py`
|
|
||||||
|
|
||||||
Confirm the new tag exists.
|
|
||||||
|
|
||||||
7. **Do NOT push** unless the user explicitly asks. Report the tag name and suggest:
|
|
||||||
|
|
||||||
```
|
|
||||||
Ready to push. When you're ready:
|
|
||||||
git push origin main --follow-tags
|
|
||||||
```
|
|
||||||
|
|
||||||
## Version Calculation Reference
|
|
||||||
|
|
||||||
Given current version `X.Y.Z`:
|
|
||||||
- `patch` -> `X.Y.(Z+1)`
|
|
||||||
- `minor` -> `X.(Y+1).0`
|
|
||||||
- `major` -> `(X+1).0.0`
|
|
||||||
|
|
||||||
## Error Recovery
|
|
||||||
|
|
||||||
- If bumpversion fails, the tag won't exist. Fix the issue and re-run — bumpversion is idempotent as long as the tag doesn't already exist.
|
|
||||||
- If you need to undo a release commit (before pushing): `git tag -d vX.Y.Z && git reset --soft HEAD~1`
|
|
||||||
- Never amend a release commit that has been pushed.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- When the tag is pushed, the release CI (`.github/workflows/release.yml`) automatically extracts the matching version section from `CHANGELOG.md` and uses it as the GitHub Release body. No manual copy-paste needed.
|
|
||||||
- The release commit message is controlled by `.bumpversion.cfg` (`Bump version: X.Y.Z -> A.B.C`). Do not override it.
|
|
||||||
- If you need to manually update the GitHub Release body after the fact: `gh release edit vX.Y.Z --notes-file <(sed -n '/## \[X.Y.Z\]/,/## \[/p' CHANGELOG.md | head -n -1)`
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 0.2.3
|
current_version = 0.1.13
|
||||||
commit = True
|
commit = True
|
||||||
tag = True
|
tag = True
|
||||||
tag_name = v{new_version}
|
tag_name = v{new_version}
|
||||||
|
|||||||
+41
-32
@@ -1,46 +1,55 @@
|
|||||||
# Version control
|
# Dependencies
|
||||||
.git
|
|
||||||
.github
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
# Desktop-only (not needed in web container)
|
|
||||||
tauri/
|
|
||||||
landing/
|
|
||||||
docs/
|
|
||||||
mlx-test/
|
|
||||||
scripts/
|
|
||||||
|
|
||||||
# Dependencies & build artifacts (rebuilt in Docker)
|
|
||||||
node_modules/
|
node_modules/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
*.egg-info/
|
*.pyd
|
||||||
dist/
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
build/
|
build/
|
||||||
*.spec
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
target/
|
||||||
|
|
||||||
# Data (will be bind-mounted)
|
# Keep web/dist for the Docker image
|
||||||
data/
|
!web/dist
|
||||||
backend/data/
|
|
||||||
|
|
||||||
# IDE & OS
|
# Development
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Data and logs
|
||||||
|
data/
|
||||||
|
*.log
|
||||||
|
*.sqlite
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Config files not needed in container
|
# Documentation
|
||||||
biome.json
|
docs/
|
||||||
.biomeignore
|
landing/
|
||||||
.bumpversion.cfg
|
mlx-test/
|
||||||
.npmrc
|
|
||||||
Makefile
|
# Test files
|
||||||
CHANGELOG.md
|
*.test.ts
|
||||||
CONTRIBUTING.md
|
*.test.tsx
|
||||||
SECURITY.md
|
*.spec.ts
|
||||||
LICENSE
|
*.spec.tsx
|
||||||
README.md
|
|
||||||
backend/README.md
|
# Keep these out
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
credentials.json
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
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
|
|
||||||
+233
-80
@@ -6,7 +6,151 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
|
|
||||||
|
env:
|
||||||
|
PROVIDER_VERSION: "1.0.0"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# ============================================
|
||||||
|
# Build TTS Providers (uploaded to R2, not GitHub)
|
||||||
|
# ============================================
|
||||||
|
build-providers:
|
||||||
|
runs-on: ${{ matrix.platform }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
# PyTorch CPU provider (Windows)
|
||||||
|
- platform: "windows-latest"
|
||||||
|
provider: "pytorch-cpu"
|
||||||
|
python-version: "3.12"
|
||||||
|
# PyTorch CUDA provider (Windows) - large binary, uploaded to R2
|
||||||
|
- platform: "windows-latest"
|
||||||
|
provider: "pytorch-cuda"
|
||||||
|
python-version: "3.12"
|
||||||
|
# PyTorch CPU provider (Linux)
|
||||||
|
- platform: "ubuntu-22.04"
|
||||||
|
provider: "pytorch-cpu"
|
||||||
|
python-version: "3.12"
|
||||||
|
# PyTorch CUDA provider (Linux) - large binary, uploaded to R2
|
||||||
|
- platform: "ubuntu-22.04"
|
||||||
|
provider: "pytorch-cuda"
|
||||||
|
python-version: "3.12"
|
||||||
|
# PyTorch CPU provider (macOS Apple Silicon)
|
||||||
|
- platform: "macos-latest"
|
||||||
|
provider: "pytorch-cpu"
|
||||||
|
python-version: "3.12"
|
||||||
|
# PyTorch CPU provider (macOS Intel)
|
||||||
|
- platform: "macos-15-intel"
|
||||||
|
provider: "pytorch-cpu"
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies (ubuntu only)
|
||||||
|
if: matrix.platform == 'ubuntu-22.04'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y llvm-dev
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
cache: "pip"
|
||||||
|
|
||||||
|
- name: Install CPU-only torch (Linux)
|
||||||
|
if: matrix.provider == 'pytorch-cpu' && matrix.platform == 'ubuntu-22.04'
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install pyinstaller
|
||||||
|
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||||
|
pip install -r providers/pytorch-cpu/requirements.txt
|
||||||
|
pip install -r backend/requirements.txt
|
||||||
|
|
||||||
|
- name: Install Python dependencies (CPU - non-Linux)
|
||||||
|
if: matrix.provider == 'pytorch-cpu' && matrix.platform != 'ubuntu-22.04'
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install pyinstaller
|
||||||
|
pip install -r providers/pytorch-cpu/requirements.txt
|
||||||
|
pip install -r backend/requirements.txt
|
||||||
|
|
||||||
|
- name: Install Python dependencies (CUDA)
|
||||||
|
if: matrix.provider == 'pytorch-cuda'
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install pyinstaller
|
||||||
|
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
|
||||||
|
pip install -r providers/pytorch-cuda/requirements.txt
|
||||||
|
pip install -r backend/requirements.txt
|
||||||
|
|
||||||
|
- name: Build provider binary
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cd providers/${{ matrix.provider }}
|
||||||
|
python build.py
|
||||||
|
|
||||||
|
- name: Package provider for distribution
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cd providers/${{ matrix.provider }}/dist
|
||||||
|
|
||||||
|
# Add platform suffix for archive name
|
||||||
|
if [ "${{ matrix.platform }}" == "windows-latest" ]; then
|
||||||
|
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-windows.zip"
|
||||||
|
# On Windows, zip the directory
|
||||||
|
powershell Compress-Archive -Path "tts-provider-${{ matrix.provider }}/*" -DestinationPath "$ARCHIVE_NAME"
|
||||||
|
elif [ "${{ matrix.platform }}" == "macos-latest" ]; then
|
||||||
|
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-macos-arm64.tar.gz"
|
||||||
|
tar -czf "$ARCHIVE_NAME" tts-provider-${{ matrix.provider }}/
|
||||||
|
elif [ "${{ matrix.platform }}" == "macos-15-intel" ]; then
|
||||||
|
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-macos-x64.tar.gz"
|
||||||
|
tar -czf "$ARCHIVE_NAME" tts-provider-${{ matrix.provider }}/
|
||||||
|
else
|
||||||
|
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-linux.tar.gz"
|
||||||
|
tar -czf "$ARCHIVE_NAME" tts-provider-${{ matrix.provider }}/
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Created archive: $ARCHIVE_NAME"
|
||||||
|
ls -lh "$ARCHIVE_NAME"
|
||||||
|
|
||||||
|
- name: Upload provider to R2
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
|
run: |
|
||||||
|
# Install AWS CLI (compatible with R2)
|
||||||
|
pip install awscli
|
||||||
|
|
||||||
|
# Configure AWS CLI for R2
|
||||||
|
aws configure set aws_access_key_id $R2_ACCESS_KEY_ID
|
||||||
|
aws configure set aws_secret_access_key $R2_SECRET_ACCESS_KEY
|
||||||
|
aws configure set region auto
|
||||||
|
|
||||||
|
# Determine archive name based on platform
|
||||||
|
if [ "${{ matrix.platform }}" == "windows-latest" ]; then
|
||||||
|
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-windows.zip"
|
||||||
|
elif [ "${{ matrix.platform }}" == "macos-latest" ]; then
|
||||||
|
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-macos-arm64.tar.gz"
|
||||||
|
elif [ "${{ matrix.platform }}" == "macos-15-intel" ]; then
|
||||||
|
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-macos-x64.tar.gz"
|
||||||
|
else
|
||||||
|
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-linux.tar.gz"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Upload to R2 (bucket: voicebox)
|
||||||
|
aws s3 cp "providers/${{ matrix.provider }}/dist/$ARCHIVE_NAME" \
|
||||||
|
"s3://voicebox/providers/v${{ env.PROVIDER_VERSION }}/$ARCHIVE_NAME" \
|
||||||
|
--endpoint-url "$R2_ENDPOINT"
|
||||||
|
|
||||||
|
echo "Uploaded $ARCHIVE_NAME to R2"
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Build Main App (without bundled TTS on Win/Linux)
|
||||||
|
# ============================================
|
||||||
release:
|
release:
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -14,14 +158,22 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
|
# macOS Apple Silicon - MLX bundled (works out of the box)
|
||||||
- platform: "macos-latest"
|
- platform: "macos-latest"
|
||||||
args: "--target aarch64-apple-darwin"
|
args: "--target aarch64-apple-darwin"
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
backend: "mlx"
|
backend: "mlx"
|
||||||
|
# macOS Intel - PyTorch bundled (smaller user base, keep simple)
|
||||||
- platform: "macos-15-intel"
|
- platform: "macos-15-intel"
|
||||||
args: "--target x86_64-apple-darwin"
|
args: "--target x86_64-apple-darwin"
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
backend: "pytorch"
|
backend: "pytorch"
|
||||||
|
# Linux - No TTS bundled, providers downloaded separately
|
||||||
|
- platform: "ubuntu-22.04"
|
||||||
|
args: ""
|
||||||
|
python-version: "3.12"
|
||||||
|
backend: "none"
|
||||||
|
# Windows - PyTorch CPU bundled (works out of the box)
|
||||||
- platform: "windows-latest"
|
- platform: "windows-latest"
|
||||||
args: ""
|
args: ""
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@@ -33,7 +185,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install dependencies (ubuntu only)
|
- name: Install dependencies (ubuntu only)
|
||||||
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
|
if: matrix.platform == 'ubuntu-22.04'
|
||||||
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 libasound2-dev
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf llvm-dev libasound2-dev
|
||||||
@@ -51,17 +203,21 @@ jobs:
|
|||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
|
|
||||||
- name: Install CPU-only PyTorch (Linux)
|
- name: Install Python dependencies (with TTS)
|
||||||
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
|
if: matrix.backend != 'none'
|
||||||
run: |
|
|
||||||
pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu
|
|
||||||
|
|
||||||
- name: Install Python dependencies
|
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install pyinstaller
|
pip install pyinstaller
|
||||||
pip install -r backend/requirements.txt
|
pip install -r backend/requirements.txt
|
||||||
pip install --no-deps chatterbox-tts
|
|
||||||
|
- name: Install Python dependencies (without TTS)
|
||||||
|
if: matrix.backend == 'none'
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install pyinstaller
|
||||||
|
# Install base requirements without PyTorch/Qwen-TTS
|
||||||
|
pip install fastapi uvicorn sqlalchemy librosa soundfile numpy httpx
|
||||||
|
pip install huggingface_hub # For Whisper downloads
|
||||||
|
|
||||||
- name: Install MLX dependencies (Apple Silicon only)
|
- name: Install MLX dependencies (Apple Silicon only)
|
||||||
if: matrix.backend == 'mlx'
|
if: matrix.backend == 'mlx'
|
||||||
@@ -123,30 +279,7 @@ jobs:
|
|||||||
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
|
||||||
- name: Extract release notes from CHANGELOG.md
|
- uses: tauri-apps/tauri-action@v0
|
||||||
id: changelog
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
# Get the version from the tag (strip leading 'v')
|
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
|
||||||
|
|
||||||
# Extract the section for this version from CHANGELOG.md
|
|
||||||
# Matches from "## [X.Y.Z]" until the next "## [" heading
|
|
||||||
NOTES=$(sed -n "/^## \[${VERSION}\]/,/^## \[/{/^## \[${VERSION}\]/d;/^## \[/d;p;}" CHANGELOG.md)
|
|
||||||
|
|
||||||
# Fall back to a placeholder if the version isn't in the changelog
|
|
||||||
if [ -z "$(echo "$NOTES" | tr -d '[:space:]')" ]; then
|
|
||||||
NOTES="See the assets below to download and install this version."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Use multiline output syntax
|
|
||||||
{
|
|
||||||
echo "notes<<CHANGELOG_EOF"
|
|
||||||
echo "$NOTES"
|
|
||||||
echo "CHANGELOG_EOF"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- uses: tauri-apps/[email protected]
|
|
||||||
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 }}
|
||||||
@@ -162,69 +295,89 @@ jobs:
|
|||||||
projectPath: tauri
|
projectPath: tauri
|
||||||
tagName: v__VERSION__
|
tagName: v__VERSION__
|
||||||
releaseName: "voicebox v__VERSION__"
|
releaseName: "voicebox v__VERSION__"
|
||||||
releaseBody: ${{ steps.changelog.outputs.notes }}
|
releaseBody: |
|
||||||
|
## What's Changed
|
||||||
|
See the assets below to download and install this version.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
- **macOS (Apple Silicon)**: Download the `aarch64.dmg` file - uses MLX for fast native inference (works out of the box)
|
||||||
|
- **macOS (Intel)**: Download the `x64.dmg` file - uses PyTorch
|
||||||
|
- **Windows**: Download the `.msi` installer - requires downloading a TTS provider on first use
|
||||||
|
- **Linux**: Download the `.AppImage` or `.deb` package - requires downloading a TTS provider on first use
|
||||||
|
|
||||||
|
### TTS Providers
|
||||||
|
Windows and Linux users will be prompted to download a TTS provider on first launch:
|
||||||
|
- **Windows**: PyTorch CPU (~300MB) or PyTorch CUDA (~2.4GB for NVIDIA GPUs)
|
||||||
|
- **Linux**: PyTorch CUDA (~2.4GB) - requires NVIDIA GPU
|
||||||
|
|
||||||
|
The app includes automatic updates - future updates will be installed automatically.
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
args: ${{ matrix.args }}
|
args: ${{ matrix.args }}
|
||||||
includeUpdaterJson: true
|
includeUpdaterJson: true
|
||||||
|
|
||||||
build-cuda-windows:
|
# ============================================
|
||||||
runs-on: windows-latest
|
# Build and Push Docker Images
|
||||||
|
# ============================================
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: read
|
||||||
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Python
|
- name: Set up Docker Buildx
|
||||||
uses: actions/setup-python@v5
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
registry: ghcr.io
|
||||||
cache: "pip"
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Install Python dependencies
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Install dependencies and build web UI
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
bun install
|
||||||
pip install pyinstaller
|
cd web
|
||||||
pip install -r backend/requirements.txt
|
bun run build
|
||||||
pip install --no-deps chatterbox-tts
|
|
||||||
|
|
||||||
- name: Install PyTorch with CUDA 12.6
|
- name: Extract version from tag
|
||||||
|
id: version
|
||||||
run: |
|
run: |
|
||||||
pip install torch --index-url https://download.pytorch.org/whl/cu126 --force-reinstall --no-deps
|
if [[ $GITHUB_REF == refs/tags/v* ]]; then
|
||||||
pip install torchaudio --index-url https://download.pytorch.org/whl/cu126 --force-reinstall --no-deps
|
VERSION=${GITHUB_REF#refs/tags/v}
|
||||||
|
else
|
||||||
|
VERSION="dev"
|
||||||
|
fi
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Verify CUDA support in torch
|
- name: Build and push CPU image
|
||||||
run: |
|
uses: docker/build-push-action@v5
|
||||||
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:
|
with:
|
||||||
files: |
|
context: .
|
||||||
release-assets/voicebox-server-cuda.part*.exe
|
file: ./Dockerfile
|
||||||
release-assets/voicebox-server-cuda.sha256
|
platforms: linux/amd64,linux/arm64
|
||||||
release-assets/voicebox-server-cuda.manifest
|
push: true
|
||||||
draft: true
|
tags: |
|
||||||
env:
|
ghcr.io/jamiepine/voicebox:latest
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
ghcr.io/jamiepine/voicebox:${{ steps.version.outputs.version }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: Upload binary as workflow artifact
|
- name: Build and push CUDA image
|
||||||
uses: actions/upload-artifact@v4
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
name: voicebox-server-cuda-windows
|
context: .
|
||||||
path: backend/dist/voicebox-server-cuda.exe
|
file: ./Dockerfile.cuda
|
||||||
retention-days: 7
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||||
|
ghcr.io/jamiepine/voicebox:${{ steps.version.outputs.version }}-cuda
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
+5
-3
@@ -15,6 +15,7 @@ dist/
|
|||||||
build/
|
build/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
*.egg
|
*.egg
|
||||||
|
*.spec
|
||||||
target/
|
target/
|
||||||
*.app
|
*.app
|
||||||
*.dmg
|
*.dmg
|
||||||
@@ -35,7 +36,10 @@ target/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Data (user-generated)
|
# Data (user-generated)
|
||||||
data/
|
data/profiles/*
|
||||||
|
data/generations/*
|
||||||
|
data/projects/*
|
||||||
|
data/voicebox.db
|
||||||
!data/.gitkeep
|
!data/.gitkeep
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
@@ -49,8 +53,6 @@ logs/
|
|||||||
# Generated files
|
# Generated files
|
||||||
app/openapi.json
|
app/openapi.json
|
||||||
tauri/src-tauri/binaries/*
|
tauri/src-tauri/binaries/*
|
||||||
tauri/src-tauri/gen/Assets.car
|
|
||||||
tauri/src-tauri/gen/voicebox.icns
|
|
||||||
|
|
||||||
# Temporary
|
# Temporary
|
||||||
tmp/
|
tmp/
|
||||||
|
|||||||
+73
-425
@@ -1,437 +1,85 @@
|
|||||||
<!-- This file is compiled automatically during the release workflow. -->
|
|
||||||
<!-- Do not edit manually — your changes will be overwritten. -->
|
|
||||||
<!-- To update the draft: ask the agent to use the draft-release-notes skill. -->
|
|
||||||
<!-- To finalize a release: ask the agent to use the release-bump skill. -->
|
|
||||||
|
|
||||||
# Changelog
|
# 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).
|
||||||
|
|
||||||
|
## [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]
|
## [Unreleased]
|
||||||
|
|
||||||
This release rewrites the backend into a modular architecture, migrates the documentation site to Fumadocs, and ships a batch of bug fixes and UI polish across the stack.
|
### Fixed
|
||||||
|
- Audio export failing when Tauri save dialog returns object instead of string path
|
||||||
|
|
||||||
The backend's 3,000-line monolith `main.py` has been decomposed into domain routers, a services layer, and a proper database package. A style guide and ruff configuration now enforce consistency. On the frontend, model loading status is now visible in the UI, effects presets get a dropdown, and several race conditions and accessibility gaps are closed.
|
### 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
|
||||||
|
|
||||||
### Backend Refactor ([#285](https://github.com/jamiepine/voicebox/pull/285))
|
### Changed
|
||||||
- Extracted all routes from `main.py` into 13 domain routers under `backend/routes/` — `main.py` dropped from ~3,100 lines to ~10
|
- **README** - Added Makefile reference and updated Quick Start with Makefile-based setup instructions alongside manual setup
|
||||||
- Moved CRUD and service modules into `backend/services/`, platform detection into `backend/utils/`
|
|
||||||
- Split monolithic `database.py` into a `database/` package with separate `models`, `session`, `migrations`, and `seed` modules
|
|
||||||
- Added `backend/STYLE_GUIDE.md` and `pyproject.toml` with ruff linting config
|
|
||||||
- Removed dead code: unused `_get_cuda_dll_excludes`, stale `studio.py`, `example_usage.py`, old `Makefile`
|
|
||||||
- Deduplicated shared logic across TTS backends into `backends/base.py`
|
|
||||||
- Improved startup logging with version, platform, data directory, and database stats
|
|
||||||
- Fixed startup database session leak — sessions now rollback and close in `finally` block
|
|
||||||
- Isolated shutdown unload calls so one backend failure doesn't block the others
|
|
||||||
- Handled null duration in `story_items` migration
|
|
||||||
- Reject model migration when target is a subdirectory of source cache
|
|
||||||
|
|
||||||
### Documentation Rewrite ([#288](https://github.com/jamiepine/voicebox/pull/288))
|
---
|
||||||
- Migrated docs site from Mintlify to Fumadocs (Next.js-based)
|
|
||||||
- Rewrote introduction and root page with content from README
|
|
||||||
- Added "Edit on GitHub" links and last-updated timestamps on all pages
|
|
||||||
- Generated OpenAPI spec and auto-generated API reference pages
|
|
||||||
- Removed stale planning docs (`CUDA_BACKEND_SWAP`, `EXTERNAL_PROVIDERS`, `MLX_AUDIO`, `TTS_PROVIDER_ARCHITECTURE`, etc.)
|
|
||||||
- Sidebar groups now expand by default; root redirects to `/docs`
|
|
||||||
- Added OG image metadata and `/og` preview page
|
|
||||||
|
|
||||||
### UI & Frontend
|
## [Unreleased - Planned]
|
||||||
- Added model loading status indicator and effects preset dropdown ([3187344](https://github.com/jamiepine/voicebox/commit/3187344))
|
|
||||||
- Fixed take-label race condition during regeneration
|
|
||||||
- Added accessible focus styling to select component
|
|
||||||
- Softened select focus indicator opacity
|
|
||||||
- Addressed 4 critical and 12 major issues from CodeRabbit review
|
|
||||||
|
|
||||||
### Platform Fixes
|
### Planned
|
||||||
- Replaced `netstat` with `TcpStream` + PowerShell for Windows port detection ([#277](https://github.com/jamiepine/voicebox/pull/277))
|
- Real-time streaming synthesis
|
||||||
- Fixed Docker frontend build and cleaned up Docker docs
|
- Conversation mode with multiple speakers
|
||||||
- Fixed macOS download links to use `.dmg` instead of `.app.tar.gz`
|
- Voice effects (pitch shift, reverb, M3GAN-style)
|
||||||
- Added dynamic download redirect routes to landing site
|
- Timeline-based audio editor
|
||||||
|
- Additional voice models (XTTS, Bark)
|
||||||
|
- Voice design from text descriptions
|
||||||
|
- Project system for saving sessions
|
||||||
|
- Plugin architecture
|
||||||
|
|
||||||
### Release Tooling
|
---
|
||||||
- Added `draft-release-notes` and `release-bump` agent skills
|
|
||||||
- Wired CI release workflow to extract notes from `CHANGELOG.md` for GitHub Releases
|
|
||||||
- Backfilled changelog with all historical releases
|
|
||||||
|
|
||||||
## [0.2.3] - 2026-03-15
|
|
||||||
|
|
||||||
The "it works in dev but not in prod" release. This version fixes a series of PyInstaller bundling issues that prevented model downloading, loading, generation, and progress tracking from working in production builds.
|
|
||||||
|
|
||||||
### Model Downloads Now Actually Work
|
|
||||||
|
|
||||||
The v0.2.1/v0.2.2 builds could not download or load models that weren't already cached from a dev install. This release fixes the entire chain:
|
|
||||||
|
|
||||||
- **Chatterbox, Chatterbox Turbo, and LuxTTS** all download, load, and generate correctly in bundled builds
|
|
||||||
- **Real-time download progress** — byte-level progress bars now work in production. The root cause: `huggingface_hub` silently disables tqdm progress bars based on logger level, which prevented our progress tracker from receiving byte updates. We now force-enable the internal counter regardless.
|
|
||||||
- **Fixed Python 3.12.0 `code.replace()` bug** — the macOS build was on Python 3.12.0, which has a [known CPython bug](https://github.com/pyinstaller/pyinstaller/issues/7992) that corrupts bytecode when PyInstaller rewrites code objects. This caused `NameError: name 'obj' is not defined` crashes during scipy/torch imports. Upgraded to Python 3.12.13.
|
|
||||||
|
|
||||||
### PyInstaller Fixes
|
|
||||||
|
|
||||||
- Collect all `inflect` files — `typeguard`'s `@typechecked` decorator calls `inspect.getsource()` at import time, which needs `.py` source files, not just bytecode. Fixes LuxTTS "could not get source code" error.
|
|
||||||
- Collect all `perth` files — bundles the pretrained watermark model (`hparams.yaml`, `.pth.tar`) needed by Chatterbox at runtime
|
|
||||||
- Collect all `piper_phonemize` files — bundles `espeak-ng-data/` (phoneme tables, language dicts) needed by LuxTTS for text-to-phoneme conversion
|
|
||||||
- Set `ESPEAK_DATA_PATH` in frozen builds so the espeak-ng C library finds the bundled data instead of looking at `/usr/share/espeak-ng-data/`
|
|
||||||
- Collect all `linacodec` files — fixes `inspect.getsource` error in Vocos codec
|
|
||||||
- Collect all `zipvoice` files — fixes source code lookup in LuxTTS voice cloning
|
|
||||||
- Copy metadata for `requests`, `transformers`, `huggingface-hub`, `tokenizers`, `safetensors`, `tqdm` — fixes `importlib.metadata` lookups in frozen binary
|
|
||||||
- Add hidden imports for `chatterbox`, `chatterbox_turbo`, `luxtts`, `zipvoice` backends
|
|
||||||
- Add `multiprocessing.freeze_support()` to fix resource_tracker subprocess crash in frozen binary
|
|
||||||
- `--noconsole` now only applied on Windows — macOS/Linux need stdout/stderr for Tauri sidecar log capture
|
|
||||||
- Hardened `sys.stdout`/`sys.stderr` devnull redirect to test writability, not just `None` check
|
|
||||||
|
|
||||||
### Updater
|
|
||||||
|
|
||||||
- Fixed updater artifact generation with `v1Compatible` for `tauri-action` signature files
|
|
||||||
- Updated `tauri-action` to v0.6 to fix updater JSON and `.sig` generation
|
|
||||||
|
|
||||||
### Other Fixes
|
|
||||||
|
|
||||||
- Full traceback logging on all backend model loading errors (was just `str(e)` before)
|
|
||||||
|
|
||||||
## [0.2.2] - 2026-03-15
|
|
||||||
|
|
||||||
- Fix Chatterbox model support in bundled builds
|
|
||||||
- Fix LuxTTS/ZipVoice support in bundled builds
|
|
||||||
- Auto-update CUDA binary when app version changes
|
|
||||||
- CUDA download progress bar
|
|
||||||
- Fix server process staying alive on macOS (SIGHUP handling, watchdog grace period)
|
|
||||||
- Hide console window when running CUDA binary on Windows
|
|
||||||
|
|
||||||
## [0.2.1] - 2026-03-15
|
|
||||||
|
|
||||||
Voicebox v0.1.x was a single-engine voice cloning app built around Qwen3-TTS. v0.2.0 is a ground-up rethink: four TTS engines, 23 languages, paralinguistic emotion controls, a post-processing effects pipeline, unlimited generation length, an async generation queue, and support for every major GPU vendor. Plus Docker.
|
|
||||||
|
|
||||||
### New TTS Engines
|
|
||||||
|
|
||||||
#### Multi-Engine Architecture
|
|
||||||
|
|
||||||
Voicebox now runs **four independent TTS engines** behind a thread-safe per-engine backend registry. Switch engines per-generation from a single dropdown — no restart required.
|
|
||||||
|
|
||||||
| Engine | Languages | Size | Key Strengths |
|
|
||||||
| --------------------------- | --------- | ------- | --------------------------------------------- |
|
|
||||||
| **Qwen3-TTS 1.7B** | 10 | ~3.5 GB | Highest quality, delivery instructions |
|
|
||||||
| **Qwen3-TTS 0.6B** | 10 | ~1.2 GB | Lighter, faster variant |
|
|
||||||
| **LuxTTS** | English | ~300 MB | CPU-friendly, 48 kHz output, 150x realtime |
|
|
||||||
| **Chatterbox Multilingual** | 23 | ~3.2 GB | Broadest language coverage, zero-shot cloning |
|
|
||||||
| **Chatterbox Turbo** | English | ~1.5 GB | 350M params, low latency, paralinguistic tags |
|
|
||||||
|
|
||||||
#### Chatterbox Multilingual — 23 Languages ([#257](https://github.com/jamiepine/voicebox/pull/257))
|
|
||||||
|
|
||||||
Zero-shot voice cloning in Arabic, Chinese, Danish, Dutch, English, Finnish, French, German, Greek, Hebrew, Hindi, Italian, Japanese, Korean, Malay, Norwegian, Polish, Portuguese, Russian, Spanish, Swahili, Swedish, and Turkish.
|
|
||||||
|
|
||||||
#### LuxTTS — Lightweight English TTS ([#254](https://github.com/jamiepine/voicebox/pull/254))
|
|
||||||
|
|
||||||
A fast, CPU-friendly English engine. ~300 MB download, 48 kHz output, runs at 150x realtime on CPU.
|
|
||||||
|
|
||||||
#### Chatterbox Turbo — Expressive English ([#258](https://github.com/jamiepine/voicebox/pull/258))
|
|
||||||
|
|
||||||
A fast 350M-parameter English model with inline paralinguistic tags.
|
|
||||||
|
|
||||||
#### Paralinguistic Tags Autocomplete ([#265](https://github.com/jamiepine/voicebox/pull/265))
|
|
||||||
|
|
||||||
Type `/` in the text input with Chatterbox Turbo selected to open an autocomplete for **9 expressive tags**: `[laugh]` `[chuckle]` `[gasp]` `[cough]` `[sigh]` `[groan]` `[sniff]` `[shush]` `[clear throat]`
|
|
||||||
|
|
||||||
### Generation
|
|
||||||
|
|
||||||
#### Unlimited Generation Length — Auto-Chunking ([#266](https://github.com/jamiepine/voicebox/pull/266))
|
|
||||||
|
|
||||||
Long text is now automatically split at sentence boundaries, generated per-chunk, and crossfaded back together. Engine-agnostic.
|
|
||||||
|
|
||||||
- Auto-chunking limit slider — 100–5,000 chars (default 800)
|
|
||||||
- Crossfade slider — 0–200ms (default 50ms)
|
|
||||||
- Max text length raised to 50,000 characters
|
|
||||||
- Smart splitting respects abbreviations, CJK punctuation, and `[tags]`
|
|
||||||
|
|
||||||
#### Asynchronous Generation Queue ([#269](https://github.com/jamiepine/voicebox/pull/269))
|
|
||||||
|
|
||||||
Generation is now fully non-blocking. Serial execution queue prevents GPU contention. Real-time SSE status streaming.
|
|
||||||
|
|
||||||
#### Generation Versions
|
|
||||||
|
|
||||||
Every generation now supports multiple versions with provenance tracking — original, effects versions, takes, source tracking, version pinning in stories, and favorites.
|
|
||||||
|
|
||||||
### Post-Processing Effects ([#271](https://github.com/jamiepine/voicebox/pull/271))
|
|
||||||
|
|
||||||
A full audio effects system powered by Spotify's `pedalboard` library: Pitch Shift, Reverb, Delay, Chorus/Flanger, Compressor, Gain, High-Pass Filter, Low-Pass Filter. 4 built-in presets, custom presets, per-profile default effects, and live preview.
|
|
||||||
|
|
||||||
### Platform Support
|
|
||||||
|
|
||||||
- **Windows Support** ([#272](https://github.com/jamiepine/voicebox/pull/272)) — Full Windows support with CUDA GPU detection
|
|
||||||
- **Linux** ([#262](https://github.com/jamiepine/voicebox/pull/262)) — AMD ROCm, NVIDIA GBM fix, WebKitGTK mic access (build from source)
|
|
||||||
- **NVIDIA CUDA Backend Swap** ([#252](https://github.com/jamiepine/voicebox/pull/252)) — Download and swap in CUDA backend from within the app
|
|
||||||
- **Intel Arc (XPU) and DirectML** — PyTorch backend supports Intel Arc and DirectML
|
|
||||||
- **Docker + Web Deployment** ([#161](https://github.com/jamiepine/voicebox/pull/161)) — 3-stage build, non-root runtime, health checks
|
|
||||||
- **Whisper Turbo** — Added `openai/whisper-large-v3-turbo` as a transcription model option
|
|
||||||
|
|
||||||
### Model Management ([#268](https://github.com/jamiepine/voicebox/pull/268))
|
|
||||||
|
|
||||||
Per-model unload, custom models directory, model folder migration, download cancel/clear UI ([#238](https://github.com/jamiepine/voicebox/pull/238)), restructured settings UI.
|
|
||||||
|
|
||||||
### Security & Reliability
|
|
||||||
|
|
||||||
- CORS hardening ([#88](https://github.com/jamiepine/voicebox/pull/88))
|
|
||||||
- Network access toggle ([#133](https://github.com/jamiepine/voicebox/pull/133))
|
|
||||||
- Offline crash fix ([#152](https://github.com/jamiepine/voicebox/pull/152))
|
|
||||||
- Atomic audio saves ([#263](https://github.com/jamiepine/voicebox/pull/263))
|
|
||||||
- Filesystem health endpoint
|
|
||||||
- Chatterbox float64 dtype fix ([#264](https://github.com/jamiepine/voicebox/pull/264))
|
|
||||||
|
|
||||||
### Accessibility ([#243](https://github.com/jamiepine/voicebox/pull/243))
|
|
||||||
|
|
||||||
Screen reader support, keyboard navigation, state-aware `aria-label` attributes on all interactive controls.
|
|
||||||
|
|
||||||
### UI Polish
|
|
||||||
|
|
||||||
- Redesigned landing page ([#274](https://github.com/jamiepine/voicebox/pull/274))
|
|
||||||
- Voices tab overhaul with inline inspector
|
|
||||||
- Responsive layout improvements
|
|
||||||
- Duplicate profile name validation ([#175](https://github.com/jamiepine/voicebox/pull/175))
|
|
||||||
|
|
||||||
### Community Contributors
|
|
||||||
|
|
||||||
[@haosenwang1018](https://github.com/haosenwang1018), [@Balneario-de-Cofrentes](https://github.com/Balneario-de-Cofrentes), [@ageofalgo](https://github.com/ageofalgo), [@mikeswann](https://github.com/mikeswann), [@rayl15](https://github.com/rayl15), [@mpecanha](https://github.com/mpecanha), [@ways2read](https://github.com/ways2read), [@ieguiguren](https://github.com/ieguiguren), [@Vaibhavee89](https://github.com/Vaibhavee89), [@pandego](https://github.com/pandego), [@luminest-llc](https://github.com/luminest-llc)
|
|
||||||
|
|
||||||
## [0.1.13] - 2026-02-23
|
|
||||||
|
|
||||||
### Stability and reliability
|
|
||||||
|
|
||||||
- [#95](https://github.com/jamiepine/voicebox/pull/95) Fix: selecting 0.6B model still downloads and uses 1.7B
|
|
||||||
- [#93](https://github.com/jamiepine/voicebox/pull/93) fix(mlx): bundle native libs and broaden error handling for Apple Silicon
|
|
||||||
- [#79](https://github.com/jamiepine/voicebox/pull/79) fix: handle non-ASCII filenames in Content-Disposition headers
|
|
||||||
- [#78](https://github.com/jamiepine/voicebox/pull/78) fix: guard getUserMedia call against undefined mediaDevices in non-secure contexts
|
|
||||||
- [#77](https://github.com/jamiepine/voicebox/pull/77) fix: await for confirmation before deleting voices and channels
|
|
||||||
- [#128](https://github.com/jamiepine/voicebox/pull/128) fix: resolve multiple issues (#96, #119, #111, #108, #121, #125, #127)
|
|
||||||
- [#40](https://github.com/jamiepine/voicebox/pull/40) Fix: audio export path resolution
|
|
||||||
|
|
||||||
### Build and packaging
|
|
||||||
|
|
||||||
- [#122](https://github.com/jamiepine/voicebox/pull/122) fix(web): add @tailwindcss/vite plugin to web config
|
|
||||||
- [#126](https://github.com/jamiepine/voicebox/pull/126) Create requirements.txt
|
|
||||||
|
|
||||||
### UX and docs
|
|
||||||
|
|
||||||
- [#44](https://github.com/jamiepine/voicebox/pull/44) Enhances floating generate box UX
|
|
||||||
- [#57](https://github.com/jamiepine/voicebox/pull/57) chore: updates repo URL in README
|
|
||||||
- [#146](https://github.com/jamiepine/voicebox/pull/146) Add Spacebot banner to landing page
|
|
||||||
- [#1](https://github.com/jamiepine/voicebox/pull/1) Improvements
|
|
||||||
|
|
||||||
## [0.1.12] - 2026-01-31
|
|
||||||
|
|
||||||
### Model Download UX Overhaul
|
|
||||||
|
|
||||||
- Real-time download progress tracking with accurate percentage and speed info
|
|
||||||
- No more downloading notifications during generation even when its not downloading
|
|
||||||
- Better error handling and status reporting throughout the download process
|
|
||||||
|
|
||||||
### Other Improvements
|
|
||||||
|
|
||||||
- Enhanced health check endpoint with GPU type information
|
|
||||||
- Improved model caching verification
|
|
||||||
- More reliable SSE progress updates
|
|
||||||
- Actual update notifications — no need to manually check in settings anymore
|
|
||||||
|
|
||||||
## [0.1.11] - 2026-01-30
|
|
||||||
|
|
||||||
- Fixed transcriptions on MLX
|
|
||||||
- Fixed model download progress (finally)
|
|
||||||
|
|
||||||
## [0.1.10] - 2026-01-30
|
|
||||||
|
|
||||||
### Faster generation on Apple Silicon
|
|
||||||
|
|
||||||
Massive speed gains, from around 20s per generation to 2-3s. Added native MLX backend support for Apple Silicon, providing significantly faster TTS and STT generation on M-series macOS machines.
|
|
||||||
|
|
||||||
- **MLX Backend** — New backend implementation optimized for Apple Silicon using MLX framework
|
|
||||||
- **Dynamic Backend Selection** — Automatically detects platform and selects between MLX (macOS) and PyTorch (other platforms)
|
|
||||||
- Refactored TTS and STT logic into modular backend implementations
|
|
||||||
- Updated build process to include MLX-specific dependencies for macOS builds
|
|
||||||
|
|
||||||
## [0.1.9] - 2026-01-30
|
|
||||||
|
|
||||||
### Improved voice profile creation flow
|
|
||||||
|
|
||||||
- Voice create drafts: No longer lose work if you close the modal
|
|
||||||
- Fixed whisper only transcribing English or Chinese, now has support for all languages
|
|
||||||
|
|
||||||
### Improved Stories editor
|
|
||||||
|
|
||||||
- Added spacebar for play/pause
|
|
||||||
- Timeline now auto-scrolls to follow playhead during playback
|
|
||||||
- Fixed misalignment of the items with mouse when picking up
|
|
||||||
- Fixed hitbox for selecting an item
|
|
||||||
- Fixed playhead jumping forward when pressing play
|
|
||||||
|
|
||||||
### Generation box improvements
|
|
||||||
|
|
||||||
- Instruct mode no longer wipes prompt text
|
|
||||||
- Improved UI cleanliness
|
|
||||||
|
|
||||||
### Misc
|
|
||||||
|
|
||||||
- Fixed "Model downloading" toast during generation when model is already downloaded
|
|
||||||
|
|
||||||
## [0.1.8] - 2026-01-29
|
|
||||||
|
|
||||||
### Model Download Timeout Issues
|
|
||||||
|
|
||||||
Fixed critical issue where model downloads would fail with "Failed to fetch" errors on Windows. Refactored download endpoints to return immediately and continue downloads in background.
|
|
||||||
|
|
||||||
### Cross-Platform Cache Path Issues
|
|
||||||
|
|
||||||
Fixed hardcoded `~/.cache/huggingface/hub` paths that don't work on Windows. All cache paths now use `hf_constants.HF_HUB_CACHE` for proper cross-platform support.
|
|
||||||
|
|
||||||
### Windows Process Management
|
|
||||||
|
|
||||||
- Added `/shutdown` endpoint for graceful server shutdown on Windows
|
|
||||||
- Added `gpu_type` field to health check response
|
|
||||||
|
|
||||||
## [0.1.7] - 2026-01-29
|
|
||||||
|
|
||||||
- Trim and split audio clips in Story Editor
|
|
||||||
- Auto-activation of stories in Story Editor with visible playhead
|
|
||||||
- Conditional auto-play support in AudioPlayer for better user control
|
|
||||||
- Refactored audio loading across HistoryTable, SampleList, and generation forms
|
|
||||||
- Audio now only auto-plays when explicitly intended, preventing unexpected playback
|
|
||||||
|
|
||||||
## [0.1.6] - 2026-01-29
|
|
||||||
|
|
||||||
### Introducing Stories
|
|
||||||
|
|
||||||
A full voice editor for composing podcasts and generated conversations.
|
|
||||||
|
|
||||||
- **Stories Editor** — Create multi-voice narratives, podcasts, or conversations with a timeline-based editor
|
|
||||||
- Compose tracks with different voices
|
|
||||||
- Edit and arrange audio segments inline
|
|
||||||
- Build generated conversations with multiple participants
|
|
||||||
- **Improved Voice Generation UI** — Auto-resizing input, default voice selection, better layout
|
|
||||||
- **Track Editor Integration** — Inline track editing within story items
|
|
||||||
|
|
||||||
## [0.1.5] - 2026-01-28
|
|
||||||
|
|
||||||
Fixed recording length limit at 0:29 to auto stop instead of passing the limit and getting an error, which would cause users to lose their recording.
|
|
||||||
|
|
||||||
## [0.1.4] - 2026-01-28
|
|
||||||
|
|
||||||
- Audio channel management system
|
|
||||||
- Native audio playback handling in AudioPlayer component
|
|
||||||
- Refactored ConnectionForm and Checkbox components
|
|
||||||
- Improved layout consistency and responsiveness
|
|
||||||
- Added safe area constants for better responsive design
|
|
||||||
|
|
||||||
## [0.1.3] - 2026-01-27
|
|
||||||
|
|
||||||
- Improved the generate textbox
|
|
||||||
- Maybe fixed Windows autoupdate restarting entire computer
|
|
||||||
|
|
||||||
## [0.1.2] - 2026-01-27
|
|
||||||
|
|
||||||
### Audio Capture & Format Conversion
|
|
||||||
|
|
||||||
- Added audio format conversion util
|
|
||||||
- Enhanced system audio capture on macOS and Windows
|
|
||||||
- Improved audio recording hooks
|
|
||||||
- Added audio input entitlement for macOS
|
|
||||||
- Added audio capture tests
|
|
||||||
|
|
||||||
### Update System
|
|
||||||
|
|
||||||
- Enhanced auto-updater functionality and update status display
|
|
||||||
|
|
||||||
## [0.1.1] - 2026-01-27
|
|
||||||
|
|
||||||
### Platform Support
|
|
||||||
|
|
||||||
- **macOS Audio Capture** — Native audio capture support for sample creation
|
|
||||||
- **Windows Audio Capture** — WASAPI implementation with improved thread safety
|
|
||||||
- **Linux Support** — Temporarily removed builds due to runner disk space constraints
|
|
||||||
|
|
||||||
### Audio Features
|
|
||||||
|
|
||||||
- Play/pause for audio samples across all components
|
|
||||||
- Three new sample components: Recording, System capture, Upload with drag-and-drop
|
|
||||||
- Audio validation, error handling, and consistent cleanup
|
|
||||||
|
|
||||||
### Voice Profile Management
|
|
||||||
|
|
||||||
- Profile import with file size validation (100MB limit)
|
|
||||||
- Enhanced profile form with new audio sample components
|
|
||||||
- Drag-and-drop support for audio file uploads
|
|
||||||
|
|
||||||
### Server Management
|
|
||||||
|
|
||||||
- Changed default URL from `localhost:8000` to `127.0.0.1:17493`
|
|
||||||
- Server reuse logic, "keep server running" preference, orphaned process handling
|
|
||||||
|
|
||||||
### Build & Release
|
|
||||||
|
|
||||||
- Added `.bumpversion.cfg` for automated version management
|
|
||||||
- Enhanced icon generation script for multi-size Windows icons
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Fixed date formatting for timezone-less date strings
|
|
||||||
- Fixed getLatestRelease file filtering
|
|
||||||
- Improved audio duration metadata on Windows
|
|
||||||
|
|
||||||
## [0.1.0] - 2026-01-27
|
|
||||||
|
|
||||||
The first public release of Voicebox — an open-source voice synthesis studio powered by Qwen3-TTS.
|
|
||||||
|
|
||||||
### Voice Cloning with Qwen3-TTS
|
|
||||||
|
|
||||||
- Automatic model download from HuggingFace
|
|
||||||
- Multiple model sizes (1.7B and 0.6B)
|
|
||||||
- Voice prompt caching for instant regeneration
|
|
||||||
- English and Chinese support
|
|
||||||
|
|
||||||
### Voice Profile Management
|
|
||||||
|
|
||||||
- Create profiles from audio files or record directly in the app
|
|
||||||
- Multiple samples per profile for higher quality cloning
|
|
||||||
- Import/Export profiles
|
|
||||||
- Automatic transcription via Whisper
|
|
||||||
|
|
||||||
### Speech Generation
|
|
||||||
|
|
||||||
- Simple text-to-speech with profile selection
|
|
||||||
- Seed control for reproducible generations
|
|
||||||
- Long-form support up to 5,000 characters
|
|
||||||
|
|
||||||
### Generation History
|
|
||||||
|
|
||||||
- Full history with metadata
|
|
||||||
- Search by text content
|
|
||||||
- Inline playback and download
|
|
||||||
|
|
||||||
### Flexible Deployment
|
|
||||||
|
|
||||||
- Local mode with bundled backend
|
|
||||||
- Remote mode for GPU servers on your network
|
|
||||||
- One-click server setup
|
|
||||||
|
|
||||||
### Desktop Experience
|
|
||||||
|
|
||||||
- Built with Tauri v2 (Rust) — native performance, not Electron
|
|
||||||
- Cross-platform: macOS and Windows
|
|
||||||
- No Python installation required
|
|
||||||
|
|
||||||
### Tech Stack
|
|
||||||
|
|
||||||
Tauri v2, React, TypeScript, Tailwind CSS, FastAPI, Qwen3-TTS, Whisper, SQLite
|
|
||||||
|
|
||||||
[Unreleased]: https://github.com/jamiepine/voicebox/compare/v0.2.3...HEAD
|
|
||||||
[0.2.3]: https://github.com/jamiepine/voicebox/compare/v0.2.2...v0.2.3
|
|
||||||
[0.2.2]: https://github.com/jamiepine/voicebox/compare/v0.2.1...v0.2.2
|
|
||||||
[0.2.1]: https://github.com/jamiepine/voicebox/compare/v0.1.13...v0.2.1
|
|
||||||
[0.1.13]: https://github.com/jamiepine/voicebox/compare/v0.1.12...v0.1.13
|
|
||||||
[0.1.12]: https://github.com/jamiepine/voicebox/compare/v0.1.11...v0.1.12
|
|
||||||
[0.1.11]: https://github.com/jamiepine/voicebox/compare/v0.1.10...v0.1.11
|
|
||||||
[0.1.10]: https://github.com/jamiepine/voicebox/compare/v0.1.9...v0.1.10
|
|
||||||
[0.1.9]: https://github.com/jamiepine/voicebox/compare/v0.1.8...v0.1.9
|
|
||||||
[0.1.8]: https://github.com/jamiepine/voicebox/compare/v0.1.7...v0.1.8
|
|
||||||
[0.1.7]: https://github.com/jamiepine/voicebox/compare/v0.1.6...v0.1.7
|
|
||||||
[0.1.6]: https://github.com/jamiepine/voicebox/compare/v0.1.5...v0.1.6
|
|
||||||
[0.1.5]: https://github.com/jamiepine/voicebox/compare/v0.1.4...v0.1.5
|
|
||||||
[0.1.4]: https://github.com/jamiepine/voicebox/compare/v0.1.3...v0.1.4
|
|
||||||
[0.1.3]: https://github.com/jamiepine/voicebox/compare/v0.1.2...v0.1.3
|
|
||||||
[0.1.2]: https://github.com/jamiepine/voicebox/compare/v0.1.1...v0.1.2
|
|
||||||
[0.1.1]: https://github.com/jamiepine/voicebox/compare/v0.1.0...v0.1.1
|
|
||||||
[0.1.0]: https://github.com/jamiepine/voicebox/releases/tag/v0.1.0
|
[0.1.0]: https://github.com/jamiepine/voicebox/releases/tag/v0.1.0
|
||||||
|
|||||||
+230
-47
@@ -14,129 +14,308 @@ Thank you for your interest in contributing to Voicebox! This document provides
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- **[Bun](https://bun.sh)** - Fast JavaScript runtime and package manager
|
- **[Bun](https://bun.sh)** - Fast JavaScript runtime and package manager
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://bun.sh/install | bash
|
curl -fsSL https://bun.sh/install | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
- **[Python 3.11+](https://python.org)** - For backend development
|
- **[Python 3.11+](https://python.org)** - For backend development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python --version # Should be 3.11 or higher
|
python --version # Should be 3.11 or higher
|
||||||
```
|
```
|
||||||
|
|
||||||
- **[Rust](https://rustup.rs)** - For Tauri desktop app (installed automatically by Tauri CLI)
|
- **[Rust](https://rustup.rs)** - For Tauri desktop app (installed automatically by Tauri CLI)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rustc --version # Check if installed
|
rustc --version # Check if installed
|
||||||
```
|
```
|
||||||
- **[Tauri Prerequisites](https://v2.tauri.app/start/prerequisites)** - Tauri-specific system dependencies (varies by OS).
|
|
||||||
|
|
||||||
- **Git** - Version control
|
- **Git** - Version control
|
||||||
|
|
||||||
### Development Setup
|
### Development Setup
|
||||||
|
|
||||||
Install [just](https://github.com/casey/just) (`brew install just`, `cargo install just`, or `winget install Casey.Just`), then:
|
**Using the Makefile (recommended for macOS/Linux):** Run `make setup` to install all dependencies, then `make dev` to start development servers. See `make help` for all available commands.
|
||||||
|
|
||||||
```bash
|
**Manual setup (required for Windows):**
|
||||||
git clone https://github.com/YOUR_USERNAME/voicebox.git
|
|
||||||
cd voicebox
|
|
||||||
|
|
||||||
just setup # creates venv, installs Python + JS deps
|
1. **Fork and clone the repository**
|
||||||
just dev # starts backend + desktop app
|
|
||||||
```
|
|
||||||
|
|
||||||
`just setup` handles everything automatically, including:
|
```bash
|
||||||
- Creating a Python virtual environment
|
git clone https://github.com/YOUR_USERNAME/voicebox.git
|
||||||
- Installing Python dependencies (with CUDA PyTorch on Windows if an NVIDIA GPU is detected)
|
cd voicebox
|
||||||
- Installing MLX dependencies on Apple Silicon
|
```
|
||||||
- Installing JavaScript dependencies
|
|
||||||
|
|
||||||
`just dev` starts the backend and desktop app together. If a backend is already running (e.g. from `just dev-backend` in another terminal), it detects it and only starts the frontend.
|
2. **Install JavaScript dependencies**
|
||||||
|
|
||||||
Other useful commands:
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
```bash
|
This installs dependencies for:
|
||||||
just dev-web # backend + web app (no Tauri/Rust build)
|
|
||||||
just dev-backend # backend only
|
|
||||||
just dev-frontend # Tauri app only (backend must be running)
|
|
||||||
just kill # stop all dev processes
|
|
||||||
just clean-all # nuke everything and start fresh
|
|
||||||
just --list # see all available commands
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Note:** In dev mode, the app connects to a manually-started Python server.
|
- `app/` - Shared React frontend
|
||||||
> The bundled server binary is only used in production builds.
|
- `tauri/` - Tauri desktop wrapper
|
||||||
|
- `web/` - Web deployment wrapper
|
||||||
|
|
||||||
#### Windows Notes
|
3. **Set up Python backend**
|
||||||
|
|
||||||
The justfile works natively on Windows via PowerShell. No WSL or Git Bash required. On Windows with an NVIDIA GPU, `just setup` automatically installs CUDA-enabled PyTorch for GPU acceleration.
|
```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
|
### Model Downloads
|
||||||
|
|
||||||
Models are automatically downloaded from HuggingFace Hub on first use:
|
Models are automatically downloaded from HuggingFace Hub on first use:
|
||||||
|
|
||||||
- **Whisper** (transcription): Auto-downloads on first transcription
|
- **Whisper** (transcription): Auto-downloads on first transcription
|
||||||
- **Qwen3-TTS** (voice cloning): Auto-downloads on first generation (~2-4GB)
|
- **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.
|
First-time usage will be slower due to model downloads, but subsequent runs will use cached models.
|
||||||
|
|
||||||
|
### TTS Provider Development
|
||||||
|
|
||||||
|
Voicebox uses a modular provider system to support different inference backends. Understanding this architecture is important when working on TTS features.
|
||||||
|
|
||||||
|
#### Provider Types
|
||||||
|
|
||||||
|
**Bundled Providers** — Included with the app binary:
|
||||||
|
|
||||||
|
- `apple-mlx` — Bundled with macOS Apple Silicon builds (`.dmg` for aarch64)
|
||||||
|
- Uses MLX for native Metal acceleration
|
||||||
|
- Configured in `.github/workflows/release.yml` with `backend: "mlx"`
|
||||||
|
|
||||||
|
**Hybrid Provider:**
|
||||||
|
|
||||||
|
- `pytorch-cpu` — Can be bundled OR downloaded depending on platform
|
||||||
|
- **Bundled** with Windows and macOS Intel builds
|
||||||
|
- macOS Intel: `.dmg` for x64 with `backend: "pytorch"`
|
||||||
|
- Windows: `.exe` installer with PyTorch CPU included
|
||||||
|
- **Downloaded** on first use for Linux builds (~300MB)
|
||||||
|
- Falls back to bundled version if external binary not found
|
||||||
|
|
||||||
|
**External-Only Providers:**
|
||||||
|
|
||||||
|
- `pytorch-cuda` — NVIDIA GPU-accelerated provider (~2.4GB)
|
||||||
|
- Windows/Linux only (no NVIDIA GPUs on macOS)
|
||||||
|
- Downloaded on demand, not bundled
|
||||||
|
- Optional for users with CUDA-capable GPUs
|
||||||
|
|
||||||
|
#### Provider Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/providers/
|
||||||
|
├── __init__.py # ProviderManager - lifecycle management
|
||||||
|
├── base.py # TTSProvider protocol
|
||||||
|
├── bundled.py # BundledProvider - wraps built-in backends
|
||||||
|
├── local.py # LocalProvider - wraps external subprocess
|
||||||
|
├── installer.py # Download and install external providers
|
||||||
|
└── types.py # Provider type definitions
|
||||||
|
|
||||||
|
providers/
|
||||||
|
├── pytorch-cpu/ # External PyTorch CPU provider
|
||||||
|
│ ├── main.py # FastAPI server
|
||||||
|
│ ├── build.py # PyInstaller build script
|
||||||
|
│ └── build_and_install.py # Build and install locally
|
||||||
|
└── pytorch-cuda/ # External PyTorch CUDA provider
|
||||||
|
├── main.py
|
||||||
|
├── build.py
|
||||||
|
└── build_and_install.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
|
||||||
|
1. **Bundled providers** run in-process within the main backend
|
||||||
|
2. **External providers** run as separate subprocess servers
|
||||||
|
3. **LocalProvider** communicates with external providers via HTTP
|
||||||
|
4. **ProviderManager** handles starting/stopping and health checks
|
||||||
|
|
||||||
|
#### Building Providers Locally
|
||||||
|
|
||||||
|
When developing provider features, you'll need to build and test external providers:
|
||||||
|
|
||||||
|
**Build a single provider:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd providers/pytorch-cpu
|
||||||
|
python build_and_install.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Build all providers:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build:providers
|
||||||
|
```
|
||||||
|
|
||||||
|
This script:
|
||||||
|
|
||||||
|
- Builds the provider binary with PyInstaller
|
||||||
|
- Detects your platform (Windows/macOS/Linux)
|
||||||
|
- Copies to the correct location:
|
||||||
|
- macOS: `~/Library/Application Support/voicebox/providers/`
|
||||||
|
- Windows: `%APPDATA%\voicebox\providers\`
|
||||||
|
- Linux: `~/.local/share/voicebox/providers/`
|
||||||
|
- Sets executable permissions on Unix
|
||||||
|
|
||||||
|
**Testing provider changes:**
|
||||||
|
|
||||||
|
1. Make changes to `providers/pytorch-cpu/main.py`
|
||||||
|
2. Run `bun run build:providers`
|
||||||
|
3. Restart the Voicebox app
|
||||||
|
4. Select the provider in Settings → TTS Provider
|
||||||
|
|
||||||
|
#### Provider Binary Distribution
|
||||||
|
|
||||||
|
For production releases, provider binaries are:
|
||||||
|
|
||||||
|
1. Built by GitHub Actions for all platforms
|
||||||
|
2. Uploaded to Cloudflare R2 at `downloads.voicebox.sh/providers/v{VERSION}/`
|
||||||
|
3. Downloaded on-demand by users based on their platform and GPU
|
||||||
|
|
||||||
|
See `.github/workflows/release.yml` for the build matrix.
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
||||||
**Build production app:**
|
**Build everything (recommended):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
just build # Build CPU server binary + Tauri installer
|
bun run build
|
||||||
```
|
```
|
||||||
|
|
||||||
On Windows, to build with CUDA support for local testing:
|
This automatically:
|
||||||
|
|
||||||
```bash
|
1. Builds the Python server binary (`./scripts/build-server.sh`)
|
||||||
just build-local # Build CPU + CUDA server binaries + Tauri installer
|
2. Builds the Tauri desktop app (`cd tauri && bun run tauri build`)
|
||||||
```
|
|
||||||
|
|
||||||
This builds the CPU sidecar (bundled with the app), the CUDA binary (placed in `%APPDATA%/com.voicebox.app/backends/` for runtime GPU switching), and the installable Tauri app.
|
|
||||||
|
|
||||||
Creates platform-specific installers (`.dmg`, `.msi`, `.AppImage`) in `tauri/src-tauri/target/release/bundle/`.
|
Creates platform-specific installers (`.dmg`, `.msi`, `.AppImage`) in `tauri/src-tauri/target/release/bundle/`.
|
||||||
|
|
||||||
**Individual build targets:**
|
**Note:** The build process detects your platform and includes the appropriate backend (MLX for Apple Silicon, PyTorch for others).
|
||||||
|
|
||||||
|
**Build server binary only:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
just build-server # CPU server binary only
|
bun run build:server
|
||||||
just build-server-cuda # CUDA server binary only (Windows)
|
# or
|
||||||
just build-tauri # Tauri desktop app only
|
./scripts/build-server.sh
|
||||||
just build-web # Web app only
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Creates platform-specific binary in `tauri/src-tauri/binaries/`
|
||||||
|
|
||||||
|
**Build provider binaries (for development):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build:providers
|
||||||
|
```
|
||||||
|
|
||||||
|
Builds all external provider binaries and installs them to the system provider directory. See [TTS Provider Development](#tts-provider-development) for details.
|
||||||
|
|
||||||
**Building with local Qwen3-TTS development version:**
|
**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:
|
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
|
```bash
|
||||||
export QWEN_TTS_PATH=~/path/to/your/Qwen3-TTS
|
export QWEN_TTS_PATH=~/path/to/your/Qwen3-TTS
|
||||||
just build-server
|
bun run build:server
|
||||||
```
|
```
|
||||||
|
|
||||||
This makes PyInstaller use your local qwen-tts version instead of the pip-installed package.
|
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
|
### Generate OpenAPI Client
|
||||||
|
|
||||||
After starting the backend server:
|
After starting the backend server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/generate-api.sh
|
./scripts/generate-api.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This downloads the OpenAPI schema and generates the TypeScript client in `app/src/lib/api/`
|
This downloads the OpenAPI schema and generates the TypeScript client in `app/src/lib/api/`
|
||||||
|
|
||||||
### Convert Assets to Web Formats
|
### Convert Assets to Web Formats
|
||||||
|
|
||||||
To optimize images and videos for the web, run:
|
To optimize images and videos for the web, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run convert:assets
|
bun run convert:assets
|
||||||
```
|
```
|
||||||
|
|
||||||
This script:
|
This script:
|
||||||
|
|
||||||
- Converts PNG → WebP (better compression, same quality)
|
- Converts PNG → WebP (better compression, same quality)
|
||||||
- Converts MOV → WebM (VP9 codec, smaller file size)
|
- Converts MOV → WebM (VP9 codec, smaller file size)
|
||||||
- Processes files in `landing/public/` and `docs/public/`
|
- Processes files in `landing/public/` and `docs/public/`
|
||||||
- **Deletes original files** after successful conversion
|
- **Deletes original files** after successful conversion
|
||||||
|
|
||||||
**Requirements:** Install `webp` and `ffmpeg`:
|
**Requirements:** Install `webp` and `ffmpeg`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install webp ffmpeg
|
brew install webp ffmpeg
|
||||||
```
|
```
|
||||||
@@ -183,6 +362,7 @@ git push origin feature/your-feature-name
|
|||||||
```
|
```
|
||||||
|
|
||||||
Then create a pull request on GitHub with:
|
Then create a pull request on GitHub with:
|
||||||
|
|
||||||
- Clear description of changes
|
- Clear description of changes
|
||||||
- Screenshots (for UI changes)
|
- Screenshots (for UI changes)
|
||||||
- Reference to related issues
|
- Reference to related issues
|
||||||
@@ -328,21 +508,23 @@ Currently, testing is primarily manual. When adding tests:
|
|||||||
Releases are managed by maintainers:
|
Releases are managed by maintainers:
|
||||||
|
|
||||||
1. **Bump version using bumpversion:**
|
1. **Bump version using bumpversion:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install bumpversion (if not already installed)
|
# Install bumpversion (if not already installed)
|
||||||
pip install bumpversion
|
pip install bumpversion
|
||||||
|
|
||||||
# Bump patch version (0.1.0 -> 0.1.1)
|
# Bump patch version (0.1.0 -> 0.1.1)
|
||||||
bumpversion patch
|
bumpversion patch
|
||||||
|
|
||||||
# Or bump minor version (0.1.0 -> 0.2.0)
|
# Or bump minor version (0.1.0 -> 0.2.0)
|
||||||
bumpversion minor
|
bumpversion minor
|
||||||
|
|
||||||
# Or bump major version (0.1.0 -> 1.0.0)
|
# Or bump major version (0.1.0 -> 1.0.0)
|
||||||
bumpversion major
|
bumpversion major
|
||||||
```
|
```
|
||||||
|
|
||||||
This automatically:
|
This automatically:
|
||||||
|
|
||||||
- Updates version numbers in all files (`tauri.conf.json`, `Cargo.toml`, all `package.json` files, `backend/main.py`)
|
- 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 commit with the version bump
|
||||||
- Creates a git tag (e.g., `v0.1.1`, `v0.2.0`)
|
- Creates a git tag (e.g., `v0.1.1`, `v0.2.0`)
|
||||||
@@ -350,6 +532,7 @@ Releases are managed by maintainers:
|
|||||||
2. **Update CHANGELOG.md** with release notes
|
2. **Update CHANGELOG.md** with release notes
|
||||||
|
|
||||||
3. **Push commits and tags:**
|
3. **Push commits and tags:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git push
|
git push
|
||||||
git push --tags
|
git push --tags
|
||||||
@@ -365,7 +548,7 @@ See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues and sol
|
|||||||
|
|
||||||
- **Backend won't start:** Check Python version (3.11+), ensure venv is activated, install dependencies
|
- **Backend won't start:** Check Python version (3.11+), ensure venv is activated, install dependencies
|
||||||
- **Tauri build fails:** Ensure Rust is installed, clean build with `cd tauri/src-tauri && cargo clean`
|
- **Tauri build fails:** Ensure Rust is installed, clean build with `cd tauri/src-tauri && cargo clean`
|
||||||
- **OpenAPI client generation fails:** Ensure backend is running, check `curl http://localhost:17493/openapi.json`
|
- **OpenAPI client generation fails:** Ensure backend is running, check `curl http://localhost:8000/openapi.json`
|
||||||
|
|
||||||
## Questions?
|
## Questions?
|
||||||
|
|
||||||
|
|||||||
+28
-66
@@ -1,81 +1,43 @@
|
|||||||
# ============================================================
|
# Base Dockerfile for Voicebox (CPU-only)
|
||||||
# Voicebox — Local TTS Server with Web UI (CPU)
|
# For GPU support, use Dockerfile.cuda
|
||||||
# 3-stage build: Frontend → Python deps → Runtime
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
# === Stage 1: Build frontend ===
|
FROM python:3.12-slim
|
||||||
FROM oven/bun:1 AS frontend
|
|
||||||
|
|
||||||
WORKDIR /build
|
# Prevent interactive prompts during build
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
# Copy workspace config and frontend source
|
ENV TZ=UTC
|
||||||
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/*
|
|
||||||
|
|
||||||
RUN pip install --no-cache-dir --upgrade pip
|
|
||||||
|
|
||||||
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
|
WORKDIR /app
|
||||||
|
|
||||||
# Install only runtime system dependencies
|
# Install system dependencies
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
curl \
|
curl \
|
||||||
|
tzdata \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy installed Python packages from builder stage
|
# Copy backend
|
||||||
COPY --from=backend-builder /install /usr/local
|
COPY backend/ /app/backend/
|
||||||
|
COPY providers/ /app/providers/
|
||||||
|
|
||||||
# Copy backend application code
|
# Copy pre-built web UI
|
||||||
COPY --chown=voicebox:voicebox backend/ /app/backend/
|
COPY web/dist/ /app/web/dist/
|
||||||
|
|
||||||
# Copy built frontend from frontend stage
|
# Install Python dependencies (without PyTorch - will be downloaded via provider system)
|
||||||
COPY --from=frontend --chown=voicebox:voicebox /build/web/dist /app/frontend/
|
RUN python -m pip install --upgrade pip && \
|
||||||
|
pip install --no-cache-dir \
|
||||||
|
fastapi uvicorn[standard] pydantic sqlalchemy alembic \
|
||||||
|
librosa soundfile numpy python-multipart Pillow \
|
||||||
|
huggingface_hub transformers accelerate
|
||||||
|
|
||||||
# Create data directories owned by non-root user
|
# Create data directory for profiles/generations
|
||||||
RUN mkdir -p /app/data/generations /app/data/profiles /app/data/cache \
|
RUN mkdir -p /app/data
|
||||||
&& chown -R voicebox:voicebox /app/data
|
|
||||||
|
|
||||||
# Switch to non-root user
|
# Health check
|
||||||
USER voicebox
|
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
# Expose the API port
|
EXPOSE 8000
|
||||||
EXPOSE 17493
|
|
||||||
|
|
||||||
# Health check — auto-restart if the server hangs
|
# Run server with web UI
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s \
|
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
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,58 @@
|
|||||||
|
# Dockerfile for Voicebox with NVIDIA GPU support (CUDA)
|
||||||
|
|
||||||
|
FROM nvidia/cuda:12.1.1-runtime-ubuntu22.04
|
||||||
|
|
||||||
|
# Prevent interactive prompts during build
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
ENV TZ=UTC
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install Python 3.12
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
software-properties-common \
|
||||||
|
&& add-apt-repository ppa:deadsnakes/ppa \
|
||||||
|
&& apt-get update && apt-get install -y \
|
||||||
|
python3.12 \
|
||||||
|
python3.12-dev \
|
||||||
|
python3.12-venv \
|
||||||
|
ffmpeg \
|
||||||
|
curl \
|
||||||
|
tzdata \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Set Python 3.12 as default and bootstrap pip
|
||||||
|
RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.12 1 && \
|
||||||
|
update-alternatives --install /usr/bin/python python /usr/bin/python3.12 1 && \
|
||||||
|
python3.12 -m ensurepip --upgrade && \
|
||||||
|
python3.12 -m pip install --upgrade pip
|
||||||
|
|
||||||
|
# Copy backend
|
||||||
|
COPY backend/ /app/backend/
|
||||||
|
COPY providers/ /app/providers/
|
||||||
|
|
||||||
|
# Copy pre-built web UI
|
||||||
|
COPY web/dist/ /app/web/dist/
|
||||||
|
|
||||||
|
# Install PyTorch with CUDA support first
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
|
||||||
|
|
||||||
|
# Install remaining dependencies
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
fastapi uvicorn[standard] pydantic sqlalchemy alembic \
|
||||||
|
transformers accelerate huggingface_hub \
|
||||||
|
librosa soundfile numpy python-multipart Pillow \
|
||||||
|
qwen-tts
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Run server with web UI
|
||||||
|
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
# Voicebox Makefile
|
||||||
|
# Unix-only (macOS/Linux). Windows users should use WSL.
|
||||||
|
|
||||||
|
SHELL := /bin/bash
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
# Directories
|
||||||
|
BACKEND_DIR := backend
|
||||||
|
TAURI_DIR := tauri
|
||||||
|
WEB_DIR := web
|
||||||
|
APP_DIR := app
|
||||||
|
|
||||||
|
# Python (prefer 3.12, fallback to 3.13, then python3)
|
||||||
|
PYTHON := $(shell command -v python3.12 2>/dev/null || command -v python3.13 2>/dev/null || echo python3)
|
||||||
|
VENV := $(CURDIR)/$(BACKEND_DIR)/venv
|
||||||
|
VENV_BIN := $(VENV)/bin
|
||||||
|
PIP := $(VENV_BIN)/pip
|
||||||
|
PYTHON_VENV := $(VENV_BIN)/python
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
BLUE := \033[0;34m
|
||||||
|
GREEN := \033[0;32m
|
||||||
|
YELLOW := \033[0;33m
|
||||||
|
NC := \033[0m # No Color
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help: ## Show this help message
|
||||||
|
@echo -e "$(BLUE)Voicebox$(NC) - Development Commands"
|
||||||
|
@echo ""
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
|
||||||
|
awk 'BEGIN {FS = ":.*?## "}; {printf " $(GREEN)%-20s$(NC) %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SETUP
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
.PHONY: setup setup-js setup-python setup-rust
|
||||||
|
|
||||||
|
setup: setup-js setup-python ## Full project setup (all dependencies)
|
||||||
|
@echo -e "$(GREEN)✓ Setup complete!$(NC)"
|
||||||
|
@echo -e " Run $(YELLOW)make dev$(NC) to start development servers"
|
||||||
|
|
||||||
|
setup-js: ## Install JavaScript dependencies (bun)
|
||||||
|
@echo -e "$(BLUE)Installing JavaScript dependencies...$(NC)"
|
||||||
|
bun install
|
||||||
|
|
||||||
|
setup-python: $(VENV)/bin/activate ## Set up Python virtual environment and dependencies
|
||||||
|
@echo -e "$(BLUE)Installing Python dependencies...$(NC)"
|
||||||
|
$(PIP) install --upgrade pip
|
||||||
|
$(PIP) install -r $(BACKEND_DIR)/requirements.txt
|
||||||
|
@if [ "$$(uname -m)" = "arm64" ] && [ "$$(uname)" = "Darwin" ]; then \
|
||||||
|
echo -e "$(BLUE)Detected Apple Silicon - installing MLX dependencies...$(NC)"; \
|
||||||
|
$(PIP) install -r $(BACKEND_DIR)/requirements-mlx.txt; \
|
||||||
|
echo -e "$(GREEN)✓ MLX backend enabled (native Metal acceleration)$(NC)"; \
|
||||||
|
fi
|
||||||
|
$(PIP) install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||||
|
@echo -e "$(GREEN)✓ Python environment ready$(NC)"
|
||||||
|
|
||||||
|
$(VENV)/bin/activate:
|
||||||
|
@echo -e "$(BLUE)Creating Python virtual environment...$(NC)"
|
||||||
|
@PY_MINOR=$$($(PYTHON) -c "import sys; print(sys.version_info[1])"); \
|
||||||
|
if [ "$$PY_MINOR" -gt 13 ]; then \
|
||||||
|
echo -e "$(YELLOW)Warning: Python 3.$$PY_MINOR detected. ML packages may not be compatible.$(NC)"; \
|
||||||
|
echo -e "$(YELLOW)Recommended: Use Python 3.12 or 3.13 (brew install [email protected])$(NC)"; \
|
||||||
|
fi
|
||||||
|
$(PYTHON) -m venv $(VENV)
|
||||||
|
|
||||||
|
setup-rust: ## Install Rust toolchain (if not present)
|
||||||
|
@command -v rustc >/dev/null 2>&1 || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DEVELOPMENT
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
.PHONY: dev dev-backend dev-frontend dev-web kill-dev
|
||||||
|
|
||||||
|
dev: ## Start backend + desktop app (parallel)
|
||||||
|
@echo -e "$(BLUE)Starting development servers...$(NC)"
|
||||||
|
@echo -e "$(YELLOW)Note: If Tauri fails, run 'make build-server' first or use separate terminals$(NC)"
|
||||||
|
@trap 'kill 0' EXIT; \
|
||||||
|
$(MAKE) dev-backend & \
|
||||||
|
sleep 2 && $(MAKE) dev-frontend & \
|
||||||
|
wait
|
||||||
|
|
||||||
|
dev-backend: ## Start FastAPI backend server
|
||||||
|
@echo -e "$(BLUE)Starting backend server on http://localhost:17493$(NC)"
|
||||||
|
$(VENV_BIN)/uvicorn backend.main:app --reload --port 17493
|
||||||
|
|
||||||
|
dev-frontend: ## Start Tauri desktop app
|
||||||
|
@echo -e "$(BLUE)Starting Tauri desktop app...$(NC)"
|
||||||
|
bun run dev
|
||||||
|
|
||||||
|
dev-web: ## Start backend + web app (parallel)
|
||||||
|
@echo -e "$(BLUE)Starting web development servers...$(NC)"
|
||||||
|
@trap 'kill 0' EXIT; \
|
||||||
|
$(MAKE) dev-backend & \
|
||||||
|
sleep 2 && cd $(WEB_DIR) && bun run dev & \
|
||||||
|
wait
|
||||||
|
|
||||||
|
kill-dev: ## Kill all development processes
|
||||||
|
@echo -e "$(YELLOW)Killing development processes...$(NC)"
|
||||||
|
-pkill -f "uvicorn main:app" 2>/dev/null || true
|
||||||
|
-pkill -f "vite" 2>/dev/null || true
|
||||||
|
@echo -e "$(GREEN)✓ Processes killed$(NC)"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BUILD
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
.PHONY: build build-server build-tauri build-web
|
||||||
|
|
||||||
|
build: build-server build-tauri ## Build everything (server binary + desktop app)
|
||||||
|
@echo -e "$(GREEN)✓ Build complete!$(NC)"
|
||||||
|
|
||||||
|
build-server: ## Build Python server binary
|
||||||
|
@echo -e "$(BLUE)Building server binary...$(NC)"
|
||||||
|
PATH="$(VENV_BIN):$$PATH" ./scripts/build-server.sh
|
||||||
|
|
||||||
|
build-tauri: ## Build Tauri desktop app
|
||||||
|
@echo -e "$(BLUE)Building Tauri desktop app...$(NC)"
|
||||||
|
cd $(TAURI_DIR) && bun run tauri build
|
||||||
|
|
||||||
|
build-web: ## Build web app
|
||||||
|
@echo -e "$(BLUE)Building web app...$(NC)"
|
||||||
|
cd $(WEB_DIR) && bun run build
|
||||||
|
@echo -e "$(GREEN)✓ Web build output in $(WEB_DIR)/dist/$(NC)"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DATABASE & API
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
.PHONY: db-init db-reset generate-api
|
||||||
|
|
||||||
|
db-init: $(VENV)/bin/activate ## Initialize SQLite database
|
||||||
|
@echo -e "$(BLUE)Initializing database...$(NC)"
|
||||||
|
cd $(BACKEND_DIR) && $(PYTHON_VENV) -c "from database import init_db; init_db()"
|
||||||
|
@echo -e "$(GREEN)✓ Database created at $(BACKEND_DIR)/data/voicebox.db$(NC)"
|
||||||
|
|
||||||
|
db-reset: ## Reset database (delete and reinitialize)
|
||||||
|
@echo -e "$(YELLOW)Resetting database...$(NC)"
|
||||||
|
rm -f $(BACKEND_DIR)/data/voicebox.db
|
||||||
|
$(MAKE) db-init
|
||||||
|
|
||||||
|
generate-api: ## Generate TypeScript API client from OpenAPI schema
|
||||||
|
@echo -e "$(BLUE)Generating API client...$(NC)"
|
||||||
|
@echo -e "$(YELLOW)Note: Backend must be running (make dev-backend)$(NC)"
|
||||||
|
./scripts/generate-api.sh
|
||||||
|
@echo -e "$(GREEN)✓ API client generated in $(APP_DIR)/src/lib/api/$(NC)"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CODE QUALITY
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
.PHONY: lint format typecheck check
|
||||||
|
|
||||||
|
lint: ## Run linter (Biome)
|
||||||
|
@echo -e "$(BLUE)Linting...$(NC)"
|
||||||
|
bun run lint
|
||||||
|
|
||||||
|
format: ## Format code (Biome)
|
||||||
|
@echo -e "$(BLUE)Formatting...$(NC)"
|
||||||
|
bun run format
|
||||||
|
|
||||||
|
typecheck: ## Run TypeScript type checking
|
||||||
|
@echo -e "$(BLUE)Type checking...$(NC)"
|
||||||
|
bun run tsc --noEmit
|
||||||
|
|
||||||
|
check: ## Run all checks (Biome lint + format + type check)
|
||||||
|
@echo -e "$(BLUE)Running all checks...$(NC)"
|
||||||
|
bun run check
|
||||||
|
@echo -e "$(GREEN)✓ All checks passed$(NC)"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# TESTING
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
.PHONY: test test-backend test-frontend
|
||||||
|
|
||||||
|
test: test-backend test-frontend ## Run all tests
|
||||||
|
@echo -e "$(GREEN)✓ All tests passed$(NC)"
|
||||||
|
|
||||||
|
test-backend: ## Run Python backend tests (requires pytest)
|
||||||
|
@echo -e "$(BLUE)Running backend tests...$(NC)"
|
||||||
|
@if [ -f "$(VENV_BIN)/pytest" ]; then \
|
||||||
|
cd $(BACKEND_DIR) && $(VENV_BIN)/pytest -v; \
|
||||||
|
else \
|
||||||
|
echo -e "$(YELLOW)pytest not installed. Run: $(PIP) install pytest$(NC)"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
test-frontend: ## Run frontend tests (requires test script in package.json)
|
||||||
|
@echo -e "$(BLUE)Running frontend tests...$(NC)"
|
||||||
|
@if bun run test --help >/dev/null 2>&1; then \
|
||||||
|
bun run test; \
|
||||||
|
else \
|
||||||
|
echo -e "$(YELLOW)No test script configured$(NC)"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LOGS & DEBUGGING
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
.PHONY: logs docs
|
||||||
|
|
||||||
|
logs: ## Tail backend logs
|
||||||
|
@echo -e "$(BLUE)Tailing logs (Ctrl+C to stop)...$(NC)"
|
||||||
|
tail -f $(BACKEND_DIR)/logs/*.log 2>/dev/null || echo "No log files found"
|
||||||
|
|
||||||
|
docs: ## Open API documentation (backend must be running)
|
||||||
|
@echo -e "$(BLUE)Opening API docs...$(NC)"
|
||||||
|
open http://localhost:17493/docs 2>/dev/null || xdg-open http://localhost:17493/docs
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CLEAN
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
.PHONY: clean clean-python clean-build clean-all
|
||||||
|
|
||||||
|
clean: ## Clean build artifacts
|
||||||
|
@echo -e "$(BLUE)Cleaning build artifacts...$(NC)"
|
||||||
|
rm -rf $(TAURI_DIR)/src-tauri/target/release
|
||||||
|
rm -rf $(WEB_DIR)/dist
|
||||||
|
rm -rf $(APP_DIR)/dist
|
||||||
|
@echo -e "$(GREEN)✓ Build artifacts cleaned$(NC)"
|
||||||
|
|
||||||
|
clean-python: ## Clean Python cache and virtual environment
|
||||||
|
@echo -e "$(BLUE)Cleaning Python files...$(NC)"
|
||||||
|
rm -rf $(VENV)
|
||||||
|
find $(BACKEND_DIR) -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
find $(BACKEND_DIR) -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||||
|
@echo -e "$(GREEN)✓ Python environment cleaned$(NC)"
|
||||||
|
|
||||||
|
clean-build: ## Clean Rust/Tauri build cache
|
||||||
|
@echo -e "$(BLUE)Cleaning Rust build cache...$(NC)"
|
||||||
|
cd $(TAURI_DIR)/src-tauri && cargo clean
|
||||||
|
@echo -e "$(GREEN)✓ Rust cache cleaned$(NC)"
|
||||||
|
|
||||||
|
clean-all: clean clean-python clean-build ## Nuclear clean (everything)
|
||||||
|
@echo -e "$(BLUE)Cleaning node_modules...$(NC)"
|
||||||
|
rm -rf node_modules
|
||||||
|
rm -rf $(APP_DIR)/node_modules
|
||||||
|
rm -rf $(TAURI_DIR)/node_modules
|
||||||
|
rm -rf $(WEB_DIR)/node_modules
|
||||||
|
@echo -e "$(GREEN)✓ Full clean complete$(NC)"
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>The open-source voice synthesis studio.</strong><br/>
|
<strong>The open-source voice synthesis studio.</strong><br/>
|
||||||
Clone voices. Generate speech. Apply effects. Build voice-powered apps.<br/>
|
Clone voices. Generate speech. Build voice-powered apps.<br/>
|
||||||
All running locally on your machine.
|
All running locally on your machine.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -27,10 +27,10 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://voicebox.sh">voicebox.sh</a> •
|
<a href="https://voicebox.sh">voicebox.sh</a> •
|
||||||
<a href="https://docs.voicebox.sh">Docs</a> •
|
|
||||||
<a href="#download">Download</a> •
|
<a href="#download">Download</a> •
|
||||||
<a href="#features">Features</a> •
|
<a href="#features">Features</a> •
|
||||||
<a href="#api">API</a>
|
<a href="#api">API</a> •
|
||||||
|
<a href="#roadmap">Roadmap</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
@@ -59,195 +59,209 @@
|
|||||||
|
|
||||||
## What is Voicebox?
|
## What is Voicebox?
|
||||||
|
|
||||||
Voicebox is a **local-first voice cloning studio** — a free and open-source alternative to ElevenLabs. Clone voices from a few seconds of audio, generate speech in 23 languages across 4 TTS engines, apply post-processing effects, and compose multi-voice projects with a timeline editor.
|
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
|
- **Complete privacy** — models and voice data stay on your machine
|
||||||
- **4 TTS engines** — Qwen3-TTS, LuxTTS, Chatterbox Multilingual, and Chatterbox Turbo
|
- **Professional tools** — multi-track timeline editor, audio trimming, conversation mixing
|
||||||
- **23 languages** — from English to Arabic, Japanese, Hindi, Swahili, and more
|
- **Model flexibility** — currently powered by Qwen3-TTS, with support for XTTS, Bark, and other models coming soon
|
||||||
- **Post-processing effects** — pitch shift, reverb, delay, chorus, compression, and filters
|
- **API-first** — use the desktop app or integrate voice synthesis into your own projects
|
||||||
- **Expressive speech** — paralinguistic tags like `[laugh]`, `[sigh]`, `[gasp]` via Chatterbox Turbo
|
|
||||||
- **Unlimited length** — auto-chunking with crossfade for scripts, articles, and chapters
|
|
||||||
- **Stories editor** — multi-track timeline for conversations, podcasts, and narratives
|
|
||||||
- **API-first** — REST API for integrating voice synthesis into your own projects
|
|
||||||
- **Native performance** — built with Tauri (Rust), not Electron
|
- **Native performance** — built with Tauri (Rust), not Electron
|
||||||
- **Runs everywhere** — macOS (MLX/Metal), Windows (CUDA), Linux, AMD ROCm, Intel Arc, Docker
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
| Platform | Download |
|
### Desktop App
|
||||||
| --------------------- | ------------------------------------------------------ |
|
|
||||||
| macOS (Apple Silicon) | [Download DMG](https://voicebox.sh/download/mac-arm) |
|
|
||||||
| macOS (Intel) | [Download DMG](https://voicebox.sh/download/mac-intel) |
|
|
||||||
| Windows | [Download MSI](https://voicebox.sh/download/windows) |
|
|
||||||
| Docker | `docker compose up` |
|
|
||||||
|
|
||||||
> **[View all binaries →](https://github.com/jamiepine/voicebox/releases/latest)**
|
| Platform | Download |
|
||||||
|
| --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| macOS (Apple Silicon) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||||
|
| macOS (Intel) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||||
|
| Windows (MSI) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||||
|
| Windows (Setup) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||||
|
| Linux (AppImage) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||||
|
| Linux (Deb) | [Download latest release](https://github.com/jamiepine/voicebox/releases/latest) |
|
||||||
|
|
||||||
> **Linux** — Pre-built binaries are not yet available. See [voicebox.sh/linux-install](https://voicebox.sh/linux-install) for build-from-source instructions.
|
### Docker
|
||||||
|
|
||||||
|
Run Voicebox with the web UI in Docker - perfect for servers and headless deployments:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CPU-only (supports amd64 and arm64)
|
||||||
|
docker run -p 8000:8000 -v voicebox-data:/app/data \
|
||||||
|
ghcr.io/jamiepine/voicebox:latest
|
||||||
|
|
||||||
|
# NVIDIA GPU (recommended for performance)
|
||||||
|
docker run --gpus all -p 8000:8000 -v voicebox-data:/app/data \
|
||||||
|
ghcr.io/jamiepine/voicebox:latest-cuda
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use Docker Compose:
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:8000 to access the web UI.
|
||||||
|
|
||||||
|
See [Docker Deployment Guide](docs/overview/docker.mdx) for cloud deployments, GPU setup, and more.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Multi-Engine Voice Cloning
|
### Voice Cloning with Qwen3-TTS
|
||||||
|
|
||||||
Four TTS engines with different strengths, switchable per-generation:
|
Powered by Alibaba's **Qwen3-TTS** — a breakthrough model that achieves near-perfect voice cloning from just a few seconds of audio.
|
||||||
|
|
||||||
| Engine | Languages | Strengths |
|
- **Instant cloning** — Upload a sample, get a voice profile
|
||||||
| --------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
- **High fidelity** — Natural prosody, emotion, and cadence
|
||||||
| **Qwen3-TTS** (0.6B / 1.7B) | 10 | High-quality multilingual cloning, delivery instructions ("speak slowly", "whisper") |
|
- **Multi-language** — English, Chinese, and more coming
|
||||||
| **LuxTTS** | English | Lightweight (~1GB VRAM), 48kHz output, 150x realtime on CPU |
|
- **Lightning fast on Mac** — MLX backend leverages Apple Silicon's Neural Engine for super fast generation
|
||||||
| **Chatterbox Multilingual** | 23 | Broadest language coverage — Arabic, Danish, Finnish, Greek, Hebrew, Hindi, Malay, Norwegian, Polish, Swahili, Swedish, Turkish and more |
|
|
||||||
| **Chatterbox Turbo** | English | Fast 350M model with paralinguistic emotion/sound tags |
|
|
||||||
|
|
||||||
### Emotions & Paralinguistic Tags
|
|
||||||
|
|
||||||
Type `/` in the text input to insert expressive tags that the model synthesizes inline with speech (Chatterbox Turbo):
|
|
||||||
|
|
||||||
`[laugh]` `[chuckle]` `[gasp]` `[cough]` `[sigh]` `[groan]` `[sniff]` `[shush]` `[clear throat]`
|
|
||||||
|
|
||||||
### Post-Processing Effects
|
|
||||||
|
|
||||||
8 audio effects powered by Spotify's `pedalboard` library. Apply after generation, preview in real time, build reusable presets.
|
|
||||||
|
|
||||||
| Effect | Description |
|
|
||||||
| ---------------- | --------------------------------------------- |
|
|
||||||
| Pitch Shift | Up or down by up to 12 semitones |
|
|
||||||
| Reverb | Configurable room size, damping, wet/dry mix |
|
|
||||||
| Delay | Echo with adjustable time, feedback, and mix |
|
|
||||||
| Chorus / Flanger | Modulated delay for metallic or lush textures |
|
|
||||||
| Compressor | Dynamic range compression |
|
|
||||||
| Gain | Volume adjustment (-40 to +40 dB) |
|
|
||||||
| High-Pass Filter | Remove low frequencies |
|
|
||||||
| Low-Pass Filter | Remove high frequencies |
|
|
||||||
|
|
||||||
Ships with 4 built-in presets (Robotic, Radio, Echo Chamber, Deep Voice) and supports custom presets. Effects can be assigned per-profile as defaults.
|
|
||||||
|
|
||||||
### Unlimited Generation Length
|
|
||||||
|
|
||||||
Text is automatically split at sentence boundaries and each chunk is generated independently, then crossfaded together. Works with all engines.
|
|
||||||
|
|
||||||
- Configurable auto-chunking limit (100–5,000 chars)
|
|
||||||
- Crossfade slider (0–200ms) for smooth transitions
|
|
||||||
- Max text length: 50,000 characters
|
|
||||||
- Smart splitting respects abbreviations, CJK punctuation, and `[tags]`
|
|
||||||
|
|
||||||
### Generation Versions
|
|
||||||
|
|
||||||
Every generation supports multiple versions with provenance tracking:
|
|
||||||
|
|
||||||
- **Original** — clean TTS output, always preserved
|
|
||||||
- **Effects versions** — apply different effects chains from any source version
|
|
||||||
- **Takes** — regenerate with a new seed for variation
|
|
||||||
- **Source tracking** — each version records its lineage
|
|
||||||
- **Favorites** — star generations for quick access
|
|
||||||
|
|
||||||
### Async Generation Queue
|
|
||||||
|
|
||||||
Generation is non-blocking. Submit and immediately start typing the next one.
|
|
||||||
|
|
||||||
- Serial execution queue prevents GPU contention
|
|
||||||
- Real-time SSE status streaming
|
|
||||||
- Failed generations can be retried
|
|
||||||
- Stale generations from crashes auto-recover on startup
|
|
||||||
|
|
||||||
### Voice Profile Management
|
### Voice Profile Management
|
||||||
|
|
||||||
- Create profiles from audio files or record directly in-app
|
- **Create profiles** from audio files or record directly in-app
|
||||||
- Import/export profiles to share or back up
|
- **Import/Export** profiles to share or backup
|
||||||
- Multi-sample support for higher quality cloning
|
- **Multi-sample support** — combine multiple samples for higher quality cloning
|
||||||
- Per-profile default effects chains
|
- **Organize** with descriptions and language tags
|
||||||
- Organize with descriptions and language tags
|
|
||||||
|
### Speech Generation
|
||||||
|
|
||||||
|
- **Text-to-speech** with any cloned voice
|
||||||
|
- **Batch generation** for long-form content
|
||||||
|
- **Smart caching** — regenerate instantly with voice prompt caching
|
||||||
|
|
||||||
### Stories Editor
|
### Stories Editor
|
||||||
|
|
||||||
Multi-voice timeline editor for conversations, podcasts, and narratives.
|
Create multi-voice narratives, podcasts, and conversations with a timeline-based editor.
|
||||||
|
|
||||||
- Multi-track composition with drag-and-drop
|
- **Multi-track composition** — arrange multiple voice tracks in a single project
|
||||||
- Inline audio trimming and splitting
|
- **Inline audio editing** — trim and split clips directly in the timeline
|
||||||
- Auto-playback with synchronized playhead
|
- **Auto-playback** — preview stories with synchronized playhead
|
||||||
- Version pinning per track clip
|
- **Voice mixing** — build conversations with multiple participants
|
||||||
|
|
||||||
### Recording & Transcription
|
### Recording & Transcription
|
||||||
|
|
||||||
- In-app recording with waveform visualization
|
- **In-app recording** with waveform visualization
|
||||||
- System audio capture (macOS and Windows)
|
- **System audio capture** — record desktop audio on macOS and Windows
|
||||||
- Automatic transcription powered by Whisper (including Whisper Turbo)
|
- **Automatic transcription** powered by Whisper
|
||||||
- Export recordings in multiple formats
|
- **Export recordings** in multiple formats
|
||||||
|
|
||||||
### Model Management
|
### Generation History
|
||||||
|
|
||||||
- Per-model unload to free GPU memory without deleting downloads
|
- **Full history** of all generated audio
|
||||||
- Custom models directory via `VOICEBOX_MODELS_DIR`
|
- **Search & filter** by voice, text, or date
|
||||||
- Model folder migration with progress tracking
|
- **Re-generate** any past generation with one click
|
||||||
- Download cancel/clear UI
|
|
||||||
|
|
||||||
### GPU Support
|
### Flexible Deployment
|
||||||
|
|
||||||
| Platform | Backend | Notes |
|
- **Desktop app** — Native apps for macOS, Windows, and Linux
|
||||||
| ------------------------ | -------------- | ---------------------------------------------- |
|
- **Docker** — Deploy to servers with the web UI included
|
||||||
| macOS (Apple Silicon) | MLX (Metal) | 4-5x faster via Neural Engine |
|
- **Remote mode** — Connect desktop app to a remote GPU server
|
||||||
| Windows / Linux (NVIDIA) | PyTorch (CUDA) | Auto-downloads CUDA binary from within the app |
|
- **Cloud ready** — Deploy to AWS, GCP, DigitalOcean, or any cloud provider
|
||||||
| Linux (AMD) | PyTorch (ROCm) | Auto-configures HSA_OVERRIDE_GFX_VERSION |
|
|
||||||
| Windows (any GPU) | DirectML | Universal Windows GPU support |
|
|
||||||
| Intel Arc | IPEX/XPU | Intel discrete GPU acceleration |
|
|
||||||
| Any | CPU | Works everywhere, just slower |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
Voicebox exposes a full REST API for integrating voice synthesis into your own apps.
|
Voicebox exposes a full REST API, so you can integrate voice synthesis into your own apps.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate speech
|
# Generate speech
|
||||||
curl -X POST http://localhost:17493/generate \
|
curl -X POST http://localhost:8000/generate \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"text": "Hello world", "profile_id": "abc123", "language": "en"}'
|
-d '{"text": "Hello world", "profile_id": "abc123", "language": "en"}'
|
||||||
|
|
||||||
# List voice profiles
|
# List voice profiles
|
||||||
curl http://localhost:17493/profiles
|
curl http://localhost:8000/profiles
|
||||||
|
|
||||||
# Create a profile
|
# Create a profile
|
||||||
curl -X POST http://localhost:17493/profiles \
|
curl -X POST http://localhost:8000/profiles \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"name": "My Voice", "language": "en"}'
|
-d '{"name": "My Voice", "language": "en"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
**Use cases:** game dialogue, podcast production, accessibility tools, voice assistants, content automation.
|
**Use cases:**
|
||||||
|
|
||||||
Full API documentation available at `http://localhost:17493/docs`.
|
- Game dialogue systems
|
||||||
|
- Podcast/video production pipelines
|
||||||
|
- Accessibility tools
|
||||||
|
- Voice assistants
|
||||||
|
- Content creation automation
|
||||||
|
|
||||||
|
Full API documentation available at `http://localhost:8000/docs` when running.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
| ------------- | ------------------------------------------------- |
|
| ---------------- | --------------------------------------------------- |
|
||||||
| Desktop App | Tauri (Rust) |
|
| Desktop App | Tauri (Rust) |
|
||||||
| Frontend | React, TypeScript, Tailwind CSS |
|
| Frontend | React, TypeScript, Tailwind CSS |
|
||||||
| State | Zustand, React Query |
|
| State | Zustand, React Query |
|
||||||
| Backend | FastAPI (Python) |
|
| Backend | FastAPI (Python) |
|
||||||
| TTS Engines | Qwen3-TTS, LuxTTS, Chatterbox, Chatterbox Turbo |
|
| Voice Model | Qwen3-TTS (PyTorch or MLX) |
|
||||||
| Effects | Pedalboard (Spotify) |
|
| Transcription | Whisper (PyTorch or MLX) |
|
||||||
| Transcription | Whisper / Whisper Turbo (PyTorch or MLX) |
|
| Inference Engine | MLX (Apple Silicon) / PyTorch (Windows/Linux/Intel) |
|
||||||
| Inference | MLX (Apple Silicon) / PyTorch (CUDA/ROCm/XPU/CPU) |
|
| Database | SQLite |
|
||||||
| Database | SQLite |
|
| Audio | WaveSurfer.js, librosa |
|
||||||
| Audio | WaveSurfer.js, librosa |
|
|
||||||
|
**Why this stack?**
|
||||||
|
|
||||||
|
- **Tauri over Electron** — 10x smaller bundle, native performance, lower memory
|
||||||
|
- **FastAPI** — Async Python with automatic OpenAPI schema generation
|
||||||
|
- **Type-safe end-to-end** — Generated TypeScript client from OpenAPI spec
|
||||||
|
|
||||||
|
### TTS Provider Architecture
|
||||||
|
|
||||||
|
Voicebox uses a modular provider system to support different inference backends:
|
||||||
|
|
||||||
|
- **`apple-mlx`** — Bundled with macOS Apple Silicon builds
|
||||||
|
|
||||||
|
- Uses MLX with native Metal acceleration (4-5x faster)
|
||||||
|
- Works out of the box, no download required
|
||||||
|
|
||||||
|
- **`pytorch-cpu`** — Universal CPU provider (bundled or downloaded)
|
||||||
|
|
||||||
|
- Bundled with Windows and macOS Intel builds
|
||||||
|
- Downloaded on first use for Linux (~300MB)
|
||||||
|
|
||||||
|
- **`pytorch-cuda`** — Optional NVIDIA GPU-accelerated provider
|
||||||
|
- Windows/Linux only (~2.4GB)
|
||||||
|
- 4-5x faster inference on CUDA-capable GPUs
|
||||||
|
|
||||||
|
macOS and Windows builds work out of the box with bundled providers. Linux users download a provider on first launch. The app automatically detects your hardware and recommends the best option. All downloadable providers are distributed via Cloudflare R2 for fast, global delivery.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
| Feature | Description |
|
Voicebox is the beginning of something bigger. Here's what's coming:
|
||||||
| ----------------------- | ---------------------------------------------- |
|
|
||||||
| **Real-time Streaming** | Stream audio as it generates, word by word |
|
### Coming Soon
|
||||||
| **Voice Design** | Create new voices from text descriptions |
|
|
||||||
| **More Models** | XTTS, Bark, and other open-source voice models |
|
| Feature | Description |
|
||||||
| **Plugin Architecture** | Extend with custom models and effects |
|
| ----------------------- | -------------------------------------------------- |
|
||||||
| **Mobile Companion** | Control Voicebox from your phone |
|
| **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -255,27 +269,48 @@ Full API documentation available at `http://localhost:17493/docs`.
|
|||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed setup and contribution guidelines.
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed setup and contribution guidelines.
|
||||||
|
|
||||||
|
**Using the Makefile (recommended):** Run `make help` to see all available commands for setup, development, building, and testing.
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
|
**With Makefile (Unix/macOS/Linux):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/jamiepine/voicebox.git
|
# Clone the repo
|
||||||
|
git clone https://github.com/voicebox-sh/voicebox.git
|
||||||
cd voicebox
|
cd voicebox
|
||||||
|
|
||||||
just setup # creates Python venv, installs all deps
|
# Setup everything
|
||||||
just dev # starts backend + desktop app
|
make setup
|
||||||
|
|
||||||
|
# Start development
|
||||||
|
make dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Install [just](https://github.com/casey/just): `brew install just` or `cargo install just`. Run `just --list` to see all commands.
|
**Manual setup (all platforms):**
|
||||||
|
|
||||||
**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org), [Tauri Prerequisites](https://v2.tauri.app/start/prerequisites/), and [Xcode](https://developer.apple.com/xcode/) on macOS.
|
|
||||||
|
|
||||||
### Building Locally
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
just build # Build CPU server binary + Tauri app
|
# Clone the repo
|
||||||
just build-local # (Windows) Build CPU + CUDA server binaries + Tauri app
|
git clone https://github.com/voicebox-sh/voicebox.git
|
||||||
|
cd voicebox
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
cd backend && pip install -r requirements.txt && cd ..
|
||||||
|
|
||||||
|
# Start development
|
||||||
|
bun run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org).
|
||||||
|
|
||||||
|
**Performance:**
|
||||||
|
|
||||||
|
- **Apple Silicon (M1/M2/M3)**: Uses MLX backend with native Metal acceleration for 4-5x faster inference
|
||||||
|
- **Windows/Linux/Intel Mac**: Uses PyTorch backend (CUDA GPU recommended, CPU supported but slower)
|
||||||
|
|
||||||
### Project Structure
|
### Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
+6
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/app",
|
"name": "@voicebox/app",
|
||||||
"version": "0.2.3",
|
"version": "0.1.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -17,6 +17,10 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
|
"@hugeicons/core-free-icons": "^3.1.1",
|
||||||
|
"@hugeicons/react": "^1.1.4",
|
||||||
|
"@iconify-json/svg-spinners": "^1.2.4",
|
||||||
|
"@iconify/react": "^6.0.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-avatar": "^1.1.0",
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.1",
|
"@radix-ui/react-dialog": "^1.1.1",
|
||||||
@@ -24,6 +28,7 @@
|
|||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-popover": "^1.1.1",
|
"@radix-ui/react-popover": "^1.1.1",
|
||||||
"@radix-ui/react-progress": "^1.1.0",
|
"@radix-ui/react-progress": "^1.1.0",
|
||||||
|
"@radix-ui/react-radio-group": "^1.2.0",
|
||||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||||
"@radix-ui/react-select": "^2.1.1",
|
"@radix-ui/react-select": "^2.1.1",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
@@ -43,7 +48,6 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"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",
|
|
||||||
"motion": "^12.29.0",
|
"motion": "^12.29.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import type { Plugin } from 'vite';
|
|
||||||
|
|
||||||
/** Vite plugin that exposes CHANGELOG.md as `virtual:changelog`. */
|
|
||||||
export function changelogPlugin(repoRoot: string): Plugin {
|
|
||||||
const virtualId = 'virtual:changelog';
|
|
||||||
const resolvedId = '\0' + virtualId;
|
|
||||||
const changelogPath = path.resolve(repoRoot, 'CHANGELOG.md');
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: 'changelog',
|
|
||||||
resolveId(id) {
|
|
||||||
if (id === virtualId) return resolvedId;
|
|
||||||
},
|
|
||||||
load(id) {
|
|
||||||
if (id === resolvedId) {
|
|
||||||
const raw = readFileSync(changelogPath, 'utf-8');
|
|
||||||
return `export default ${JSON.stringify(raw)};`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+6
-20
@@ -8,7 +8,6 @@ import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
|||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
import { router } from '@/router';
|
import { router } from '@/router';
|
||||||
import { useLogStore } from '@/stores/logStore';
|
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
const LOADING_MESSAGES = [
|
const LOADING_MESSAGES = [
|
||||||
@@ -41,7 +40,7 @@ function App() {
|
|||||||
const serverStartingRef = useRef(false);
|
const serverStartingRef = useRef(false);
|
||||||
|
|
||||||
// Automatically check for app updates on startup and show toast notifications
|
// Automatically check for app updates on startup and show toast notifications
|
||||||
useAutoUpdater({ checkOnMount: true, showToast: true });
|
useAutoUpdater(true);
|
||||||
|
|
||||||
// Sync stored setting to Rust on startup
|
// Sync stored setting to Rust on startup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -64,14 +63,6 @@ function App() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [platform.lifecycle]);
|
}, [platform.lifecycle]);
|
||||||
|
|
||||||
// Subscribe to server logs
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = platform.lifecycle.subscribeToServerLogs((entry) => {
|
|
||||||
useLogStore.getState().addEntry(entry);
|
|
||||||
});
|
|
||||||
return unsubscribe;
|
|
||||||
}, [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 (!platform.metadata.isTauri) {
|
if (!platform.metadata.isTauri) {
|
||||||
@@ -91,8 +82,7 @@ function App() {
|
|||||||
console.log('Dev mode: Skipping auto-start of server (run it separately)');
|
console.log('Dev mode: Skipping auto-start of server (run it separately)');
|
||||||
setServerReady(true); // Mark as ready so UI doesn't show loading screen
|
setServerReady(true); // Mark as ready so UI doesn't show loading screen
|
||||||
// Mark that server was not started by app (so we don't try to stop it on close)
|
// Mark that server was not started by app (so we don't try to stop it on close)
|
||||||
// @ts-expect-error - adding property to window
|
(window as any).__voiceboxServerStartedByApp = false;
|
||||||
window.__voiceboxServerStartedByApp = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,26 +92,22 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serverStartingRef.current = true;
|
serverStartingRef.current = true;
|
||||||
const isRemote = useServerStore.getState().mode === 'remote';
|
console.log('Production mode: Starting bundled server...');
|
||||||
const customModelsDir = useServerStore.getState().customModelsDir;
|
|
||||||
console.log(`Production mode: Starting bundled server... (remote: ${isRemote})`);
|
|
||||||
|
|
||||||
platform.lifecycle
|
platform.lifecycle
|
||||||
.startServer(isRemote, customModelsDir)
|
.startServer(false)
|
||||||
.then((serverUrl) => {
|
.then((serverUrl) => {
|
||||||
console.log('Server is ready at:', serverUrl);
|
console.log('Server is ready at:', serverUrl);
|
||||||
// Update the server URL in the store with the dynamically assigned port
|
// Update the server URL in the store with the dynamically assigned port
|
||||||
useServerStore.getState().setServerUrl(serverUrl);
|
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
|
(window as any).__voiceboxServerStartedByApp = true;
|
||||||
window.__voiceboxServerStartedByApp = true;
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to auto-start server:', error);
|
console.error('Failed to auto-start server:', error);
|
||||||
serverStartingRef.current = false;
|
serverStartingRef.current = false;
|
||||||
// @ts-expect-error - adding property to window
|
(window as any).__voiceboxServerStartedByApp = false;
|
||||||
window.__voiceboxServerStartedByApp = false;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup: stop server on actual unmount (not StrictMode remount)
|
// Cleanup: stop server on actual unmount (not StrictMode remount)
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
import { PauseIcon, PlayIcon, RepeatIcon, VolumeHighIcon, VolumeMuteIcon, Cancel01Icon } from '@hugeicons/core-free-icons';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import WaveSurfer from 'wavesurfer.js';
|
import WaveSurfer from 'wavesurfer.js';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
import { debug } from '@/lib/utils/debug';
|
import { debug } from '@/lib/utils/debug';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
export function AudioPlayer() {
|
export function AudioPlayer() {
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
const volumeLabelId = useId();
|
|
||||||
const {
|
const {
|
||||||
audioUrl,
|
audioUrl,
|
||||||
audioId,
|
audioId,
|
||||||
profileId,
|
profileId,
|
||||||
|
title,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
currentTime,
|
currentTime,
|
||||||
duration,
|
duration,
|
||||||
@@ -62,7 +63,7 @@ export function AudioPlayer() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return shouldUseNative;
|
return shouldUseNative;
|
||||||
}, [profileChannels, channels, platform.metadata.isTauri]);
|
}, [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);
|
||||||
@@ -72,21 +73,31 @@ export function AudioPlayer() {
|
|||||||
const isUsingNativePlaybackRef = 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);
|
||||||
const [wsReady, setWsReady] = useState(false);
|
|
||||||
|
|
||||||
// Create WaveSurfer once when the player becomes visible (audioUrl is set).
|
// Initialize WaveSurfer (only when audioUrl exists and container is ready)
|
||||||
// This instance is reused for all subsequent audio loads - never destroyed until unmount.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!audioUrl) return;
|
// Don't initialize if no audioUrl or already initialized
|
||||||
if (wavesurferRef.current) return; // already created
|
if (!audioUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wavesurferRef.current) {
|
||||||
|
debug.log('WaveSurfer already initialized, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug.log('Creating NEW WaveSurfer instance');
|
||||||
|
|
||||||
|
// Wait for container to be properly rendered
|
||||||
const initWaveSurfer = () => {
|
const initWaveSurfer = () => {
|
||||||
const container = waveformRef.current;
|
const container = waveformRef.current;
|
||||||
if (!container) {
|
if (!container) {
|
||||||
|
// Container not ready yet, retry
|
||||||
setTimeout(initWaveSurfer, 50);
|
setTimeout(initWaveSurfer, 50);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if container has dimensions and is visible
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
const style = window.getComputedStyle(container);
|
const style = window.getComputedStyle(container);
|
||||||
const isVisible =
|
const isVisible =
|
||||||
@@ -96,221 +107,488 @@ export function AudioPlayer() {
|
|||||||
style.visibility !== 'hidden';
|
style.visibility !== 'hidden';
|
||||||
|
|
||||||
if (!isVisible) {
|
if (!isVisible) {
|
||||||
|
// Retry after a short delay
|
||||||
setTimeout(initWaveSurfer, 50);
|
setTimeout(initWaveSurfer, 50);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug.log('Creating WaveSurfer instance', {
|
debug.log('Initializing WaveSurfer...', {
|
||||||
|
container,
|
||||||
width: rect.width,
|
width: rect.width,
|
||||||
height: rect.height,
|
height: rect.height,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Get computed CSS variable values
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const getCSSVar = (varName: string) => {
|
const getCSSVar = (varName: string) => {
|
||||||
const value = getComputedStyle(root).getPropertyValue(varName).trim();
|
const value = getComputedStyle(root).getPropertyValue(varName).trim();
|
||||||
return value ? `hsl(${value})` : '';
|
return value ? `hsl(${value})` : '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const waveColor = getCSSVar('--muted');
|
||||||
|
const progressColor = getCSSVar('--accent');
|
||||||
|
const cursorColor = getCSSVar('--accent');
|
||||||
|
|
||||||
const wavesurfer = WaveSurfer.create({
|
const wavesurfer = WaveSurfer.create({
|
||||||
container,
|
container: container,
|
||||||
waveColor: getCSSVar('--muted'),
|
waveColor: waveColor,
|
||||||
progressColor: getCSSVar('--accent'),
|
progressColor: progressColor,
|
||||||
cursorColor: getCSSVar('--accent'),
|
cursorColor: cursorColor,
|
||||||
cursorWidth: 3,
|
|
||||||
barWidth: 2,
|
barWidth: 2,
|
||||||
barRadius: 2,
|
barRadius: 2,
|
||||||
height: 80,
|
height: 80,
|
||||||
normalize: true,
|
normalize: true,
|
||||||
interact: true,
|
|
||||||
dragToSeek: { debounceTime: 0 },
|
|
||||||
mediaControls: false,
|
|
||||||
backend: 'WebAudio',
|
backend: 'WebAudio',
|
||||||
});
|
interact: true, // Enable interaction (click to seek)
|
||||||
|
mediaControls: false, // Don't show native controls
|
||||||
// Wire up event handlers (these persist for the lifetime of the instance)
|
|
||||||
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().catch((err) => debug.error('Loop play failed:', err));
|
|
||||||
} else {
|
|
||||||
wavesurfer.pause();
|
|
||||||
setIsPlaying(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCurrentTime(time);
|
|
||||||
});
|
|
||||||
|
|
||||||
wavesurfer.on('ready', () => {
|
|
||||||
const dur = wavesurfer.getDuration();
|
|
||||||
setDuration(dur);
|
|
||||||
loadingRef.current = false;
|
|
||||||
setIsLoading(false);
|
|
||||||
setError(null);
|
|
||||||
debug.log('Audio ready, duration:', dur);
|
|
||||||
|
|
||||||
wavesurfer.setVolume(usePlayerStore.getState().volume);
|
|
||||||
wavesurfer.setMuted(false);
|
|
||||||
|
|
||||||
// Auto-play if the flag is set (story mode advance or explicit play)
|
|
||||||
const shouldAutoPlayNow = usePlayerStore.getState().shouldAutoPlay;
|
|
||||||
if (shouldAutoPlayNow) {
|
|
||||||
usePlayerStore.getState().clearAutoPlayFlag();
|
|
||||||
wavesurfer.play().catch((err) => {
|
|
||||||
debug.error('Failed to autoplay:', err);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
debug.log('Skipping auto-play - shouldAutoPlay is false');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
wavesurfer.on('play', () => setIsPlaying(true));
|
|
||||||
wavesurfer.on('pause', () => {
|
|
||||||
setIsPlaying(false);
|
|
||||||
setCurrentTime(wavesurfer.getCurrentTime());
|
|
||||||
});
|
|
||||||
|
|
||||||
wavesurfer.on('seeking', (time) => setCurrentTime(time));
|
|
||||||
|
|
||||||
// Mute audio during drag-to-seek to prevent popping from the WebAudio
|
|
||||||
// backend's hard stop/start cycle on each seek. Unmute with a short
|
|
||||||
// fade-in when the drag ends.
|
|
||||||
const seekMedia = wavesurfer.getMediaElement() as any;
|
|
||||||
const seekGain: GainNode | null = seekMedia?.getGainNode?.() ?? null;
|
|
||||||
if (seekGain) {
|
|
||||||
const ctx = seekGain.context as AudioContext;
|
|
||||||
wavesurfer.on('dragstart', () => {
|
|
||||||
seekGain.gain.cancelScheduledValues(ctx.currentTime);
|
|
||||||
seekGain.gain.setTargetAtTime(0, ctx.currentTime, 0.002);
|
|
||||||
});
|
|
||||||
wavesurfer.on('dragend', () => {
|
|
||||||
seekGain.gain.cancelScheduledValues(ctx.currentTime);
|
|
||||||
seekGain.gain.setTargetAtTime(1, ctx.currentTime, 0.01);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
wavesurfer.on('finish', () => {
|
|
||||||
const loop = usePlayerStore.getState().isLooping;
|
|
||||||
if (loop) {
|
|
||||||
wavesurfer.seekTo(0);
|
|
||||||
wavesurfer.play().catch((err) => debug.error('Loop play failed:', err));
|
|
||||||
} else {
|
|
||||||
setIsPlaying(false);
|
|
||||||
const onFinish = usePlayerStore.getState().onFinish;
|
|
||||||
if (onFinish) onFinish();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
wavesurfer.on('error', (err) => {
|
|
||||||
debug.error('WaveSurfer error:', err);
|
|
||||||
setIsLoading(false);
|
|
||||||
setError(`Audio error: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wavesurfer.on('loading', (percent) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
if (percent === 100) setIsLoading(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
wavesurferRef.current = wavesurfer;
|
wavesurferRef.current = wavesurfer;
|
||||||
setWsReady(true);
|
|
||||||
debug.log('WaveSurfer created successfully');
|
debug.log('WaveSurfer created successfully');
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
debug.error('Failed to create WaveSurfer:', err);
|
debug.error('Failed to create WaveSurfer:', error);
|
||||||
setError(
|
setError(
|
||||||
`Failed to initialize waveform: ${err instanceof Error ? err.message : String(err)}`,
|
`Failed to initialize waveform: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wavesurfer = wavesurferRef.current;
|
||||||
|
if (!wavesurfer) return;
|
||||||
|
|
||||||
|
// Update store when time changes
|
||||||
|
wavesurfer.on('timeupdate', (time) => {
|
||||||
|
setCurrentTime(time);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update store when duration is loaded
|
||||||
|
wavesurfer.on('ready', async () => {
|
||||||
|
const dur = wavesurfer.getDuration();
|
||||||
|
setDuration(dur);
|
||||||
|
loadingRef.current = false;
|
||||||
|
setIsLoading(false);
|
||||||
|
setError(null);
|
||||||
|
debug.log('Audio ready, duration:', dur);
|
||||||
|
debug.log('Waveform should be visible now');
|
||||||
|
|
||||||
|
// Ensure volume is set
|
||||||
|
const currentVolume = usePlayerStore.getState().volume;
|
||||||
|
wavesurfer.setVolume(currentVolume);
|
||||||
|
|
||||||
|
// Get the underlying audio element and ensure it's not muted
|
||||||
|
// (unless we're using native playback, which will be set later)
|
||||||
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
|
if (mediaElement && !isUsingNativePlaybackRef.current) {
|
||||||
|
mediaElement.volume = currentVolume;
|
||||||
|
mediaElement.muted = false;
|
||||||
|
debug.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-play when ready - check if we should use native playback
|
||||||
|
// Get current values from the store and queries at runtime (not captured closure values)
|
||||||
|
const currentAudioUrl = usePlayerStore.getState().audioUrl;
|
||||||
|
const currentProfileId = usePlayerStore.getState().profileId;
|
||||||
|
|
||||||
|
debug.log('Auto-play check - capturing runtime values...');
|
||||||
|
|
||||||
|
// Fetch profile channels at runtime (not using captured value)
|
||||||
|
let runtimeProfileChannels = null;
|
||||||
|
let runtimeChannels = null;
|
||||||
|
|
||||||
|
if (platform.metadata.isTauri && currentProfileId) {
|
||||||
|
try {
|
||||||
|
runtimeProfileChannels = await apiClient.getProfileChannels(currentProfileId);
|
||||||
|
debug.log('Runtime profileChannels:', runtimeProfileChannels);
|
||||||
|
|
||||||
|
if (runtimeProfileChannels && runtimeProfileChannels.channel_ids.length > 0) {
|
||||||
|
runtimeChannels = await apiClient.listChannels();
|
||||||
|
debug.log('Runtime channels:', runtimeChannels);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debug.error('Failed to fetch runtime channel data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug.log('Auto-play check:', {
|
||||||
|
isTauri: platform.metadata.isTauri,
|
||||||
|
currentAudioUrl,
|
||||||
|
currentProfileId,
|
||||||
|
hasProfileChannels: !!runtimeProfileChannels,
|
||||||
|
hasChannels: !!runtimeChannels,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
platform.metadata.isTauri &&
|
||||||
|
currentAudioUrl &&
|
||||||
|
currentProfileId &&
|
||||||
|
runtimeProfileChannels &&
|
||||||
|
runtimeChannels
|
||||||
|
) {
|
||||||
|
debug.log('Attempting native audio playback...');
|
||||||
|
|
||||||
|
// Stop any existing native playback first
|
||||||
|
if (isUsingNativePlaybackRef.current) {
|
||||||
|
try {
|
||||||
|
platform.audio.stopPlayback();
|
||||||
|
debug.log('Stopped existing native playback before starting new one');
|
||||||
|
} catch (error) {
|
||||||
|
debug.error('Failed to stop existing playback:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Collect all device IDs from assigned channels
|
||||||
|
const assignedChannels = runtimeChannels.filter((ch: any) =>
|
||||||
|
runtimeProfileChannels.channel_ids.includes(ch.id),
|
||||||
|
);
|
||||||
|
debug.log('Assigned channels for playback:', assignedChannels);
|
||||||
|
|
||||||
|
// Check if any assigned channel has non-default devices
|
||||||
|
const shouldUseNative = assignedChannels.some(
|
||||||
|
(ch: any) => ch.device_ids.length > 0 && !ch.is_default,
|
||||||
|
);
|
||||||
|
debug.log('Should use native playback:', shouldUseNative);
|
||||||
|
|
||||||
|
if (!shouldUseNative) {
|
||||||
|
debug.log('No custom devices assigned, falling back to WaveSurfer');
|
||||||
|
// Reset native playback flag and unmute WaveSurfer
|
||||||
|
isUsingNativePlaybackRef.current = false;
|
||||||
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
|
if (mediaElement) {
|
||||||
|
const currentVolume = usePlayerStore.getState().volume;
|
||||||
|
mediaElement.volume = currentVolume;
|
||||||
|
mediaElement.muted = false;
|
||||||
|
debug.log(
|
||||||
|
'WaveSurfer unmuted for normal playback - volume:',
|
||||||
|
mediaElement.volume,
|
||||||
|
'muted:',
|
||||||
|
mediaElement.muted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const deviceIds = assignedChannels.flatMap((ch: any) => ch.device_ids);
|
||||||
|
debug.log('Device IDs to play to:', deviceIds);
|
||||||
|
|
||||||
|
if (deviceIds.length > 0) {
|
||||||
|
debug.log('Fetching audio data from:', currentAudioUrl);
|
||||||
|
// Fetch audio data
|
||||||
|
const response = await fetch(currentAudioUrl);
|
||||||
|
const audioData = new Uint8Array(await response.arrayBuffer());
|
||||||
|
debug.log('Audio data size:', audioData.length);
|
||||||
|
|
||||||
|
// Play via native audio
|
||||||
|
debug.log('Invoking play_audio_to_devices...');
|
||||||
|
try {
|
||||||
|
await platform.audio.playToDevices(audioData, deviceIds);
|
||||||
|
debug.log('play_audio_to_devices completed successfully');
|
||||||
|
|
||||||
|
// Mark that we're using native playback
|
||||||
|
isUsingNativePlaybackRef.current = true;
|
||||||
|
|
||||||
|
// Mute WaveSurfer's audio element to prevent UI audio output
|
||||||
|
// Keep WaveSurfer running for visualization
|
||||||
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
|
if (mediaElement) {
|
||||||
|
mediaElement.volume = 0;
|
||||||
|
mediaElement.muted = true;
|
||||||
|
debug.log(
|
||||||
|
'WaveSurfer muted for native playback - volume:',
|
||||||
|
mediaElement.volume,
|
||||||
|
'muted:',
|
||||||
|
mediaElement.muted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start WaveSurfer playback for visualization (muted)
|
||||||
|
wavesurfer.play().catch((error) => {
|
||||||
|
debug.error('Failed to start WaveSurfer visualization:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsPlaying(true);
|
||||||
|
debug.log('Auto-playing via native audio routing - SUCCESS');
|
||||||
|
return;
|
||||||
|
} catch (invokeError) {
|
||||||
|
debug.error('play_audio_to_devices invoke failed:', invokeError);
|
||||||
|
throw invokeError;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debug.log('No device IDs found, falling back to WaveSurfer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debug.error(
|
||||||
|
'Native playback failed during auto-play, falling back to WaveSurfer:',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
// Reset native playback flag and unmute WaveSurfer
|
||||||
|
isUsingNativePlaybackRef.current = false;
|
||||||
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
|
if (mediaElement) {
|
||||||
|
const currentVolume = usePlayerStore.getState().volume;
|
||||||
|
mediaElement.volume = currentVolume;
|
||||||
|
mediaElement.muted = false;
|
||||||
|
debug.log(
|
||||||
|
'WaveSurfer unmuted after native playback failure - volume:',
|
||||||
|
mediaElement.volume,
|
||||||
|
'muted:',
|
||||||
|
mediaElement.muted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Fall through to WaveSurfer playback
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debug.log('Not using native playback, using WaveSurfer');
|
||||||
|
// Reset native playback flag and unmute WaveSurfer
|
||||||
|
isUsingNativePlaybackRef.current = false;
|
||||||
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
|
if (mediaElement) {
|
||||||
|
const currentVolume = usePlayerStore.getState().volume;
|
||||||
|
mediaElement.volume = currentVolume;
|
||||||
|
mediaElement.muted = false;
|
||||||
|
debug.log(
|
||||||
|
'WaveSurfer unmuted for normal playback - volume:',
|
||||||
|
mediaElement.volume,
|
||||||
|
'muted:',
|
||||||
|
mediaElement.muted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only auto-play if shouldAutoPlay flag is set (user explicitly clicked to play)
|
||||||
|
const shouldAutoPlayNow = usePlayerStore.getState().shouldAutoPlay;
|
||||||
|
if (shouldAutoPlayNow) {
|
||||||
|
// Clear the flag first
|
||||||
|
usePlayerStore.getState().clearAutoPlayFlag();
|
||||||
|
|
||||||
|
// Use a small delay to ensure audio element is fully ready
|
||||||
|
setTimeout(() => {
|
||||||
|
wavesurfer.play().catch((error) => {
|
||||||
|
debug.error('Failed to autoplay:', error);
|
||||||
|
// Don't show error for autoplay failures (browser restrictions)
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
debug.log('Skipping auto-play - shouldAutoPlay is false');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle play/pause
|
||||||
|
wavesurfer.on('play', () => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
// Ensure audio element volume is set correctly
|
||||||
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
|
if (mediaElement) {
|
||||||
|
// Double-check: if using native playback, keep WaveSurfer muted
|
||||||
|
// Otherwise, ensure it's unmuted
|
||||||
|
if (isUsingNativePlaybackRef.current) {
|
||||||
|
mediaElement.volume = 0;
|
||||||
|
mediaElement.muted = true;
|
||||||
|
debug.log('Playing (native mode) - WaveSurfer muted for visualization only');
|
||||||
|
} else {
|
||||||
|
// Ensure WaveSurfer is unmuted for normal playback
|
||||||
|
const currentVolume = usePlayerStore.getState().volume;
|
||||||
|
mediaElement.volume = currentVolume;
|
||||||
|
mediaElement.muted = false;
|
||||||
|
debug.log(
|
||||||
|
'Playing (normal mode) - volume:',
|
||||||
|
mediaElement.volume,
|
||||||
|
'muted:',
|
||||||
|
mediaElement.muted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
wavesurfer.on('pause', () => setIsPlaying(false));
|
||||||
|
wavesurfer.on('finish', () => {
|
||||||
|
// Check loop state from store
|
||||||
|
const loop = usePlayerStore.getState().isLooping;
|
||||||
|
if (loop) {
|
||||||
|
wavesurfer.seekTo(0);
|
||||||
|
wavesurfer.play();
|
||||||
|
} else {
|
||||||
|
setIsPlaying(false);
|
||||||
|
// Trigger finish callback if set
|
||||||
|
const onFinish = usePlayerStore.getState().onFinish;
|
||||||
|
if (onFinish) {
|
||||||
|
onFinish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle errors
|
||||||
|
wavesurfer.on('error', (error) => {
|
||||||
|
debug.error('WaveSurfer error:', error);
|
||||||
|
setIsLoading(false);
|
||||||
|
setError(`Audio error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle loading
|
||||||
|
wavesurfer.on('loading', (percent) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
if (percent === 100) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load audio immediately if audioUrl is already set
|
||||||
|
if (audioUrl) {
|
||||||
|
debug.log('WaveSurfer ready, loading audio:', audioUrl);
|
||||||
|
loadingRef.current = true;
|
||||||
|
setIsLoading(true);
|
||||||
|
// Stop any current playback before loading new audio
|
||||||
|
if (wavesurfer.isPlaying()) {
|
||||||
|
wavesurfer.pause();
|
||||||
|
}
|
||||||
|
wavesurfer
|
||||||
|
.load(audioUrl)
|
||||||
|
.then(() => {
|
||||||
|
debug.log('Audio loaded into WaveSurfer');
|
||||||
|
loadingRef.current = false;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
debug.error('Failed to load audio into WaveSurfer:', error);
|
||||||
|
loadingRef.current = false;
|
||||||
|
setIsLoading(false);
|
||||||
|
setError(
|
||||||
|
`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let rafId: number;
|
// Use double requestAnimationFrame to ensure DOM is fully rendered
|
||||||
rafId = requestAnimationFrame(() => {
|
let rafId1: number;
|
||||||
initWaveSurfer();
|
let rafId2: number;
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
rafId1 = requestAnimationFrame(() => {
|
||||||
|
rafId2 = requestAnimationFrame(() => {
|
||||||
|
// Add a small delay to ensure container is fully laid out
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
initWaveSurfer();
|
||||||
|
}, 10);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(rafId);
|
debug.log('Cleaning up WaveSurfer initialization effect');
|
||||||
};
|
if (rafId1) cancelAnimationFrame(rafId1);
|
||||||
// Only run on mount-like conditions. audioUrl is here so we create the instance
|
if (rafId2) cancelAnimationFrame(rafId2);
|
||||||
// when the player first appears, but we guard against re-creation above.
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [audioUrl, setIsPlaying, setDuration, setCurrentTime]);
|
|
||||||
|
|
||||||
// Destroy WaveSurfer only on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (wavesurferRef.current) {
|
if (wavesurferRef.current) {
|
||||||
debug.log('Destroying WaveSurfer instance (unmount)');
|
debug.log('Destroying WaveSurfer instance');
|
||||||
try {
|
try {
|
||||||
|
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||||
|
if (mediaElement) {
|
||||||
|
mediaElement.pause();
|
||||||
|
mediaElement.src = '';
|
||||||
|
}
|
||||||
wavesurferRef.current.destroy();
|
wavesurferRef.current.destroy();
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
debug.error('Error destroying WaveSurfer:', err);
|
debug.error('Error destroying WaveSurfer:', error);
|
||||||
}
|
}
|
||||||
wavesurferRef.current = null;
|
wavesurferRef.current = null;
|
||||||
setWsReady(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, [audioUrl, setIsPlaying, setCurrentTime, setDuration]);
|
||||||
|
|
||||||
// Load audio when URL changes (reuses the existing WaveSurfer instance)
|
// Load audio when URL changes (only if WaveSurfer is already initialized)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wavesurfer = wavesurferRef.current;
|
const wavesurfer = wavesurferRef.current;
|
||||||
if (!wavesurfer || !wsReady) return;
|
|
||||||
|
|
||||||
if (!audioUrl) {
|
if (!audioUrl || !wavesurfer) {
|
||||||
// No audio - pause and reset
|
// Reset state when no audio or WaveSurfer not ready
|
||||||
wavesurfer.pause();
|
if (!audioUrl && wavesurfer) {
|
||||||
wavesurfer.seekTo(0);
|
wavesurfer.pause();
|
||||||
loadingRef.current = false;
|
wavesurfer.seekTo(0);
|
||||||
setIsLoading(false);
|
loadingRef.current = false;
|
||||||
setDuration(0);
|
setIsLoading(false);
|
||||||
setCurrentTime(0);
|
setDuration(0);
|
||||||
setError(null);
|
setCurrentTime(0);
|
||||||
isUsingNativePlaybackRef.current = false;
|
setError(null);
|
||||||
|
// Reset native playback flag
|
||||||
|
isUsingNativePlaybackRef.current = false;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset native playback state
|
// Stop native playback if it was active
|
||||||
isUsingNativePlaybackRef.current = false;
|
if (isUsingNativePlaybackRef.current && platform.metadata.isTauri) {
|
||||||
wavesurfer.setMuted(false);
|
try {
|
||||||
wavesurfer.setVolume(usePlayerStore.getState().volume);
|
platform.audio.stopPlayback();
|
||||||
|
debug.log('Stopped native audio playback');
|
||||||
// Stop current playback and reset position before loading new audio.
|
} catch (error) {
|
||||||
// With the WebAudio backend, pause() accumulates playedDuration internally.
|
debug.error('Failed to stop native playback:', error);
|
||||||
// seekTo(0) resets it so the new track starts from the beginning.
|
|
||||||
debug.log('Loading new audio URL:', audioUrl);
|
|
||||||
try {
|
|
||||||
if (wavesurfer.isPlaying()) {
|
|
||||||
wavesurfer.pause();
|
|
||||||
}
|
}
|
||||||
wavesurfer.seekTo(0);
|
|
||||||
} catch (err) {
|
|
||||||
debug.error('Error resetting before load:', err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset native playback flag when loading new audio
|
||||||
|
// Also unmute WaveSurfer if it was muted
|
||||||
|
if (isUsingNativePlaybackRef.current) {
|
||||||
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
|
if (mediaElement) {
|
||||||
|
mediaElement.muted = false;
|
||||||
|
mediaElement.volume = usePlayerStore.getState().volume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isUsingNativePlaybackRef.current = false;
|
||||||
|
|
||||||
|
// CRITICAL: Force stop any current playback and cancel any pending loads
|
||||||
|
// This must happen BEFORE any early returns
|
||||||
|
debug.log('Audio URL changed to:', audioUrl);
|
||||||
|
|
||||||
|
// COMPLETELY stop and destroy the current audio
|
||||||
|
try {
|
||||||
|
// First pause if playing
|
||||||
|
if (wavesurfer.isPlaying()) {
|
||||||
|
debug.log('Pausing current playback');
|
||||||
|
wavesurfer.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the media element explicitly
|
||||||
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
|
if (mediaElement) {
|
||||||
|
debug.log('Stopping media element');
|
||||||
|
mediaElement.pause();
|
||||||
|
mediaElement.currentTime = 0;
|
||||||
|
mediaElement.src = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use empty() to completely destroy the waveform and media element
|
||||||
|
debug.log('Calling wavesurfer.empty() to destroy audio');
|
||||||
|
wavesurfer.empty();
|
||||||
|
} catch (error) {
|
||||||
|
debug.error('Error stopping previous audio:', error);
|
||||||
|
// Continue anyway to load new audio
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset loading state to allow new load (cancel any pending loads)
|
||||||
|
loadingRef.current = false;
|
||||||
|
|
||||||
|
// Now start the new load
|
||||||
loadingRef.current = true;
|
loadingRef.current = true;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
|
|
||||||
|
// Load new audio
|
||||||
|
debug.log('Starting new audio load for:', audioUrl);
|
||||||
wavesurfer
|
wavesurfer
|
||||||
.load(audioUrl)
|
.load(audioUrl)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
debug.log('Audio loaded into WaveSurfer');
|
debug.log('Audio load promise resolved');
|
||||||
loadingRef.current = false;
|
// Don't set loading to false here - wait for 'ready' event
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((error) => {
|
||||||
debug.error('Failed to load audio:', err);
|
debug.error('Failed to load audio:', error);
|
||||||
|
debug.error('Audio URL:', audioUrl);
|
||||||
loadingRef.current = false;
|
loadingRef.current = false;
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setError(`Failed to load audio: ${err instanceof Error ? err.message : String(err)}`);
|
setError(`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
});
|
});
|
||||||
}, [audioUrl, wsReady, setCurrentTime, setDuration]);
|
}, [audioUrl, setCurrentTime, setDuration]);
|
||||||
|
|
||||||
// Sync play/pause state (only when user clicks play/pause button, not auto-sync)
|
// Sync play/pause state (only when user clicks play/pause button, not auto-sync)
|
||||||
// This effect is kept for external state changes but should be minimal
|
// This effect is kept for external state changes but should be minimal
|
||||||
@@ -318,6 +596,7 @@ export function AudioPlayer() {
|
|||||||
if (!wavesurferRef.current || duration === 0) return;
|
if (!wavesurferRef.current || duration === 0) return;
|
||||||
|
|
||||||
if (isPlaying && wavesurferRef.current.isPlaying() === false) {
|
if (isPlaying && wavesurferRef.current.isPlaying() === false) {
|
||||||
|
// Only auto-play if audio is ready
|
||||||
wavesurferRef.current.play().catch((error) => {
|
wavesurferRef.current.play().catch((error) => {
|
||||||
debug.error('Failed to play:', error);
|
debug.error('Failed to play:', error);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
@@ -332,6 +611,20 @@ export function AudioPlayer() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (wavesurferRef.current) {
|
if (wavesurferRef.current) {
|
||||||
wavesurferRef.current.setVolume(volume);
|
wavesurferRef.current.setVolume(volume);
|
||||||
|
// Also ensure the underlying audio element volume is set
|
||||||
|
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||||
|
if (mediaElement) {
|
||||||
|
// If using native playback, keep WaveSurfer muted regardless of volume setting
|
||||||
|
if (isUsingNativePlaybackRef.current) {
|
||||||
|
mediaElement.volume = 0;
|
||||||
|
mediaElement.muted = true;
|
||||||
|
debug.log('Volume sync: Using native playback, keeping WaveSurfer muted');
|
||||||
|
} else {
|
||||||
|
mediaElement.volume = volume;
|
||||||
|
mediaElement.muted = volume === 0;
|
||||||
|
debug.log('Volume synced:', volume, 'muted:', mediaElement.muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [volume]);
|
}, [volume]);
|
||||||
|
|
||||||
@@ -356,6 +649,7 @@ export function AudioPlayer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset to beginning and play
|
||||||
debug.log('Restarting current audio from beginning');
|
debug.log('Restarting current audio from beginning');
|
||||||
wavesurfer.seekTo(0);
|
wavesurfer.seekTo(0);
|
||||||
wavesurfer.play().catch((error) => {
|
wavesurfer.play().catch((error) => {
|
||||||
@@ -364,35 +658,34 @@ export function AudioPlayer() {
|
|||||||
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clear the restart flag
|
||||||
clearRestartFlag();
|
clearRestartFlag();
|
||||||
}, [shouldRestart, duration, setIsPlaying, clearRestartFlag]);
|
}, [shouldRestart, duration, setIsPlaying, clearRestartFlag]);
|
||||||
|
|
||||||
// Auto-play is handled exclusively in the WaveSurfer 'ready' event handler.
|
// Handle shouldAutoPlay flag - for story mode auto-advance
|
||||||
// A separate effect here would race with the ready event since the WebAudio
|
const shouldAutoPlay = usePlayerStore((state) => state.shouldAutoPlay);
|
||||||
// backend needs to fully decode the audio before play() works correctly.
|
const clearAutoPlayFlag = usePlayerStore((state) => state.clearAutoPlayFlag);
|
||||||
|
|
||||||
// Spacebar to play/pause (capture phase so it fires before focused elements)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const wavesurfer = wavesurferRef.current;
|
||||||
if (e.code !== 'Space') return;
|
if (!wavesurfer || !shouldAutoPlay || duration === 0) {
|
||||||
// Ignore if user is typing in an input/textarea
|
return;
|
||||||
const tag = (e.target as HTMLElement)?.tagName;
|
}
|
||||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) {
|
|
||||||
return;
|
// Auto-play the newly loaded audio
|
||||||
}
|
debug.log('Auto-playing next track in story mode');
|
||||||
if (audioUrl && duration > 0 && wavesurferRef.current) {
|
wavesurfer.seekTo(0);
|
||||||
e.preventDefault();
|
wavesurfer.play().catch((error) => {
|
||||||
e.stopPropagation();
|
debug.error('Failed to auto-play:', error);
|
||||||
if (wavesurferRef.current.isPlaying()) {
|
setIsPlaying(false);
|
||||||
wavesurferRef.current.pause();
|
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
} else {
|
});
|
||||||
wavesurferRef.current.play().catch((err) => debug.error('Spacebar play failed:', err));
|
|
||||||
}
|
// Clear the auto-play flag
|
||||||
}
|
clearAutoPlayFlag();
|
||||||
};
|
}, [shouldAutoPlay, duration, setIsPlaying, clearAutoPlayFlag]);
|
||||||
document.addEventListener('keydown', onKeyDown, true);
|
|
||||||
return () => document.removeEventListener('keydown', onKeyDown, true);
|
// Handle loop - WaveSurfer handles this via the 'finish' event
|
||||||
}, [audioUrl, duration]);
|
|
||||||
|
|
||||||
const handlePlayPause = async () => {
|
const handlePlayPause = async () => {
|
||||||
// Standard WaveSurfer playback (works for both normal and native playback modes)
|
// Standard WaveSurfer playback (works for both normal and native playback modes)
|
||||||
@@ -451,8 +744,11 @@ export function AudioPlayer() {
|
|||||||
isUsingNativePlaybackRef.current = true;
|
isUsingNativePlaybackRef.current = true;
|
||||||
|
|
||||||
// Mute WaveSurfer and start it for visualization
|
// Mute WaveSurfer and start it for visualization
|
||||||
wavesurferRef.current.setVolume(0);
|
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||||
wavesurferRef.current.setMuted(true);
|
if (mediaElement) {
|
||||||
|
mediaElement.volume = 0;
|
||||||
|
mediaElement.muted = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Start WaveSurfer for visualization (muted)
|
// Start WaveSurfer for visualization (muted)
|
||||||
wavesurferRef.current.play().catch((error) => {
|
wavesurferRef.current.play().catch((error) => {
|
||||||
@@ -476,8 +772,11 @@ export function AudioPlayer() {
|
|||||||
} else {
|
} else {
|
||||||
// Ensure WaveSurfer is not muted if not using native playback
|
// Ensure WaveSurfer is not muted if not using native playback
|
||||||
if (!isUsingNativePlaybackRef.current) {
|
if (!isUsingNativePlaybackRef.current) {
|
||||||
wavesurferRef.current.setMuted(false);
|
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||||
wavesurferRef.current.setVolume(volume);
|
if (mediaElement) {
|
||||||
|
mediaElement.muted = false;
|
||||||
|
mediaElement.volume = volume;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wavesurferRef.current.play().catch((error) => {
|
wavesurferRef.current.play().catch((error) => {
|
||||||
@@ -531,32 +830,27 @@ export function AudioPlayer() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={handlePlayPause}
|
onClick={handlePlayPause}
|
||||||
disabled={isLoading || duration === 0}
|
disabled={isLoading || duration === 0}
|
||||||
className={`shrink-0 -mt-2 ${isPlaying ? 'bg-accent text-accent-foreground' : ''}`}
|
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 ? (
|
{isPlaying ? <HugeiconsIcon icon={PauseIcon} size={20} className="h-5 w-5" /> : <HugeiconsIcon icon={PlayIcon} size={20} className="h-5 w-5" />}
|
||||||
<Pause className="h-5 w-5 fill-current" />
|
|
||||||
) : (
|
|
||||||
<Play className="h-5 w-5 fill-current" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Waveform */}
|
{/* Waveform */}
|
||||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||||
<div ref={waveformRef} className="w-full min-h-[80px] select-none" />
|
<div ref={waveformRef} className="w-full min-h-[80px]" />
|
||||||
<Slider
|
{duration > 0 && (
|
||||||
value={duration > 0 ? [(currentTime / duration) * 100] : [0]}
|
<Slider
|
||||||
onValueChange={handleSeek}
|
value={duration > 0 ? [(currentTime / duration) * 100] : [0]}
|
||||||
max={100}
|
onValueChange={handleSeek}
|
||||||
step={0.1}
|
max={100}
|
||||||
className="w-full"
|
step={0.1}
|
||||||
aria-label="Playback position"
|
className="w-full"
|
||||||
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
|
/>
|
||||||
/>
|
)}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-xs text-muted-foreground text-center py-2">Loading audio...</div>
|
||||||
|
)}
|
||||||
{error && <div className="text-xs text-destructive text-center py-2">{error}</div>}
|
{error && <div className="text-xs text-destructive text-center py-2">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -567,44 +861,38 @@ export function AudioPlayer() {
|
|||||||
<span className="font-mono">{formatAudioDuration(duration)}</span>
|
<span className="font-mono">{formatAudioDuration(duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
{title && (
|
||||||
|
<div className="text-sm font-medium truncate max-w-[200px] shrink-0">{title}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Loop Button */}
|
{/* Loop Button */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={toggleLoop}
|
onClick={toggleLoop}
|
||||||
className={isLooping ? 'bg-accent text-accent-foreground' : ''}
|
className={isLooping ? 'text-primary' : ''}
|
||||||
title="Toggle loop"
|
title="Toggle loop"
|
||||||
aria-label={isLooping ? 'Stop looping' : 'Loop'}
|
|
||||||
>
|
>
|
||||||
<Repeat className="h-4 w-4" />
|
<HugeiconsIcon icon={RepeatIcon} size={16} className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Volume Control */}
|
{/* Volume Control */}
|
||||||
<div
|
<div className="flex items-center gap-2 shrink-0 w-[120px]">
|
||||||
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 ? <HugeiconsIcon icon={VolumeHighIcon} size={16} className="h-4 w-4" /> : <HugeiconsIcon icon={VolumeMuteIcon} size={16} 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>
|
||||||
|
|
||||||
@@ -615,9 +903,8 @@ export function AudioPlayer() {
|
|||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
title="Close player"
|
title="Close player"
|
||||||
aria-label="Close player"
|
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<HugeiconsIcon icon={Cancel01Icon} size={20} className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { CheckmarkCircle01Icon, CheckmarkCircle02Icon, Edit01Icon, Add01Icon, SpeakerIcon, Delete01Icon } from '@hugeicons/core-free-icons';
|
||||||
import { useState } from 'react';
|
import { 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';
|
||||||
@@ -23,8 +24,8 @@ import {
|
|||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
interface AudioDevice {
|
interface AudioDevice {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -124,13 +125,6 @@ export function AudioTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleChannelDelete = async (e, channelId) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (await confirm('Delete this channel?')) {
|
|
||||||
deleteChannel.mutate(channelId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const allChannels = channels || [];
|
const allChannels = channels || [];
|
||||||
const allDevices = devices || [];
|
const allDevices = devices || [];
|
||||||
const selectedChannel = selectedChannelId
|
const selectedChannel = selectedChannelId
|
||||||
@@ -142,7 +136,7 @@ export function AudioTab() {
|
|||||||
<div className="flex items-center justify-between mb-6 shrink-0">
|
<div className="flex items-center justify-between mb-6 shrink-0">
|
||||||
<h2 className="text-2xl font-bold">Audio Channels</h2>
|
<h2 className="text-2xl font-bold">Audio Channels</h2>
|
||||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<HugeiconsIcon icon={Add01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||||
New Channel
|
New Channel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,18 +151,18 @@ export function AudioTab() {
|
|||||||
>
|
>
|
||||||
{allChannels.length === 0 ? (
|
{allChannels.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
||||||
<Speaker className="h-12 w-12 text-muted-foreground mb-4" />
|
<HugeiconsIcon icon={SpeakerIcon} size={48} className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
No audio channels yet. Create your first channel to route voices to specific
|
No audio channels yet. Create your first channel to route voices to specific
|
||||||
devices.
|
devices.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<HugeiconsIcon icon={Add01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||||
Create Channel
|
Create Channel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 p-2">
|
||||||
{allChannels.map((channel) => {
|
{allChannels.map((channel) => {
|
||||||
const isSelected = selectedChannelId === channel.id;
|
const isSelected = selectedChannelId === channel.id;
|
||||||
return (
|
return (
|
||||||
@@ -185,7 +179,7 @@ export function AudioTab() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<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">
|
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||||
<Speaker className="h-4 w-4 text-muted-foreground" />
|
<HugeiconsIcon icon={SpeakerIcon} size={16} className="h-4 w-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<h3 className="font-semibold text-base truncate">{channel.name}</h3>
|
<h3 className="font-semibold text-base truncate">{channel.name}</h3>
|
||||||
@@ -242,15 +236,20 @@ export function AudioTab() {
|
|||||||
setEditingChannel(channel.id);
|
setEditingChannel(channel.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<HugeiconsIcon icon={Edit01Icon} size={16} className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
onClick={(e) => handleChannelDelete(e, channel.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (confirm('Delete this channel?')) {
|
||||||
|
deleteChannel.mutate(channel.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<HugeiconsIcon icon={Delete01Icon} size={16} className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -327,10 +326,10 @@ export function AudioTab() {
|
|||||||
isConnected ? 'bg-accent border-accent' : 'border-muted-foreground/30',
|
isConnected ? 'bg-accent border-accent' : 'border-muted-foreground/30',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isConnected && <Check className="h-3 w-3 text-accent-foreground" />}
|
{isConnected && <HugeiconsIcon icon={CheckmarkCircle01Icon} size={12} className="h-3 w-3 text-accent-foreground" />}
|
||||||
</div>
|
</div>
|
||||||
) : device.is_default ? (
|
) : device.is_default ? (
|
||||||
<CheckCircle2 className="h-4 w-4 text-primary shrink-0" />
|
<HugeiconsIcon icon={CheckmarkCircle02Icon} size={16} className="h-4 w-4 text-primary shrink-0" />
|
||||||
) : null}
|
) : null}
|
||||||
<span className={cn('truncate flex-1', device.is_default && 'font-medium')}>
|
<span className={cn('truncate flex-1', device.is_default && 'font-medium')}>
|
||||||
{device.name}
|
{device.name}
|
||||||
@@ -341,11 +340,9 @@ export function AudioTab() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
||||||
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
|
<HugeiconsIcon icon={CheckmarkCircle02Icon} size={48} className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
<p className="text-muted-foreground text-center">
|
<p className="text-muted-foreground text-center">
|
||||||
{platform.metadata.isTauri
|
{platform.metadata.isTauri ? 'No audio devices found' : 'Audio device selection requires Tauri'}
|
||||||
? 'No audio devices found'
|
|
||||||
: 'Audio device selection requires Tauri'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -498,7 +495,7 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh
|
|||||||
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<HugeiconsIcon icon={Delete01Icon} size={12} className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -606,7 +603,7 @@ function EditChannelDialog({
|
|||||||
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<HugeiconsIcon icon={Delete01Icon} size={12} className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -652,7 +649,7 @@ function EditChannelDialog({
|
|||||||
setSelectedVoices(selectedVoices.filter((id) => id !== profileId))
|
setSelectedVoices(selectedVoices.filter((id) => id !== profileId))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<HugeiconsIcon icon={Delete01Icon} size={12} className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,377 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,422 +0,0 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { Loader2, Play, Save, Trash2, Wand2 } from 'lucide-react';
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
|
||||||
import { GenerationPicker } from '@/components/Effects/GenerationPicker';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
|
||||||
import { apiClient } from '@/lib/api/client';
|
|
||||||
import type { HistoryResponse } from '@/lib/api/types';
|
|
||||||
import { useHistory } from '@/lib/hooks/useHistory';
|
|
||||||
import { useEffectsStore } from '@/stores/effectsStore';
|
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
|
||||||
|
|
||||||
export function EffectsDetail() {
|
|
||||||
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
|
|
||||||
const isCreatingNew = useEffectsStore((s) => s.isCreatingNew);
|
|
||||||
const workingChain = useEffectsStore((s) => s.workingChain);
|
|
||||||
const setWorkingChain = useEffectsStore((s) => s.setWorkingChain);
|
|
||||||
const setSelectedPresetId = useEffectsStore((s) => s.setSelectedPresetId);
|
|
||||||
const setIsCreatingNew = useEffectsStore((s) => s.setIsCreatingNew);
|
|
||||||
|
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [description, setDescription] = useState('');
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [deleting, setDeleting] = useState(false);
|
|
||||||
|
|
||||||
// "Save as Custom" dialog state
|
|
||||||
const [saveAsDialogOpen, setSaveAsDialogOpen] = useState(false);
|
|
||||||
const [saveAsName, setSaveAsName] = useState('');
|
|
||||||
const [saveAsDescription, setSaveAsDescription] = useState('');
|
|
||||||
|
|
||||||
// Preview state
|
|
||||||
const [previewGenId, setPreviewGenId] = useState<string | null>(null);
|
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
|
||||||
const blobUrlRef = useRef<string | null>(null);
|
|
||||||
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
// Auto-select the most recent generation as preview source
|
|
||||||
const { data: historyData } = useHistory({ limit: 1 });
|
|
||||||
useEffect(() => {
|
|
||||||
if (!previewGenId && historyData?.items?.length) {
|
|
||||||
const first = historyData.items.find((g) => g.status === 'completed');
|
|
||||||
if (first) setPreviewGenId(first.id);
|
|
||||||
}
|
|
||||||
}, [historyData, previewGenId]);
|
|
||||||
|
|
||||||
const { data: preset } = useQuery({
|
|
||||||
queryKey: ['effect-preset', selectedPresetId],
|
|
||||||
queryFn: () =>
|
|
||||||
selectedPresetId
|
|
||||||
? apiClient
|
|
||||||
.listEffectPresets()
|
|
||||||
.then((all) => all.find((p) => p.id === selectedPresetId) ?? null)
|
|
||||||
: null,
|
|
||||||
enabled: !!selectedPresetId,
|
|
||||||
staleTime: 30_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync name/description when selecting a preset
|
|
||||||
useEffect(() => {
|
|
||||||
if (preset) {
|
|
||||||
setName(preset.name);
|
|
||||||
setDescription(preset.description ?? '');
|
|
||||||
} else if (isCreatingNew) {
|
|
||||||
setName('');
|
|
||||||
setDescription('');
|
|
||||||
}
|
|
||||||
}, [preset, isCreatingNew]);
|
|
||||||
|
|
||||||
// Cleanup blob URL on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (blobUrlRef.current) {
|
|
||||||
URL.revokeObjectURL(blobUrlRef.current);
|
|
||||||
blobUrlRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isEditing = !!selectedPresetId || isCreatingNew;
|
|
||||||
const isBuiltIn = preset?.is_builtin ?? false;
|
|
||||||
|
|
||||||
async function handlePreview() {
|
|
||||||
if (!previewGenId || workingChain.length === 0) return;
|
|
||||||
|
|
||||||
setPreviewLoading(true);
|
|
||||||
try {
|
|
||||||
const blob = await apiClient.previewEffects(previewGenId, workingChain);
|
|
||||||
|
|
||||||
// Revoke old blob URL
|
|
||||||
if (blobUrlRef.current) {
|
|
||||||
URL.revokeObjectURL(blobUrlRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
blobUrlRef.current = url;
|
|
||||||
|
|
||||||
// Play through the main audio player
|
|
||||||
setAudioWithAutoPlay(url, `preview-${Date.now()}`, null, 'Effects Preview');
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Preview failed',
|
|
||||||
description: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setPreviewLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSelectGeneration(gen: HistoryResponse) {
|
|
||||||
setPreviewGenId(gen.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSaveNew() {
|
|
||||||
if (!name.trim()) {
|
|
||||||
toast({ title: 'Name required', variant: 'destructive' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const created = await apiClient.createEffectPreset({
|
|
||||||
name: name.trim(),
|
|
||||||
description: description.trim() || undefined,
|
|
||||||
effects_chain: workingChain,
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
|
||||||
setIsCreatingNew(false);
|
|
||||||
setSelectedPresetId(created.id);
|
|
||||||
toast({ title: 'Preset saved', description: `"${created.name}" has been created.` });
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Failed to save',
|
|
||||||
description: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSaveExisting() {
|
|
||||||
if (!selectedPresetId || !name.trim()) return;
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await apiClient.updateEffectPreset(selectedPresetId, {
|
|
||||||
name: name.trim(),
|
|
||||||
description: description.trim() || undefined,
|
|
||||||
effects_chain: workingChain,
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['effect-preset', selectedPresetId] });
|
|
||||||
toast({ title: 'Preset updated' });
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Failed to save',
|
|
||||||
description: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSaveAsNew() {
|
|
||||||
// Open the dialog with a suggested name based on the current preset
|
|
||||||
setSaveAsName(`${name} (Copy)`);
|
|
||||||
setSaveAsDescription(description);
|
|
||||||
setSaveAsDialogOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSaveAsConfirm() {
|
|
||||||
if (!saveAsName.trim()) {
|
|
||||||
toast({ title: 'Name required', variant: 'destructive' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const created = await apiClient.createEffectPreset({
|
|
||||||
name: saveAsName.trim(),
|
|
||||||
description: saveAsDescription.trim() || undefined,
|
|
||||||
effects_chain: workingChain,
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
|
||||||
setSaveAsDialogOpen(false);
|
|
||||||
setSelectedPresetId(created.id);
|
|
||||||
toast({ title: 'Preset saved', description: `"${created.name}" has been created.` });
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Failed to save',
|
|
||||||
description: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete() {
|
|
||||||
if (!selectedPresetId) return;
|
|
||||||
setDeleting(true);
|
|
||||||
try {
|
|
||||||
await apiClient.deleteEffectPreset(selectedPresetId);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
|
||||||
setSelectedPresetId(null);
|
|
||||||
setWorkingChain([]);
|
|
||||||
toast({ title: 'Preset deleted' });
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Failed to delete',
|
|
||||||
description: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setDeleting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isEditing) {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
|
||||||
<div className="text-center space-y-2">
|
|
||||||
<Wand2 className="h-10 w-10 mx-auto opacity-30" />
|
|
||||||
<p className="text-sm">Select a preset or create a new one</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full min-h-0">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-lg font-semibold">
|
|
||||||
{isCreatingNew ? 'New Preset' : isBuiltIn ? preset?.name : 'Edit Preset'}
|
|
||||||
</h2>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{!isBuiltIn && !isCreatingNew && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 text-destructive hover:text-destructive gap-1.5"
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={deleting}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
{deleting ? 'Deleting...' : 'Delete'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5"
|
|
||||||
onClick={handleSaveExisting}
|
|
||||||
disabled={saving || workingChain.length === 0}
|
|
||||||
>
|
|
||||||
<Save className="h-3.5 w-3.5" />
|
|
||||||
{saving ? 'Saving...' : 'Save'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isCreatingNew && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5"
|
|
||||||
onClick={handleSaveNew}
|
|
||||||
disabled={saving || workingChain.length === 0}
|
|
||||||
>
|
|
||||||
<Save className="h-3.5 w-3.5" />
|
|
||||||
{saving ? 'Saving...' : 'Save Preset'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isBuiltIn && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="h-8 gap-1.5"
|
|
||||||
onClick={handleSaveAsNew}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
<Save className="h-3.5 w-3.5" />
|
|
||||||
{saving ? 'Saving...' : 'Save as Custom'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scrollable content */}
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-5 pr-1">
|
|
||||||
{/* Name & description */}
|
|
||||||
{(isCreatingNew || !isBuiltIn) && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Name</Label>
|
|
||||||
<Input
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="My preset..."
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Description</Label>
|
|
||||||
<Textarea
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="Describe what this preset does..."
|
|
||||||
className="min-h-[60px] resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Built-in description (read-only) */}
|
|
||||||
{isBuiltIn && preset?.description && (
|
|
||||||
<p className="text-sm text-muted-foreground">{preset.description}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Effects chain editor */}
|
|
||||||
<EffectsChainEditor value={workingChain} onChange={setWorkingChain} showPresets={false} />
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Preview section */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-xs">Preview</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<GenerationPicker
|
|
||||||
selectedId={previewGenId}
|
|
||||||
onSelect={handleSelectGeneration}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 shrink-0"
|
|
||||||
onClick={handlePreview}
|
|
||||||
disabled={!previewGenId || workingChain.length === 0 || previewLoading}
|
|
||||||
>
|
|
||||||
{previewLoading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
Processing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Play className="h-3.5 w-3.5" />
|
|
||||||
Preview
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-muted-foreground">
|
|
||||||
Preview applies effects to the clean version without saving.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save as Custom dialog */}
|
|
||||||
<Dialog open={saveAsDialogOpen} onOpenChange={setSaveAsDialogOpen}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Save as Custom Preset</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Create a new custom preset based on the current effects chain.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-3 py-2">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Name</Label>
|
|
||||||
<Input
|
|
||||||
value={saveAsName}
|
|
||||||
onChange={(e) => setSaveAsName(e.target.value)}
|
|
||||||
placeholder="My preset..."
|
|
||||||
className="h-9"
|
|
||||||
autoFocus
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' && saveAsName.trim()) {
|
|
||||||
handleSaveAsConfirm();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Description</Label>
|
|
||||||
<Textarea
|
|
||||||
value={saveAsDescription}
|
|
||||||
onChange={(e) => setSaveAsDescription(e.target.value)}
|
|
||||||
placeholder="Describe what this preset does..."
|
|
||||||
className="min-h-[60px] resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setSaveAsDialogOpen(false)} disabled={saving}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSaveAsConfirm} disabled={saving || !saveAsName.trim()}>
|
|
||||||
<Save className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
{saving ? 'Saving...' : 'Save'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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,103 +0,0 @@
|
|||||||
import type { UseFormReturn } from 'react-hook-form';
|
|
||||||
import { FormControl } from '@/components/ui/form';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { getLanguageOptionsForEngine } from '@/lib/constants/languages';
|
|
||||||
import type { GenerationFormValues } from '@/lib/hooks/useGenerationForm';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Engine/model options and their display metadata.
|
|
||||||
* Adding a new engine means adding one entry here.
|
|
||||||
*/
|
|
||||||
const ENGINE_OPTIONS = [
|
|
||||||
{ value: 'qwen:1.7B', label: 'Qwen3-TTS 1.7B' },
|
|
||||||
{ value: 'qwen:0.6B', label: 'Qwen3-TTS 0.6B' },
|
|
||||||
{ value: 'luxtts', label: 'LuxTTS' },
|
|
||||||
{ value: 'chatterbox', label: 'Chatterbox' },
|
|
||||||
{ value: 'chatterbox_turbo', label: 'Chatterbox Turbo' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const ENGINE_DESCRIPTIONS: Record<string, string> = {
|
|
||||||
qwen: 'Multi-language, two sizes',
|
|
||||||
luxtts: 'Fast, English-focused',
|
|
||||||
chatterbox: '23 languages, incl. Hebrew',
|
|
||||||
chatterbox_turbo: 'English, [laugh] [cough] tags',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Engines that only support English and should force language to 'en' on select. */
|
|
||||||
const ENGLISH_ONLY_ENGINES = new Set(['luxtts', 'chatterbox_turbo']);
|
|
||||||
|
|
||||||
function getSelectValue(engine: string, modelSize?: string): string {
|
|
||||||
if (engine === 'qwen') return `qwen:${modelSize || '1.7B'}`;
|
|
||||||
return engine;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEngineChange(form: UseFormReturn<GenerationFormValues>, value: string) {
|
|
||||||
if (value.startsWith('qwen:')) {
|
|
||||||
const [, modelSize] = value.split(':');
|
|
||||||
form.setValue('engine', 'qwen');
|
|
||||||
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
|
|
||||||
// Validate language is supported by Qwen
|
|
||||||
const currentLang = form.getValues('language');
|
|
||||||
const available = getLanguageOptionsForEngine('qwen');
|
|
||||||
if (!available.some((l) => l.value === currentLang)) {
|
|
||||||
form.setValue('language', available[0]?.value ?? 'en');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
form.setValue('engine', value as GenerationFormValues['engine']);
|
|
||||||
form.setValue('modelSize', undefined as unknown as '1.7B' | '0.6B');
|
|
||||||
if (ENGLISH_ONLY_ENGINES.has(value)) {
|
|
||||||
form.setValue('language', 'en');
|
|
||||||
} else {
|
|
||||||
// If current language isn't supported by the new engine, reset to first available
|
|
||||||
const currentLang = form.getValues('language');
|
|
||||||
const available = getLanguageOptionsForEngine(value);
|
|
||||||
if (!available.some((l) => l.value === currentLang)) {
|
|
||||||
form.setValue('language', available[0]?.value ?? 'en');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EngineModelSelectorProps {
|
|
||||||
form: UseFormReturn<GenerationFormValues>;
|
|
||||||
compact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EngineModelSelector({ form, compact }: EngineModelSelectorProps) {
|
|
||||||
const engine = form.watch('engine') || 'qwen';
|
|
||||||
const modelSize = form.watch('modelSize');
|
|
||||||
const selectValue = getSelectValue(engine, modelSize);
|
|
||||||
|
|
||||||
const itemClass = compact ? 'text-xs text-muted-foreground' : undefined;
|
|
||||||
const triggerClass = compact
|
|
||||||
? 'h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all'
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select value={selectValue} onValueChange={(v) => handleEngineChange(form, v)}>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger className={triggerClass}>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
{ENGINE_OPTIONS.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value} className={itemClass}>
|
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns a human-readable description for the currently selected engine. */
|
|
||||||
export function getEngineDescription(engine: string): string {
|
|
||||||
return ENGINE_DESCRIPTIONS[engine] ?? '';
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { SparklesIcon, TextSquareIcon } from '@hugeicons/core-free-icons';
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
import { useMatchRoute } from '@tanstack/react-router';
|
import { useMatchRoute } from '@tanstack/react-router';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Loader2, Sparkles } from 'lucide-react';
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||||
@@ -13,17 +14,14 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
|
import { LANGUAGE_OPTIONS } from '@/lib/constants/languages';
|
||||||
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
||||||
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
|
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
|
||||||
import { useStory } from '@/lib/hooks/useStories';
|
import { useAddStoryItem, useStory } from '@/lib/hooks/useStories';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
import { EngineModelSelector } from './EngineModelSelector';
|
|
||||||
import { ParalinguisticInput } from './ParalinguisticInput';
|
|
||||||
|
|
||||||
interface FloatingGenerateBoxProps {
|
interface FloatingGenerateBoxProps {
|
||||||
isPlayerOpen?: boolean;
|
isPlayerOpen?: boolean;
|
||||||
@@ -39,7 +37,7 @@ export function FloatingGenerateBox({
|
|||||||
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
||||||
const { data: profiles } = useProfiles();
|
const { data: profiles } = useProfiles();
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null);
|
const [isInstructMode, setIsInstructMode] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const matchRoute = useMatchRoute();
|
const matchRoute = useMatchRoute();
|
||||||
@@ -47,13 +45,8 @@ export function FloatingGenerateBox({
|
|||||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||||
const { data: currentStory } = useStory(selectedStoryId);
|
const { data: currentStory } = useStory(selectedStoryId);
|
||||||
const addPendingStoryAdd = useGenerationStore((s) => s.addPendingStoryAdd);
|
const addStoryItem = useAddStoryItem();
|
||||||
|
const { toast } = useToast();
|
||||||
// Fetch effect presets for the dropdown
|
|
||||||
const { data: effectPresets } = useQuery({
|
|
||||||
queryKey: ['effectPresets'],
|
|
||||||
queryFn: () => apiClient.listEffectPresets(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate if track editor is visible (on stories route with items)
|
// Calculate if track editor is visible (on stories route with items)
|
||||||
const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0;
|
const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0;
|
||||||
@@ -61,16 +54,27 @@ export function FloatingGenerateBox({
|
|||||||
const { form, handleSubmit, isPending } = useGenerationForm({
|
const { form, handleSubmit, isPending } = useGenerationForm({
|
||||||
onSuccess: async (generationId) => {
|
onSuccess: async (generationId) => {
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
// Defer the story add until TTS completes -- useGenerationProgress handles it
|
// If on stories route and a story is selected, add generation to story
|
||||||
if (isStoriesRoute && selectedStoryId && generationId) {
|
if (isStoriesRoute && selectedStoryId && generationId) {
|
||||||
addPendingStoryAdd(generationId, selectedStoryId);
|
try {
|
||||||
|
await addStoryItem.mutateAsync({
|
||||||
|
storyId: selectedStoryId,
|
||||||
|
data: { generation_id: generationId },
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: 'Added to story',
|
||||||
|
description: `Generation added to "${currentStory?.name || 'story'}"`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to add to story',
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : 'Could not add generation to story',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getEffectsChain: () => {
|
|
||||||
if (!selectedPresetId || !effectPresets) return undefined;
|
|
||||||
const preset = effectPresets.find((p) => p.id === selectedPresetId);
|
|
||||||
return preset?.effects_chain;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click away handler to collapse the box
|
// Click away handler to collapse the box
|
||||||
@@ -110,13 +114,6 @@ export function FloatingGenerateBox({
|
|||||||
}
|
}
|
||||||
}, [selectedProfileId, profiles, setSelectedProfileId]);
|
}, [selectedProfileId, profiles, setSelectedProfileId]);
|
||||||
|
|
||||||
// Sync generation form language with selected profile's language
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedProfile?.language) {
|
|
||||||
form.setValue('language', selectedProfile.language as LanguageCode);
|
|
||||||
}
|
|
||||||
}, [selectedProfile, form]);
|
|
||||||
|
|
||||||
// Auto-resize textarea based on content (only when expanded)
|
// Auto-resize textarea based on content (only when expanded)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExpanded) {
|
if (!isExpanded) {
|
||||||
@@ -179,7 +176,7 @@ export function FloatingGenerateBox({
|
|||||||
isStoriesRoute
|
isStoriesRoute
|
||||||
? // Position aligned with story list: after sidebar + padding, width 360px
|
? // Position aligned with story list: after sidebar + padding, width 360px
|
||||||
'left-[calc(5rem+2rem)] w-[360px]'
|
'left-[calc(5rem+2rem)] w-[360px]'
|
||||||
: 'left-[calc(5rem+2rem)] right-8 lg:right-auto lg:w-[calc((100%-5rem-4rem)/2-1rem)]',
|
: 'left-[calc(5rem+2rem)] w-[calc((100%-5rem-4rem)/2-1rem)]',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
// On stories route: offset by track editor height when visible
|
// On stories route: offset by track editor height when visible
|
||||||
@@ -198,46 +195,33 @@ export function FloatingGenerateBox({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<motion.div className="flex-1" transition={{ duration: 0.3, ease: 'easeOut' }}>
|
<motion.div
|
||||||
<FormField
|
className={cn('flex-1', isExpanded && 'mr-12')}
|
||||||
control={form.control}
|
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||||
name="text"
|
>
|
||||||
render={({ field }) => (
|
{/* Text field - hidden when in instruct mode */}
|
||||||
<FormItem>
|
<div style={{ display: isInstructMode ? 'none' : 'block' }}>
|
||||||
<FormControl>
|
<FormField
|
||||||
<motion.div
|
control={form.control}
|
||||||
animate={{
|
name="text"
|
||||||
height: isExpanded ? 'auto' : '32px',
|
render={({ field }) => (
|
||||||
}}
|
<FormItem>
|
||||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
<FormControl>
|
||||||
style={{ overflow: 'hidden' }}
|
<motion.div
|
||||||
>
|
animate={{
|
||||||
{form.watch('engine') === 'chatterbox_turbo' ? (
|
height: isExpanded ? 'auto' : '32px',
|
||||||
<ParalinguisticInput
|
}}
|
||||||
value={field.value}
|
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||||
onChange={field.onChange}
|
style={{ overflow: 'hidden' }}
|
||||||
placeholder={
|
>
|
||||||
isStoriesRoute && currentStory
|
|
||||||
? `Generate speech for "${currentStory.name}"... (type / for effects)`
|
|
||||||
: selectedProfile
|
|
||||||
? `Type / for effects like [laugh], [sigh]...`
|
|
||||||
: 'Select a voice profile above...'
|
|
||||||
}
|
|
||||||
className="px-3 py-2 resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm w-full"
|
|
||||||
style={{
|
|
||||||
minHeight: isExpanded ? '100px' : '32px',
|
|
||||||
maxHeight: '300px',
|
|
||||||
overflowY: 'auto',
|
|
||||||
}}
|
|
||||||
disabled={!selectedProfileId}
|
|
||||||
onClick={() => setIsExpanded(true)}
|
|
||||||
onFocus={() => setIsExpanded(true)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Textarea
|
<Textarea
|
||||||
{...field}
|
{...field}
|
||||||
ref={(node: HTMLTextAreaElement | null) => {
|
ref={(node: HTMLTextAreaElement | null) => {
|
||||||
textareaRef.current = node;
|
// 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') {
|
if (typeof field.ref === 'function') {
|
||||||
field.ref(node);
|
field.ref(node);
|
||||||
}
|
}
|
||||||
@@ -258,13 +242,57 @@ export function FloatingGenerateBox({
|
|||||||
onClick={() => setIsExpanded(true)}
|
onClick={() => setIsExpanded(true)}
|
||||||
onFocus={() => setIsExpanded(true)}
|
onFocus={() => setIsExpanded(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
</motion.div>
|
||||||
</motion.div>
|
</FormControl>
|
||||||
</FormControl>
|
<FormMessage className="text-xs" />
|
||||||
<FormMessage className="text-xs" />
|
</FormItem>
|
||||||
</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>
|
</motion.div>
|
||||||
|
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
@@ -272,20 +300,13 @@ export function FloatingGenerateBox({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending || !selectedProfileId}
|
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"
|
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg transition-all duration-200"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label={
|
|
||||||
isPending
|
|
||||||
? 'Generating...'
|
|
||||||
: !selectedProfileId
|
|
||||||
? 'Select a voice profile first'
|
|
||||||
: 'Generate speech'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Sparkles className="h-4 w-4" />
|
<HugeiconsIcon icon={SparklesIcon} size={16} className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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]">
|
<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]">
|
||||||
@@ -296,6 +317,37 @@ export function FloatingGenerateBox({
|
|||||||
: 'Generate speech'}
|
: 'Generate speech'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<AnimatePresence>
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="absolute top-0 right-[calc(100%+0.5rem)]"
|
||||||
|
>
|
||||||
|
<div className="group relative">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsInstructMode(!isInstructMode)}
|
||||||
|
className={cn(
|
||||||
|
'h-10 w-10 rounded-full transition-all duration-200',
|
||||||
|
isInstructMode
|
||||||
|
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
|
||||||
|
: 'bg-card border border-border hover:bg-background/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={TextSquareIcon} size={16} className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
|
||||||
|
Fine tune instructions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -331,58 +383,51 @@ export function FloatingGenerateBox({
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="language"
|
name="language"
|
||||||
render={({ field }) => {
|
render={({ field }) => (
|
||||||
const engineLangs = getLanguageOptionsForEngine(
|
<FormItem className="flex-1 space-y-0">
|
||||||
form.watch('engine') || 'qwen',
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
);
|
<FormControl>
|
||||||
return (
|
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
||||||
<FormItem className="flex-1 space-y-0">
|
<SelectValue />
|
||||||
<Select onValueChange={field.onChange} value={field.value}>
|
</SelectTrigger>
|
||||||
<FormControl>
|
</FormControl>
|
||||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
<SelectContent>
|
||||||
<SelectValue />
|
{LANGUAGE_OPTIONS.map((lang) => (
|
||||||
</SelectTrigger>
|
<SelectItem key={lang.value} value={lang.value} className="text-xs">
|
||||||
</FormControl>
|
{lang.label}
|
||||||
<SelectContent>
|
</SelectItem>
|
||||||
{engineLangs.map((lang) => (
|
))}
|
||||||
<SelectItem key={lang.value} value={lang.value} className="text-xs">
|
</SelectContent>
|
||||||
{lang.label}
|
</Select>
|
||||||
</SelectItem>
|
<FormMessage className="text-xs" />
|
||||||
))}
|
</FormItem>
|
||||||
</SelectContent>
|
)}
|
||||||
</Select>
|
|
||||||
<FormMessage className="text-xs" />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormItem className="flex-1 space-y-0">
|
<FormField
|
||||||
<EngineModelSelector form={form} compact />
|
control={form.control}
|
||||||
</FormItem>
|
name="modelSize"
|
||||||
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1 space-y-0">
|
<FormItem className="flex-1 space-y-0">
|
||||||
<Select
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
value={selectedPresetId || 'none'}
|
<FormControl>
|
||||||
onValueChange={(value) =>
|
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
||||||
setSelectedPresetId(value === 'none' ? null : value)
|
<SelectValue />
|
||||||
}
|
</SelectTrigger>
|
||||||
>
|
</FormControl>
|
||||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
<SelectContent>
|
||||||
<SelectValue placeholder="No effects" />
|
<SelectItem value="1.7B" className="text-xs text-muted-foreground">
|
||||||
</SelectTrigger>
|
Qwen3-TTS 1.7B
|
||||||
<SelectContent>
|
</SelectItem>
|
||||||
<SelectItem value="none" className="text-xs">
|
<SelectItem value="0.6B" className="text-xs text-muted-foreground">
|
||||||
No effects
|
Qwen3-TTS 0.6B
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
{effectPresets?.map((preset) => (
|
</SelectContent>
|
||||||
<SelectItem key={preset.id} value={preset.id} className="text-xs">
|
</Select>
|
||||||
{preset.name}
|
<FormMessage className="text-xs" />
|
||||||
</SelectItem>
|
</FormItem>
|
||||||
))}
|
)}
|
||||||
</SelectContent>
|
/>
|
||||||
</Select>
|
|
||||||
</FormItem>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Loader2, Mic } from 'lucide-react';
|
import { Mic01Icon } from '@hugeicons/core-free-icons';
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { 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 {
|
||||||
@@ -19,12 +21,10 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { getLanguageOptionsForEngine } from '@/lib/constants/languages';
|
import { LANGUAGE_OPTIONS } from '@/lib/constants/languages';
|
||||||
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
||||||
import { useProfile } from '@/lib/hooks/useProfiles';
|
import { useProfile } from '@/lib/hooks/useProfiles';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
import { EngineModelSelector, getEngineDescription } from './EngineModelSelector';
|
|
||||||
import { ParalinguisticInput } from './ParalinguisticInput';
|
|
||||||
|
|
||||||
export function GenerationForm() {
|
export function GenerationForm() {
|
||||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||||
@@ -48,7 +48,11 @@ export function GenerationForm() {
|
|||||||
<FormLabel>Voice Profile</FormLabel>
|
<FormLabel>Voice Profile</FormLabel>
|
||||||
{selectedProfile ? (
|
{selectedProfile ? (
|
||||||
<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" />
|
<HugeiconsIcon
|
||||||
|
icon={Mic01Icon}
|
||||||
|
size={16}
|
||||||
|
className="h-4 w-4 text-muted-foreground"
|
||||||
|
/>
|
||||||
<span className="font-medium">{selectedProfile.name}</span>
|
<span className="font-medium">{selectedProfile.name}</span>
|
||||||
<span className="text-sm text-muted-foreground">{selectedProfile.language}</span>
|
<span className="text-sm text-muted-foreground">{selectedProfile.language}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,90 +70,87 @@ export function GenerationForm() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Text to Speak</FormLabel>
|
<FormLabel>Text to Speak</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
{form.watch('engine') === 'chatterbox_turbo' ? (
|
<Textarea
|
||||||
<ParalinguisticInput
|
placeholder="Enter the text you want to generate..."
|
||||||
value={field.value}
|
className="min-h-[150px]"
|
||||||
onChange={field.onChange}
|
{...field}
|
||||||
placeholder="Enter text... type / for effects like [laugh], [sigh]"
|
/>
|
||||||
className="min-h-[150px] rounded-md border border-input bg-background px-3 py-2"
|
</FormControl>
|
||||||
/>
|
<FormDescription>Max 5000 characters</FormDescription>
|
||||||
) : (
|
<FormMessage />
|
||||||
<Textarea
|
</FormItem>
|
||||||
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>
|
||||||
{form.watch('engine') === 'chatterbox_turbo'
|
Natural language instructions to control speech delivery (tone, emotion, pace).
|
||||||
? 'Max 5000 characters. Type / to insert sound effects.'
|
Max 500 characters
|
||||||
: 'Max 5000 characters'}
|
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{form.watch('engine') === 'qwen' && (
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="instruct"
|
name="language"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Delivery Instructions (optional)</FormLabel>
|
<FormLabel>Language</FormLabel>
|
||||||
<FormControl>
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<Textarea
|
<FormControl>
|
||||||
placeholder="e.g. Speak slowly with emphasis, Warm and friendly tone, Professional and authoritative..."
|
<SelectTrigger>
|
||||||
className="min-h-[80px]"
|
<SelectValue />
|
||||||
{...field}
|
</SelectTrigger>
|
||||||
/>
|
</FormControl>
|
||||||
</FormControl>
|
<SelectContent>
|
||||||
<FormDescription>
|
{LANGUAGE_OPTIONS.map((lang) => (
|
||||||
Natural language instructions to control speech delivery (tone, emotion,
|
<SelectItem key={lang.value} value={lang.value}>
|
||||||
pace). Max 500 characters
|
{lang.label}
|
||||||
</FormDescription>
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Model</FormLabel>
|
|
||||||
<EngineModelSelector form={form} />
|
|
||||||
<FormDescription>
|
|
||||||
{getEngineDescription(form.watch('engine') || 'qwen')}
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="language"
|
name="modelSize"
|
||||||
render={({ field }) => {
|
render={({ field }) => (
|
||||||
const engineLangs = getLanguageOptionsForEngine(form.watch('engine') || 'qwen');
|
<FormItem>
|
||||||
return (
|
<FormLabel>Model Size</FormLabel>
|
||||||
<FormItem>
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<FormLabel>Language</FormLabel>
|
<FormControl>
|
||||||
<Select onValueChange={field.onChange} value={field.value}>
|
<SelectTrigger>
|
||||||
<FormControl>
|
<SelectValue />
|
||||||
<SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectValue />
|
</FormControl>
|
||||||
</SelectTrigger>
|
<SelectContent>
|
||||||
</FormControl>
|
<SelectItem value="1.7B">Qwen TTS 1.7B (Higher Quality)</SelectItem>
|
||||||
<SelectContent>
|
<SelectItem value="0.6B">Qwen TTS 0.6B (Faster)</SelectItem>
|
||||||
{engineLangs.map((lang) => (
|
</SelectContent>
|
||||||
<SelectItem key={lang.value} value={lang.value}>
|
</Select>
|
||||||
{lang.label}
|
<FormDescription>Larger models produce better quality</FormDescription>
|
||||||
</SelectItem>
|
<FormMessage />
|
||||||
))}
|
</FormItem>
|
||||||
</SelectContent>
|
)}
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@@ -178,7 +179,7 @@ export function GenerationForm() {
|
|||||||
<Button type="submit" className="w-full" disabled={isPending || !selectedProfileId}>
|
<Button type="submit" className="w-full" disabled={isPending || !selectedProfileId}>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Icon icon="svg-spinners:ring-resize" className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Generating...
|
Generating...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,422 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,22 +1,14 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import {
|
import {
|
||||||
AlignCenter,
|
Archive01Icon,
|
||||||
AudioLines,
|
Delete01Icon,
|
||||||
AudioWaveform,
|
Download01Icon,
|
||||||
Download,
|
MoreHorizontalIcon,
|
||||||
FileArchive,
|
PlayIcon,
|
||||||
Loader2,
|
WaveIcon,
|
||||||
MoreHorizontal,
|
} from '@hugeicons/core-free-icons';
|
||||||
Play,
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
RotateCcw,
|
import { Icon } from '@iconify/react';
|
||||||
Star,
|
|
||||||
Trash2,
|
|
||||||
Wand2,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -32,17 +24,10 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { EffectConfig, GenerationVersionResponse, HistoryResponse } from '@/lib/api/types';
|
import type { HistoryResponse } from '@/lib/api/types';
|
||||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import {
|
import {
|
||||||
useDeleteGeneration,
|
useDeleteGeneration,
|
||||||
@@ -52,39 +37,11 @@ import {
|
|||||||
useImportGeneration,
|
useImportGeneration,
|
||||||
} from '@/lib/hooks/useHistory';
|
} from '@/lib/hooks/useHistory';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { formatDate, formatDuration, formatEngineName } from '@/lib/utils/format';
|
import { formatDate, formatDuration } from '@/lib/utils/format';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
// ─── Audio Bars ─────────────────────────────────────────────────────────────
|
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
|
||||||
|
// This is the new alternate history view with fixed height rows
|
||||||
function AudioBars({ mode }: { mode: 'idle' | 'generating' | 'playing' }) {
|
|
||||||
const barColor = mode !== 'idle' ? 'bg-accent' : 'bg-muted-foreground/40';
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-[2px] h-5">
|
|
||||||
{[0, 1, 2, 3, 4].map((i) => (
|
|
||||||
<motion.div
|
|
||||||
key={`${mode}-${i}`}
|
|
||||||
className={`w-[3px] rounded-full ${barColor}`}
|
|
||||||
animate={
|
|
||||||
mode === 'generating'
|
|
||||||
? { height: ['6px', '16px', '6px'] }
|
|
||||||
: mode === 'playing'
|
|
||||||
? { height: ['8px', '14px', '4px', '12px', '8px'] }
|
|
||||||
: { height: '8px' }
|
|
||||||
}
|
|
||||||
transition={
|
|
||||||
mode === 'generating'
|
|
||||||
? { duration: 0.6, repeat: Infinity, delay: i * 0.08, ease: 'easeInOut' }
|
|
||||||
: mode === 'playing'
|
|
||||||
? { duration: 1.2, repeat: Infinity, delay: i * 0.15, ease: 'easeInOut' }
|
|
||||||
: { duration: 0.4, ease: 'easeOut' }
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS WITH INFINITE SCROLL
|
// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS WITH INFINITE SCROLL
|
||||||
export function HistoryTable() {
|
export function HistoryTable() {
|
||||||
@@ -101,18 +58,8 @@ export function HistoryTable() {
|
|||||||
const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(
|
const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [effectsDialogOpen, setEffectsDialogOpen] = useState(false);
|
|
||||||
const [effectsTargetId, setEffectsTargetId] = useState<string | null>(null);
|
|
||||||
const [effectsTargetVersions, setEffectsTargetVersions] = useState<GenerationVersionResponse[]>(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const [effectsSourceVersionId, setEffectsSourceVersionId] = useState<string | null>(null);
|
|
||||||
const [effectsChain, setEffectsChain] = useState<EffectConfig[]>([]);
|
|
||||||
const [applyingEffects, setApplyingEffects] = useState(false);
|
|
||||||
const [expandedVersionsId, setExpandedVersionsId] = useState<string | null>(null);
|
|
||||||
const limit = 20;
|
const limit = 20;
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: historyData,
|
data: historyData,
|
||||||
@@ -127,7 +74,6 @@ export function HistoryTable() {
|
|||||||
const exportGeneration = useExportGeneration();
|
const exportGeneration = useExportGeneration();
|
||||||
const exportGenerationAudio = useExportGenerationAudio();
|
const exportGenerationAudio = useExportGenerationAudio();
|
||||||
const importGeneration = useImportGeneration();
|
const importGeneration = useImportGeneration();
|
||||||
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
|
|
||||||
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
||||||
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
|
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
|
||||||
const currentAudioId = usePlayerStore((state) => state.audioId);
|
const currentAudioId = usePlayerStore((state) => state.audioId);
|
||||||
@@ -153,9 +99,7 @@ export function HistoryTable() {
|
|||||||
}
|
}
|
||||||
}, [historyData, page]);
|
}, [historyData, page]);
|
||||||
|
|
||||||
// Reset to page 0 when deletions, imports, or generation completions occur
|
// Reset to page 0 when deletions or imports occur
|
||||||
const pendingCount = useGenerationStore((state) => state.pendingGenerationIds.size);
|
|
||||||
const prevPendingCountRef = useRef(pendingCount);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (deleteGeneration.isSuccess || importGeneration.isSuccess) {
|
if (deleteGeneration.isSuccess || importGeneration.isSuccess) {
|
||||||
setPage(0);
|
setPage(0);
|
||||||
@@ -163,19 +107,6 @@ export function HistoryTable() {
|
|||||||
}
|
}
|
||||||
}, [deleteGeneration.isSuccess, importGeneration.isSuccess]);
|
}, [deleteGeneration.isSuccess, importGeneration.isSuccess]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// A generation finished (pending count decreased) — scroll back to show it
|
|
||||||
if (
|
|
||||||
prevPendingCountRef.current > 0 &&
|
|
||||||
pendingCount < prevPendingCountRef.current &&
|
|
||||||
page !== 0
|
|
||||||
) {
|
|
||||||
setPage(0);
|
|
||||||
setAllHistory([]);
|
|
||||||
}
|
|
||||||
prevPendingCountRef.current = pendingCount;
|
|
||||||
}, [pendingCount, page]);
|
|
||||||
|
|
||||||
// Intersection Observer for infinite scroll
|
// Intersection Observer for infinite scroll
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadMoreEl = loadMoreRef.current;
|
const loadMoreEl = loadMoreRef.current;
|
||||||
@@ -266,120 +197,6 @@ export function HistoryTable() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRetry = async (generationId: string) => {
|
|
||||||
try {
|
|
||||||
const result = await apiClient.retryGeneration(generationId);
|
|
||||||
addPendingGeneration(result.id);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['history'] });
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Retry failed',
|
|
||||||
description: error instanceof Error ? error.message : 'Could not retry generation',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRegenerate = async (generationId: string) => {
|
|
||||||
try {
|
|
||||||
await apiClient.regenerateGeneration(generationId);
|
|
||||||
addPendingGeneration(generationId);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['history'] });
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Regenerate failed',
|
|
||||||
description: error instanceof Error ? error.message : 'Could not regenerate',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleFavorite = async (generationId: string) => {
|
|
||||||
try {
|
|
||||||
await apiClient.toggleFavorite(generationId);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['history'] });
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Failed to update favorite',
|
|
||||||
description: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApplyEffects = (generationId: string) => {
|
|
||||||
const gen = allHistory.find((g) => g.id === generationId);
|
|
||||||
const versions = gen?.versions ?? [];
|
|
||||||
setEffectsTargetId(generationId);
|
|
||||||
setEffectsTargetVersions(versions);
|
|
||||||
// Default to clean/original version (no effects chain)
|
|
||||||
const cleanVersion = versions.find((v) => !v.effects_chain || v.effects_chain.length === 0);
|
|
||||||
setEffectsSourceVersionId(cleanVersion?.id ?? null);
|
|
||||||
setEffectsChain([]);
|
|
||||||
setEffectsDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApplyEffectsConfirm = async () => {
|
|
||||||
if (!effectsTargetId || effectsChain.length === 0) return;
|
|
||||||
setApplyingEffects(true);
|
|
||||||
try {
|
|
||||||
const newVersion = await apiClient.applyEffectsToGeneration(effectsTargetId, {
|
|
||||||
effects_chain: effectsChain,
|
|
||||||
source_version_id: effectsSourceVersionId ?? undefined,
|
|
||||||
set_as_default: true,
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['history'] });
|
|
||||||
|
|
||||||
// If the player is currently on this generation, reload with the new version audio
|
|
||||||
if (currentAudioId === effectsTargetId) {
|
|
||||||
const gen = allHistory.find((g) => g.id === effectsTargetId);
|
|
||||||
if (gen) {
|
|
||||||
const versionUrl = apiClient.getVersionAudioUrl(newVersion.id);
|
|
||||||
setAudioWithAutoPlay(
|
|
||||||
versionUrl,
|
|
||||||
effectsTargetId,
|
|
||||||
gen.profile_id,
|
|
||||||
gen.text.substring(0, 50),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setEffectsDialogOpen(false);
|
|
||||||
toast({ title: 'Effects applied', description: 'A new version has been created.' });
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Failed to apply effects',
|
|
||||||
description: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setApplyingEffects(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSwitchVersion = async (generationId: string, versionId: string) => {
|
|
||||||
try {
|
|
||||||
await apiClient.setDefaultVersion(generationId, versionId);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['history'] });
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Failed to switch version',
|
|
||||||
description: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePlayVersion = (
|
|
||||||
generationId: string,
|
|
||||||
versionId: string,
|
|
||||||
text: string,
|
|
||||||
profileId: string,
|
|
||||||
) => {
|
|
||||||
const audioUrl = apiClient.getVersionAudioUrl(versionId);
|
|
||||||
setAudioWithAutoPlay(audioUrl, generationId, profileId, text.substring(0, 50));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImportConfirm = () => {
|
const handleImportConfirm = () => {
|
||||||
if (selectedFile) {
|
if (selectedFile) {
|
||||||
importGeneration.mutate(selectedFile, {
|
importGeneration.mutate(selectedFile, {
|
||||||
@@ -408,7 +225,10 @@ export function HistoryTable() {
|
|||||||
if (isLoading && page === 0) {
|
if (isLoading && page === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<Icon
|
||||||
|
icon="svg-spinners:ring-resize"
|
||||||
|
className="h-8 w-8 animate-spin text-muted-foreground"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -436,266 +256,104 @@ export function HistoryTable() {
|
|||||||
>
|
>
|
||||||
{history.map((gen) => {
|
{history.map((gen) => {
|
||||||
const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying;
|
const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying;
|
||||||
const isInProgress = gen.status === 'loading_model' || gen.status === 'generating';
|
|
||||||
const isGenerating = isInProgress;
|
|
||||||
const isFailed = gen.status === 'failed';
|
|
||||||
const isPlayable = !isGenerating && !isFailed;
|
|
||||||
const hasVersions = gen.versions && gen.versions.length > 1;
|
|
||||||
const isVersionsExpanded = expandedVersionsId === gen.id;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={gen.id}
|
key={gen.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'border rounded-md bg-card transition-colors text-left w-full',
|
'flex items-stretch gap-4 h-26 border rounded-md p-3 bg-card hover:bg-muted/70 transition-colors text-left w-full',
|
||||||
isCurrentlyPlaying && 'bg-muted/70',
|
isCurrentlyPlaying && 'bg-muted/70',
|
||||||
)}
|
)}
|
||||||
>
|
onMouseDown={(e) => {
|
||||||
{/* Main row */}
|
// Don't trigger play if clicking on textarea or if text is selected
|
||||||
<div
|
const target = e.target as HTMLElement;
|
||||||
role={isPlayable ? 'button' : undefined}
|
if (target.closest('textarea') || window.getSelection()?.toString()) {
|
||||||
tabIndex={isPlayable ? 0 : undefined}
|
return;
|
||||||
className={cn(
|
|
||||||
'flex items-stretch gap-4 h-26 p-3 outline-none',
|
|
||||||
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) => {
|
handlePlay(gen.id, gen.text, gen.profile_id);
|
||||||
if (!isPlayable) return;
|
}}
|
||||||
const target = e.target as HTMLElement;
|
>
|
||||||
if (target.closest('textarea') || window.getSelection()?.toString()) {
|
{/* Waveform icon */}
|
||||||
return;
|
<div className="flex items-center shrink-0">
|
||||||
}
|
<HugeiconsIcon
|
||||||
handlePlay(gen.id, gen.text, gen.profile_id);
|
icon={WaveIcon}
|
||||||
}}
|
size={20}
|
||||||
onKeyDown={(e) => {
|
className="h-5 w-5 text-muted-foreground"
|
||||||
if (!isPlayable) return;
|
/>
|
||||||
const target = e.target as HTMLElement;
|
</div>
|
||||||
if (target.closest('textarea') || target.closest('button')) return;
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
{/* Left side - Meta information */}
|
||||||
e.preventDefault();
|
<div className="flex flex-col gap-1.5 w-48 shrink-0 justify-center">
|
||||||
handlePlay(gen.id, gen.text, gen.profile_id);
|
<div className="font-medium text-sm truncate" title={gen.profile_name}>
|
||||||
}
|
{gen.profile_name}
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Status icon */}
|
|
||||||
<div className="flex items-center shrink-0 w-10 justify-center overflow-hidden">
|
|
||||||
<AudioBars
|
|
||||||
mode={isGenerating ? 'generating' : isCurrentlyPlaying ? 'playing' : 'idle'}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{/* Left side - Meta information */}
|
<span className="text-xs text-muted-foreground">{gen.language}</span>
|
||||||
<div className="flex flex-col gap-1.5 w-48 shrink-0 justify-center">
|
<span className="text-xs text-muted-foreground">
|
||||||
<div className="font-medium text-sm truncate" title={gen.profile_name}>
|
{formatDuration(gen.duration)}
|
||||||
{gen.profile_name}
|
</span>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground">{gen.language}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatEngineName(gen.engine, gen.model_size)}
|
|
||||||
</span>
|
|
||||||
{isFailed ? (
|
|
||||||
<span className="text-xs text-destructive">Failed</span>
|
|
||||||
) : !isGenerating ? (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatDuration(gen.duration ?? 0)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{isInProgress ? (
|
|
||||||
<span className="text-accent">
|
|
||||||
{gen.status === 'loading_model' ? 'Loading model...' : 'Generating...'}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
formatDate(gen.created_at)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
{/* Right side - Transcript textarea */}
|
{formatDate(gen.created_at)}
|
||||||
<div className="flex-1 min-w-0 flex">
|
|
||||||
<Textarea
|
|
||||||
value={gen.text}
|
|
||||||
className="flex-1 resize-none text-sm text-muted-foreground select-text"
|
|
||||||
readOnly
|
|
||||||
aria-label={`Transcript for sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Far right - Actions */}
|
|
||||||
<div
|
|
||||||
className="shrink-0 flex flex-col justify-center items-center gap-0.5"
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={cn(
|
|
||||||
'h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground',
|
|
||||||
gen.is_favorited && 'text-accent hover:text-accent',
|
|
||||||
)}
|
|
||||||
aria-label={gen.is_favorited ? 'Unfavorite' : 'Favorite'}
|
|
||||||
onClick={() => handleToggleFavorite(gen.id)}
|
|
||||||
>
|
|
||||||
<Star
|
|
||||||
className="h-2 w-2"
|
|
||||||
fill={gen.is_favorited ? 'currentColor' : 'none'}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
{hasVersions && (
|
|
||||||
<Button
|
|
||||||
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>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<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="Actions"
|
|
||||||
disabled={isGenerating}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="h-2 w-2" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
|
|
||||||
>
|
|
||||||
<Play className="mr-2 h-4 w-4" />
|
|
||||||
Play
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleDownloadAudio(gen.id, gen.text)}
|
|
||||||
disabled={exportGenerationAudio.isPending}
|
|
||||||
>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Export Audio
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleExportPackage(gen.id, gen.text)}
|
|
||||||
disabled={exportGeneration.isPending}
|
|
||||||
>
|
|
||||||
<FileArchive className="mr-2 h-4 w-4" />
|
|
||||||
Export Package
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => handleApplyEffects(gen.id)}>
|
|
||||||
<Wand2 className="mr-2 h-4 w-4" />
|
|
||||||
Apply Effects
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => handleRegenerate(gen.id)}>
|
|
||||||
<RotateCcw className="mr-2 h-4 w-4" />
|
|
||||||
Regenerate
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
|
||||||
disabled={deleteGeneration.isPending}
|
|
||||||
// className="text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expandable versions panel */}
|
{/* Right side - Transcript textarea */}
|
||||||
<AnimatePresence>
|
<div className="flex-1 min-w-0 flex">
|
||||||
{isVersionsExpanded && gen.versions && (
|
<Textarea
|
||||||
<motion.div
|
value={gen.text}
|
||||||
initial={{ height: 0, opacity: 0 }}
|
className="flex-1 resize-none text-sm text-muted-foreground select-text"
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
readOnly
|
||||||
exit={{ height: 0, opacity: 0 }}
|
/>
|
||||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
</div>
|
||||||
className="overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="border-t border-border/50">
|
|
||||||
<div className="divide-y divide-border/40">
|
|
||||||
{gen.versions.map((v) => {
|
|
||||||
// Show source provenance when effects were applied to a non-clean version
|
|
||||||
const sourceVersion = v.source_version_id
|
|
||||||
? gen.versions?.find((sv) => sv.id === v.source_version_id)
|
|
||||||
: null;
|
|
||||||
const showSource =
|
|
||||||
sourceVersion &&
|
|
||||||
sourceVersion.effects_chain &&
|
|
||||||
sourceVersion.effects_chain.length > 0;
|
|
||||||
|
|
||||||
return (
|
{/* Far right - Ellipsis actions */}
|
||||||
<button
|
<div
|
||||||
key={v.id}
|
className="w-10 shrink-0 flex justify-end"
|
||||||
type="button"
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
className="flex items-center gap-2 w-full h-9 px-3 text-left hover:bg-muted/50 transition-colors"
|
onClick={(e) => e.stopPropagation()}
|
||||||
onClick={() => {
|
>
|
||||||
handlePlayVersion(gen.id, v.id, gen.text, gen.profile_id);
|
<DropdownMenu>
|
||||||
if (!v.is_default) {
|
<DropdownMenuTrigger asChild>
|
||||||
handleSwitchVersion(gen.id, v.id);
|
<Button
|
||||||
}
|
variant="ghost"
|
||||||
}}
|
size="icon"
|
||||||
>
|
className="h-8 w-8"
|
||||||
<AudioLines className="h-3 w-3 shrink-0 text-muted-foreground" />
|
aria-label="Actions"
|
||||||
<span className="truncate text-xs font-medium">{v.label}</span>
|
>
|
||||||
{v.effects_chain && v.effects_chain.length > 0 && (
|
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} className="h-4 w-4" />
|
||||||
<span className="text-[10px] text-muted-foreground truncate">
|
</Button>
|
||||||
{v.effects_chain.map((e) => e.type).join(' → ')}
|
</DropdownMenuTrigger>
|
||||||
</span>
|
<DropdownMenuContent align="end">
|
||||||
)}
|
<DropdownMenuItem
|
||||||
{showSource && (
|
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
|
||||||
<span className="text-[10px] text-muted-foreground/60 truncate">
|
>
|
||||||
from {sourceVersion.label}
|
<HugeiconsIcon icon={PlayIcon} size={16} className="mr-2 h-4 w-4" />
|
||||||
</span>
|
Play
|
||||||
)}
|
</DropdownMenuItem>
|
||||||
<span className="flex-1" />
|
<DropdownMenuItem
|
||||||
{v.is_default && (
|
onClick={() => handleDownloadAudio(gen.id, gen.text)}
|
||||||
<span className="text-[10px] bg-accent/15 text-accent px-1.5 py-0.5 rounded-full">
|
disabled={exportGenerationAudio.isPending}
|
||||||
active
|
>
|
||||||
</span>
|
<HugeiconsIcon icon={Download01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||||
)}
|
Export Audio
|
||||||
</button>
|
</DropdownMenuItem>
|
||||||
);
|
<DropdownMenuItem
|
||||||
})}
|
onClick={() => handleExportPackage(gen.id, gen.text)}
|
||||||
</div>
|
disabled={exportGeneration.isPending}
|
||||||
</div>
|
>
|
||||||
</motion.div>
|
<HugeiconsIcon icon={Archive01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||||
)}
|
Export Package
|
||||||
</AnimatePresence>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
||||||
|
disabled={deleteGeneration.isPending}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={Delete01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -703,7 +361,12 @@ export function HistoryTable() {
|
|||||||
{/* Load more trigger element */}
|
{/* Load more trigger element */}
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div ref={loadMoreRef} className="flex items-center justify-center py-4">
|
<div ref={loadMoreRef} className="flex items-center justify-center py-4">
|
||||||
{isFetching && <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />}
|
{isFetching && (
|
||||||
|
<Icon
|
||||||
|
icon="svg-spinners:ring-resize"
|
||||||
|
className="h-6 w-6 animate-spin text-muted-foreground"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -777,57 +440,6 @@ export function HistoryTable() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={effectsDialogOpen} onOpenChange={setEffectsDialogOpen}>
|
|
||||||
<DialogContent className="max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Apply Effects</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Configure post-processing effects to apply to this generation. A new version will be
|
|
||||||
created.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{effectsTargetVersions.length > 1 && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs font-medium text-muted-foreground">Source</label>
|
|
||||||
<Select
|
|
||||||
value={effectsSourceVersionId ?? ''}
|
|
||||||
onValueChange={(val) => setEffectsSourceVersionId(val || null)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue placeholder="Select source version" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{effectsTargetVersions.map((v) => (
|
|
||||||
<SelectItem key={v.id} value={v.id} className="text-xs">
|
|
||||||
{v.label}
|
|
||||||
{v.effects_chain && v.effects_chain.length > 0 && (
|
|
||||||
<span className="text-muted-foreground ml-1.5">
|
|
||||||
({v.effects_chain.map((e) => e.type).join(' + ')})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="py-2 max-h-80 overflow-y-auto">
|
|
||||||
<EffectsChainEditor value={effectsChain} onChange={setEffectsChain} />
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setEffectsDialogOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleApplyEffectsConfirm}
|
|
||||||
disabled={applyingEffects || effectsChain.length === 0}
|
|
||||||
>
|
|
||||||
{applyingEffects ? 'Applying...' : 'Apply'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Sparkles, Upload } from 'lucide-react';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { SparklesIcon, Upload01Icon } from '@hugeicons/core-free-icons';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
||||||
import { HistoryTable } from '@/components/History/HistoryTable';
|
import { HistoryTable } from '@/components/History/HistoryTable';
|
||||||
@@ -13,7 +14,7 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
|
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
|
||||||
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import { useImportProfile } from '@/lib/hooks/useProfiles';
|
import { useImportProfile } from '@/lib/hooks/useProfiles';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
@@ -77,9 +78,9 @@ export function MainEditor() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
// Main view: Profiles top left, Generator bottom left, History right
|
// Main view: Profiles top left, Generator bottom left, History right
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-6 h-full min-h-0 overflow-hidden relative">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0 overflow-hidden relative">
|
||||||
{/* Left Column */}
|
{/* Left Column */}
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden relative lg:overflow-hidden">
|
<div className="flex flex-col min-h-0 overflow-hidden relative">
|
||||||
{/* Scroll Mask - Always visible, behind content */}
|
{/* Scroll Mask - Always visible, behind content */}
|
||||||
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />
|
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@ export function MainEditor() {
|
|||||||
<h2 className="text-2xl font-bold">Voicebox</h2>
|
<h2 className="text-2xl font-bold">Voicebox</h2>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" onClick={handleImportClick}>
|
<Button variant="outline" onClick={handleImportClick}>
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
<HugeiconsIcon icon={Upload01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||||
Import Voice
|
Import Voice
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
@@ -100,7 +101,7 @@ export function MainEditor() {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
<Button onClick={() => setDialogOpen(true)}>
|
||||||
<Sparkles className="mr-2 h-4 w-4" />
|
<HugeiconsIcon icon={SparklesIcon} size={16} className="mr-2 h-4 w-4" />
|
||||||
Create Voice
|
Create Voice
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,7 +111,10 @@ export function MainEditor() {
|
|||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className={cn('flex-1 min-h-0 overflow-y-auto pt-14 pb-4', isPlayerVisible && 'lg:pb-32')}
|
className={cn(
|
||||||
|
'flex-1 min-h-0 overflow-y-auto pt-14',
|
||||||
|
isPlayerVisible ? BOTTOM_SAFE_AREA_PADDING : 'pb-4',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="shrink-0 flex flex-col">
|
<div className="shrink-0 flex flex-col">
|
||||||
@@ -120,9 +124,6 @@ export function MainEditor() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider - single column only */}
|
|
||||||
{/* <div className="border-t border-border -my-3 lg:hidden" /> */}
|
|
||||||
|
|
||||||
{/* Right Column - History */}
|
{/* Right Column - History */}
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||||
<HistoryTable />
|
<HistoryTable />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
|
|||||||
|
|
||||||
export function ModelsTab() {
|
export function ModelsTab() {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="space-y-4 overflow-y-auto flex flex-col">
|
||||||
<ModelManagement />
|
<ModelManagement />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Loader2, XCircle } from 'lucide-react';
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -17,10 +14,10 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
const connectionSchema = z.object({
|
const connectionSchema = z.object({
|
||||||
serverUrl: z.string().url('Please enter a valid URL'),
|
serverUrl: z.string().url('Please enter a valid URL'),
|
||||||
@@ -34,10 +31,7 @@ export function ConnectionForm() {
|
|||||||
const setServerUrl = useServerStore((state) => state.setServerUrl);
|
const setServerUrl = useServerStore((state) => state.setServerUrl);
|
||||||
const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose);
|
const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose);
|
||||||
const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose);
|
const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose);
|
||||||
const mode = useServerStore((state) => state.mode);
|
|
||||||
const setMode = useServerStore((state) => state.setMode);
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { data: health, isLoading, error: healthError } = useServerHealth();
|
|
||||||
|
|
||||||
const form = useForm<ConnectionFormValues>({
|
const form = useForm<ConnectionFormValues>({
|
||||||
resolver: zodResolver(connectionSchema),
|
resolver: zodResolver(connectionSchema),
|
||||||
@@ -55,7 +49,7 @@ export function ConnectionForm() {
|
|||||||
|
|
||||||
function onSubmit(data: ConnectionFormValues) {
|
function onSubmit(data: ConnectionFormValues) {
|
||||||
setServerUrl(data.serverUrl);
|
setServerUrl(data.serverUrl);
|
||||||
form.reset(data);
|
form.reset(data); // Reset form state after successful submission
|
||||||
toast({
|
toast({
|
||||||
title: 'Server URL updated',
|
title: 'Server URL updated',
|
||||||
description: `Connected to ${data.serverUrl}`,
|
description: `Connected to ${data.serverUrl}`,
|
||||||
@@ -63,7 +57,7 @@ export function ConnectionForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card role="region" aria-label="Server Connection" tabIndex={0}>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Server Connection</CardTitle>
|
<CardTitle>Server Connection</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -89,42 +83,10 @@ 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);
|
||||||
@@ -153,39 +115,6 @@ 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,112 @@
|
|||||||
|
import { Folder01Icon, FolderOpenIcon } from '@hugeicons/core-free-icons';
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { useSystemFolders } from '@/lib/hooks/useSystemFolders';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
|
interface FolderRowProps {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
path: string | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
canOpen: boolean;
|
||||||
|
onOpen: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderRow({ label, description, path, isLoading, canOpen, onOpen }: FolderRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{label}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{description}</div>
|
||||||
|
</div>
|
||||||
|
{canOpen && path && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onOpen}
|
||||||
|
disabled={isLoading || !path}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={FolderOpenIcon} size={16} className="h-4 w-4 mr-2" />
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={isLoading ? 'Loading...' : path || 'Not available'}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-xs text-muted-foreground select-all cursor-text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataFolders() {
|
||||||
|
const { data: folders, isLoading, error } = useSystemFolders();
|
||||||
|
const platform = usePlatform();
|
||||||
|
const isTauri = platform.metadata.isTauri;
|
||||||
|
|
||||||
|
const handleOpenFolder = async (path: string | undefined) => {
|
||||||
|
if (!path) return;
|
||||||
|
const success = await platform.filesystem.openFolder(path);
|
||||||
|
if (!success && isTauri) {
|
||||||
|
console.error('Failed to open folder:', path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<HugeiconsIcon icon={Folder01Icon} size={20} className="h-5 w-5" />
|
||||||
|
Data Folders
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{isTauri
|
||||||
|
? 'Click "Open" to view folders in your file explorer, or copy the paths below.'
|
||||||
|
: 'These are the server-side folder paths where your data is stored.'}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{error ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||||
|
<Icon icon="lucide:alert-circle" className="h-4 w-4" />
|
||||||
|
<span>Failed to load folder paths: {error.message}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FolderRow
|
||||||
|
label="App Data"
|
||||||
|
description="Voices, generations, and app database"
|
||||||
|
path={folders?.data_dir}
|
||||||
|
isLoading={isLoading}
|
||||||
|
canOpen={isTauri}
|
||||||
|
onOpen={() => handleOpenFolder(folders?.data_dir)}
|
||||||
|
/>
|
||||||
|
<FolderRow
|
||||||
|
label="Models"
|
||||||
|
description="Downloaded AI models from HuggingFace Hub"
|
||||||
|
path={folders?.models_dir}
|
||||||
|
isLoading={isLoading}
|
||||||
|
canOpen={isTauri}
|
||||||
|
onOpen={() => handleOpenFolder(folders?.models_dir)}
|
||||||
|
/>
|
||||||
|
<FolderRow
|
||||||
|
label="Providers"
|
||||||
|
description="External TTS provider binaries (PyTorch CPU/CUDA)"
|
||||||
|
path={folders?.providers_dir}
|
||||||
|
isLoading={isLoading}
|
||||||
|
canOpen={isTauri}
|
||||||
|
onOpen={() => handleOpenFolder(folders?.providers_dir)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,369 +0,0 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { AlertCircle, Download, Loader2, RotateCw, Trash2 } from 'lucide-react';
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import { apiClient } from '@/lib/api/client';
|
|
||||||
import type { CudaDownloadProgress } from '@/lib/api/types';
|
|
||||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
|
||||||
|
|
||||||
type RestartPhase = 'idle' | 'stopping' | 'waiting' | 'ready';
|
|
||||||
|
|
||||||
export function GpuAcceleration() {
|
|
||||||
const platform = usePlatform();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
|
||||||
const { data: health } = useServerHealth();
|
|
||||||
|
|
||||||
const [restartPhase, setRestartPhase] = useState<RestartPhase>('idle');
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [downloadProgress, setDownloadProgress] = useState<CudaDownloadProgress | null>(null);
|
|
||||||
const healthPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
|
|
||||||
// Query CUDA backend status
|
|
||||||
const {
|
|
||||||
data: cudaStatus,
|
|
||||||
isLoading: _cudaStatusLoading,
|
|
||||||
refetch: refetchCudaStatus,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ['cuda-status', serverUrl],
|
|
||||||
queryFn: () => apiClient.getCudaStatus(),
|
|
||||||
refetchInterval: (query) => (query.state.status === 'pending' ? false : 10000),
|
|
||||||
retry: 1,
|
|
||||||
enabled: !!health, // Only fetch when backend is reachable
|
|
||||||
});
|
|
||||||
|
|
||||||
// Derived state
|
|
||||||
const isCurrentlyCuda = health?.backend_variant === 'cuda';
|
|
||||||
const cudaAvailable = cudaStatus?.available ?? false;
|
|
||||||
const cudaDownloading = cudaStatus?.downloading ?? false;
|
|
||||||
|
|
||||||
// Clean up health poll on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (healthPollRef.current) {
|
|
||||||
clearInterval(healthPollRef.current);
|
|
||||||
healthPollRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// SSE progress tracking during download
|
|
||||||
useEffect(() => {
|
|
||||||
if (!cudaDownloading || !serverUrl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventSource = new EventSource(`${serverUrl}/backend/cuda-progress`);
|
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data) as CudaDownloadProgress;
|
|
||||||
setDownloadProgress(data);
|
|
||||||
|
|
||||||
if (data.status === 'complete') {
|
|
||||||
eventSource.close();
|
|
||||||
setDownloadProgress(null);
|
|
||||||
refetchCudaStatus();
|
|
||||||
} else if (data.status === 'error') {
|
|
||||||
eventSource.close();
|
|
||||||
setError(data.error || 'Download failed');
|
|
||||||
setDownloadProgress(null);
|
|
||||||
refetchCudaStatus();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error parsing CUDA progress event:', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
eventSource.onerror = () => {
|
|
||||||
eventSource.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
eventSource.close();
|
|
||||||
};
|
|
||||||
}, [cudaDownloading, serverUrl, refetchCudaStatus]);
|
|
||||||
|
|
||||||
// Start aggressive health polling during restart
|
|
||||||
const startHealthPolling = useCallback(() => {
|
|
||||||
if (healthPollRef.current) return;
|
|
||||||
|
|
||||||
healthPollRef.current = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const result = await apiClient.getHealth();
|
|
||||||
if (result.status === 'healthy') {
|
|
||||||
// Server is back up
|
|
||||||
if (healthPollRef.current) {
|
|
||||||
clearInterval(healthPollRef.current);
|
|
||||||
healthPollRef.current = null;
|
|
||||||
}
|
|
||||||
setRestartPhase('ready');
|
|
||||||
// Invalidate all queries to refresh UI
|
|
||||||
queryClient.invalidateQueries();
|
|
||||||
// Reset after a moment
|
|
||||||
setTimeout(() => setRestartPhase('idle'), 2000);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Server still down, keep polling
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}, [queryClient]);
|
|
||||||
|
|
||||||
const handleDownload = async () => {
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await apiClient.downloadCudaBackend();
|
|
||||||
refetchCudaStatus();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const msg = e instanceof Error ? e.message : 'Failed to start download';
|
|
||||||
if (msg.includes('already downloaded')) {
|
|
||||||
refetchCudaStatus();
|
|
||||||
} else {
|
|
||||||
setError(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRestart = async () => {
|
|
||||||
setError(null);
|
|
||||||
setRestartPhase('stopping');
|
|
||||||
|
|
||||||
try {
|
|
||||||
setRestartPhase('waiting');
|
|
||||||
startHealthPolling();
|
|
||||||
await platform.lifecycle.restartServer();
|
|
||||||
// Invoke resolved — server is likely ready. Stop polling and refresh.
|
|
||||||
if (healthPollRef.current) {
|
|
||||||
clearInterval(healthPollRef.current);
|
|
||||||
healthPollRef.current = null;
|
|
||||||
}
|
|
||||||
setRestartPhase('ready');
|
|
||||||
queryClient.invalidateQueries();
|
|
||||||
setTimeout(() => setRestartPhase('idle'), 2000);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
setRestartPhase('idle');
|
|
||||||
if (healthPollRef.current) {
|
|
||||||
clearInterval(healthPollRef.current);
|
|
||||||
healthPollRef.current = null;
|
|
||||||
}
|
|
||||||
setError(e instanceof Error ? e.message : 'Restart failed');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSwitchToCpu = async () => {
|
|
||||||
// To switch to CPU: delete the CUDA binary, then restart.
|
|
||||||
// start_server always prefers CUDA if present, so we must remove it first.
|
|
||||||
setError(null);
|
|
||||||
setRestartPhase('stopping');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await apiClient.deleteCudaBackend();
|
|
||||||
setRestartPhase('waiting');
|
|
||||||
startHealthPolling();
|
|
||||||
await platform.lifecycle.restartServer();
|
|
||||||
// Invoke resolved — server is likely ready
|
|
||||||
if (healthPollRef.current) {
|
|
||||||
clearInterval(healthPollRef.current);
|
|
||||||
healthPollRef.current = null;
|
|
||||||
}
|
|
||||||
setRestartPhase('ready');
|
|
||||||
queryClient.invalidateQueries();
|
|
||||||
setTimeout(() => setRestartPhase('idle'), 2000);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
setRestartPhase('idle');
|
|
||||||
if (healthPollRef.current) {
|
|
||||||
clearInterval(healthPollRef.current);
|
|
||||||
healthPollRef.current = null;
|
|
||||||
}
|
|
||||||
setError(e instanceof Error ? e.message : 'Failed to switch to CPU');
|
|
||||||
refetchCudaStatus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await apiClient.deleteCudaBackend();
|
|
||||||
refetchCudaStatus();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Failed to delete CUDA backend');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatBytes = (bytes: number): string => {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Don't render until health data is available
|
|
||||||
if (!health) return null;
|
|
||||||
|
|
||||||
// If the system already has native GPU (MPS, etc.), only show info - no CUDA needed
|
|
||||||
const hasNativeGpu =
|
|
||||||
health.gpu_available &&
|
|
||||||
!isCurrentlyCuda &&
|
|
||||||
health.gpu_type &&
|
|
||||||
!health.gpu_type.includes('CUDA');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>GPU Acceleration</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* GPU status */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
{health.gpu_available && health.gpu_type ? (
|
|
||||||
<>
|
|
||||||
<div className="text-sm font-medium">
|
|
||||||
{health.gpu_type.replace(/^(CUDA|ROCm|MPS|Metal|XPU|DirectML)\s*\((.+)\)$/, '$2') ||
|
|
||||||
health.gpu_type}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{health.gpu_type.replace(/\s*\(.+\)$/, '')}
|
|
||||||
{health.vram_used_mb != null && health.vram_used_mb > 0
|
|
||||||
? ` \u00b7 ${health.vram_used_mb.toFixed(0)} MB VRAM used`
|
|
||||||
: ''}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="text-sm font-medium">CPU</div>
|
|
||||||
<div className="text-sm text-muted-foreground">No GPU acceleration available</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Native GPU detected - no CUDA download needed */}
|
|
||||||
|
|
||||||
{/* CUDA download section - only show when no GPU is active (native or CUDA) */}
|
|
||||||
{!hasNativeGpu && !isCurrentlyCuda && (
|
|
||||||
<>
|
|
||||||
{/* Download progress (manual download or auto-update) */}
|
|
||||||
{cudaDownloading && downloadProgress && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
<span>
|
|
||||||
{downloadProgress.filename ||
|
|
||||||
(cudaAvailable
|
|
||||||
? 'Updating CUDA backend...'
|
|
||||||
: 'Downloading CUDA backend...')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{downloadProgress.total > 0 && (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{downloadProgress.progress.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{downloadProgress.total > 0 && (
|
|
||||||
<>
|
|
||||||
<Progress value={downloadProgress.progress} className="h-2" />
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{formatBytes(downloadProgress.current)} /{' '}
|
|
||||||
{formatBytes(downloadProgress.total)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Restart in progress */}
|
|
||||||
{restartPhase !== 'idle' && (
|
|
||||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-primary/5 border">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
<span className="text-sm">
|
|
||||||
{restartPhase === 'stopping' && 'Stopping server...'}
|
|
||||||
{restartPhase === 'waiting' && 'Restarting server...'}
|
|
||||||
{restartPhase === 'ready' && 'Server restarted successfully!'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error display */}
|
|
||||||
{error && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
|
||||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
||||||
<span>{error}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
{restartPhase === 'idle' && !cudaDownloading && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/* Not downloaded yet - show download button */}
|
|
||||||
{!cudaAvailable && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Download the CUDA backend (~2.4 GB) for NVIDIA GPU acceleration. Requires an
|
|
||||||
NVIDIA GPU with CUDA support.
|
|
||||||
</p>
|
|
||||||
<Button onClick={handleDownload} className="w-full" size="sm">
|
|
||||||
<Download className="h-4 w-4 mr-2" />
|
|
||||||
Download CUDA Backend
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Downloaded but not active - show switch button */}
|
|
||||||
{cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
CUDA backend is downloaded and ready. Restart the server to enable GPU
|
|
||||||
acceleration.
|
|
||||||
</p>
|
|
||||||
<Button onClick={handleRestart} className="w-full" size="sm">
|
|
||||||
<RotateCw className="h-4 w-4 mr-2" />
|
|
||||||
Switch to CUDA Backend
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Currently active - show switch back to CPU */}
|
|
||||||
{isCurrentlyCuda && platform.metadata.isTauri && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Running with CUDA GPU acceleration. Switch back to CPU if needed (you can
|
|
||||||
re-download later).
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
onClick={handleSwitchToCpu}
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<RotateCw className="h-4 w-4 mr-2" />
|
|
||||||
Switch to CPU Backend
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete option when downloaded (and not active) */}
|
|
||||||
{cudaAvailable && !isCurrentlyCuda && (
|
|
||||||
<Button
|
|
||||||
onClick={handleDelete}
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full text-muted-foreground hover:text-destructive"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
Remove CUDA Backend
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
|||||||
import { Loader2, XCircle } from 'lucide-react';
|
import { CancelCircleIcon } from '@hugeicons/core-free-icons';
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
@@ -12,7 +14,11 @@ interface ModelProgressProps {
|
|||||||
isDownloading?: boolean;
|
isDownloading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelProgress({ modelName, displayName, isDownloading = false }: ModelProgressProps) {
|
export function ModelProgress({
|
||||||
|
modelName,
|
||||||
|
displayName,
|
||||||
|
isDownloading = false,
|
||||||
|
}: ModelProgressProps) {
|
||||||
const [progress, setProgress] = useState<ModelProgressType | null>(null);
|
const [progress, setProgress] = useState<ModelProgressType | null>(null);
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
|
|
||||||
@@ -74,10 +80,12 @@ export function ModelProgress({ modelName, displayName, isDownloading = false }:
|
|||||||
const getStatusIcon = () => {
|
const getStatusIcon = () => {
|
||||||
switch (progress.status) {
|
switch (progress.status) {
|
||||||
case 'error':
|
case 'error':
|
||||||
return <XCircle className="h-4 w-4 text-destructive" />;
|
return (
|
||||||
|
<HugeiconsIcon icon={CancelCircleIcon} size={16} className="h-4 w-4 text-destructive" />
|
||||||
|
);
|
||||||
case 'downloading':
|
case 'downloading':
|
||||||
case 'extracting':
|
case 'extracting':
|
||||||
return <Loader2 className="h-4 w-4 animate-spin" />;
|
return <Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import { Delete01Icon, Download01Icon } from '@hugeicons/core-free-icons';
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||||
|
|
||||||
|
const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
|
||||||
|
const isWindows = () => navigator.platform.toLowerCase().includes('win');
|
||||||
|
const getPlatformName = () => {
|
||||||
|
if (isMacOS()) return 'macOS';
|
||||||
|
if (isWindows()) return 'Windows';
|
||||||
|
return 'Linux';
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderType =
|
||||||
|
| 'auto'
|
||||||
|
| 'apple-mlx'
|
||||||
|
| 'bundled-pytorch'
|
||||||
|
| 'pytorch-cpu'
|
||||||
|
| 'pytorch-cuda'
|
||||||
|
| 'remote'
|
||||||
|
| 'openai';
|
||||||
|
|
||||||
|
export function ProviderSettings() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [downloadingProvider, setDownloadingProvider] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: providersData, isLoading } = useQuery({
|
||||||
|
queryKey: ['providers'],
|
||||||
|
queryFn: async () => {
|
||||||
|
return await apiClient.listProviders();
|
||||||
|
},
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: activeProvider } = useQuery({
|
||||||
|
queryKey: ['activeProvider'],
|
||||||
|
queryFn: async () => {
|
||||||
|
return await apiClient.getActiveProvider();
|
||||||
|
},
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Callbacks for download completion
|
||||||
|
const handleDownloadComplete = useCallback(() => {
|
||||||
|
setDownloadingProvider(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['providers'] });
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
const handleDownloadError = useCallback(() => {
|
||||||
|
setDownloadingProvider(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Use progress toast hook for the downloading provider
|
||||||
|
useModelDownloadToast({
|
||||||
|
modelName: downloadingProvider || '',
|
||||||
|
displayName: downloadingProvider || '',
|
||||||
|
enabled: !!downloadingProvider,
|
||||||
|
onComplete: handleDownloadComplete,
|
||||||
|
onError: handleDownloadError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [providerToDelete, setProviderToDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const downloadMutation = useMutation({
|
||||||
|
mutationFn: async (providerType: string) => {
|
||||||
|
return await apiClient.downloadProvider(providerType);
|
||||||
|
},
|
||||||
|
onSuccess: (_, providerType) => {
|
||||||
|
setDownloadingProvider(providerType);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['providers'] });
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Download failed',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startMutation = useMutation({
|
||||||
|
mutationFn: async (providerType: string) => {
|
||||||
|
return await apiClient.startProvider(providerType);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['activeProvider'] });
|
||||||
|
toast({
|
||||||
|
title: 'Provider started',
|
||||||
|
description: 'The provider has been started successfully',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to start provider',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (providerType: string) => {
|
||||||
|
return await apiClient.deleteProvider(providerType);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['providers'] });
|
||||||
|
toast({
|
||||||
|
title: 'Provider deleted',
|
||||||
|
description: 'The provider has been deleted successfully',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to delete provider',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDownload = async (providerType: string) => {
|
||||||
|
downloadMutation.mutate(providerType);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStart = async (providerType: string) => {
|
||||||
|
startMutation.mutate(providerType);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (providerType: string) => {
|
||||||
|
setProviderToDelete(providerType);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (providerToDelete) {
|
||||||
|
deleteMutation.mutate(providerToDelete);
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setProviderToDelete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>TTS Provider</CardTitle>
|
||||||
|
<CardDescription>Choose how Voicebox generates speech</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Icon icon="svg-spinners:ring-resize" className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const installedProviders = providersData?.installed || [];
|
||||||
|
|
||||||
|
// Determine current active provider
|
||||||
|
const currentProvider = activeProvider?.provider;
|
||||||
|
console.log('currentProvider', currentProvider);
|
||||||
|
const selectedProvider = currentProvider as ProviderType;
|
||||||
|
|
||||||
|
const isStarting = startMutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>TTS Provider</CardTitle>
|
||||||
|
<CardDescription>Choose how Voicebox generates speech.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="relative">
|
||||||
|
{isStarting && (
|
||||||
|
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center z-10 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Icon icon="svg-spinners:ring-resize" className="h-5 w-5" />
|
||||||
|
<span>Starting provider...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<RadioGroup
|
||||||
|
value={selectedProvider}
|
||||||
|
onValueChange={(value) => handleStart(value)}
|
||||||
|
disabled={isStarting}
|
||||||
|
>
|
||||||
|
{/* PyTorch CUDA */}
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<div className={`flex items-center space-x-3 flex-1 ${isMacOS() || !installedProviders.includes('pytorch-cuda') ? 'opacity-50' : ''}`}>
|
||||||
|
<RadioGroupItem value="pytorch-cuda" id="cuda" disabled={isMacOS() || isStarting || !installedProviders.includes('pytorch-cuda')} />
|
||||||
|
<Label
|
||||||
|
htmlFor="cuda"
|
||||||
|
className={`flex-1 ${isMacOS() || isStarting || !installedProviders.includes('pytorch-cuda') ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">PyTorch CUDA</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
NVIDIA GPU-accelerated provider
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isMacOS() && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-muted-foreground">2.4GB</span>
|
||||||
|
<Button size="sm" variant="secondary" disabled>
|
||||||
|
Not Available on macOS
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isMacOS() && !installedProviders.includes('pytorch-cuda') && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-muted-foreground">2.4GB</span>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('pytorch-cuda')}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={downloadingProvider === 'pytorch-cuda' || isStarting}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{downloadingProvider === 'pytorch-cuda' ? (
|
||||||
|
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<HugeiconsIcon icon={Download01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||||
|
Download
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{installedProviders.includes('pytorch-cuda') && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDelete('pytorch-cuda')}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isStarting}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={Delete01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||||
|
Uninstall
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PyTorch CPU */}
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<div className={`flex items-center space-x-3 flex-1 ${!installedProviders.includes('pytorch-cpu') ? 'opacity-50' : ''}`}>
|
||||||
|
<RadioGroupItem value="pytorch-cpu" id="cpu" disabled={isStarting || !installedProviders.includes('pytorch-cpu')} />
|
||||||
|
<Label
|
||||||
|
htmlFor="cpu"
|
||||||
|
className={`flex-1 ${isStarting || !installedProviders.includes('pytorch-cpu') ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">PyTorch CPU</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Works on any system, slower inference
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!installedProviders.includes('pytorch-cpu') && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-muted-foreground">242MB</span>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownload('pytorch-cpu')}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={downloadingProvider === 'pytorch-cpu' || isStarting}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{downloadingProvider === 'pytorch-cpu' ? (
|
||||||
|
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<HugeiconsIcon icon={Download01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||||
|
Download
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{installedProviders.includes('pytorch-cpu') && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDelete('pytorch-cpu')}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isStarting}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={Delete01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||||
|
Uninstall
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MLX bundled (macOS Apple Silicon only) */}
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<div className={`flex items-center space-x-3 flex-1 ${!isMacOS() ? 'opacity-50' : ''}`}>
|
||||||
|
<RadioGroupItem value="apple-mlx" id="mlx" disabled={isStarting || !isMacOS()} />
|
||||||
|
<Label
|
||||||
|
htmlFor="mlx"
|
||||||
|
className={`flex-1 ${isStarting || !isMacOS() ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">Apple MLX</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{isMacOS()
|
||||||
|
? 'Bundled with this version, optimized for Apple Silicon'
|
||||||
|
: 'Only available on Apple Silicon'}
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{!isMacOS() && (
|
||||||
|
<Button size="sm" variant="secondary" disabled>
|
||||||
|
Not Available on {getPlatformName()}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remote */}
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<div className="flex items-center space-x-3 flex-1 opacity-50">
|
||||||
|
<RadioGroupItem value="remote" id="remote" disabled />
|
||||||
|
<Label htmlFor="remote" className="flex-1 cursor-not-allowed">
|
||||||
|
<div className="font-medium">Remote Server</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Connect to your own TTS server
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="secondary" disabled>
|
||||||
|
Coming Soon
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OpenAI */}
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<div className="flex items-center space-x-3 flex-1 opacity-50">
|
||||||
|
<RadioGroupItem value="openai" id="openai" disabled />
|
||||||
|
<Label htmlFor="openai" className="flex-1 cursor-not-allowed">
|
||||||
|
<div className="font-medium">OpenAI API</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Use OpenAI's TTS API</div>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="secondary" disabled>
|
||||||
|
Coming Soon
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
<p className="text-xs text-muted-foreground mt-5">
|
||||||
|
Note: PyTorch and MLX use different versions of the same model. When switching between
|
||||||
|
them, you will need to redownload the model.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Provider</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete {providerToDelete}? This will remove the provider
|
||||||
|
binary from your system. You can download it again later if needed.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
import { Loader2, XCircle } from 'lucide-react';
|
import { CancelCircleIcon } from '@hugeicons/core-free-icons';
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { 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 role="region" aria-label="Server Status" tabIndex={0}>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Server Status</CardTitle>
|
<CardTitle>Server Status</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -19,14 +22,24 @@ 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" />
|
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||||
<span className="text-sm">Checking connection...</span>
|
<span className="text-sm">Checking connection...</span>
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<XCircle className="h-4 w-4 text-destructive" />
|
<HugeiconsIcon icon={CancelCircleIcon} size={16} className="h-4 w-4 text-destructive" />
|
||||||
<span className="text-sm text-destructive">Connection failed: {error.message}</span>
|
<span className="text-sm text-destructive">Connection failed: {error.message}</span>
|
||||||
</div>
|
</div>
|
||||||
) : health ? (
|
) : health ? (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AlertCircle, Download, RefreshCw } from 'lucide-react';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { AlertCircleIcon, Download01Icon, Refresh01Icon } from '@hugeicons/core-free-icons';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, 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';
|
||||||
@@ -11,7 +12,6 @@ export function UpdateStatus() {
|
|||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
|
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
|
||||||
const [currentVersion, setCurrentVersion] = useState<string>('');
|
const [currentVersion, setCurrentVersion] = useState<string>('');
|
||||||
const isDev = !import.meta.env?.PROD;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
platform.metadata
|
platform.metadata
|
||||||
@@ -21,7 +21,7 @@ export function UpdateStatus() {
|
|||||||
}, [platform]);
|
}, [platform]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card role="region" aria-label="App Updates" tabIndex={0}>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>App Updates</CardTitle>
|
<CardTitle>App Updates</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -29,110 +29,97 @@ 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">
|
<div className="text-sm text-muted-foreground">v{currentVersion}</div>
|
||||||
v{currentVersion}
|
|
||||||
{isDev ? ' (dev)' : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{!isDev && (
|
<Button
|
||||||
<Button
|
onClick={checkForUpdates}
|
||||||
onClick={checkForUpdates}
|
disabled={status.checking || status.downloading || status.readyToInstall}
|
||||||
disabled={status.checking || status.downloading || status.readyToInstall}
|
variant="outline"
|
||||||
variant="outline"
|
size="sm"
|
||||||
size="sm"
|
>
|
||||||
>
|
<HugeiconsIcon icon={Refresh01Icon} size={16} className={`h-4 w-4 mr-2 ${status.checking ? 'animate-spin' : ''}`} />
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${status.checking ? 'animate-spin' : ''}`} />
|
Check for Updates
|
||||||
Check for Updates
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isDev ? (
|
{status.checking && (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
Auto-updates are disabled in development mode.
|
<HugeiconsIcon icon={Refresh01Icon} size={16} className="h-4 w-4 animate-spin" />
|
||||||
|
Checking for updates...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
<>
|
|
||||||
{status.checking && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
|
||||||
Checking for updates...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status.error && (
|
{status.error && (
|
||||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<HugeiconsIcon icon={AlertCircleIcon} size={16} className="h-4 w-4" />
|
||||||
{status.error}
|
{status.error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status.available && !status.downloading && !status.readyToInstall && (
|
{status.available && !status.downloading && !status.readyToInstall && (
|
||||||
<div className="space-y-3 p-4 border rounded-lg bg-primary/5">
|
<div className="space-y-3 p-4 border rounded-lg bg-primary/5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold">Update Available</div>
|
<div className="font-semibold">Update Available</div>
|
||||||
<div className="text-sm text-muted-foreground">Version {status.version}</div>
|
<div className="text-sm text-muted-foreground">Version {status.version}</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge>New</Badge>
|
<Badge>New</Badge>
|
||||||
|
</div>
|
||||||
|
<Button onClick={downloadAndInstall} className="w-full" size="sm">
|
||||||
|
<HugeiconsIcon icon={Download01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||||
|
Download Update
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status.downloading && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HugeiconsIcon icon={Download01Icon} size={16} 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>
|
||||||
<Button onClick={downloadAndInstall} className="w-full" size="sm">
|
)}
|
||||||
<Download className="h-4 w-4 mr-2" />
|
</div>
|
||||||
Download Update
|
)}
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status.downloading && (
|
{status.readyToInstall && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3 p-4 border rounded-lg bg-accent/30 border-accent/50">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div>
|
||||||
<Download className="h-4 w-4" />
|
<div className="font-semibold">Update Ready to Install</div>
|
||||||
Downloading update...
|
|
||||||
</div>
|
|
||||||
{status.downloadProgress !== undefined && (
|
|
||||||
<span className="text-muted-foreground">{status.downloadProgress}%</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Progress value={status.downloadProgress} />
|
|
||||||
{status.downloadedBytes !== undefined &&
|
|
||||||
status.totalBytes !== undefined &&
|
|
||||||
status.totalBytes > 0 && (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB /{' '}
|
|
||||||
{(status.totalBytes / 1024 / 1024).toFixed(1)} MB
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status.readyToInstall && (
|
|
||||||
<div className="space-y-3 p-4 border rounded-lg bg-accent/30 border-accent/50">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold">Update Ready to Install</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Version {status.version} has been downloaded
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
The app needs to restart to complete the installation. You can do this now or
|
Version {status.version} has been downloaded
|
||||||
later at your convenience.
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={restartAndInstall} className="w-full" size="sm">
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
|
||||||
Restart Now
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
The app needs to restart to complete the installation. You can do this now or later at
|
||||||
|
your convenience.
|
||||||
|
</div>
|
||||||
|
<Button onClick={restartAndInstall} className="w-full" size="sm">
|
||||||
|
<HugeiconsIcon icon={Refresh01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||||
|
Restart Now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!status.available && !status.checking && !status.error && (
|
{!status.available && !status.checking && !status.error && status.checking === false && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
You're up to date
|
You're up to date
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
import { ArrowUpRight } from 'lucide-react';
|
|
||||||
import type { CSSProperties, ReactNode } from 'react';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
|
||||||
|
|
||||||
function FadeIn({ delay = 0, children }: { delay?: number; children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="animate-[fadeInUp_0.5s_ease_both]"
|
|
||||||
style={{ animationDelay: `${delay}ms` } as CSSProperties}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AboutPage() {
|
|
||||||
const platform = usePlatform();
|
|
||||||
const [version, setVersion] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
platform.metadata
|
|
||||||
.getVersion()
|
|
||||||
.then(setVersion)
|
|
||||||
.catch(() => setVersion(''));
|
|
||||||
}, [platform]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<style>{`
|
|
||||||
@keyframes fadeInUp {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(8px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
<div className="max-w-md mx-auto h-full flex items-center">
|
|
||||||
<div className="flex flex-col items-center text-center space-y-5">
|
|
||||||
<FadeIn delay={0}>
|
|
||||||
<img src={voiceboxLogo} alt="Voicebox" className="w-20 h-20 object-contain" />
|
|
||||||
</FadeIn>
|
|
||||||
|
|
||||||
<FadeIn delay={80}>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<h1 className="text-lg font-semibold">Voicebox</h1>
|
|
||||||
<p className="text-xs text-muted-foreground/60 h-4">
|
|
||||||
{version ? `v${version}` : '\u00A0'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</FadeIn>
|
|
||||||
|
|
||||||
<FadeIn delay={160}>
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed max-w-sm">
|
|
||||||
The open-source voice synthesis studio. Clone voices, generate speech, apply effects,
|
|
||||||
and build voice-powered apps — all running locally on your machine.
|
|
||||||
</p>
|
|
||||||
</FadeIn>
|
|
||||||
|
|
||||||
<FadeIn delay={240}>
|
|
||||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
||||||
<span>Created by</span>
|
|
||||||
<a
|
|
||||||
href="https://github.com/jamiepine"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-accent hover:underline"
|
|
||||||
>
|
|
||||||
Jamie Pine
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</FadeIn>
|
|
||||||
|
|
||||||
<FadeIn delay={320}>
|
|
||||||
<div className="flex flex-wrap justify-center gap-3 pt-2">
|
|
||||||
<a
|
|
||||||
href="https://buymeacoffee.com/jamiepine"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="group inline-flex items-center gap-2 rounded-lg border border-border/60 px-4 py-2 text-sm transition-colors hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4 text-[#FFDD00]"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="m20.216 6.415-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 0 0-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 0 0-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 0 1-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 0 1 3.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 0 1-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 0 1-4.743.295 37.059 37.059 0 0 1-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0 0 11.343.376.483.483 0 0 1 .535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 0 1 .39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 0 1-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 0 1-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 0 0-1.322-.238c-.826 0-1.491.284-2.26.613z" />
|
|
||||||
</svg>
|
|
||||||
Buy me a coffee
|
|
||||||
<ArrowUpRight className="h-3.5 w-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://github.com/jamiepine/voicebox"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="group inline-flex items-center gap-2 rounded-lg border border-border/60 px-4 py-2 text-sm transition-colors hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4 text-muted-foreground"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
|
||||||
</svg>
|
|
||||||
GitHub
|
|
||||||
<ArrowUpRight className="h-3.5 w-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</FadeIn>
|
|
||||||
|
|
||||||
<FadeIn delay={400}>
|
|
||||||
<p className="text-xs text-muted-foreground/40 pt-4">
|
|
||||||
Licensed under{' '}
|
|
||||||
<a
|
|
||||||
href="https://github.com/jamiepine/voicebox/blob/main/LICENSE"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:text-muted-foreground/60 transition-colors"
|
|
||||||
>
|
|
||||||
MIT
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</FadeIn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
import changelogRaw from 'virtual:changelog';
|
|
||||||
import { useMemo, useState } from 'react';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { type ChangelogEntry, parseChangelog } from '@/lib/utils/parseChangelog';
|
|
||||||
|
|
||||||
function renderMarkdown(md: string): React.ReactNode[] {
|
|
||||||
const lines = md.split('\n');
|
|
||||||
const elements: React.ReactNode[] = [];
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < lines.length) {
|
|
||||||
const line = lines[i];
|
|
||||||
|
|
||||||
// Skip empty lines
|
|
||||||
if (line.trim() === '') {
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tables — collect all lines starting with |
|
|
||||||
if (line.trim().startsWith('|')) {
|
|
||||||
const tableLines: string[] = [];
|
|
||||||
while (i < lines.length && lines[i].trim().startsWith('|')) {
|
|
||||||
tableLines.push(lines[i]);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
elements.push(renderTable(tableLines, elements.length));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Headings
|
|
||||||
if (line.startsWith('#### ')) {
|
|
||||||
elements.push(
|
|
||||||
<h5 key={elements.length} className="text-sm font-medium mt-5 mb-1">
|
|
||||||
{inlineMarkdown(line.slice(5))}
|
|
||||||
</h5>,
|
|
||||||
);
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (line.startsWith('### ')) {
|
|
||||||
elements.push(
|
|
||||||
<h4 key={elements.length} className="text-sm font-medium mt-6 mb-2">
|
|
||||||
{inlineMarkdown(line.slice(4))}
|
|
||||||
</h4>,
|
|
||||||
);
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// List items — collect consecutive
|
|
||||||
if (line.startsWith('- ')) {
|
|
||||||
const items: string[] = [];
|
|
||||||
while (i < lines.length && lines[i].startsWith('- ')) {
|
|
||||||
items.push(lines[i].slice(2));
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
elements.push(
|
|
||||||
<ul key={elements.length} className="space-y-1 my-2">
|
|
||||||
{items.map((item, idx) => (
|
|
||||||
<li key={idx} className="text-sm text-muted-foreground flex gap-2">
|
|
||||||
<span className="text-muted-foreground/50 select-none shrink-0">•</span>
|
|
||||||
<span>{inlineMarkdown(item)}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paragraph
|
|
||||||
elements.push(
|
|
||||||
<p key={elements.length} className="text-sm text-muted-foreground my-2">
|
|
||||||
{inlineMarkdown(line)}
|
|
||||||
</p>,
|
|
||||||
);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return elements;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTable(tableLines: string[], keyBase: number): React.ReactNode {
|
|
||||||
const parseRow = (line: string) =>
|
|
||||||
line
|
|
||||||
.split('|')
|
|
||||||
.slice(1, -1)
|
|
||||||
.map((c) => c.trim());
|
|
||||||
|
|
||||||
const headers = parseRow(tableLines[0]);
|
|
||||||
// Skip separator line (index 1)
|
|
||||||
const rows = tableLines.slice(2).map(parseRow);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={keyBase} className="overflow-x-auto my-3">
|
|
||||||
<table className="text-sm w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b">
|
|
||||||
{headers.map((h, hIdx) => (
|
|
||||||
<th
|
|
||||||
key={hIdx}
|
|
||||||
className="text-left py-1.5 pr-4 text-muted-foreground font-medium text-xs"
|
|
||||||
>
|
|
||||||
{inlineMarkdown(h)}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((row, rowIdx) => (
|
|
||||||
<tr key={rowIdx} className="border-b border-border/50">
|
|
||||||
{row.map((cell, cellIdx) => (
|
|
||||||
<td key={cellIdx} className="py-1.5 pr-4 text-muted-foreground">
|
|
||||||
{inlineMarkdown(cell)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function inlineMarkdown(text: string): React.ReactNode {
|
|
||||||
// Process inline markdown: bold, code, links
|
|
||||||
const parts: React.ReactNode[] = [];
|
|
||||||
// Regex matches: **bold**, `code`, [text](url)
|
|
||||||
const inlineRe = /\*\*(.+?)\*\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\)/g;
|
|
||||||
let lastIndex = 0;
|
|
||||||
let match: RegExpExecArray | null = inlineRe.exec(text);
|
|
||||||
|
|
||||||
while (match !== null) {
|
|
||||||
if (match.index > lastIndex) {
|
|
||||||
parts.push(text.slice(lastIndex, match.index));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (match[1] !== undefined) {
|
|
||||||
// Bold
|
|
||||||
parts.push(
|
|
||||||
<strong key={parts.length} className="font-medium text-foreground">
|
|
||||||
{match[1]}
|
|
||||||
</strong>,
|
|
||||||
);
|
|
||||||
} else if (match[2] !== undefined) {
|
|
||||||
// Code
|
|
||||||
parts.push(
|
|
||||||
<code key={parts.length} className="px-1 py-0.5 rounded bg-muted text-xs font-mono">
|
|
||||||
{match[2]}
|
|
||||||
</code>,
|
|
||||||
);
|
|
||||||
} else if (match[3] !== undefined && match[4] !== undefined) {
|
|
||||||
// Link
|
|
||||||
parts.push(
|
|
||||||
<a
|
|
||||||
key={parts.length}
|
|
||||||
href={match[4]}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-accent hover:underline"
|
|
||||||
>
|
|
||||||
{match[3]}
|
|
||||||
</a>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
lastIndex = match.index + match[0].length;
|
|
||||||
match = inlineRe.exec(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastIndex < text.length) {
|
|
||||||
parts.push(text.slice(lastIndex));
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.length === 1 ? parts[0] : parts;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const content = useMemo(() => renderMarkdown(entry.body), [entry.body]);
|
|
||||||
const isLong = entry.body.split('\n').length > 12;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border-b border-border/50 pb-6">
|
|
||||||
<div className="flex items-baseline gap-3 mb-1">
|
|
||||||
<h3 className="text-sm font-medium">{entry.version}</h3>
|
|
||||||
{entry.date && <span className="text-xs text-muted-foreground">{entry.date}</span>}
|
|
||||||
{entry.version === 'Unreleased' && <Badge variant="outline">dev</Badge>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={isLong && !expanded ? 'max-h-48 overflow-hidden relative' : ''}>
|
|
||||||
{content}
|
|
||||||
{isLong && !expanded && (
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-background to-transparent" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLong && (
|
|
||||||
<button
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
className="text-xs text-accent hover:underline mt-2"
|
|
||||||
>
|
|
||||||
{expanded ? 'Show less' : 'Show more'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChangelogPage() {
|
|
||||||
const entries = useMemo(() => parseChangelog(changelogRaw), []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 max-w-2xl">
|
|
||||||
{entries.map((entry) => (
|
|
||||||
<ChangelogEntryCard key={entry.version} entry={entry} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { AlertCircle, ArrowUpRight, Book, Download, Loader2, RefreshCw } from 'lucide-react';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import * as z from 'zod';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import { Toggle } from '@/components/ui/toggle';
|
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
|
||||||
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
|
||||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
|
||||||
import { SettingRow, SettingSection } from './SettingRow';
|
|
||||||
|
|
||||||
const connectionSchema = z.object({
|
|
||||||
serverUrl: z.string().url('Please enter a valid URL'),
|
|
||||||
});
|
|
||||||
|
|
||||||
type ConnectionFormValues = z.infer<typeof connectionSchema>;
|
|
||||||
|
|
||||||
export function GeneralPage() {
|
|
||||||
const platform = usePlatform();
|
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
|
||||||
const setServerUrl = useServerStore((state) => state.setServerUrl);
|
|
||||||
const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose);
|
|
||||||
const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose);
|
|
||||||
const mode = useServerStore((state) => state.mode);
|
|
||||||
const setMode = useServerStore((state) => state.setMode);
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { data: health, isLoading, error: healthError } = useServerHealth();
|
|
||||||
|
|
||||||
const form = useForm<ConnectionFormValues>({
|
|
||||||
resolver: zodResolver(connectionSchema),
|
|
||||||
defaultValues: { serverUrl },
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
form.reset({ serverUrl });
|
|
||||||
}, [serverUrl, form]);
|
|
||||||
|
|
||||||
const { isDirty } = form.formState;
|
|
||||||
|
|
||||||
function onSubmit(data: ConnectionFormValues) {
|
|
||||||
setServerUrl(data.serverUrl);
|
|
||||||
form.reset(data);
|
|
||||||
toast({
|
|
||||||
title: 'Server URL updated',
|
|
||||||
description: `Connected to ${data.serverUrl}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8 max-w-2xl">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<a
|
|
||||||
href="https://docs.voicebox.sh"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="group flex items-center gap-3 rounded-lg border border-border/60 p-4 transition-colors hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<Book className="h-5 w-5 shrink-0 text-accent" strokeWidth={2.5} />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-medium">Read the Docs</div>
|
|
||||||
<div className="text-xs text-muted-foreground">docs.voicebox.sh</div>
|
|
||||||
</div>
|
|
||||||
<ArrowUpRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://discord.gg/StkzQasqPS"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="group flex items-center gap-3 rounded-lg border border-border/60 p-4 transition-colors hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="h-5 w-5 shrink-0 text-accent"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
|
||||||
</svg>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-medium">Join the Discord</div>
|
|
||||||
<div className="text-xs text-muted-foreground">Get help & share voices</div>
|
|
||||||
</div>
|
|
||||||
<ArrowUpRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SettingSection>
|
|
||||||
<SettingRow
|
|
||||||
title="Server URL"
|
|
||||||
description="The address of your voicebox backend server."
|
|
||||||
action={
|
|
||||||
<ConnectionStatus health={health} isLoading={isLoading} healthError={healthError} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex gap-2">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="serverUrl"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="http://127.0.0.1:17493" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{isDirty && (
|
|
||||||
<Button type="submit" size="sm">
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Keep server running when app closes"
|
|
||||||
description="The server will continue running in the background after closing the app."
|
|
||||||
htmlFor="keepServerRunning"
|
|
||||||
action={
|
|
||||||
<Toggle
|
|
||||||
id="keepServerRunning"
|
|
||||||
checked={keepServerRunningOnClose}
|
|
||||||
onCheckedChange={(checked: boolean) => {
|
|
||||||
setKeepServerRunningOnClose(checked);
|
|
||||||
platform.lifecycle.setKeepServerRunning(checked).catch((error) => {
|
|
||||||
console.error('Failed to sync setting to Rust:', error);
|
|
||||||
setKeepServerRunningOnClose(!checked);
|
|
||||||
toast({
|
|
||||||
title: 'Failed to update setting',
|
|
||||||
description: 'Could not sync setting to backend.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
});
|
|
||||||
toast({
|
|
||||||
title: 'Setting updated',
|
|
||||||
description: checked
|
|
||||||
? 'Server will continue running when app closes'
|
|
||||||
: 'Server will stop when app closes',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{platform.metadata.isTauri && (
|
|
||||||
<SettingRow
|
|
||||||
title="Allow network access"
|
|
||||||
description="Makes the server accessible from other devices on your network. Restart the app after changing."
|
|
||||||
htmlFor="allowNetworkAccess"
|
|
||||||
action={
|
|
||||||
<Toggle
|
|
||||||
id="allowNetworkAccess"
|
|
||||||
checked={mode === 'remote'}
|
|
||||||
onCheckedChange={(checked: boolean) => {
|
|
||||||
setMode(checked ? 'remote' : 'local');
|
|
||||||
toast({
|
|
||||||
title: 'Setting updated',
|
|
||||||
description: checked
|
|
||||||
? 'Network access enabled. Restart the app to apply.'
|
|
||||||
: 'Network access disabled. Restart the app to apply.',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SettingSection>
|
|
||||||
|
|
||||||
<ApiReferenceCard serverUrl={serverUrl} />
|
|
||||||
|
|
||||||
{platform.metadata.isTauri && <UpdatesSection />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConnectionStatus({
|
|
||||||
health,
|
|
||||||
isLoading,
|
|
||||||
healthError,
|
|
||||||
}: {
|
|
||||||
health: ReturnType<typeof useServerHealth>['data'];
|
|
||||||
isLoading: boolean;
|
|
||||||
healthError: ReturnType<typeof useServerHealth>['error'];
|
|
||||||
}) {
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 rounded-full border border-border/60 px-3 py-1">
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
|
||||||
<span className="text-xs text-muted-foreground">Connecting</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (healthError) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 rounded-full border border-destructive/30 px-3 py-1">
|
|
||||||
<span className="relative flex h-2 w-2">
|
|
||||||
<span className="absolute inline-flex h-full w-full rounded-full bg-destructive/40" />
|
|
||||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-destructive" />
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-destructive">Offline</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (health) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 rounded-full border border-accent/30 px-3 py-1">
|
|
||||||
<span className="relative flex h-2 w-2">
|
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent/60" />
|
|
||||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-accent shadow-[0_0_6px_1px_hsl(var(--accent)/0.5)]" />
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">Online</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function UpdatesSection() {
|
|
||||||
const platform = usePlatform();
|
|
||||||
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
|
|
||||||
const [currentVersion, setCurrentVersion] = useState<string>('');
|
|
||||||
const isDev = !import.meta.env?.PROD;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
platform.metadata
|
|
||||||
.getVersion()
|
|
||||||
.then(setCurrentVersion)
|
|
||||||
.catch(() => setCurrentVersion('Unknown'));
|
|
||||||
}, [platform]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingSection title="App Updates" description={`v${currentVersion}${isDev ? ' (dev)' : ''}`}>
|
|
||||||
{isDev ? (
|
|
||||||
<SettingRow
|
|
||||||
title="Development mode"
|
|
||||||
description="Auto-updates are disabled in development mode."
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<SettingRow
|
|
||||||
title="Check for updates"
|
|
||||||
description={
|
|
||||||
status.available
|
|
||||||
? `Version ${status.version} available`
|
|
||||||
: status.checking
|
|
||||||
? 'Checking...'
|
|
||||||
: "You're up to date"
|
|
||||||
}
|
|
||||||
action={
|
|
||||||
<Button
|
|
||||||
onClick={checkForUpdates}
|
|
||||||
disabled={status.checking || status.downloading || status.readyToInstall}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`h-3.5 w-3.5 mr-1.5 ${status.checking ? 'animate-spin' : ''}`}
|
|
||||||
/>
|
|
||||||
Check
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{status.error && (
|
|
||||||
<SettingRow title="Update error">
|
|
||||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
{status.error}
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status.available && !status.downloading && !status.readyToInstall && (
|
|
||||||
<SettingRow
|
|
||||||
title={`Update to ${status.version}`}
|
|
||||||
description="Download and install the latest version."
|
|
||||||
action={
|
|
||||||
<Button onClick={downloadAndInstall} size="sm">
|
|
||||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status.downloading && (
|
|
||||||
<SettingRow title="Downloading update...">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Progress value={status.downloadProgress} />
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
||||||
{status.downloadedBytes !== undefined &&
|
|
||||||
status.totalBytes !== undefined &&
|
|
||||||
status.totalBytes > 0 ? (
|
|
||||||
<span>
|
|
||||||
{(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB /{' '}
|
|
||||||
{(status.totalBytes / 1024 / 1024).toFixed(1)} MB
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
{status.downloadProgress !== undefined && <span>{status.downloadProgress}%</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status.readyToInstall && (
|
|
||||||
<SettingRow
|
|
||||||
title="Update ready to install"
|
|
||||||
description={`Version ${status.version} has been downloaded. Restart to complete.`}
|
|
||||||
action={
|
|
||||||
<Button onClick={restartAndInstall} size="sm">
|
|
||||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
Restart Now
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SettingSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const API_ENDPOINTS = [
|
|
||||||
{ method: 'POST', path: '/generate', label: 'Generate speech' },
|
|
||||||
{ method: 'GET', path: '/health', label: 'Server status' },
|
|
||||||
{ method: 'GET', path: '/profiles', label: 'List voices' },
|
|
||||||
{ method: 'GET', path: '/history', label: 'Past generations' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function ApiReferenceCard({ serverUrl }: { serverUrl: string }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-border/60 p-4 space-y-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium">API Access</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Integrate Voicebox into your workflow via the REST API at{' '}
|
|
||||||
<code className="text-xs bg-muted px-1 py-0.5 rounded font-mono">{serverUrl}</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{API_ENDPOINTS.map((ep) => (
|
|
||||||
<div key={ep.path} className="flex items-center gap-2.5 py-1">
|
|
||||||
<span
|
|
||||||
className={`text-[10px] font-mono font-semibold w-9 text-center rounded px-1 py-px ${
|
|
||||||
ep.method === 'POST' ? 'bg-accent/10 text-accent' : 'bg-muted text-muted-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{ep.method}
|
|
||||||
</span>
|
|
||||||
<code className="text-xs font-mono text-muted-foreground">{ep.path}</code>
|
|
||||||
<span className="text-xs text-muted-foreground/50 ml-auto">{ep.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
<a
|
|
||||||
href={`${serverUrl}/docs`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-accent hover:underline"
|
|
||||||
>
|
|
||||||
View the full API reference
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import { FolderOpen } from 'lucide-react';
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Slider } from '@/components/ui/slider';
|
|
||||||
import { Toggle } from '@/components/ui/toggle';
|
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
|
||||||
import { SettingRow, SettingSection } from './SettingRow';
|
|
||||||
|
|
||||||
export function GenerationPage() {
|
|
||||||
const platform = usePlatform();
|
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
|
||||||
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);
|
|
||||||
const [opening, setOpening] = useState(false);
|
|
||||||
const [generationsPath, setGenerationsPath] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch(`${serverUrl}/health/filesystem`)
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => {
|
|
||||||
const genDir = data.directories?.find((d: { path: string }) =>
|
|
||||||
d.path.includes('generations'),
|
|
||||||
);
|
|
||||||
if (genDir?.path) setGenerationsPath(genDir.path);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, [serverUrl]);
|
|
||||||
|
|
||||||
const openGenerationsFolder = useCallback(async () => {
|
|
||||||
if (!generationsPath) return;
|
|
||||||
setOpening(true);
|
|
||||||
try {
|
|
||||||
await platform.filesystem.openPath(generationsPath);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to open generations folder:', e);
|
|
||||||
} finally {
|
|
||||||
setOpening(false);
|
|
||||||
}
|
|
||||||
}, [platform, generationsPath]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8 max-w-2xl">
|
|
||||||
<SettingSection
|
|
||||||
title="Generation"
|
|
||||||
description="Controls for long text generation. These settings apply to all engines."
|
|
||||||
>
|
|
||||||
<SettingRow
|
|
||||||
title="Auto-chunking limit"
|
|
||||||
description="Long text is split into chunks at sentence boundaries. Lower values can improve quality for long outputs."
|
|
||||||
action={
|
|
||||||
<span className="text-sm tabular-nums text-muted-foreground">
|
|
||||||
{maxChunkChars} chars
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Slider
|
|
||||||
id="maxChunkChars"
|
|
||||||
value={[maxChunkChars]}
|
|
||||||
onValueChange={([value]) => setMaxChunkChars(value)}
|
|
||||||
min={100}
|
|
||||||
max={5000}
|
|
||||||
step={50}
|
|
||||||
aria-label="Auto-chunking character limit"
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Chunk crossfade"
|
|
||||||
description="Blends audio between chunks to smooth transitions. Set to 0 for a hard cut."
|
|
||||||
action={
|
|
||||||
<span className="text-sm tabular-nums text-muted-foreground">
|
|
||||||
{crossfadeMs === 0 ? 'Cut' : `${crossfadeMs}ms`}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Slider
|
|
||||||
id="crossfadeMs"
|
|
||||||
value={[crossfadeMs]}
|
|
||||||
onValueChange={([value]) => setCrossfadeMs(value)}
|
|
||||||
min={0}
|
|
||||||
max={200}
|
|
||||||
step={10}
|
|
||||||
aria-label="Chunk crossfade duration"
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Normalize audio"
|
|
||||||
description="Adjusts output volume to a consistent level across generations."
|
|
||||||
htmlFor="normalizeAudio"
|
|
||||||
action={
|
|
||||||
<Toggle
|
|
||||||
id="normalizeAudio"
|
|
||||||
checked={normalizeAudio}
|
|
||||||
onCheckedChange={setNormalizeAudio}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Autoplay on generate"
|
|
||||||
description="Automatically play audio when a generation completes."
|
|
||||||
htmlFor="autoplayOnGenerate"
|
|
||||||
action={
|
|
||||||
<Toggle
|
|
||||||
id="autoplayOnGenerate"
|
|
||||||
checked={autoplayOnGenerate}
|
|
||||||
onCheckedChange={setAutoplayOnGenerate}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Generations folder"
|
|
||||||
description={generationsPath ?? 'Where generated audio files are stored on disk.'}
|
|
||||||
action={
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={openGenerationsFolder}
|
|
||||||
disabled={opening || !generationsPath}
|
|
||||||
>
|
|
||||||
<FolderOpen className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
Open
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingSection>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,405 +0,0 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { AlertCircle, Cpu, Download, Loader2, RotateCw, Trash2 } from 'lucide-react';
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import { apiClient } from '@/lib/api/client';
|
|
||||||
import type { CudaDownloadProgress, HealthResponse } from '@/lib/api/types';
|
|
||||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
|
||||||
import { SettingRow, SettingSection } from './SettingRow';
|
|
||||||
|
|
||||||
type RestartPhase = 'idle' | 'stopping' | 'waiting' | 'ready';
|
|
||||||
|
|
||||||
function AppleLogo({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
||||||
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GpuIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
className={className}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<rect x="4" y="6" width="16" height="12" rx="2" />
|
|
||||||
<path d="M2 10h2M2 14h2M20 10h2M20 14h2" />
|
|
||||||
<path d="M9 10h6M9 14h4" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GpuInfoCard({ health }: { health: HealthResponse }) {
|
|
||||||
const hasGpu = health.gpu_available && health.gpu_type;
|
|
||||||
|
|
||||||
// Parse GPU name from type string like "CUDA (NVIDIA RTX 4090)" or "MPS (Apple M2 Pro)"
|
|
||||||
const gpuName = hasGpu
|
|
||||||
? health.gpu_type!.replace(/^(CUDA|ROCm|MPS|Metal|XPU|DirectML)\s*\((.+)\)$/, '$2') ||
|
|
||||||
health.gpu_type!
|
|
||||||
: null;
|
|
||||||
const gpuBackend = hasGpu ? health.gpu_type!.replace(/\s*\(.+\)$/, '') : null;
|
|
||||||
const isApple = gpuBackend === 'MPS' || gpuBackend === 'Metal';
|
|
||||||
const showBackendVariant = health.backend_variant && health.backend_variant !== 'cpu';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-border/60 p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{hasGpu ? (
|
|
||||||
isApple ? (
|
|
||||||
<AppleLogo className="h-5 w-5 shrink-0 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<GpuIcon className="h-5 w-5 shrink-0 text-accent" />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<Cpu className="h-5 w-5 shrink-0 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0 space-y-0.5">
|
|
||||||
<div className="text-sm font-medium">{hasGpu ? gpuName : 'CPU Only'}</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
|
||||||
{hasGpu ? (
|
|
||||||
<>
|
|
||||||
<span>{gpuBackend}</span>
|
|
||||||
{showBackendVariant && (
|
|
||||||
<>
|
|
||||||
<span className="text-border">|</span>
|
|
||||||
<span className="uppercase">{health.backend_variant}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{health.vram_used_mb != null && health.vram_used_mb > 0 && (
|
|
||||||
<>
|
|
||||||
<span className="text-border">|</span>
|
|
||||||
<span>{health.vram_used_mb.toFixed(0)} MB VRAM</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span>No GPU acceleration detected</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{hasGpu && (
|
|
||||||
<div className="flex items-center gap-2 rounded-full border border-accent/30 px-2.5 py-0.5">
|
|
||||||
<span className="relative flex h-1.5 w-1.5">
|
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent/60" />
|
|
||||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-accent shadow-[0_0_4px_1px_hsl(var(--accent)/0.4)]" />
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] font-medium text-muted-foreground">Active</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GpuPage() {
|
|
||||||
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);
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isCurrentlyCuda = health?.backend_variant === 'cuda';
|
|
||||||
const cudaAvailable = cudaStatus?.available ?? false;
|
|
||||||
const cudaDownloading = cudaStatus?.downloading ?? false;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (healthPollRef.current) {
|
|
||||||
clearInterval(healthPollRef.current);
|
|
||||||
healthPollRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const clearHealthPolling = useCallback(() => {
|
|
||||||
if (healthPollRef.current) {
|
|
||||||
clearInterval(healthPollRef.current);
|
|
||||||
healthPollRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startHealthPolling = useCallback(() => {
|
|
||||||
clearHealthPolling();
|
|
||||||
|
|
||||||
healthPollRef.current = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const result = await apiClient.getHealth();
|
|
||||||
if (result.status === 'healthy') {
|
|
||||||
clearHealthPolling();
|
|
||||||
setRestartPhase('ready');
|
|
||||||
queryClient.invalidateQueries();
|
|
||||||
setTimeout(() => setRestartPhase('idle'), 2000);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Server still down, keep polling
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}, [queryClient, clearHealthPolling]);
|
|
||||||
|
|
||||||
const restartServerWithPolling = useCallback(
|
|
||||||
async (errorMessage: string) => {
|
|
||||||
setRestartPhase('stopping');
|
|
||||||
try {
|
|
||||||
await platform.lifecycle.restartServer();
|
|
||||||
setRestartPhase('waiting');
|
|
||||||
startHealthPolling();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
clearHealthPolling();
|
|
||||||
setRestartPhase('idle');
|
|
||||||
throw new Error(e instanceof Error ? e.message : errorMessage);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[platform, startHealthPolling, clearHealthPolling],
|
|
||||||
);
|
|
||||||
|
|
||||||
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);
|
|
||||||
try {
|
|
||||||
await restartServerWithPolling('Restart failed');
|
|
||||||
} catch (e: unknown) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Restart failed');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSwitchToCpu = async () => {
|
|
||||||
setError(null);
|
|
||||||
setRestartPhase('stopping');
|
|
||||||
try {
|
|
||||||
await apiClient.deleteCudaBackend();
|
|
||||||
await restartServerWithPolling('Failed to switch to CPU');
|
|
||||||
} catch (e: unknown) {
|
|
||||||
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]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!health) return null;
|
|
||||||
|
|
||||||
const hasNativeGpu =
|
|
||||||
health.gpu_available &&
|
|
||||||
!isCurrentlyCuda &&
|
|
||||||
health.gpu_type &&
|
|
||||||
!health.gpu_type.includes('CUDA');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8 max-w-2xl">
|
|
||||||
<GpuInfoCard health={health} />
|
|
||||||
|
|
||||||
{/* CUDA section — only when no native GPU and not already on CUDA */}
|
|
||||||
{!hasNativeGpu && !isCurrentlyCuda && (
|
|
||||||
<SettingSection
|
|
||||||
title="CUDA Backend"
|
|
||||||
description="NVIDIA GPU acceleration via a downloadable CUDA backend."
|
|
||||||
>
|
|
||||||
{/* Download progress */}
|
|
||||||
{cudaDownloading && downloadProgress && (
|
|
||||||
<SettingRow title="Downloading CUDA backend...">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Progress value={downloadProgress.progress} className="h-2" />
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
{downloadProgress.filename ||
|
|
||||||
(cudaAvailable ? 'Updating...' : 'Downloading...')}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{downloadProgress.total > 0
|
|
||||||
? `${formatBytes(downloadProgress.current)} / ${formatBytes(downloadProgress.total)}`
|
|
||||||
: `${downloadProgress.progress.toFixed(1)}%`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Restart in progress */}
|
|
||||||
{restartPhase !== 'idle' && (
|
|
||||||
<SettingRow
|
|
||||||
title={
|
|
||||||
restartPhase === 'ready'
|
|
||||||
? 'Server restarted successfully'
|
|
||||||
: restartPhase === 'waiting'
|
|
||||||
? 'Restarting server...'
|
|
||||||
: 'Stopping server...'
|
|
||||||
}
|
|
||||||
action={<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
|
||||||
<SettingRow title="Error">
|
|
||||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
|
||||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
||||||
<span>{error}</span>
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
{restartPhase === 'idle' && !cudaDownloading && (
|
|
||||||
<>
|
|
||||||
{!cudaAvailable && !isCurrentlyCuda && (
|
|
||||||
<SettingRow
|
|
||||||
title="Download CUDA backend"
|
|
||||||
description="~2.4 GB download. Requires an NVIDIA GPU with CUDA support."
|
|
||||||
action={
|
|
||||||
<Button onClick={handleDownload} size="sm">
|
|
||||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && (
|
|
||||||
<SettingRow
|
|
||||||
title="Switch to CUDA backend"
|
|
||||||
description="CUDA backend is downloaded and ready. Restart to enable."
|
|
||||||
action={
|
|
||||||
<Button onClick={handleRestart} size="sm">
|
|
||||||
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
Restart
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isCurrentlyCuda && platform.metadata.isTauri && (
|
|
||||||
<SettingRow
|
|
||||||
title="Switch to CPU backend"
|
|
||||||
description="Disable GPU acceleration. You can re-download CUDA later."
|
|
||||||
action={
|
|
||||||
<Button onClick={handleSwitchToCpu} variant="outline" size="sm">
|
|
||||||
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
Switch
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{cudaAvailable && !isCurrentlyCuda && (
|
|
||||||
<SettingRow
|
|
||||||
title="Remove CUDA backend"
|
|
||||||
description="Delete the downloaded CUDA binary to free disk space."
|
|
||||||
action={
|
|
||||||
<Button
|
|
||||||
onClick={handleDelete}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-muted-foreground hover:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SettingSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground/60 leading-relaxed">
|
|
||||||
Voicebox automatically detects and uses the best available GPU on your system. On Apple
|
|
||||||
Silicon Macs, the MLX backend runs natively on the Neural Engine and GPU via Metal
|
|
||||||
Performance Shaders (MPS), with no additional setup required. On Windows and Linux with
|
|
||||||
NVIDIA GPUs, you can download an optional CUDA backend for hardware-accelerated inference.
|
|
||||||
AMD ROCm, Intel XPU, and DirectML are also supported where available through PyTorch. When
|
|
||||||
no GPU is detected, Voicebox falls back to CPU — all engines still work, just slower.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
|
||||||
import { type LogEntry, useLogStore } from '@/stores/logStore';
|
|
||||||
|
|
||||||
function formatTime(timestamp: number): string {
|
|
||||||
const d = new Date(timestamp);
|
|
||||||
return d.toLocaleTimeString(undefined, {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
hour12: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function LogLine({ entry }: { entry: LogEntry }) {
|
|
||||||
return (
|
|
||||||
<div className="flex gap-3 font-mono text-xs leading-5 hover:bg-muted/30">
|
|
||||||
<span className="text-muted-foreground/50 select-none shrink-0">
|
|
||||||
{formatTime(entry.timestamp)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'whitespace-pre-wrap break-all',
|
|
||||||
entry.stream === 'stderr' ? 'text-orange-400/80' : 'text-muted-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{entry.line}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LogsPage() {
|
|
||||||
const entries = useLogStore((s) => s.entries);
|
|
||||||
const clear = useLogStore((s) => s.clear);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [autoScroll, setAutoScroll] = useState(true);
|
|
||||||
|
|
||||||
// Auto-scroll to bottom when new entries arrive
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoScroll && containerRef.current) {
|
|
||||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [entries.length, autoScroll]);
|
|
||||||
|
|
||||||
// Detect manual scroll to disable auto-scroll
|
|
||||||
const handleScroll = () => {
|
|
||||||
const el = containerRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
|
||||||
setAutoScroll(atBottom);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full min-h-0">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium">Server Logs</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{entries.length} {entries.length === 1 ? 'line' : 'lines'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{!autoScroll && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setAutoScroll(true);
|
|
||||||
containerRef.current?.scrollTo({ top: containerRef.current.scrollHeight });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Scroll to bottom
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant="outline" size="sm" onClick={clear}>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
className="flex-1 min-h-0 overflow-y-auto rounded-md border bg-black/20 p-3"
|
|
||||||
>
|
|
||||||
{entries.length === 0 ? (
|
|
||||||
<div className="text-sm text-muted-foreground/50 font-mono space-y-1">
|
|
||||||
<p>No log output yet.</p>
|
|
||||||
{!import.meta.env?.PROD && (
|
|
||||||
<p>
|
|
||||||
Server logs are only captured when the app manages the server process (production
|
|
||||||
builds).
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
entries.map((entry) => <LogLine key={entry.id} entry={entry} />)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,70 +1,31 @@
|
|||||||
import { Link, Outlet, useMatchRoute } from '@tanstack/react-router';
|
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
||||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { DataFolders } from '@/components/ServerSettings/DataFolders';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { ProviderSettings } from '@/components/ServerSettings/ProviderSettings';
|
||||||
|
import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
|
||||||
|
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
|
||||||
|
|
||||||
interface SettingsTab {
|
export function ServerTab() {
|
||||||
label: string;
|
|
||||||
path:
|
|
||||||
| '/settings'
|
|
||||||
| '/settings/generation'
|
|
||||||
| '/settings/gpu'
|
|
||||||
| '/settings/logs'
|
|
||||||
| '/settings/changelog'
|
|
||||||
| '/settings/about';
|
|
||||||
tauriOnly?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabs: SettingsTab[] = [
|
|
||||||
{ label: 'General', path: '/settings' },
|
|
||||||
{ label: 'Generation', path: '/settings/generation' },
|
|
||||||
{ label: 'GPU', path: '/settings/gpu', tauriOnly: true },
|
|
||||||
{ label: 'Logs', path: '/settings/logs', tauriOnly: true },
|
|
||||||
{ label: 'Changelog', path: '/settings/changelog' },
|
|
||||||
{ label: 'About', path: '/settings/about' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function SettingsLayout() {
|
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
const isPlayerVisible = !!usePlayerStore((state) => state.audioUrl);
|
|
||||||
const matchRoute = useMatchRoute();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="space-y-4 overflow-y-auto flex flex-col">
|
||||||
<nav className="flex gap-1 border-b shrink-0">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{tabs.map((tab) => {
|
<ConnectionForm />
|
||||||
if (tab.tauriOnly && !platform.metadata.isTauri) return null;
|
<ServerStatus />
|
||||||
|
</div>
|
||||||
const isActive =
|
<ProviderSettings />
|
||||||
tab.path === '/settings'
|
<DataFolders />
|
||||||
? matchRoute({ to: tab.path, fuzzy: false })
|
{platform.metadata.isTauri && <UpdateStatus />}
|
||||||
: matchRoute({ to: tab.path });
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Created by{' '}
|
||||||
return (
|
<a
|
||||||
<Link
|
href="https://github.com/jamiepine"
|
||||||
key={tab.path}
|
target="_blank"
|
||||||
to={tab.path}
|
rel="noopener noreferrer"
|
||||||
className={cn(
|
className="text-accent hover:underline"
|
||||||
'px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px',
|
>
|
||||||
isActive
|
Jamie Pine
|
||||||
? 'border-accent text-foreground'
|
</a>
|
||||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex-1 overflow-y-auto pt-6 pb-6 px-2 -mx-2',
|
|
||||||
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A section header with title and optional description, separated by a border.
|
|
||||||
*/
|
|
||||||
export function SettingSection({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{title && <h3 className="text-sm font-medium">{title}</h3>}
|
|
||||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
|
||||||
<div className={`${title || description ? 'pt-3' : ''} space-y-0 divide-y divide-border/60`}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single settings row: label+description on the left, action on the right.
|
|
||||||
* Use for toggles, inputs, buttons, badges — any control type.
|
|
||||||
*/
|
|
||||||
export function SettingRow({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
htmlFor,
|
|
||||||
action,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
htmlFor?: string;
|
|
||||||
/** Right-aligned control (checkbox, button, badge, etc.) */
|
|
||||||
action?: ReactNode;
|
|
||||||
/** Full-width content rendered below the label row (for sliders, inputs, etc.) */
|
|
||||||
children?: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="py-3">
|
|
||||||
<div className="flex items-center justify-between gap-8">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<label
|
|
||||||
htmlFor={htmlFor}
|
|
||||||
className={`text-sm font-medium leading-none select-none ${htmlFor ? 'cursor-pointer' : ''}`}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</label>
|
|
||||||
{description && <p className="text-sm text-muted-foreground mt-0.5">{description}</p>}
|
|
||||||
</div>
|
|
||||||
{action && <div className="shrink-0">{action}</div>}
|
|
||||||
</div>
|
|
||||||
{children && <div className="mt-3">{children}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,34 +1,37 @@
|
|||||||
|
import {
|
||||||
|
Book01Icon,
|
||||||
|
Mic01Icon,
|
||||||
|
PackageIcon,
|
||||||
|
ServerStack01Icon,
|
||||||
|
SpeakerIcon,
|
||||||
|
VolumeHighIcon,
|
||||||
|
} from '@hugeicons/core-free-icons';
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
import { Link, useMatchRoute } from '@tanstack/react-router';
|
import { Link, useMatchRoute } from '@tanstack/react-router';
|
||||||
import { AudioLines, Box, Mic, Settings, Speaker, Volume2, Wand2 } from 'lucide-react';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import type { UpdateStatus } from '@/platform/types';
|
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
import { version } from '../../package.json';
|
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
isMacOS?: boolean;
|
isMacOS?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'main', path: '/', icon: Volume2, label: 'Generate' },
|
{ id: 'main', path: '/', icon: VolumeHighIcon, label: 'Generate' },
|
||||||
{ id: 'stories', path: '/stories', icon: AudioLines, label: 'Stories' },
|
{ id: 'stories', path: '/stories', icon: Book01Icon, label: 'Stories' },
|
||||||
{ id: 'voices', path: '/voices', icon: Mic, label: 'Voices' },
|
{ id: 'voices', path: '/voices', icon: Mic01Icon, label: 'Voices' },
|
||||||
{ id: 'effects', path: '/effects', icon: Wand2, label: 'Effects' },
|
{ id: 'audio', path: '/audio', icon: SpeakerIcon, label: 'Audio' },
|
||||||
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
|
{ id: 'models', path: '/models', icon: PackageIcon, label: 'Models' },
|
||||||
{ id: 'models', path: '/models', icon: Box, label: 'Models' },
|
{ id: 'server', path: '/server', icon: ServerStack01Icon, label: 'Server' },
|
||||||
{ id: 'settings', path: '/settings', icon: Settings, label: 'Settings' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar({ isMacOS }: SidebarProps) {
|
export function Sidebar({ isMacOS }: SidebarProps) {
|
||||||
|
const isGenerating = useGenerationStore((state) => state.isGenerating);
|
||||||
|
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||||
|
const isPlayerVisible = !!audioUrl;
|
||||||
const matchRoute = useMatchRoute();
|
const matchRoute = useMatchRoute();
|
||||||
const isPlayerOpen = !!usePlayerStore((s) => s.audioUrl);
|
|
||||||
const platform = usePlatform();
|
|
||||||
|
|
||||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>(platform.updater.getStatus());
|
|
||||||
useEffect(() => platform.updater.subscribe(setUpdateStatus), [platform.updater]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -39,73 +42,49 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
|||||||
>
|
>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<img
|
<img src={voiceboxLogo} alt="Voicebox" className="w-12 h-12 object-contain" />
|
||||||
src={voiceboxLogo}
|
|
||||||
alt="Voicebox"
|
|
||||||
className="w-12 h-12 object-contain"
|
|
||||||
style={{
|
|
||||||
filter:
|
|
||||||
'drop-shadow(0 0 6px hsl(var(--accent) / 0.5)) drop-shadow(0 0 14px hsl(var(--accent) / 0.35)) drop-shadow(0 0 28px hsl(var(--accent) / 0.2))',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation Buttons */}
|
{/* Navigation Buttons */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{tabs.map((tab, index) => {
|
{tabs.map((tab) => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
|
// For index route, use exact match; for others, use default matching
|
||||||
const isActive =
|
const isActive =
|
||||||
tab.path === '/'
|
tab.path === '/' ? matchRoute({ to: '/' }) : matchRoute({ to: tab.path });
|
||||||
? matchRoute({ to: '/', fuzzy: false })
|
|
||||||
: matchRoute({ to: tab.path, fuzzy: true });
|
|
||||||
|
|
||||||
// Accent fades as buttons get further from the logo
|
|
||||||
const accentOpacity = Math.max(0.08, 0.5 - index * 0.07);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
to={tab.path}
|
to={tab.path}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200 overflow-hidden',
|
'w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200',
|
||||||
isActive
|
'hover:bg-muted/50',
|
||||||
? 'bg-white/[0.07] text-foreground shadow-lg backdrop-blur-sm border border-white/[0.08]'
|
isActive ? 'bg-muted/50 text-foreground shadow-lg' : 'text-muted-foreground',
|
||||||
: 'text-muted-foreground hover:bg-muted/50',
|
|
||||||
)}
|
)}
|
||||||
title={tab.label}
|
title={tab.label}
|
||||||
aria-label={tab.label}
|
aria-label={tab.label}
|
||||||
>
|
>
|
||||||
{isActive && (
|
<HugeiconsIcon icon={Icon} size={20} className="h-5 w-5" />
|
||||||
<div
|
|
||||||
className="absolute inset-0 rounded-full pointer-events-none"
|
|
||||||
style={{
|
|
||||||
maskImage: 'linear-gradient(to bottom, black, transparent 60%)',
|
|
||||||
WebkitMaskImage: 'linear-gradient(to bottom, black, transparent 60%)',
|
|
||||||
border: `1px solid hsl(var(--accent) / ${accentOpacity})`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Icon className="h-5 w-5 relative z-10" />
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Version */}
|
{/* Spacer to push loader to bottom */}
|
||||||
<div
|
<div className="flex-1" />
|
||||||
className="mt-auto flex flex-col items-center gap-1.5 transition-all duration-300"
|
|
||||||
style={{ paddingBottom: isPlayerOpen ? '7rem' : undefined }}
|
{/* Generation Loader */}
|
||||||
>
|
{isGenerating && (
|
||||||
<span className="text-[10px] text-muted-foreground/50">v{version}</span>
|
<div
|
||||||
{updateStatus.available && (
|
className={cn(
|
||||||
<Link
|
'w-full flex items-center justify-center transition-all duration-200',
|
||||||
to="/settings"
|
isPlayerVisible ? 'mb-[120px]' : 'mb-0',
|
||||||
className="text-[9px] font-semibold tracking-wide uppercase px-2 py-0.5 rounded-full bg-accent/15 text-accent hover:bg-accent/25 transition-colors"
|
)}
|
||||||
>
|
>
|
||||||
Update
|
<Icon icon="svg-spinners:ring-resize" className="h-6 w-6 text-accent animate-spin" />
|
||||||
</Link>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
|
||||||
import { StoryContent } from './StoryContent';
|
import { StoryContent } from './StoryContent';
|
||||||
import { StoryList } from './StoryList';
|
import { StoryList } from './StoryList';
|
||||||
|
|
||||||
export function StoriesTab() {
|
export function StoriesTab() {
|
||||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
@@ -21,7 +18,7 @@ export function StoriesTab() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating Generate Box - position is managed via storyStore.trackEditorHeight */}
|
{/* Floating Generate Box - position is managed via storyStore.trackEditorHeight */}
|
||||||
<FloatingGenerateBox showVoiceSelector isPlayerOpen={!!audioUrl} />
|
<FloatingGenerateBox showVoiceSelector />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { GripVertical, Mic, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
import { useState } from 'react';
|
import { DragDropVerticalIcon, MoreHorizontalIcon, PlayIcon, Delete01Icon } from '@hugeicons/core-free-icons';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -10,10 +10,10 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { ProfileAvatar } from '@/components/VoiceProfiles/ProfileAvatar';
|
||||||
import type { StoryItemDetail } from '@/lib/api/types';
|
import type { StoryItemDetail } from '@/lib/api/types';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
|
||||||
|
|
||||||
interface StoryChatItemProps {
|
interface StoryChatItemProps {
|
||||||
item: StoryItemDetail;
|
item: StoryItemDetail;
|
||||||
@@ -35,10 +35,6 @@ export function StoryChatItem({
|
|||||||
isDragging,
|
isDragging,
|
||||||
}: StoryChatItemProps) {
|
}: StoryChatItemProps) {
|
||||||
const seek = useStoryStore((state) => state.seek);
|
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
|
// Check if this item is currently playing based on timecode
|
||||||
const itemStartMs = item.start_time_ms;
|
const itemStartMs = item.start_time_ms;
|
||||||
@@ -74,27 +70,18 @@ export function StoryChatItem({
|
|||||||
className="shrink-0 cursor-grab active:cursor-grabbing touch-none text-muted-foreground hover:text-foreground transition-colors"
|
className="shrink-0 cursor-grab active:cursor-grabbing touch-none text-muted-foreground hover:text-foreground transition-colors"
|
||||||
{...dragHandleProps}
|
{...dragHandleProps}
|
||||||
>
|
>
|
||||||
<GripVertical className="h-5 w-5" />
|
<HugeiconsIcon icon={DragDropVerticalIcon} size={20} className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Voice Avatar */}
|
{/* Voice Avatar */}
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center overflow-hidden">
|
<ProfileAvatar
|
||||||
{!avatarError ? (
|
profileId={item.profile_id}
|
||||||
<img
|
size="lg"
|
||||||
src={avatarUrl}
|
grayscale={!isCurrentlyPlaying}
|
||||||
alt={`${item.profile_name} avatar`}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@@ -119,16 +106,16 @@ export function StoryChatItem({
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Actions">
|
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Actions">
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={handlePlay}>
|
<DropdownMenuItem onClick={handlePlay}>
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<HugeiconsIcon icon={PlayIcon} size={16} className="mr-2 h-4 w-4" />
|
||||||
Play from here
|
Play from here
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={onRemove} className="text-destructive focus:text-destructive">
|
<DropdownMenuItem onClick={onRemove} className="text-destructive focus:text-destructive">
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<HugeiconsIcon icon={Delete01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||||
Remove from Story
|
Remove from Story
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -13,11 +13,9 @@ import {
|
|||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { Link } from '@tanstack/react-router';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { Download01Icon, Add01Icon } from '@hugeicons/core-free-icons';
|
||||||
import { Download, Plus } from 'lucide-react';
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import Loader from 'react-loaders';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
@@ -31,7 +29,6 @@ import {
|
|||||||
useStory,
|
useStory,
|
||||||
} from '@/lib/hooks/useStories';
|
} from '@/lib/hooks/useStories';
|
||||||
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
|
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
import { SortableStoryChatItem } from './StoryChatItem';
|
import { SortableStoryChatItem } from './StoryChatItem';
|
||||||
|
|
||||||
@@ -44,7 +41,6 @@ export function StoryContent() {
|
|||||||
const addStoryItem = useAddStoryItem();
|
const addStoryItem = useAddStoryItem();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
|
|
||||||
|
|
||||||
// Add generation popover state
|
// Add generation popover state
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -58,9 +54,9 @@ export function StoryContent() {
|
|||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
return historyData.items.filter(
|
return historyData.items.filter(
|
||||||
(gen) =>
|
(gen) =>
|
||||||
gen.status === 'completed' &&
|
|
||||||
!storyGenerationIds.has(gen.id) &&
|
!storyGenerationIds.has(gen.id) &&
|
||||||
(gen.text.toLowerCase().includes(query) || gen.profile_name.toLowerCase().includes(query)),
|
(gen.text.toLowerCase().includes(query) ||
|
||||||
|
gen.profile_name.toLowerCase().includes(query)),
|
||||||
);
|
);
|
||||||
}, [historyData, story, searchQuery]);
|
}, [historyData, story, searchQuery]);
|
||||||
|
|
||||||
@@ -272,35 +268,11 @@ export function StoryContent() {
|
|||||||
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
|
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2">
|
||||||
<AnimatePresence>
|
|
||||||
{pendingCount > 0 && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.9, width: 0 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, width: 'auto' }}
|
|
||||||
exit={{ opacity: 0, scale: 0.9, width: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="flex items-center gap-2 h-8 pl-1.5 pr-3 rounded-full bg-card border border-border hover:bg-muted/50 transition-all duration-200 cursor-pointer"
|
|
||||||
>
|
|
||||||
<div className="shrink-0 w-10 h-5 overflow-hidden flex items-center justify-center">
|
|
||||||
<div className="scale-[0.45]">
|
|
||||||
<Loader type="line-scale" active />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
|
||||||
Generating {pendingCount} {pendingCount === 1 ? 'audio' : 'audios'}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
|
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<HugeiconsIcon icon={Add01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -316,7 +288,9 @@ export function StoryContent() {
|
|||||||
<div className="max-h-60 overflow-y-auto">
|
<div className="max-h-60 overflow-y-auto">
|
||||||
{availableGenerations.length === 0 ? (
|
{availableGenerations.length === 0 ? (
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
{searchQuery ? 'No matching generations found' : 'No available generations'}
|
{searchQuery
|
||||||
|
? 'No matching generations found'
|
||||||
|
: 'No available generations'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
availableGenerations.map((gen) => (
|
availableGenerations.map((gen) => (
|
||||||
@@ -343,7 +317,7 @@ export function StoryContent() {
|
|||||||
onClick={handleExportAudio}
|
onClick={handleExportAudio}
|
||||||
disabled={exportAudio.isPending}
|
disabled={exportAudio.isPending}
|
||||||
>
|
>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<HugeiconsIcon icon={Download01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||||
Export Audio
|
Export Audio
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
import { useEffect, useState } from 'react';
|
import { Add01Icon, Book01Icon, MoreHorizontalIcon, PencilIcon, Delete01Icon } from '@hugeicons/core-free-icons';
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -29,13 +30,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import {
|
import { useStories, useCreateStory, useUpdateStory, useDeleteStory, useStory } from '@/lib/hooks/useStories';
|
||||||
useCreateStory,
|
|
||||||
useDeleteStory,
|
|
||||||
useStories,
|
|
||||||
useStory,
|
|
||||||
useUpdateStory,
|
|
||||||
} from '@/lib/hooks/useStories';
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { formatDate } from '@/lib/utils/format';
|
import { formatDate } from '@/lib/utils/format';
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
@@ -45,7 +40,7 @@ export function StoryList() {
|
|||||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||||
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
|
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
|
||||||
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||||
const { data: selectedStory } = useStory(selectedStoryId);
|
const { data: currentStory } = useStory(selectedStoryId);
|
||||||
const createStory = useCreateStory();
|
const createStory = useCreateStory();
|
||||||
const updateStory = useUpdateStory();
|
const updateStory = useUpdateStory();
|
||||||
const deleteStory = useDeleteStory();
|
const deleteStory = useDeleteStory();
|
||||||
@@ -62,12 +57,15 @@ export function StoryList() {
|
|||||||
const [newStoryDescription, setNewStoryDescription] = useState('');
|
const [newStoryDescription, setNewStoryDescription] = useState('');
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// Auto-select the first story when the list loads with no selection
|
// Calculate bottom padding to account for FloatingGenerateBox and StoryTrackEditor
|
||||||
useEffect(() => {
|
const hasTrackEditor = currentStory && currentStory.items.length > 0;
|
||||||
if (!selectedStoryId && stories && stories.length > 0) {
|
const bottomPadding = useMemo(() => {
|
||||||
setSelectedStoryId(stories[0].id);
|
// FloatingGenerateBox height (~100px) + gap (24px)
|
||||||
}
|
const generateBoxHeight = 124;
|
||||||
}, [selectedStoryId, stories, setSelectedStoryId]);
|
// Track editor height when visible
|
||||||
|
const editorHeight = hasTrackEditor ? trackEditorHeight + 24 : 0;
|
||||||
|
return generateBoxHeight + editorHeight;
|
||||||
|
}, [hasTrackEditor, trackEditorHeight]);
|
||||||
|
|
||||||
const handleCreateStory = () => {
|
const handleCreateStory = () => {
|
||||||
if (!newStoryName.trim()) {
|
if (!newStoryName.trim()) {
|
||||||
@@ -185,98 +183,86 @@ export function StoryList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storyList = stories || [];
|
const storyList = stories || [];
|
||||||
const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col relative overflow-hidden">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
{/* Scroll Mask */}
|
{/* Header */}
|
||||||
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
<div className="flex items-center justify-between mb-4 px-1">
|
||||||
|
<h2 className="text-2xl font-bold">Stories</h2>
|
||||||
{/* Fixed Header */}
|
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
||||||
<div className="absolute top-0 left-0 right-0 z-20">
|
<HugeiconsIcon icon={Add01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||||
<div className="flex items-center justify-between mb-4 px-1">
|
New Story
|
||||||
<h2 className="text-2xl font-bold">Stories</h2>
|
</Button>
|
||||||
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
New Story
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Story List */}
|
{/* Story List */}
|
||||||
<div
|
<div
|
||||||
className="flex-1 overflow-y-auto pt-14 relative z-0"
|
className="flex-1 min-h-0 overflow-y-auto space-y-2"
|
||||||
style={{ paddingBottom: hasTrackEditor ? `${trackEditorHeight + 140}px` : '170px' }}
|
style={{ paddingBottom: `${bottomPadding}px` }}
|
||||||
>
|
>
|
||||||
{storyList.length === 0 ? (
|
{storyList.length === 0 ? (
|
||||||
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
||||||
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
<HugeiconsIcon icon={Book01Icon} size={48} className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||||
<p className="text-sm">No stories yet</p>
|
<p className="text-sm">No stories yet</p>
|
||||||
<p className="text-xs mt-2">Create your first story to get started</p>
|
<p className="text-xs mt-2">Create your first story to get started</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-0.5">
|
storyList.map((story) => (
|
||||||
{storyList.map((story) => (
|
<div
|
||||||
<div
|
key={story.id}
|
||||||
key={story.id}
|
className={cn(
|
||||||
role="button"
|
'h-24 p-4 border rounded-2xl transition-colors group flex items-center',
|
||||||
tabIndex={0}
|
selectedStoryId === story.id && 'bg-muted border-primary',
|
||||||
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',
|
<div className="flex items-start justify-between gap-2 w-full min-w-0">
|
||||||
)}
|
<button
|
||||||
aria-label={`Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}`}
|
type="button"
|
||||||
aria-pressed={selectedStoryId === story.id}
|
className="flex-1 min-w-0 text-left cursor-pointer overflow-hidden"
|
||||||
onClick={() => setSelectedStoryId(story.id)}
|
onClick={() => setSelectedStoryId(story.id)}
|
||||||
onKeyDown={(e) => {
|
>
|
||||||
if (e.target !== e.currentTarget) return;
|
<h3 className="font-medium truncate">{story.name}</h3>
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
{story.description && (
|
||||||
e.preventDefault();
|
<p className="text-sm text-muted-foreground mt-1 truncate">
|
||||||
setSelectedStoryId(story.id);
|
{story.description}
|
||||||
}
|
</p>
|
||||||
}}
|
)}
|
||||||
>
|
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
||||||
<div className="flex items-start justify-between gap-2 w-full min-w-0">
|
<span>
|
||||||
<div className="flex-1 min-w-0 text-left overflow-hidden">
|
{story.item_count} {story.item_count === 1 ? 'item' : 'items'}
|
||||||
<h3 className="text-sm font-medium truncate">{story.name}</h3>
|
</span>
|
||||||
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
<span>•</span>
|
||||||
<span>
|
<span>{formatDate(story.updated_at)}</span>
|
||||||
{story.item_count} {story.item_count === 1 ? 'item' : 'items'}
|
|
||||||
</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>{formatDate(story.updated_at)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
</button>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button
|
<DropdownMenuTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
size="icon"
|
||||||
onClick={(e) => e.stopPropagation()}
|
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
aria-label={`Actions for ${story.name}`}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => handleEditClick(story)}>
|
<DropdownMenuItem onClick={() => handleEditClick(story)}>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<HugeiconsIcon icon={PencilIcon} size={16} className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleDeleteClick(story.id)}
|
onClick={() => handleDeleteClick(story.id)}
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<HugeiconsIcon icon={Delete01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,18 @@
|
|||||||
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
import {
|
import {
|
||||||
Check,
|
Copy01Icon,
|
||||||
Copy,
|
DragDropHorizontalIcon,
|
||||||
GalleryVerticalEnd,
|
RemoveIcon,
|
||||||
GripHorizontal,
|
PauseIcon,
|
||||||
Minus,
|
PlayIcon,
|
||||||
Pause,
|
Add01Icon,
|
||||||
Play,
|
Scissor01Icon,
|
||||||
Plus,
|
SquareIcon,
|
||||||
Scissors,
|
Delete01Icon,
|
||||||
Square,
|
} from '@hugeicons/core-free-icons';
|
||||||
Trash2,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import WaveSurfer from 'wavesurfer.js';
|
import WaveSurfer from 'wavesurfer.js';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { StoryItemDetail } from '@/lib/api/types';
|
import type { StoryItemDetail } from '@/lib/api/types';
|
||||||
@@ -27,7 +20,6 @@ import {
|
|||||||
useDuplicateStoryItem,
|
useDuplicateStoryItem,
|
||||||
useMoveStoryItem,
|
useMoveStoryItem,
|
||||||
useRemoveStoryItem,
|
useRemoveStoryItem,
|
||||||
useSetStoryItemVersion,
|
|
||||||
useSplitStoryItem,
|
useSplitStoryItem,
|
||||||
useTrimStoryItem,
|
useTrimStoryItem,
|
||||||
} from '@/lib/hooks/useStories';
|
} from '@/lib/hooks/useStories';
|
||||||
@@ -37,14 +29,12 @@ import { useStoryStore } from '@/stores/storyStore';
|
|||||||
// Clip waveform component with trim support
|
// Clip waveform component with trim support
|
||||||
function ClipWaveform({
|
function ClipWaveform({
|
||||||
generationId,
|
generationId,
|
||||||
versionId,
|
|
||||||
width,
|
width,
|
||||||
trimStartMs,
|
trimStartMs,
|
||||||
trimEndMs,
|
trimEndMs,
|
||||||
duration,
|
duration,
|
||||||
}: {
|
}: {
|
||||||
generationId: string;
|
generationId: string;
|
||||||
versionId?: string;
|
|
||||||
width: number;
|
width: number;
|
||||||
trimStartMs: number;
|
trimStartMs: number;
|
||||||
trimEndMs: number;
|
trimEndMs: number;
|
||||||
@@ -90,9 +80,7 @@ function ClipWaveform({
|
|||||||
|
|
||||||
wavesurferRef.current = wavesurfer;
|
wavesurferRef.current = wavesurfer;
|
||||||
|
|
||||||
const audioUrl = versionId
|
const audioUrl = apiClient.getAudioUrl(generationId);
|
||||||
? apiClient.getVersionAudioUrl(versionId)
|
|
||||||
: apiClient.getAudioUrl(generationId);
|
|
||||||
wavesurfer.load(audioUrl).catch(() => {
|
wavesurfer.load(audioUrl).catch(() => {
|
||||||
// Ignore load errors
|
// Ignore load errors
|
||||||
});
|
});
|
||||||
@@ -101,7 +89,7 @@ function ClipWaveform({
|
|||||||
wavesurfer.destroy();
|
wavesurfer.destroy();
|
||||||
wavesurferRef.current = null;
|
wavesurferRef.current = null;
|
||||||
};
|
};
|
||||||
}, [generationId, versionId, fullWaveformWidth]);
|
}, [generationId, fullWaveformWidth]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full opacity-60 overflow-hidden">
|
<div className="w-full h-full opacity-60 overflow-hidden">
|
||||||
@@ -148,57 +136,12 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
const splitItem = useSplitStoryItem();
|
const splitItem = useSplitStoryItem();
|
||||||
const duplicateItem = useDuplicateStoryItem();
|
const duplicateItem = useDuplicateStoryItem();
|
||||||
const removeItem = useRemoveStoryItem();
|
const removeItem = useRemoveStoryItem();
|
||||||
const setItemVersion = useSetStoryItemVersion();
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// Selection state
|
// Selection state
|
||||||
const selectedClipId = useStoryStore((state) => state.selectedClipId);
|
const selectedClipId = useStoryStore((state) => state.selectedClipId);
|
||||||
const setSelectedClipId = useStoryStore((state) => state.setSelectedClipId);
|
const setSelectedClipId = useStoryStore((state) => state.setSelectedClipId);
|
||||||
|
|
||||||
// Selected clip item (for version picker)
|
|
||||||
const selectedItem = useMemo(
|
|
||||||
() => (selectedClipId ? items.find((i) => i.id === selectedClipId) : undefined),
|
|
||||||
[selectedClipId, items],
|
|
||||||
);
|
|
||||||
const selectedItemVersions = selectedItem?.versions;
|
|
||||||
const hasMultipleVersions = selectedItemVersions && selectedItemVersions.length > 1;
|
|
||||||
|
|
||||||
// Determine which version label is active for the selected clip
|
|
||||||
const activeVersionLabel = useMemo(() => {
|
|
||||||
if (!selectedItem || !selectedItemVersions) return null;
|
|
||||||
// If the item has a pinned version_id, find its label
|
|
||||||
if (selectedItem.version_id) {
|
|
||||||
const pinned = selectedItemVersions.find((v) => v.id === selectedItem.version_id);
|
|
||||||
return pinned?.label ?? null;
|
|
||||||
}
|
|
||||||
// Otherwise use the generation's default version
|
|
||||||
const defaultVersion = selectedItemVersions.find((v) => v.is_default);
|
|
||||||
return defaultVersion?.label ?? null;
|
|
||||||
}, [selectedItem, selectedItemVersions]);
|
|
||||||
|
|
||||||
const handleSetVersion = useCallback(
|
|
||||||
(versionId: string | null) => {
|
|
||||||
if (!selectedClipId) return;
|
|
||||||
setItemVersion.mutate(
|
|
||||||
{
|
|
||||||
storyId,
|
|
||||||
itemId: selectedClipId,
|
|
||||||
data: { version_id: versionId },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onError: (error) => {
|
|
||||||
toast({
|
|
||||||
title: 'Failed to set version',
|
|
||||||
description: error instanceof Error ? error.message : String(error),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[selectedClipId, storyId, setItemVersion, toast],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Trim state
|
// Trim state
|
||||||
const [trimmingItem, setTrimmingItem] = useState<string | null>(null);
|
const [trimmingItem, setTrimmingItem] = useState<string | null>(null);
|
||||||
const [trimSide, setTrimSide] = useState<'start' | 'end' | null>(null);
|
const [trimSide, setTrimSide] = useState<'start' | 'end' | null>(null);
|
||||||
@@ -781,7 +724,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
onMouseDown={handleResizeStart}
|
onMouseDown={handleResizeStart}
|
||||||
aria-label="Resize track editor"
|
aria-label="Resize track editor"
|
||||||
>
|
>
|
||||||
<GripHorizontal className="h-3 w-3 text-muted-foreground/50 group-hover:text-muted-foreground" />
|
<HugeiconsIcon icon={DragDropHorizontalIcon} size={12} className="h-3 w-3 text-muted-foreground/50 group-hover:text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
@@ -794,9 +737,8 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handlePlayPause}
|
onClick={handlePlayPause}
|
||||||
title="Play/Pause (Space)"
|
title="Play/Pause (Space)"
|
||||||
aria-label={isCurrentlyPlaying ? 'Pause' : 'Play'}
|
|
||||||
>
|
>
|
||||||
{isCurrentlyPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
{isCurrentlyPlaying ? <HugeiconsIcon icon={PauseIcon} size={16} className="h-4 w-4" /> : <HugeiconsIcon icon={PlayIcon} size={16} className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -804,9 +746,8 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handleStop}
|
onClick={handleStop}
|
||||||
disabled={!isCurrentlyPlaying}
|
disabled={!isCurrentlyPlaying}
|
||||||
aria-label="Stop"
|
|
||||||
>
|
>
|
||||||
<Square className="h-3 w-3" />
|
<HugeiconsIcon icon={SquareIcon} size={12} className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-xs text-muted-foreground tabular-nums ml-2">
|
<span className="text-xs text-muted-foreground tabular-nums ml-2">
|
||||||
{formatTime(currentTimeMs)} / {formatTime(totalDurationMs)}
|
{formatTime(currentTimeMs)} / {formatTime(totalDurationMs)}
|
||||||
@@ -822,9 +763,8 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handleSplit}
|
onClick={handleSplit}
|
||||||
title="Split at playhead (S)"
|
title="Split at playhead (S)"
|
||||||
aria-label="Split at playhead"
|
|
||||||
>
|
>
|
||||||
<Scissors className="h-4 w-4" />
|
<HugeiconsIcon icon={Scissor01Icon} size={16} className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -832,9 +772,8 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handleDuplicate}
|
onClick={handleDuplicate}
|
||||||
title="Duplicate (Cmd/Ctrl+D)"
|
title="Duplicate (Cmd/Ctrl+D)"
|
||||||
aria-label="Duplicate clip"
|
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4" />
|
<HugeiconsIcon icon={Copy01Icon} size={16} className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -842,76 +781,20 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
title="Delete (Delete/Backspace)"
|
title="Delete (Delete/Backspace)"
|
||||||
aria-label="Delete clip"
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<HugeiconsIcon icon={Delete01Icon} size={16} className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{hasMultipleVersions && (
|
|
||||||
<>
|
|
||||||
<div className="w-px h-4 bg-border mx-1" />
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="h-7 gap-1.5 px-2 text-xs"
|
|
||||||
title="Change version/take"
|
|
||||||
>
|
|
||||||
<GalleryVerticalEnd className="h-3.5 w-3.5" />
|
|
||||||
<span className="max-w-[80px] truncate">
|
|
||||||
{activeVersionLabel ?? 'default'}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="center" className="min-w-[160px]">
|
|
||||||
{selectedItemVersions.map((version) => {
|
|
||||||
const isActive = selectedItem?.version_id
|
|
||||||
? version.id === selectedItem.version_id
|
|
||||||
: version.is_default;
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={version.id}
|
|
||||||
onClick={() => handleSetVersion(version.id)}
|
|
||||||
className="gap-2 text-xs"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn('h-3 w-3', isActive ? 'opacity-100' : 'opacity-0')}
|
|
||||||
/>
|
|
||||||
<span className="truncate">{version.label}</span>
|
|
||||||
{version.effects_chain && version.effects_chain.length > 0 && (
|
|
||||||
<span className="text-muted-foreground ml-auto text-[10px]">
|
|
||||||
{version.effects_chain.length} fx
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Zoom controls - right side */}
|
{/* Zoom controls - right side */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground">Zoom:</span>
|
<span className="text-xs text-muted-foreground">Zoom:</span>
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomOut}>
|
||||||
variant="ghost"
|
<HugeiconsIcon icon={RemoveIcon} size={12} className="h-3 w-3" />
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={handleZoomOut}
|
|
||||||
aria-label="Zoom out"
|
|
||||||
>
|
|
||||||
<Minus className="h-3 w-3" />
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomIn}>
|
||||||
variant="ghost"
|
<HugeiconsIcon icon={Add01Icon} size={12} className="h-3 w-3" />
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={handleZoomIn}
|
|
||||||
aria-label="Zoom in"
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -955,7 +838,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="h-6 border-b bg-muted/20 sticky top-0 z-10 cursor-pointer text-left"
|
className="h-6 border-b bg-muted/20 sticky top-0 z-10 cursor-pointer text-left"
|
||||||
style={{ width: `${timelineWidth}px` }}
|
style={{ width: `${timelineWidth}px` }}
|
||||||
onClick={handleTimelineClick}
|
onClick={(e) => handleTimelineClick(e as unknown as React.MouseEvent<HTMLDivElement>)}
|
||||||
aria-label="Seek timeline"
|
aria-label="Seek timeline"
|
||||||
>
|
>
|
||||||
{timeMarkers.map((ms) => (
|
{timeMarkers.map((ms) => (
|
||||||
@@ -996,7 +879,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute inset-0 z-0 cursor-pointer"
|
className="absolute inset-0 z-0 cursor-pointer"
|
||||||
onClick={handleTimelineClick}
|
onClick={(e) => handleTimelineClick(e as unknown as React.MouseEvent<HTMLDivElement>)}
|
||||||
aria-label="Seek timeline"
|
aria-label="Seek timeline"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1059,7 +942,6 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
<div className="absolute inset-0 top-3">
|
<div className="absolute inset-0 top-3">
|
||||||
<ClipWaveform
|
<ClipWaveform
|
||||||
generationId={item.generation_id}
|
generationId={item.generation_id}
|
||||||
versionId={item.version_id}
|
|
||||||
width={clipWidth}
|
width={clipWidth}
|
||||||
trimStartMs={displayTrimStart}
|
trimStartMs={displayTrimStart}
|
||||||
trimEndMs={displayTrimEnd}
|
trimEndMs={displayTrimEnd}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
const isWindows = navigator.userAgent.includes('Windows');
|
|
||||||
|
|
||||||
export function TitleBarDragRegion() {
|
export function TitleBarDragRegion() {
|
||||||
if (isWindows) return null;
|
return (
|
||||||
|
<div
|
||||||
return <div data-tauri-drag-region className="fixed top-0 left-0 right-0 h-12 z-[9999]" />;
|
data-tauri-drag-region
|
||||||
|
className="fixed top-0 left-0 right-0 h-12 z-[9999]"
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Mic, Pause, Play, Square } from 'lucide-react';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { Mic01Icon, PauseIcon, PlayIcon, SquareIcon } from '@hugeicons/core-free-icons';
|
||||||
import { memo, useEffect, useState } from 'react';
|
import { memo, useEffect, useState } from 'react';
|
||||||
import { Visualizer } from 'react-sound-visualizer';
|
import { Visualizer } from 'react-sound-visualizer';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -58,7 +59,6 @@ export function AudioSampleRecording({
|
|||||||
// Request microphone access when component mounts
|
// Request microphone access when component mounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showWaveform) return;
|
if (!showWaveform) return;
|
||||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return;
|
|
||||||
|
|
||||||
let stream: MediaStream | null = null;
|
let stream: MediaStream | null = null;
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ export function AudioSampleRecording({
|
|||||||
size="lg"
|
size="lg"
|
||||||
className="relative z-10 flex items-center gap-2"
|
className="relative z-10 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Mic className="h-5 w-5" />
|
<HugeiconsIcon icon={Mic01Icon} size={20} className="h-5 w-5" />
|
||||||
Start Recording
|
Start Recording
|
||||||
</Button>
|
</Button>
|
||||||
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
||||||
@@ -123,7 +123,7 @@ export function AudioSampleRecording({
|
|||||||
onClick={onStop}
|
onClick={onStop}
|
||||||
className="relative z-10 flex items-center gap-2 bg-accent text-accent-foreground hover:bg-accent/90"
|
className="relative z-10 flex items-center gap-2 bg-accent text-accent-foreground hover:bg-accent/90"
|
||||||
>
|
>
|
||||||
<Square className="h-4 w-4" />
|
<HugeiconsIcon icon={SquareIcon} size={16} className="h-4 w-4" />
|
||||||
Stop Recording
|
Stop Recording
|
||||||
</Button>
|
</Button>
|
||||||
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
||||||
@@ -135,19 +135,13 @@ export function AudioSampleRecording({
|
|||||||
{file && !isRecording && (
|
{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 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">
|
<div className="flex items-center gap-2">
|
||||||
<Mic className="h-5 w-5 text-primary" />
|
<HugeiconsIcon icon={Mic01Icon} size={20} className="h-5 w-5 text-primary" />
|
||||||
<span className="font-medium">Recording complete</span>
|
<span className="font-medium">Recording complete</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button type="button" size="icon" variant="outline" onClick={onPlayPause}>
|
||||||
type="button"
|
{isPlaying ? <HugeiconsIcon icon={PauseIcon} size={16} className="h-4 w-4" /> : <HugeiconsIcon icon={PlayIcon} size={16} className="h-4 w-4" />}
|
||||||
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>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -156,7 +150,7 @@ export function AudioSampleRecording({
|
|||||||
disabled={isTranscribing}
|
disabled={isTranscribing}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Mic className="h-4 w-4" />
|
<HugeiconsIcon icon={Mic01Icon} size={16} className="h-4 w-4" />
|
||||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Mic, Monitor, Pause, Play, Square } from 'lucide-react';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { Mic01Icon, DeskIcon, PauseIcon, PlayIcon, SquareIcon } from '@hugeicons/core-free-icons';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
@@ -35,7 +36,7 @@ export function AudioSampleSystem({
|
|||||||
{!isRecording && !file && (
|
{!isRecording && !file && (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
||||||
<Button type="button" onClick={onStart} size="lg" className="flex items-center gap-2">
|
<Button type="button" onClick={onStart} size="lg" className="flex items-center gap-2">
|
||||||
<Monitor className="h-5 w-5" />
|
<HugeiconsIcon icon={DeskIcon} size={20} className="h-5 w-5" />
|
||||||
Start Capture
|
Start Capture
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
@@ -60,7 +61,7 @@ export function AudioSampleSystem({
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Square className="h-4 w-4" />
|
<HugeiconsIcon icon={SquareIcon} size={16} className="h-4 w-4" />
|
||||||
Stop Capture
|
Stop Capture
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
@@ -72,19 +73,13 @@ export function AudioSampleSystem({
|
|||||||
{file && !isRecording && (
|
{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 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">
|
<div className="flex items-center gap-2">
|
||||||
<Monitor className="h-5 w-5 text-primary" />
|
<HugeiconsIcon icon={DeskIcon} size={20} className="h-5 w-5 text-primary" />
|
||||||
<span className="font-medium">Capture complete</span>
|
<span className="font-medium">Capture complete</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button type="button" size="icon" variant="outline" onClick={onPlayPause}>
|
||||||
type="button"
|
{isPlaying ? <HugeiconsIcon icon={PauseIcon} size={16} className="h-4 w-4" /> : <HugeiconsIcon icon={PlayIcon} size={16} className="h-4 w-4" />}
|
||||||
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>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -93,7 +88,7 @@ export function AudioSampleSystem({
|
|||||||
disabled={isTranscribing}
|
disabled={isTranscribing}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Mic className="h-4 w-4" />
|
<HugeiconsIcon icon={Mic01Icon} size={16} className="h-4 w-4" />
|
||||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Mic, Pause, Play, Upload } from 'lucide-react';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { Mic01Icon, PauseIcon, PlayIcon, Upload01Icon } from '@hugeicons/core-free-icons';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||||
@@ -89,7 +90,7 @@ export function AudioSampleUpload({
|
|||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Upload className="h-5 w-5" />
|
<HugeiconsIcon icon={Upload01Icon} size={20} className="h-5 w-5" />
|
||||||
Choose File
|
Choose File
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
@@ -99,7 +100,7 @@ export function AudioSampleUpload({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Upload className="h-5 w-5 text-primary" />
|
<HugeiconsIcon icon={Upload01Icon} size={20} className="h-5 w-5 text-primary" />
|
||||||
<span className="font-medium">File uploaded</span>
|
<span className="font-medium">File uploaded</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||||
@@ -110,9 +111,8 @@ export function AudioSampleUpload({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onPlayPause}
|
onClick={onPlayPause}
|
||||||
disabled={isValidating}
|
disabled={isValidating}
|
||||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
{isPlaying ? <HugeiconsIcon icon={PauseIcon} size={16} className="h-4 w-4" /> : <HugeiconsIcon icon={PlayIcon} size={16} className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -121,7 +121,7 @@ export function AudioSampleUpload({
|
|||||||
disabled={isTranscribing || isValidating || isDisabled}
|
disabled={isTranscribing || isValidating || isDisabled}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Mic className="h-4 w-4" />
|
<HugeiconsIcon icon={Mic01Icon} size={16} className="h-4 w-4" />
|
||||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { Mic01Icon } from '@hugeicons/core-free-icons';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
|
interface ProfileAvatarProps {
|
||||||
|
profileId: string;
|
||||||
|
avatarPath?: string | null;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
grayscale?: boolean;
|
||||||
|
className?: string;
|
||||||
|
alt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'h-6 w-6',
|
||||||
|
md: 'h-8 w-8',
|
||||||
|
lg: 'h-10 w-10',
|
||||||
|
xl: 'h-24 w-24',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconSizes = {
|
||||||
|
sm: 14,
|
||||||
|
md: 16,
|
||||||
|
lg: 20,
|
||||||
|
xl: 40,
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconClassNames = {
|
||||||
|
sm: 'h-3.5 w-3.5',
|
||||||
|
md: 'h-4 w-4',
|
||||||
|
lg: 'h-5 w-5',
|
||||||
|
xl: 'h-10 w-10',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProfileAvatar({
|
||||||
|
profileId,
|
||||||
|
avatarPath,
|
||||||
|
size = 'md',
|
||||||
|
grayscale = false,
|
||||||
|
className,
|
||||||
|
alt = 'Profile avatar',
|
||||||
|
}: ProfileAvatarProps) {
|
||||||
|
const [avatarError, setAvatarError] = useState(false);
|
||||||
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
|
|
||||||
|
// If avatarPath is explicitly null or empty string, don't try to load avatar
|
||||||
|
// Otherwise, always try to load (avatarPath might not be available in all contexts)
|
||||||
|
const avatarUrl =
|
||||||
|
avatarPath === null || avatarPath === '' ? null : `${serverUrl}/profiles/${profileId}/avatar`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
sizeClasses[size],
|
||||||
|
'rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{avatarUrl && !avatarError ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={alt}
|
||||||
|
className={cn(
|
||||||
|
'h-full w-full object-cover transition-all duration-200',
|
||||||
|
grayscale && 'grayscale',
|
||||||
|
)}
|
||||||
|
onError={() => setAvatarError(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<HugeiconsIcon
|
||||||
|
icon={Mic01Icon}
|
||||||
|
size={iconSizes[size]}
|
||||||
|
className={cn(iconClassNames[size], 'text-muted-foreground')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Download, Edit, Sparkles, Trash2 } from 'lucide-react';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { Download01Icon, Edit01Icon, Delete01Icon } from '@hugeicons/core-free-icons';
|
||||||
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';
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import { ProfileAvatar } from '@/components/VoiceProfiles/ProfileAvatar';
|
||||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||||
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
|
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
@@ -23,7 +25,6 @@ interface ProfileCardProps {
|
|||||||
|
|
||||||
export function ProfileCard({ profile }: ProfileCardProps) {
|
export function ProfileCard({ profile }: ProfileCardProps) {
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
|
||||||
const deleteProfile = useDeleteProfile();
|
const deleteProfile = useDeleteProfile();
|
||||||
const exportProfile = useExportProfile();
|
const exportProfile = useExportProfile();
|
||||||
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
||||||
@@ -57,35 +58,24 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
|||||||
exportProfile.mutate(profile.id);
|
exportProfile.mutate(profile.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.closest('button')) return;
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSelect();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectLabel = isSelected
|
|
||||||
? `${profile.name}, ${profile.language}. Selected as voice for generation.`
|
|
||||||
: `${profile.name}, ${profile.language}. Select as voice for generation.`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
'cursor-pointer hover:shadow-md transition-all flex flex-col h-[162px]',
|
'cursor-pointer hover:shadow-md transition-all flex flex-col',
|
||||||
isSelected && 'ring-2 ring-accent shadow-md',
|
isSelected && 'ring-2 ring-primary shadow-md',
|
||||||
)}
|
)}
|
||||||
onClick={handleSelect}
|
onClick={handleSelect}
|
||||||
tabIndex={0}
|
|
||||||
role="button"
|
|
||||||
aria-label={selectLabel}
|
|
||||||
aria-pressed={isSelected}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
>
|
>
|
||||||
<CardHeader className="p-3 pb-2">
|
<CardHeader className="p-3 pb-2">
|
||||||
<CardTitle className="text-base font-medium">
|
<CardTitle className="flex items-center gap-1.5 text-base font-medium">
|
||||||
|
<ProfileAvatar
|
||||||
|
profileId={profile.id}
|
||||||
|
avatarPath={profile.avatar_path}
|
||||||
|
size="sm"
|
||||||
|
grayscale={!isSelected}
|
||||||
|
alt={`${profile.name} avatar`}
|
||||||
|
/>
|
||||||
<span className="break-words">{profile.name}</span>
|
<span className="break-words">{profile.name}</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -93,23 +83,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 flex items-center gap-1.5">
|
<div className="mb-2">
|
||||||
<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={Download}
|
icon={(props) => <HugeiconsIcon icon={Download01Icon} size={14} {...props} />}
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
disabled={exportProfile.isPending}
|
disabled={exportProfile.isPending}
|
||||||
aria-label="Export profile"
|
aria-label="Export profile"
|
||||||
/>
|
/>
|
||||||
<CircleButton
|
<CircleButton
|
||||||
icon={Edit}
|
icon={(props) => <HugeiconsIcon icon={Edit01Icon} size={14} {...props} />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleEdit();
|
handleEdit();
|
||||||
@@ -117,7 +104,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
|||||||
aria-label="Edit profile"
|
aria-label="Edit profile"
|
||||||
/>
|
/>
|
||||||
<CircleButton
|
<CircleButton
|
||||||
icon={Trash2}
|
icon={(props) => <HugeiconsIcon icon={Delete01Icon} size={14} {...props} />}
|
||||||
onClick={handleDeleteClick}
|
onClick={handleDeleteClick}
|
||||||
disabled={deleteProfile.isPending}
|
disabled={deleteProfile.isPending}
|
||||||
aria-label="Delete profile"
|
aria-label="Delete profile"
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Edit2, Mic, Monitor, Upload, X } from 'lucide-react';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { Edit02Icon, Mic01Icon, DeskIcon, Upload01Icon, Cancel01Icon } from '@hugeicons/core-free-icons';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -31,8 +31,6 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
|
||||||
import type { EffectConfig } from '@/lib/api/types';
|
|
||||||
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
|
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
|
||||||
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
|
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
|
||||||
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
||||||
@@ -46,7 +44,7 @@ import {
|
|||||||
} from '@/lib/hooks/useProfiles';
|
} from '@/lib/hooks/useProfiles';
|
||||||
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
||||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||||
import { convertToWav, formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
|
import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore';
|
import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore';
|
||||||
@@ -128,8 +126,6 @@ export function ProfileForm() {
|
|||||||
const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer();
|
const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer();
|
||||||
const isCreating = !editingProfileId;
|
const isCreating = !editingProfileId;
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
const [profileEffectsChain, setProfileEffectsChain] = useState<EffectConfig[]>([]);
|
|
||||||
const [effectsDirty, setEffectsDirty] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm<ProfileFormValues>({
|
const form = useForm<ProfileFormValues>({
|
||||||
resolver: zodResolver(profileSchema),
|
resolver: zodResolver(profileSchema),
|
||||||
@@ -285,8 +281,6 @@ export function ProfileForm() {
|
|||||||
referenceText: undefined,
|
referenceText: undefined,
|
||||||
avatarFile: undefined,
|
avatarFile: undefined,
|
||||||
});
|
});
|
||||||
setProfileEffectsChain(editingProfile.effects_chain ?? []);
|
|
||||||
setEffectsDirty(false);
|
|
||||||
} else if (profileFormDraft && open) {
|
} else if (profileFormDraft && open) {
|
||||||
// Restore from draft when opening in create mode
|
// Restore from draft when opening in create mode
|
||||||
form.reset({
|
form.reset({
|
||||||
@@ -442,24 +436,6 @@ export function ProfileForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save effects chain if changed
|
|
||||||
if (effectsDirty) {
|
|
||||||
try {
|
|
||||||
await apiClient.updateProfileEffects(
|
|
||||||
editingProfileId,
|
|
||||||
profileEffectsChain.length > 0 ? profileEffectsChain : null,
|
|
||||||
);
|
|
||||||
} catch (fxError) {
|
|
||||||
toast({
|
|
||||||
title: 'Effects update failed',
|
|
||||||
description:
|
|
||||||
fxError instanceof Error ? fxError.message : 'Failed to save effects chain',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Voice updated',
|
title: 'Voice updated',
|
||||||
description: `"${data.name}" has been updated successfully.`,
|
description: `"${data.name}" has been updated successfully.`,
|
||||||
@@ -530,23 +506,10 @@ export function ProfileForm() {
|
|||||||
language: data.language,
|
language: data.language,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert non-WAV uploads to WAV so the backend can always use soundfile.
|
|
||||||
// Recorded audio is already WAV (from useAudioRecording's convertToWav call).
|
|
||||||
let fileToUpload: File = sampleFile;
|
|
||||||
if (!sampleFile.type.includes('wav') && !sampleFile.name.toLowerCase().endsWith('.wav')) {
|
|
||||||
try {
|
|
||||||
const wavBlob = await convertToWav(sampleFile);
|
|
||||||
const wavName = sampleFile.name.replace(/\.[^.]+$/, '.wav');
|
|
||||||
fileToUpload = new File([wavBlob], wavName, { type: 'audio/wav' });
|
|
||||||
} catch {
|
|
||||||
// If browser can't decode the format, send the original and let the backend try.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await addSample.mutateAsync({
|
await addSample.mutateAsync({
|
||||||
profileId: profile.id,
|
profileId: profile.id,
|
||||||
file: fileToUpload,
|
file: sampleFile,
|
||||||
referenceText: referenceText,
|
referenceText: referenceText,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -673,7 +636,7 @@ export function ProfileForm() {
|
|||||||
setSampleMode('record');
|
setSampleMode('record');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3 mr-1" />
|
<HugeiconsIcon icon={Cancel01Icon} size={12} className="h-3 w-3 mr-1" />
|
||||||
Discard
|
Discard
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -706,16 +669,16 @@ export function ProfileForm() {
|
|||||||
className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||||
>
|
>
|
||||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||||
<Upload className="h-4 w-4 shrink-0" />
|
<HugeiconsIcon icon={Upload01Icon} size={16} className="h-4 w-4 shrink-0" />
|
||||||
Upload
|
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 shrink-0" />
|
<HugeiconsIcon icon={Mic01Icon} size={16} className="h-4 w-4 shrink-0" />
|
||||||
Record
|
Record
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||||
<Monitor className="h-4 w-4 shrink-0" />
|
<HugeiconsIcon icon={DeskIcon} size={16} className="h-4 w-4 shrink-0" />
|
||||||
System Audio
|
System Audio
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
@@ -836,7 +799,7 @@ export function ProfileForm() {
|
|||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Mic className="h-10 w-10 text-muted-foreground" />
|
<HugeiconsIcon icon={Mic01Icon} size={40} className="h-10 w-10 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -844,7 +807,7 @@ export function ProfileForm() {
|
|||||||
onClick={() => avatarInputRef.current?.click()}
|
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"
|
className="absolute inset-0 rounded-full bg-accent/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer"
|
||||||
>
|
>
|
||||||
<Edit2 className="h-6 w-6 text-accent-foreground" />
|
<HugeiconsIcon icon={Edit02Icon} size={24} className="h-6 w-6 text-accent-foreground" />
|
||||||
</button>
|
</button>
|
||||||
{(avatarPreview || editingProfile?.avatar_path) && (
|
{(avatarPreview || editingProfile?.avatar_path) && (
|
||||||
<button
|
<button
|
||||||
@@ -853,7 +816,7 @@ export function ProfileForm() {
|
|||||||
disabled={deleteAvatar.isPending}
|
disabled={deleteAvatar.isPending}
|
||||||
className="absolute bottom-0 right-0 h-6 w-6 rounded-full bg-background/60 backdrop-blur-sm text-muted-foreground flex items-center justify-center hover:bg-background/80 hover:text-foreground transition-colors shadow-sm border border-border/50"
|
className="absolute bottom-0 right-0 h-6 w-6 rounded-full bg-background/60 backdrop-blur-sm text-muted-foreground flex items-center justify-center hover:bg-background/80 hover:text-foreground transition-colors shadow-sm border border-border/50"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<HugeiconsIcon icon={Cancel01Icon} size={14} className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -923,23 +886,6 @@ export function ProfileForm() {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{editingProfileId && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<FormLabel>Default Effects</FormLabel>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Effects applied automatically to all new generations with this voice.
|
|
||||||
</p>
|
|
||||||
<EffectsChainEditor
|
|
||||||
value={profileEffectsChain}
|
|
||||||
onChange={(chain) => {
|
|
||||||
setProfileEffectsChain(chain);
|
|
||||||
setEffectsDirty(true);
|
|
||||||
}}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Mic, Sparkles } from 'lucide-react';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { Mic01Icon, SparklesIcon } from '@hugeicons/core-free-icons';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { useProfiles } from '@/lib/hooks/useProfiles';
|
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||||
@@ -30,22 +31,20 @@ export function ProfileList() {
|
|||||||
{allProfiles.length === 0 ? (
|
{allProfiles.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
<Mic className="h-12 w-12 text-muted-foreground mb-4" />
|
<HugeiconsIcon icon={Mic01Icon} size={48} className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
No voice profiles yet. Create your first profile to get started.
|
No voice profiles yet. Create your first profile to get started.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
<Button onClick={() => setDialogOpen(true)}>
|
||||||
<Sparkles className="mr-2 h-4 w-4" />
|
<HugeiconsIcon icon={SparklesIcon} size={16} className="mr-2 h-4 w-4" />
|
||||||
Create Voice
|
Create Voice
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<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]">
|
<div className="grid gap-4 grid-cols-3 auto-rows-auto p-1 pb-[150px]">
|
||||||
{allProfiles.map((profile) => (
|
{allProfiles.map((profile) => (
|
||||||
<div key={profile.id} className="shrink-0 w-[200px] lg:w-auto lg:shrink">
|
<ProfileCard key={profile.id} profile={profile} />
|
||||||
<ProfileCard profile={profile} />
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Check, Edit, Pause, Play, Plus, Trash2, Volume2, X } from 'lucide-react';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { CheckmarkCircle01Icon, Edit01Icon, PauseIcon, PlayIcon, Add01Icon, Delete01Icon, VolumeHighIcon, Cancel01Icon } from '@hugeicons/core-free-icons';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { 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 { CircleButton } from '@/components/ui/circle-button';
|
||||||
@@ -102,9 +103,8 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
|||||||
className="h-7 w-7 shrink-0"
|
className="h-7 w-7 shrink-0"
|
||||||
onClick={handlePlayPause}
|
onClick={handlePlayPause}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
aria-label={isPlaying ? 'Pause sample' : 'Play sample'}
|
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5 ml-0.5" />}
|
{isPlaying ? <HugeiconsIcon icon={PauseIcon} size={14} className="h-3.5 w-3.5" /> : <HugeiconsIcon icon={PlayIcon} size={14} className="h-3.5 w-3.5 ml-0.5" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||||
@@ -114,8 +114,6 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
|||||||
max={100}
|
max={100}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
aria-label="Sample playback position"
|
|
||||||
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 min-w-[70px]">
|
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 min-w-[70px]">
|
||||||
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
|
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
|
||||||
@@ -131,9 +129,8 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
|||||||
className="h-7 w-7 shrink-0"
|
className="h-7 w-7 shrink-0"
|
||||||
onClick={handleStop}
|
onClick={handleStop}
|
||||||
title="Stop"
|
title="Stop"
|
||||||
aria-label="Stop playback"
|
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<HugeiconsIcon icon={Cancel01Icon} size={14} className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,7 +210,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
<div className="space-y-4 pt-4">
|
<div className="space-y-4 pt-4">
|
||||||
{samples && samples.length === 0 ? (
|
{samples && samples.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
|
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
|
||||||
<Volume2 className="h-8 w-8 text-muted-foreground/50 mb-2" />
|
<HugeiconsIcon icon={VolumeHighIcon} size={32} className="h-8 w-8 text-muted-foreground/50 mb-2" />
|
||||||
<p className="text-sm text-muted-foreground">No samples yet</p>
|
<p className="text-sm text-muted-foreground">No samples yet</p>
|
||||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||||
Add your first audio sample to get started
|
Add your first audio sample to get started
|
||||||
@@ -236,7 +233,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
/* Edit Mode */
|
/* Edit Mode */
|
||||||
<div className="p-4 space-y-3">
|
<div className="p-4 space-y-3">
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
|
||||||
<Edit className="h-3 w-3" />
|
<HugeiconsIcon icon={Edit01Icon} size={12} className="h-3 w-3" />
|
||||||
<span>Editing transcription</span>
|
<span>Editing transcription</span>
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -254,7 +251,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
onClick={handleCancelEdit}
|
onClick={handleCancelEdit}
|
||||||
disabled={updateSample.isPending}
|
disabled={updateSample.isPending}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4 mr-1" />
|
<HugeiconsIcon icon={Cancel01Icon} size={16} className="h-4 w-4 mr-1" />
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -263,7 +260,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
onClick={() => handleSaveEdit(sample.id)}
|
onClick={() => handleSaveEdit(sample.id)}
|
||||||
disabled={updateSample.isPending}
|
disabled={updateSample.isPending}
|
||||||
>
|
>
|
||||||
<Check className="h-4 w-4 mr-1" />
|
<HugeiconsIcon icon={CheckmarkCircle01Icon} size={16} className="h-4 w-4 mr-1" />
|
||||||
{updateSample.isPending ? 'Saving...' : 'Save'}
|
{updateSample.isPending ? 'Saving...' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -282,12 +279,12 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<CircleButton
|
<CircleButton
|
||||||
icon={Edit}
|
icon={(props) => <HugeiconsIcon icon={Edit01Icon} size={14} {...props} />}
|
||||||
title="Edit transcription"
|
title="Edit transcription"
|
||||||
onClick={() => handleStartEdit(sample.id, sample.reference_text)}
|
onClick={() => handleStartEdit(sample.id, sample.reference_text)}
|
||||||
/>
|
/>
|
||||||
<CircleButton
|
<CircleButton
|
||||||
icon={Trash2}
|
icon={(props) => <HugeiconsIcon icon={Delete01Icon} size={14} {...props} />}
|
||||||
title="Delete sample"
|
title="Delete sample"
|
||||||
onClick={() => handleDeleteClick(sample.id)}
|
onClick={() => handleDeleteClick(sample.id)}
|
||||||
disabled={deleteSample.isPending}
|
disabled={deleteSample.isPending}
|
||||||
@@ -316,7 +313,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => setUploadOpen(true)}
|
onClick={() => setUploadOpen(true)}
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<HugeiconsIcon icon={Add01Icon} size={16} className="mr-2 h-4 w-4" />
|
||||||
Add Sample
|
Add Sample
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Mic, Monitor, Upload } from 'lucide-react';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { Mic01Icon, DeskIcon, Upload01Icon } from '@hugeicons/core-free-icons';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
@@ -236,16 +237,16 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||||
>
|
>
|
||||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||||
<Upload className="h-4 w-4 shrink-0" />
|
<HugeiconsIcon icon={Upload01Icon} size={16} className="h-4 w-4 shrink-0" />
|
||||||
Upload
|
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 shrink-0" />
|
<HugeiconsIcon icon={Mic01Icon} size={16} className="h-4 w-4 shrink-0" />
|
||||||
Record
|
Record
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||||
<Monitor className="h-4 w-4 shrink-0" />
|
<HugeiconsIcon icon={DeskIcon} size={16} className="h-4 w-4 shrink-0" />
|
||||||
System Audio
|
System Audio
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,340 +0,0 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Edit2, Mic, X } from 'lucide-react';
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import * as z from 'zod';
|
|
||||||
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@/components/ui/form';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
|
||||||
import { SampleList } from '@/components/VoiceProfiles/SampleList';
|
|
||||||
import { apiClient } from '@/lib/api/client';
|
|
||||||
import type { EffectConfig } from '@/lib/api/types';
|
|
||||||
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
|
|
||||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
|
||||||
import {
|
|
||||||
useDeleteAvatar,
|
|
||||||
useProfile,
|
|
||||||
useUpdateProfile,
|
|
||||||
useUploadAvatar,
|
|
||||||
} from '@/lib/hooks/useProfiles';
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
|
||||||
|
|
||||||
const profileSchema = z.object({
|
|
||||||
name: z.string().min(1, 'Name is required').max(100),
|
|
||||||
description: z.string().max(500).optional(),
|
|
||||||
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
|
||||||
});
|
|
||||||
|
|
||||||
type ProfileFormValues = z.infer<typeof profileSchema>;
|
|
||||||
|
|
||||||
interface VoiceInspectorProps {
|
|
||||||
profileId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
|
||||||
const { data: profile } = useProfile(profileId);
|
|
||||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
|
||||||
const isPlayerVisible = !!audioUrl;
|
|
||||||
const updateProfile = useUpdateProfile();
|
|
||||||
const uploadAvatar = useUploadAvatar();
|
|
||||||
const deleteAvatar = useDeleteAvatar();
|
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
|
||||||
const [avatarError, setAvatarError] = useState(false);
|
|
||||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const [effectsChain, setEffectsChain] = useState<EffectConfig[]>([]);
|
|
||||||
const [effectsDirty, setEffectsDirty] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm<ProfileFormValues>({
|
|
||||||
resolver: zodResolver(profileSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
language: 'en',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Populate form when profile loads
|
|
||||||
useEffect(() => {
|
|
||||||
if (profile) {
|
|
||||||
form.reset({
|
|
||||||
name: profile.name,
|
|
||||||
description: profile.description || '',
|
|
||||||
language: profile.language as LanguageCode,
|
|
||||||
});
|
|
||||||
setEffectsChain(profile.effects_chain ?? []);
|
|
||||||
setEffectsDirty(false);
|
|
||||||
}
|
|
||||||
}, [profile, form]);
|
|
||||||
|
|
||||||
// Avatar preview
|
|
||||||
useEffect(() => {
|
|
||||||
if (profile?.avatar_path) {
|
|
||||||
setAvatarPreview(`${serverUrl}/profiles/${profile.id}/avatar`);
|
|
||||||
} else {
|
|
||||||
setAvatarPreview(null);
|
|
||||||
}
|
|
||||||
setAvatarError(false);
|
|
||||||
}, [profile, serverUrl]);
|
|
||||||
|
|
||||||
function handleAvatarFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
if (!file.type.startsWith('image/')) {
|
|
||||||
toast({
|
|
||||||
title: 'Invalid file type',
|
|
||||||
description: 'Please select PNG, JPG, or WebP',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
|
||||||
toast({
|
|
||||||
title: 'File too large',
|
|
||||||
description: 'Image must be less than 5MB',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Upload immediately
|
|
||||||
uploadAvatar.mutate(
|
|
||||||
{ profileId, file },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
setAvatarPreview(URL.createObjectURL(file));
|
|
||||||
toast({ title: 'Avatar updated' });
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast({
|
|
||||||
title: 'Avatar upload failed',
|
|
||||||
description: err instanceof Error ? err.message : 'Unknown error',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRemoveAvatar() {
|
|
||||||
if (profile?.avatar_path) {
|
|
||||||
try {
|
|
||||||
await deleteAvatar.mutateAsync(profileId);
|
|
||||||
toast({ title: 'Avatar removed' });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: 'Failed to remove avatar',
|
|
||||||
description: err instanceof Error ? err.message : 'Unknown error',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setAvatarPreview(null);
|
|
||||||
if (avatarInputRef.current) avatarInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSubmit(data: ProfileFormValues) {
|
|
||||||
try {
|
|
||||||
await updateProfile.mutateAsync({
|
|
||||||
profileId,
|
|
||||||
data: {
|
|
||||||
name: data.name,
|
|
||||||
description: data.description,
|
|
||||||
language: data.language,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (effectsDirty) {
|
|
||||||
try {
|
|
||||||
await apiClient.updateProfileEffects(
|
|
||||||
profileId,
|
|
||||||
effectsChain.length > 0 ? effectsChain : null,
|
|
||||||
);
|
|
||||||
setEffectsDirty(false);
|
|
||||||
} catch (fxError) {
|
|
||||||
toast({
|
|
||||||
title: 'Effects update failed',
|
|
||||||
description: fxError instanceof Error ? fxError.message : 'Failed to save effects',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({ title: 'Voice updated', description: `"${data.name}" saved.` });
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Error',
|
|
||||||
description: error instanceof Error ? error.message : 'Failed to save profile',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!profile) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDirty = form.formState.isDirty || effectsDirty;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col overflow-hidden">
|
|
||||||
<div className={cn('flex-1 overflow-y-auto', isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}>
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-0">
|
|
||||||
{/* Avatar */}
|
|
||||||
<div className="flex justify-center pt-5 pb-3">
|
|
||||||
<div className="relative group">
|
|
||||||
<div className="h-20 w-20 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden border-2 border-border">
|
|
||||||
{avatarPreview && !avatarError ? (
|
|
||||||
<img
|
|
||||||
src={avatarPreview}
|
|
||||||
alt={profile.name}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
onError={() => setAvatarError(true)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Mic className="h-8 w-8 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => avatarInputRef.current?.click()}
|
|
||||||
className="absolute inset-0 rounded-full bg-accent/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer"
|
|
||||||
>
|
|
||||||
<Edit2 className="h-5 w-5 text-accent-foreground" />
|
|
||||||
</button>
|
|
||||||
{avatarPreview && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleRemoveAvatar}
|
|
||||||
disabled={deleteAvatar.isPending}
|
|
||||||
className="absolute bottom-0 right-0 h-5 w-5 rounded-full bg-background/60 backdrop-blur-sm text-muted-foreground flex items-center justify-center hover:bg-background/80 hover:text-foreground transition-colors shadow-sm border border-border/50"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
ref={avatarInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/png,image/jpeg,image/webp"
|
|
||||||
onChange={handleAvatarFileChange}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Fields */}
|
|
||||||
<div className="space-y-3 px-5">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="My Voice" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="description"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Description</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea placeholder="Describe this voice..." rows={2} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="language"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Language</FormLabel>
|
|
||||||
<Select onValueChange={field.onChange} value={field.value}>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
{LANGUAGE_OPTIONS.map((lang) => (
|
|
||||||
<SelectItem key={lang.value} value={lang.value}>
|
|
||||||
{lang.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Effects */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<FormLabel>Default Effects</FormLabel>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Applied automatically to new generations with this voice.
|
|
||||||
</p>
|
|
||||||
<EffectsChainEditor
|
|
||||||
value={effectsChain}
|
|
||||||
onChange={(chain) => {
|
|
||||||
setEffectsChain(chain);
|
|
||||||
setEffectsDirty(true);
|
|
||||||
}}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save */}
|
|
||||||
{isDirty && (
|
|
||||||
<Button type="submit" className="w-full" disabled={updateProfile.isPending}>
|
|
||||||
{updateProfile.isPending ? 'Saving...' : 'Save Changes'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Samples */}
|
|
||||||
<div className="px-5 pb-5">
|
|
||||||
<SampleList profileId={profileId} />
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Mic, Plus, Search, Sparkles } from 'lucide-react';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { Edit01Icon, MoreHorizontalIcon, Add01Icon, Delete01Icon } from '@hugeicons/core-free-icons';
|
||||||
|
import { useMemo, useRef } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { MultiSelect } from '@/components/ui/multi-select';
|
import { MultiSelect } from '@/components/ui/multi-select';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -14,49 +19,37 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm';
|
import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm';
|
||||||
|
import { ProfileAvatar } from '@/components/VoiceProfiles/ProfileAvatar';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import { useProfiles } from '@/lib/hooks/useProfiles';
|
import { useHistory } from '@/lib/hooks/useHistory';
|
||||||
|
import { useDeleteProfile, useProfileSamples, useProfiles } from '@/lib/hooks/useProfiles';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
import { VoiceInspector } from './VoiceInspector';
|
|
||||||
|
|
||||||
export function VoicesTab() {
|
export function VoicesTab() {
|
||||||
const { data: profiles, isLoading } = useProfiles();
|
const { data: profiles, isLoading } = useProfiles();
|
||||||
|
const { data: historyData } = useHistory({ limit: 1000 });
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||||
const selectedVoiceId = useUIStore((state) => state.selectedVoiceId);
|
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
||||||
const setSelectedVoiceId = useUIStore((state) => state.setSelectedVoiceId);
|
const deleteProfile = useDeleteProfile();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||||
const isPlayerVisible = !!audioUrl;
|
const isPlayerVisible = !!audioUrl;
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
|
|
||||||
const filteredProfiles = useMemo(() => {
|
// Get generation counts per profile
|
||||||
if (!profiles) return [];
|
const generationCounts = useMemo(() => {
|
||||||
if (!search.trim()) return profiles;
|
const counts: Record<string, number> = {};
|
||||||
const q = search.toLowerCase();
|
if (historyData?.items) {
|
||||||
return profiles.filter(
|
historyData.items.forEach((item) => {
|
||||||
(p) =>
|
counts[item.profile_id] = (counts[item.profile_id] || 0) + 1;
|
||||||
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
|
return counts;
|
||||||
if (selectedVoiceId && profiles && !profiles.find((p) => p.id === selectedVoiceId)) {
|
}, [historyData]);
|
||||||
setSelectedVoiceId(profiles.length > 0 ? profiles[0].id : null);
|
|
||||||
}
|
|
||||||
}, [profiles, selectedVoiceId, setSelectedVoiceId]);
|
|
||||||
|
|
||||||
// Get channel assignments for each profile
|
// Get channel assignments for each profile
|
||||||
const { data: channelAssignments } = useQuery({
|
const { data: channelAssignments } = useQuery({
|
||||||
@@ -83,6 +76,17 @@ export function VoicesTab() {
|
|||||||
queryFn: () => apiClient.listChannels(),
|
queryFn: () => apiClient.listChannels(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleEdit = (profileId: string) => {
|
||||||
|
setEditingProfileId(profileId);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (profileId: string) => {
|
||||||
|
if (confirm('Are you sure you want to delete this profile?')) {
|
||||||
|
deleteProfile.mutate(profileId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleChannelChange = async (profileId: string, channelIds: string[]) => {
|
const handleChannelChange = async (profileId: string, channelIds: string[]) => {
|
||||||
try {
|
try {
|
||||||
await apiClient.setProfileChannels(profileId, channelIds);
|
await apiClient.setProfileChannels(profileId, channelIds);
|
||||||
@@ -101,76 +105,56 @@ export function VoicesTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex gap-0 overflow-hidden -mx-8">
|
<div className="h-full flex flex-col relative overflow-hidden">
|
||||||
{/* Left: Table */}
|
{/* Scroll Mask - Always visible, behind content */}
|
||||||
<div className="flex-1 min-w-0 flex flex-col relative overflow-hidden">
|
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||||
{/* Scroll Mask */}
|
|
||||||
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
|
||||||
|
|
||||||
{/* Fixed Header */}
|
{/* Fixed Header */}
|
||||||
<div className="absolute top-0 left-0 right-0 z-20 pl-8 pr-8">
|
<div className="absolute top-0 left-0 right-0 z-20">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold">Voices</h1>
|
<h1 className="text-2xl font-bold">Voices</h1>
|
||||||
<div className="flex-1" />
|
<Button onClick={() => setDialogOpen(true)}>
|
||||||
<div className="relative w-[240px]">
|
<HugeiconsIcon icon={Add01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
New Voice
|
||||||
<Input
|
</Button>
|
||||||
placeholder="Search voices..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
className="h-10 pl-8 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
New Voice
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scrollable Content */}
|
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
className={cn(
|
|
||||||
'flex-1 overflow-y-auto overflow-x-hidden pt-16 relative z-0',
|
|
||||||
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Table className="table-fixed [&_td:first-child]:pl-8 [&_th:first-child]:pl-8">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="w-[30%]">Name</TableHead>
|
|
||||||
<TableHead className="w-[10%]">Language</TableHead>
|
|
||||||
<TableHead className="w-[10%]">Generations</TableHead>
|
|
||||||
<TableHead className="w-[8%]">Samples</TableHead>
|
|
||||||
<TableHead className="w-[8%]">Effects</TableHead>
|
|
||||||
<TableHead className="w-[24%]">Channels</TableHead>
|
|
||||||
<TableHead className="w-6"></TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredProfiles.map((profile) => (
|
|
||||||
<VoiceRow
|
|
||||||
key={profile.id}
|
|
||||||
profile={profile}
|
|
||||||
isSelected={selectedVoiceId === profile.id}
|
|
||||||
onSelect={() => setSelectedVoiceId(profile.id)}
|
|
||||||
channelIds={channelAssignments?.[profile.id] || []}
|
|
||||||
channels={channels || []}
|
|
||||||
onChannelChange={(channelIds) => handleChannelChange(profile.id, channelIds)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Inspector */}
|
{/* Scrollable Content */}
|
||||||
{selectedVoiceId && (
|
<div
|
||||||
<div className="w-[340px] shrink-0 border-l border-t rounded-tl-xl bg-muted/30">
|
ref={scrollRef}
|
||||||
<VoiceInspector key={selectedVoiceId} profileId={selectedVoiceId} />
|
className={cn(
|
||||||
</div>
|
'flex-1 overflow-y-auto pt-16 relative z-0',
|
||||||
)}
|
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Language</TableHead>
|
||||||
|
<TableHead>Generations</TableHead>
|
||||||
|
<TableHead>Samples</TableHead>
|
||||||
|
<TableHead>Channels</TableHead>
|
||||||
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{profiles?.map((profile) => (
|
||||||
|
<VoiceRow
|
||||||
|
key={profile.id}
|
||||||
|
profile={profile}
|
||||||
|
generationCount={generationCounts[profile.id] || 0}
|
||||||
|
channelIds={channelAssignments?.[profile.id] || []}
|
||||||
|
channels={channels || []}
|
||||||
|
onChannelChange={(channelIds) => handleChannelChange(profile.id, channelIds)}
|
||||||
|
onEdit={() => handleEdit(profile.id)}
|
||||||
|
onDelete={() => handleDelete(profile.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ProfileForm />
|
<ProfileForm />
|
||||||
</div>
|
</div>
|
||||||
@@ -179,71 +163,46 @@ export function VoicesTab() {
|
|||||||
|
|
||||||
interface VoiceRowProps {
|
interface VoiceRowProps {
|
||||||
profile: VoiceProfileResponse;
|
profile: VoiceProfileResponse;
|
||||||
isSelected: boolean;
|
generationCount: number;
|
||||||
onSelect: () => void;
|
|
||||||
channelIds: string[];
|
channelIds: string[];
|
||||||
channels: Array<{ id: string; name: string; is_default: boolean }>;
|
channels: Array<{ id: string; name: string; is_default: boolean }>;
|
||||||
onChannelChange: (channelIds: string[]) => void;
|
onChannelChange: (channelIds: string[]) => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function VoiceRow({
|
function VoiceRow({
|
||||||
profile,
|
profile,
|
||||||
isSelected,
|
generationCount,
|
||||||
onSelect,
|
|
||||||
channelIds,
|
channelIds,
|
||||||
channels,
|
channels,
|
||||||
onChannelChange,
|
onChannelChange,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
}: VoiceRowProps) {
|
}: VoiceRowProps) {
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const { data: samples } = useProfileSamples(profile.id);
|
||||||
const [avatarError, setAvatarError] = useState(false);
|
|
||||||
const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null;
|
|
||||||
|
|
||||||
const enabledEffects = profile.effects_chain?.filter((e) => e.enabled) ?? [];
|
|
||||||
const effectsSummary = enabledEffects.map((e) => e.type).join(' → ');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow className="cursor-pointer" onClick={onEdit}>
|
||||||
className={cn('cursor-pointer', isSelected ? 'bg-muted/50' : 'hover:bg-muted/50')}
|
|
||||||
onClick={onSelect}
|
|
||||||
>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex w-full min-w-0 items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
<ProfileAvatar
|
||||||
{avatarUrl && !avatarError ? (
|
profileId={profile.id}
|
||||||
<img
|
avatarPath={profile.avatar_path}
|
||||||
src={avatarUrl}
|
size="md"
|
||||||
alt={`${profile.name} avatar`}
|
alt={`${profile.name} avatar`}
|
||||||
className="h-full w-full object-cover"
|
/>
|
||||||
onError={() => setAvatarError(true)}
|
<div>
|
||||||
/>
|
<div className="font-medium">{profile.name}</div>
|
||||||
) : (
|
|
||||||
<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 && (
|
{profile.description && (
|
||||||
<div className="text-sm text-muted-foreground truncate">{profile.description}</div>
|
<div className="text-sm text-muted-foreground">{profile.description}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{profile.language}</TableCell>
|
<TableCell onClick={(e) => e.stopPropagation()}>{profile.language}</TableCell>
|
||||||
<TableCell>{profile.generation_count}</TableCell>
|
<TableCell onClick={(e) => e.stopPropagation()}>{generationCount}</TableCell>
|
||||||
<TableCell>{profile.sample_count}</TableCell>
|
<TableCell onClick={(e) => e.stopPropagation()}>{samples?.length || 0}</TableCell>
|
||||||
<TableCell>
|
|
||||||
{enabledEffects.length > 0 ? (
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1 text-xs text-accent"
|
|
||||||
title={effectsSummary}
|
|
||||||
>
|
|
||||||
<Sparkles className="h-3 w-3 fill-accent" />
|
|
||||||
{enabledEffects.length}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
options={channels.map((ch) => ({
|
options={channels.map((ch) => ({
|
||||||
@@ -253,10 +212,28 @@ function VoiceRow({
|
|||||||
value={channelIds}
|
value={channelIds}
|
||||||
onChange={onChannelChange}
|
onChange={onChannelChange}
|
||||||
placeholder="Select channels..."
|
placeholder="Select channels..."
|
||||||
className="w-full"
|
className="min-w-[200px]"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell />
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem onClick={onEdit}>
|
||||||
|
<HugeiconsIcon icon={Edit01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onDelete} className="text-destructive">
|
||||||
|
<HugeiconsIcon icon={Delete01Icon} size={16} className="h-4 w-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Check } from 'lucide-react';
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { CheckmarkCircle01Icon } from '@hugeicons/core-free-icons';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
export interface CheckboxProps {
|
export interface CheckboxProps {
|
||||||
@@ -34,7 +35,7 @@ const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{checked && <Check className="h-3 w-3 text-accent-foreground" />}
|
{checked && <HugeiconsIcon icon={CheckmarkCircle01Icon} size={12} className="h-3 w-3 text-accent-foreground" />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { X } from 'lucide-react';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { Cancel01Icon } from '@hugeicons/core-free-icons';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ const DialogContent = React.forwardRef<
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
<X className="h-4 w-4" />
|
<HugeiconsIcon icon={Cancel01Icon} size={16} className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { MoreHorizontalIcon } from '@hugeicons/core-free-icons';
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/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 * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<MoreHorizontal className="ml-auto h-4 w-4" />
|
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} className="ml-auto h-4 w-4" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
));
|
));
|
||||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
@@ -97,7 +98,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|||||||
>
|
>
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<HugeiconsIcon icon={MoreHorizontalIcon} size={16} className="h-4 w-4" />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
@@ -119,7 +120,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|||||||
>
|
>
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<MoreHorizontal className="h-2 w-2 fill-current" />
|
<HugeiconsIcon icon={MoreHorizontalIcon} size={8} className="h-2 w-2 fill-current" />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ChevronDown, Check } from 'lucide-react';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { ArrowDown01Icon, CheckmarkCircle01Icon } from '@hugeicons/core-free-icons';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -36,7 +37,7 @@ const MultiSelectCheckboxItem = React.forwardRef<
|
|||||||
>
|
>
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<Check className="h-4 w-4" />
|
<HugeiconsIcon icon={CheckmarkCircle01Icon} size={16} className="h-4 w-4" />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
@@ -78,7 +79,7 @@ export function MultiSelect({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="line-clamp-1">{displayText}</span>
|
<span className="line-clamp-1">{displayText}</span>
|
||||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
<HugeiconsIcon icon={ArrowDown01Icon} size={16} className="h-4 w-4 opacity-50" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CircleIcon } from '@hugeicons/core-free-icons';
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
const RadioGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return <RadioGroupPrimitive.Root className={cn('grid gap-2', className)} {...props} ref={ref} />;
|
||||||
|
});
|
||||||
|
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const RadioGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'aspect-square h-4 w-4 rounded-full border border-accent text-accent ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||||
|
<HugeiconsIcon
|
||||||
|
icon={CircleIcon}
|
||||||
|
size={10}
|
||||||
|
className="h-2.5 w-2.5 fill-current text-current"
|
||||||
|
/>
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem };
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { CheckmarkCircle01Icon, ArrowDown01Icon, ArrowUp01Icon } from '@hugeicons/core-free-icons';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
@@ -16,14 +17,14 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<SelectPrimitive.Icon asChild>
|
<SelectPrimitive.Icon asChild>
|
||||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
<HugeiconsIcon icon={ArrowDown01Icon} size={16} className="h-4 w-4 opacity-50" />
|
||||||
</SelectPrimitive.Icon>
|
</SelectPrimitive.Icon>
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
));
|
));
|
||||||
@@ -38,7 +39,7 @@ const SelectScrollUpButton = React.forwardRef<
|
|||||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUp className="h-4 w-4" />
|
<HugeiconsIcon icon={ArrowUp01Icon} size={16} className="h-4 w-4" />
|
||||||
</SelectPrimitive.ScrollUpButton>
|
</SelectPrimitive.ScrollUpButton>
|
||||||
));
|
));
|
||||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
@@ -52,7 +53,7 @@ const SelectScrollDownButton = React.forwardRef<
|
|||||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-4 w-4" />
|
<HugeiconsIcon icon={ArrowDown01Icon} size={16} className="h-4 w-4" />
|
||||||
</SelectPrimitive.ScrollDownButton>
|
</SelectPrimitive.ScrollDownButton>
|
||||||
));
|
));
|
||||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||||
@@ -108,14 +109,14 @@ 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 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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<SelectPrimitive.ItemIndicator>
|
<SelectPrimitive.ItemIndicator>
|
||||||
<Check className="h-4 w-4" />
|
<HugeiconsIcon icon={CheckmarkCircle01Icon} size={16} className="h-4 w-4" />
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as SliderPrimitive from '@radix-ui/react-slider';
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import * as SliderPrimitive from '@radix-ui/react-slider';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
const Slider = React.forwardRef<
|
const Slider = React.forwardRef<
|
||||||
@@ -14,7 +14,7 @@ const Slider = React.forwardRef<
|
|||||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||||
</SliderPrimitive.Track>
|
</SliderPrimitive.Track>
|
||||||
<SliderPrimitive.Thumb className="block h-0 w-0 outline-none disabled:pointer-events-none disabled:opacity-50 after:block after:h-5 after:w-5 after:rounded-full after:border-2 after:border-primary after:bg-background after:ring-offset-background after:transition-colors after:absolute after:top-1/2 after:left-1/2 after:-translate-x-1/2 after:-translate-y-1/2 focus-visible:after:ring-2 focus-visible:after:ring-ring focus-visible:after:ring-offset-2" />
|
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 translate-x-0.5" />
|
||||||
</SliderPrimitive.Root>
|
</SliderPrimitive.Root>
|
||||||
));
|
));
|
||||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||||
|
|||||||
@@ -14,11 +14,7 @@ const TableHeader = React.forwardRef<
|
|||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<thead
|
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn('[&_tr]:border-b [&_tr]:hover:bg-transparent', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
TableHeader.displayName = 'TableHeader';
|
TableHeader.displayName = 'TableHeader';
|
||||||
|
|
||||||
@@ -46,7 +42,10 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
|
|||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<tr
|
<tr
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('border-b hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
|
className={cn(
|
||||||
|
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as ToastPrimitives from '@radix-ui/react-toast';
|
import * as ToastPrimitives from '@radix-ui/react-toast';
|
||||||
import { cva, type VariantProps } from 'class-variance-authority';
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
import { X } from 'lucide-react';
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { Cancel01Icon } from '@hugeicons/core-free-icons';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
@@ -79,7 +80,7 @@ const ToastClose = React.forwardRef<
|
|||||||
toast-close=""
|
toast-close=""
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<HugeiconsIcon icon={Cancel01Icon} size={16} className="h-4 w-4" />
|
||||||
</ToastPrimitives.Close>
|
</ToastPrimitives.Close>
|
||||||
));
|
));
|
||||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { usePlayerStore } from '@/stores/playerStore';
|
|
||||||
import {
|
import {
|
||||||
Toast,
|
Toast,
|
||||||
ToastClose,
|
ToastClose,
|
||||||
@@ -11,7 +10,6 @@ import { useToast } from './use-toast';
|
|||||||
|
|
||||||
export function Toaster() {
|
export function Toaster() {
|
||||||
const { toasts } = useToast();
|
const { toasts } = useToast();
|
||||||
const isPlayerOpen = !!usePlayerStore((s) => s.audioUrl);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
@@ -25,7 +23,7 @@ export function Toaster() {
|
|||||||
<ToastClose />
|
<ToastClose />
|
||||||
</Toast>
|
</Toast>
|
||||||
))}
|
))}
|
||||||
<ToastViewport className={isPlayerOpen ? 'sm:bottom-44' : ''} />
|
<ToastViewport />
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
|
||||||
|
|
||||||
export interface ToggleProps {
|
|
||||||
checked?: boolean;
|
|
||||||
onCheckedChange?: (checked: boolean) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
className?: string;
|
|
||||||
id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(
|
|
||||||
({ checked = false, onCheckedChange, disabled = false, className, id, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
ref={ref}
|
|
||||||
id={id}
|
|
||||||
role="switch"
|
|
||||||
aria-checked={checked}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => {
|
|
||||||
if (!disabled && onCheckedChange) {
|
|
||||||
onCheckedChange(!checked);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors',
|
|
||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
||||||
checked ? 'bg-accent' : 'bg-muted-foreground/25',
|
|
||||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform',
|
|
||||||
checked ? 'translate-x-[18px]' : 'translate-x-[2px]',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
Toggle.displayName = 'Toggle';
|
|
||||||
|
|
||||||
export { Toggle };
|
|
||||||
Vendored
-5
@@ -1,8 +1,3 @@
|
|||||||
interface Window {
|
interface Window {
|
||||||
__voiceboxServerStartedByApp?: boolean;
|
__voiceboxServerStartedByApp?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'virtual:changelog' {
|
|
||||||
const raw: string;
|
|
||||||
export default raw;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
|
||||||
import type { UpdateStatus } from '@/platform/types';
|
|
||||||
|
|
||||||
// Re-export UpdateStatus for backwards compatibility
|
|
||||||
export type { UpdateStatus };
|
|
||||||
|
|
||||||
export function useAutoUpdater(checkOnMount = false) {
|
|
||||||
const platform = usePlatform();
|
|
||||||
const [status, setStatus] = useState<UpdateStatus>(platform.updater.getStatus());
|
|
||||||
const hasCheckedRef = useRef(false);
|
|
||||||
|
|
||||||
// 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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) {
|
|
||||||
hasCheckedRef.current = true;
|
|
||||||
checkForUpdates();
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [platform.metadata.isTauricheckOnMountcheckForUpdates]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
status,
|
|
||||||
checkForUpdates,
|
|
||||||
downloadAndInstall,
|
|
||||||
restartAndInstall,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Download, RefreshCw } from 'lucide-react';
|
import { Download01Icon, Refresh01Icon } from '@hugeicons/core-free-icons';
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { ToastAction } from '@/components/ui/toast';
|
import { ToastAction } from '@/components/ui/toast';
|
||||||
@@ -73,7 +74,7 @@ export function useAutoUpdater(options: boolean | UseAutoUpdaterOptions = false)
|
|||||||
}
|
}
|
||||||
// Empty dependency array - only run once on mount
|
// Empty dependency array - only run once on mount
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [platform.metadata.isTauricheckOnMountcheckForUpdates]);
|
}, [platform.metadata.isTauri, checkOnMount, checkForUpdates]);
|
||||||
|
|
||||||
// Show toast when update is available
|
// Show toast when update is available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -132,7 +133,7 @@ export function useAutoUpdater(options: boolean | UseAutoUpdaterOptions = false)
|
|||||||
toastUpdateRef.current({
|
toastUpdateRef.current({
|
||||||
title: (
|
title: (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Download className="h-4 w-4 animate-pulse" />
|
<HugeiconsIcon icon={Download01Icon} size={16} className="h-4 w-4 animate-pulse" />
|
||||||
<span>Downloading Update</span>
|
<span>Downloading Update</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -174,7 +175,7 @@ export function useAutoUpdater(options: boolean | UseAutoUpdaterOptions = false)
|
|||||||
duration: Infinity,
|
duration: Infinity,
|
||||||
action: (
|
action: (
|
||||||
<ToastAction altText="Restart now" onClick={handleRestartNow}>
|
<ToastAction altText="Restart now" onClick={handleRestartNow}>
|
||||||
<RefreshCw className="h-3 w-3 mr-1" />
|
<HugeiconsIcon icon={Refresh01Icon} size={12} className="h-3 w-3 mr-1" />
|
||||||
Restart Now
|
Restart Now
|
||||||
</ToastAction>
|
</ToastAction>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
@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);
|
||||||
@@ -156,18 +155,3 @@
|
|||||||
animation: fadeIn 0.5s ease-out 0.15s forwards;
|
animation: fadeIn 0.5s ease-out 0.15s forwards;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* react-loaders */
|
|
||||||
.line-scale-pulse-out-rapid > div,
|
|
||||||
.line-scale > div {
|
|
||||||
background-color: hsl(var(--accent)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader-hidden {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader-hidden > div > div {
|
|
||||||
animation-play-state: paused !important;
|
|
||||||
background-color: hsl(var(--muted-foreground)) !important;
|
|
||||||
}
|
|
||||||
|
|||||||
+80
-212
@@ -2,15 +2,9 @@ import type { LanguageCode } from '@/lib/constants/languages';
|
|||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
import type {
|
import type {
|
||||||
ActiveTasksResponse,
|
ActiveTasksResponse,
|
||||||
ApplyEffectsRequest,
|
FolderPathsResponse,
|
||||||
AvailableEffectsResponse,
|
|
||||||
CudaStatus,
|
|
||||||
EffectConfig,
|
|
||||||
EffectPresetCreate,
|
|
||||||
EffectPresetResponse,
|
|
||||||
GenerationRequest,
|
GenerationRequest,
|
||||||
GenerationResponse,
|
GenerationResponse,
|
||||||
GenerationVersionResponse,
|
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
HistoryListResponse,
|
HistoryListResponse,
|
||||||
HistoryQuery,
|
HistoryQuery,
|
||||||
@@ -27,29 +21,12 @@ import type {
|
|||||||
StoryItemReorder,
|
StoryItemReorder,
|
||||||
StoryItemSplit,
|
StoryItemSplit,
|
||||||
StoryItemTrim,
|
StoryItemTrim,
|
||||||
StoryItemVersionUpdate,
|
|
||||||
StoryResponse,
|
StoryResponse,
|
||||||
TranscriptionResponse,
|
TranscriptionResponse,
|
||||||
VoiceProfileCreate,
|
VoiceProfileCreate,
|
||||||
VoiceProfileResponse,
|
VoiceProfileResponse,
|
||||||
WhisperModelSize,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
function formatErrorDetail(detail: unknown, fallback: string): string {
|
|
||||||
if (typeof detail === 'string') return detail;
|
|
||||||
if (Array.isArray(detail)) {
|
|
||||||
return detail
|
|
||||||
.map((e: Record<string, unknown>) => e.msg || e.message || JSON.stringify(e))
|
|
||||||
.join('; ');
|
|
||||||
}
|
|
||||||
if (detail && typeof detail === 'object') {
|
|
||||||
const obj = detail as Record<string, unknown>;
|
|
||||||
if (typeof obj.message === 'string') return obj.message;
|
|
||||||
return JSON.stringify(detail);
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
private getBaseUrl(): string {
|
private getBaseUrl(): string {
|
||||||
const serverUrl = useServerStore.getState().serverUrl;
|
const serverUrl = useServerStore.getState().serverUrl;
|
||||||
@@ -70,7 +47,7 @@ class ApiClient {
|
|||||||
const error = await response.json().catch(() => ({
|
const error = await response.json().catch(() => ({
|
||||||
detail: response.statusText,
|
detail: response.statusText,
|
||||||
}));
|
}));
|
||||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
@@ -81,6 +58,11 @@ class ApiClient {
|
|||||||
return this.request<HealthResponse>('/health');
|
return this.request<HealthResponse>('/health');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// System
|
||||||
|
async getSystemFolders(): Promise<FolderPathsResponse> {
|
||||||
|
return this.request<FolderPathsResponse>('/system/folders');
|
||||||
|
}
|
||||||
|
|
||||||
// Profiles
|
// Profiles
|
||||||
async createProfile(data: VoiceProfileCreate): Promise<VoiceProfileResponse> {
|
async createProfile(data: VoiceProfileCreate): Promise<VoiceProfileResponse> {
|
||||||
return this.request<VoiceProfileResponse>('/profiles', {
|
return this.request<VoiceProfileResponse>('/profiles', {
|
||||||
@@ -129,7 +111,7 @@ class ApiClient {
|
|||||||
const error = await response.json().catch(() => ({
|
const error = await response.json().catch(() => ({
|
||||||
detail: response.statusText,
|
detail: response.statusText,
|
||||||
}));
|
}));
|
||||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
@@ -163,7 +145,7 @@ class ApiClient {
|
|||||||
const error = await response.json().catch(() => ({
|
const error = await response.json().catch(() => ({
|
||||||
detail: response.statusText,
|
detail: response.statusText,
|
||||||
}));
|
}));
|
||||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.blob();
|
return response.blob();
|
||||||
@@ -183,7 +165,7 @@ class ApiClient {
|
|||||||
const error = await response.json().catch(() => ({
|
const error = await response.json().catch(() => ({
|
||||||
detail: response.statusText,
|
detail: response.statusText,
|
||||||
}));
|
}));
|
||||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
@@ -203,7 +185,7 @@ class ApiClient {
|
|||||||
const error = await response.json().catch(() => ({
|
const error = await response.json().catch(() => ({
|
||||||
detail: response.statusText,
|
detail: response.statusText,
|
||||||
}));
|
}));
|
||||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
@@ -223,21 +205,74 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async retryGeneration(generationId: string): Promise<GenerationResponse> {
|
// Providers
|
||||||
return this.request<GenerationResponse>(`/generate/${generationId}/retry`, {
|
async listProviders(): Promise<{
|
||||||
|
providers: Array<{
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
installed: boolean;
|
||||||
|
size_mb: number | null;
|
||||||
|
}>;
|
||||||
|
installed: string[];
|
||||||
|
}> {
|
||||||
|
return this.request('/providers');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveProvider(): Promise<{
|
||||||
|
provider: string;
|
||||||
|
health: {
|
||||||
|
status: string;
|
||||||
|
provider: string;
|
||||||
|
version: string | null;
|
||||||
|
model: string | null;
|
||||||
|
device: string | null;
|
||||||
|
};
|
||||||
|
status: {
|
||||||
|
model_loaded: boolean;
|
||||||
|
model_size: string | null;
|
||||||
|
available_sizes: string[];
|
||||||
|
gpu_available: boolean | null;
|
||||||
|
vram_used_mb: number | null;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
return this.request('/providers/active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async startProvider(providerType: string): Promise<{
|
||||||
|
message: string;
|
||||||
|
provider: {
|
||||||
|
status: string;
|
||||||
|
provider: string;
|
||||||
|
version: string | null;
|
||||||
|
model: string | null;
|
||||||
|
device: string | null;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
return this.request('/providers/start', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ provider_type: providerType }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopProvider(): Promise<{ message: string }> {
|
||||||
|
return this.request('/providers/stop', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async regenerateGeneration(generationId: string): Promise<GenerationResponse> {
|
async downloadProvider(providerType: string): Promise<{
|
||||||
return this.request<GenerationResponse>(`/generate/${generationId}/regenerate`, {
|
message: string;
|
||||||
|
provider_type: string;
|
||||||
|
}> {
|
||||||
|
return this.request('/providers/download', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ provider_type: providerType }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleFavorite(generationId: string): Promise<{ is_favorited: boolean }> {
|
async deleteProvider(providerType: string): Promise<{ message: string }> {
|
||||||
return this.request<{ is_favorited: boolean }>(`/history/${generationId}/favorite`, {
|
return this.request(`/providers/${providerType}`, {
|
||||||
method: 'POST',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +308,7 @@ class ApiClient {
|
|||||||
const error = await response.json().catch(() => ({
|
const error = await response.json().catch(() => ({
|
||||||
detail: response.statusText,
|
detail: response.statusText,
|
||||||
}));
|
}));
|
||||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.blob();
|
return response.blob();
|
||||||
@@ -287,13 +322,15 @@ class ApiClient {
|
|||||||
const error = await response.json().catch(() => ({
|
const error = await response.json().catch(() => ({
|
||||||
detail: response.statusText,
|
detail: response.statusText,
|
||||||
}));
|
}));
|
||||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.blob();
|
return response.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
async importGeneration(file: File): Promise<{
|
async importGeneration(
|
||||||
|
file: File,
|
||||||
|
): Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
profile_id: string;
|
profile_id: string;
|
||||||
profile_name: string;
|
profile_name: string;
|
||||||
@@ -313,17 +350,12 @@ class ApiClient {
|
|||||||
const error = await response.json().catch(() => ({
|
const error = await response.json().catch(() => ({
|
||||||
detail: response.statusText,
|
detail: response.statusText,
|
||||||
}));
|
}));
|
||||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generation status SSE
|
|
||||||
getGenerationStatusUrl(generationId: string): string {
|
|
||||||
return `${this.getBaseUrl()}/generate/${generationId}/status`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audio
|
// Audio
|
||||||
getAudioUrl(audioId: string): string {
|
getAudioUrl(audioId: string): string {
|
||||||
return `${this.getBaseUrl()}/audio/${audioId}`;
|
return `${this.getBaseUrl()}/audio/${audioId}`;
|
||||||
@@ -334,19 +366,12 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Transcription
|
// Transcription
|
||||||
async transcribeAudio(
|
async transcribeAudio(file: File, language?: LanguageCode): Promise<TranscriptionResponse> {
|
||||||
file: File,
|
|
||||||
language?: LanguageCode,
|
|
||||||
model?: WhisperModelSize,
|
|
||||||
): Promise<TranscriptionResponse> {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
if (language) {
|
if (language) {
|
||||||
formData.append('language', language);
|
formData.append('language', language);
|
||||||
}
|
}
|
||||||
if (model) {
|
|
||||||
formData.append('model', model);
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${this.getBaseUrl()}/transcribe`;
|
const url = `${this.getBaseUrl()}/transcribe`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -358,7 +383,7 @@ class ApiClient {
|
|||||||
const error = await response.json().catch(() => ({
|
const error = await response.json().catch(() => ({
|
||||||
detail: response.statusText,
|
detail: response.statusText,
|
||||||
}));
|
}));
|
||||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
@@ -369,21 +394,6 @@ 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 }> {
|
||||||
console.log(
|
console.log(
|
||||||
'[API] triggerModelDownload called for:',
|
'[API] triggerModelDownload called for:',
|
||||||
@@ -405,28 +415,11 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async unloadModel(modelName: string): Promise<{ message: string }> {
|
|
||||||
return this.request<{ message: string }>(`/models/${modelName}/unload`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async cancelDownload(modelName: string): Promise<{ message: string }> {
|
|
||||||
return this.request<{ message: string }>('/models/download/cancel', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Task Management
|
// Task Management
|
||||||
async getActiveTasks(): Promise<ActiveTasksResponse> {
|
async getActiveTasks(): Promise<ActiveTasksResponse> {
|
||||||
return this.request<ActiveTasksResponse>('/tasks/active');
|
return this.request<ActiveTasksResponse>('/tasks/active');
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearAllTasks(): Promise<{ message: string }> {
|
|
||||||
return this.request<{ message: string }>('/tasks/clear', { method: 'POST' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audio Channels
|
// Audio Channels
|
||||||
async listChannels(): Promise<
|
async listChannels(): Promise<
|
||||||
Array<{
|
Array<{
|
||||||
@@ -500,23 +493,6 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// CUDA Backend Management
|
|
||||||
async getCudaStatus(): Promise<CudaStatus> {
|
|
||||||
return this.request<CudaStatus>('/backend/cuda-status');
|
|
||||||
}
|
|
||||||
|
|
||||||
async downloadCudaBackend(): Promise<{ message: string; progress_key: string }> {
|
|
||||||
return this.request<{ message: string; progress_key: string }>('/backend/download-cuda', {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteCudaBackend(): Promise<{ message: string }> {
|
|
||||||
return this.request<{ message: string }>('/backend/cuda', {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stories
|
// Stories
|
||||||
async listStories(): Promise<StoryResponse[]> {
|
async listStories(): Promise<StoryResponse[]> {
|
||||||
return this.request<StoryResponse[]>('/stories');
|
return this.request<StoryResponse[]>('/stories');
|
||||||
@@ -612,17 +588,6 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async setStoryItemVersion(
|
|
||||||
storyId: string,
|
|
||||||
itemId: string,
|
|
||||||
data: StoryItemVersionUpdate,
|
|
||||||
): Promise<StoryItemDetail> {
|
|
||||||
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/version`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async exportStoryAudio(storyId: string): Promise<Blob> {
|
async exportStoryAudio(storyId: string): Promise<Blob> {
|
||||||
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
|
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
@@ -631,104 +596,7 @@ class ApiClient {
|
|||||||
const error = await response.json().catch(() => ({
|
const error = await response.json().catch(() => ({
|
||||||
detail: response.statusText,
|
detail: response.statusText,
|
||||||
}));
|
}));
|
||||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
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(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.blob();
|
return response.blob();
|
||||||
|
|||||||
+9
-141
@@ -13,9 +13,6 @@ export interface VoiceProfileResponse {
|
|||||||
description?: string;
|
description?: string;
|
||||||
language: string;
|
language: string;
|
||||||
avatar_path?: string;
|
avatar_path?: string;
|
||||||
effects_chain?: EffectConfig[];
|
|
||||||
generation_count: number;
|
|
||||||
sample_count: number;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -31,35 +28,13 @@ export interface ProfileSampleResponse {
|
|||||||
reference_text: string;
|
reference_text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EffectConfig {
|
|
||||||
type: string;
|
|
||||||
enabled: boolean;
|
|
||||||
params: Record<string, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GenerationRequest {
|
export interface GenerationRequest {
|
||||||
profile_id: string;
|
profile_id: string;
|
||||||
text: string;
|
text: string;
|
||||||
language: LanguageCode;
|
language: LanguageCode;
|
||||||
seed?: number;
|
seed?: number;
|
||||||
model_size?: '1.7B' | '0.6B';
|
model_size?: '1.7B' | '0.6B';
|
||||||
engine?: 'qwen' | 'luxtts' | 'chatterbox' | 'chatterbox_turbo';
|
|
||||||
instruct?: string;
|
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 {
|
||||||
@@ -67,18 +42,10 @@ export interface GenerationResponse {
|
|||||||
profile_id: string;
|
profile_id: string;
|
||||||
text: string;
|
text: string;
|
||||||
language: string;
|
language: string;
|
||||||
audio_path?: string;
|
audio_path: string;
|
||||||
duration?: number;
|
duration: number;
|
||||||
seed?: number;
|
seed?: number;
|
||||||
instruct?: string;
|
|
||||||
engine?: string;
|
|
||||||
model_size?: string;
|
|
||||||
status: 'loading_model' | 'generating' | 'completed' | 'failed';
|
|
||||||
error?: string;
|
|
||||||
is_favorited?: boolean;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
versions?: GenerationVersionResponse[];
|
|
||||||
active_version_id?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryQuery {
|
export interface HistoryQuery {
|
||||||
@@ -90,8 +57,6 @@ 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 {
|
||||||
@@ -99,11 +64,8 @@ export interface HistoryListResponse {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WhisperModelSize = 'base' | 'small' | 'medium' | 'large' | 'turbo';
|
|
||||||
|
|
||||||
export interface TranscriptionRequest {
|
export interface TranscriptionRequest {
|
||||||
language?: LanguageCode;
|
language?: LanguageCode;
|
||||||
model?: WhisperModelSize;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TranscriptionResponse {
|
export interface TranscriptionResponse {
|
||||||
@@ -117,29 +79,7 @@ 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 {
|
||||||
@@ -156,29 +96,12 @@ 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
|
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[];
|
||||||
}
|
}
|
||||||
@@ -191,11 +114,6 @@ export interface ActiveDownloadTask {
|
|||||||
model_name: string;
|
model_name: string;
|
||||||
status: string;
|
status: string;
|
||||||
started_at: string;
|
started_at: string;
|
||||||
error?: string;
|
|
||||||
progress?: number; // 0-100 percentage
|
|
||||||
current?: number; // bytes downloaded
|
|
||||||
total?: number; // total bytes
|
|
||||||
filename?: string; // current file being downloaded
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveGenerationTask {
|
export interface ActiveGenerationTask {
|
||||||
@@ -210,6 +128,12 @@ export interface ActiveTasksResponse {
|
|||||||
generations: ActiveGenerationTask[];
|
generations: ActiveGenerationTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FolderPathsResponse {
|
||||||
|
data_dir: string;
|
||||||
|
models_dir: string;
|
||||||
|
providers_dir: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StoryCreate {
|
export interface StoryCreate {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -228,7 +152,6 @@ export interface StoryItemDetail {
|
|||||||
id: string;
|
id: string;
|
||||||
story_id: string;
|
story_id: string;
|
||||||
generation_id: string;
|
generation_id: string;
|
||||||
version_id?: string;
|
|
||||||
start_time_ms: number;
|
start_time_ms: number;
|
||||||
track: number;
|
track: number;
|
||||||
trim_start_ms: number;
|
trim_start_ms: number;
|
||||||
@@ -243,12 +166,6 @@ export interface StoryItemDetail {
|
|||||||
seed?: number;
|
seed?: number;
|
||||||
instruct?: string;
|
instruct?: string;
|
||||||
generation_created_at: string;
|
generation_created_at: string;
|
||||||
versions?: GenerationVersionResponse[];
|
|
||||||
active_version_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StoryItemVersionUpdate {
|
|
||||||
version_id: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoryDetailResponse {
|
export interface StoryDetailResponse {
|
||||||
@@ -292,52 +209,3 @@ export interface StoryItemTrim {
|
|||||||
export interface StoryItemSplit {
|
export interface StoryItemSplit {
|
||||||
split_time_ms: number;
|
split_time_ms: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Effects
|
|
||||||
|
|
||||||
export interface EffectPresetResponse {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
effects_chain: EffectConfig[];
|
|
||||||
is_builtin: boolean;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EffectPresetCreate {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
effects_chain: EffectConfig[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EffectPresetUpdate {
|
|
||||||
name?: string;
|
|
||||||
description?: string;
|
|
||||||
effects_chain?: EffectConfig[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AvailableEffectParam {
|
|
||||||
default: number;
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
step: number;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AvailableEffect {
|
|
||||||
type: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
params: Record<string, AvailableEffectParam>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AvailableEffectsResponse {
|
|
||||||
effects: AvailableEffect[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApplyEffectsRequest {
|
|
||||||
effects_chain: EffectConfig[];
|
|
||||||
source_version_id?: string;
|
|
||||||
label?: string;
|
|
||||||
set_as_default?: boolean;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,86 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* Supported languages for voice generation, per engine.
|
* Supported languages for Qwen3-TTS
|
||||||
*
|
* Based on: https://github.com/QwenLM/Qwen3-TTS
|
||||||
* Qwen3-TTS supports 10 languages.
|
|
||||||
* LuxTTS is English-only.
|
|
||||||
* Chatterbox Multilingual supports 23 languages.
|
|
||||||
* Chatterbox Turbo is English-only.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** All languages that any engine supports. */
|
export const SUPPORTED_LANGUAGES = {
|
||||||
export const ALL_LANGUAGES = {
|
zh: 'Chinese',
|
||||||
ar: 'Arabic',
|
|
||||||
da: 'Danish',
|
|
||||||
de: 'German',
|
|
||||||
el: 'Greek',
|
|
||||||
en: 'English',
|
en: 'English',
|
||||||
es: 'Spanish',
|
|
||||||
fi: 'Finnish',
|
|
||||||
fr: 'French',
|
|
||||||
he: 'Hebrew',
|
|
||||||
hi: 'Hindi',
|
|
||||||
it: 'Italian',
|
|
||||||
ja: 'Japanese',
|
ja: 'Japanese',
|
||||||
ko: 'Korean',
|
ko: 'Korean',
|
||||||
ms: 'Malay',
|
de: 'German',
|
||||||
nl: 'Dutch',
|
fr: 'French',
|
||||||
no: 'Norwegian',
|
|
||||||
pl: 'Polish',
|
|
||||||
pt: 'Portuguese',
|
|
||||||
ru: 'Russian',
|
ru: 'Russian',
|
||||||
sv: 'Swedish',
|
pt: 'Portuguese',
|
||||||
sw: 'Swahili',
|
es: 'Spanish',
|
||||||
tr: 'Turkish',
|
it: 'Italian',
|
||||||
zh: 'Chinese',
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type LanguageCode = keyof typeof ALL_LANGUAGES;
|
export type LanguageCode = keyof typeof SUPPORTED_LANGUAGES;
|
||||||
|
|
||||||
/** Per-engine supported language codes. */
|
export const LANGUAGE_CODES = Object.keys(SUPPORTED_LANGUAGES) as LanguageCode[];
|
||||||
export const ENGINE_LANGUAGES: Record<string, readonly LanguageCode[]> = {
|
|
||||||
qwen: ['zh', 'en', 'ja', 'ko', 'de', 'fr', 'ru', 'pt', 'es', 'it'],
|
|
||||||
luxtts: ['en'],
|
|
||||||
chatterbox: [
|
|
||||||
'ar',
|
|
||||||
'da',
|
|
||||||
'de',
|
|
||||||
'el',
|
|
||||||
'en',
|
|
||||||
'es',
|
|
||||||
'fi',
|
|
||||||
'fr',
|
|
||||||
'he',
|
|
||||||
'hi',
|
|
||||||
'it',
|
|
||||||
'ja',
|
|
||||||
'ko',
|
|
||||||
'ms',
|
|
||||||
'nl',
|
|
||||||
'no',
|
|
||||||
'pl',
|
|
||||||
'pt',
|
|
||||||
'ru',
|
|
||||||
'sv',
|
|
||||||
'sw',
|
|
||||||
'tr',
|
|
||||||
'zh',
|
|
||||||
],
|
|
||||||
chatterbox_turbo: ['en'],
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/** Helper: get language options for a given engine. */
|
|
||||||
export function getLanguageOptionsForEngine(engine: string) {
|
|
||||||
const codes = ENGINE_LANGUAGES[engine] ?? ENGINE_LANGUAGES.qwen;
|
|
||||||
return codes.map((code) => ({
|
|
||||||
value: code,
|
|
||||||
label: ALL_LANGUAGES[code],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Backwards-compatible exports used elsewhere ──────────────────────
|
|
||||||
export const SUPPORTED_LANGUAGES = ALL_LANGUAGES;
|
|
||||||
export const LANGUAGE_CODES = Object.keys(ALL_LANGUAGES) as LanguageCode[];
|
|
||||||
export const LANGUAGE_OPTIONS = LANGUAGE_CODES.map((code) => ({
|
export const LANGUAGE_OPTIONS = LANGUAGE_CODES.map((code) => ({
|
||||||
value: code,
|
value: code,
|
||||||
label: ALL_LANGUAGES[code],
|
label: SUPPORTED_LANGUAGES[code],
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -2,14 +2,11 @@
|
|||||||
* UI layout constants for safe area padding
|
* UI layout constants for safe area padding
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const isWindows = typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top safe area padding - height of the drag region bar
|
* Top safe area padding - height of the drag region bar
|
||||||
* On macOS this accounts for the overlay titlebar (48px).
|
* Corresponds to Tailwind's pt-12 (3rem / 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';
|
export const TOP_SAFE_AREA_PADDING = 'pt-12';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bottom safe area padding - height of the audio player
|
* Bottom safe area padding - height of the audio player
|
||||||
|
|||||||
@@ -20,13 +20,11 @@ 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
|
||||||
@@ -89,34 +87,31 @@ export function useAudioRecording({
|
|||||||
};
|
};
|
||||||
|
|
||||||
mediaRecorder.onstop = async () => {
|
mediaRecorder.onstop = async () => {
|
||||||
// Snapshot the cancellation flag and recorded duration immediately —
|
|
||||||
// cancelRecording() clears chunks and sets cancelledRef synchronously
|
|
||||||
// before this async handler runs, so we must check it first.
|
|
||||||
const wasCancelled = cancelledRef.current;
|
|
||||||
const recordedDuration = startTimeRef.current
|
|
||||||
? (Date.now() - startTimeRef.current) / 1000
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||||
|
|
||||||
// Stop all tracks now that we have the data
|
|
||||||
streamRef.current?.getTracks().forEach((track) => {
|
|
||||||
track.stop();
|
|
||||||
});
|
|
||||||
streamRef.current = null;
|
|
||||||
|
|
||||||
// Don't fire completion callback if the recording was cancelled
|
|
||||||
if (wasCancelled) return;
|
|
||||||
|
|
||||||
// Convert to WAV format to avoid needing ffmpeg on backend
|
// Convert to WAV format to avoid needing ffmpeg on backend
|
||||||
try {
|
try {
|
||||||
const wavBlob = await convertToWav(webmBlob);
|
const wavBlob = await convertToWav(webmBlob);
|
||||||
|
|
||||||
|
// Pass the actual recorded duration
|
||||||
|
const recordedDuration = startTimeRef.current
|
||||||
|
? (Date.now() - startTimeRef.current) / 1000
|
||||||
|
: undefined;
|
||||||
onRecordingComplete?.(wavBlob, recordedDuration);
|
onRecordingComplete?.(wavBlob, recordedDuration);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error converting audio to WAV:', err);
|
console.error('Error converting audio to WAV:', err);
|
||||||
// Fallback to original blob if conversion fails
|
// Fallback to original blob if conversion fails
|
||||||
|
const recordedDuration = startTimeRef.current
|
||||||
|
? (Date.now() - startTimeRef.current) / 1000
|
||||||
|
: undefined;
|
||||||
onRecordingComplete?.(webmBlob, recordedDuration);
|
onRecordingComplete?.(webmBlob, recordedDuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop all tracks
|
||||||
|
streamRef.current?.getTracks().forEach((track) => {
|
||||||
|
track.stop();
|
||||||
|
});
|
||||||
|
streamRef.current = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
mediaRecorder.onerror = (event) => {
|
mediaRecorder.onerror = (event) => {
|
||||||
@@ -172,10 +167,9 @@ export function useAudioRecording({
|
|||||||
|
|
||||||
const cancelRecording = useCallback(() => {
|
const cancelRecording = useCallback(() => {
|
||||||
if (mediaRecorderRef.current) {
|
if (mediaRecorderRef.current) {
|
||||||
cancelledRef.current = true; // Must be set before stop() triggers onstop
|
|
||||||
chunksRef.current = [];
|
|
||||||
mediaRecorderRef.current.stop();
|
mediaRecorderRef.current.stop();
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
|
chunksRef.current = [];
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,20 +4,18 @@ import { useForm } from 'react-hook-form';
|
|||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { EffectConfig } from '@/lib/api/types';
|
|
||||||
import { LANGUAGE_CODES, type LanguageCode } from '@/lib/constants/languages';
|
import { LANGUAGE_CODES, type LanguageCode } from '@/lib/constants/languages';
|
||||||
import { useGeneration } from '@/lib/hooks/useGeneration';
|
import { useGeneration } from '@/lib/hooks/useGeneration';
|
||||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
const generationSchema = z.object({
|
const generationSchema = z.object({
|
||||||
text: z.string().min(1, '').max(50000),
|
text: z.string().min(1, 'Text is required').max(5000),
|
||||||
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
||||||
seed: z.number().int().optional(),
|
seed: z.number().int().optional(),
|
||||||
modelSize: z.enum(['1.7B', '0.6B']).optional(),
|
modelSize: z.enum(['1.7B', '0.6B']).optional(),
|
||||||
instruct: z.string().max(500).optional(),
|
instruct: z.string().max(500).optional(),
|
||||||
engine: z.enum(['qwen', 'luxtts', 'chatterbox', 'chatterbox_turbo']).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type GenerationFormValues = z.infer<typeof generationSchema>;
|
export type GenerationFormValues = z.infer<typeof generationSchema>;
|
||||||
@@ -25,16 +23,13 @@ export type GenerationFormValues = z.infer<typeof generationSchema>;
|
|||||||
interface UseGenerationFormOptions {
|
interface UseGenerationFormOptions {
|
||||||
onSuccess?: (generationId: string) => void;
|
onSuccess?: (generationId: string) => void;
|
||||||
defaultValues?: Partial<GenerationFormValues>;
|
defaultValues?: Partial<GenerationFormValues>;
|
||||||
getEffectsChain?: () => EffectConfig[] | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const generation = useGeneration();
|
const generation = useGeneration();
|
||||||
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
|
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
||||||
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
|
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
|
||||||
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
|
|
||||||
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
|
|
||||||
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
|
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
|
||||||
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -52,7 +47,6 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
seed: undefined,
|
seed: undefined,
|
||||||
modelSize: '1.7B',
|
modelSize: '1.7B',
|
||||||
instruct: '',
|
instruct: '',
|
||||||
engine: 'qwen',
|
|
||||||
...options.defaultValues,
|
...options.defaultValues,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -71,27 +65,11 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const engine = data.engine || 'qwen';
|
setIsGenerating(true);
|
||||||
const modelName =
|
|
||||||
engine === 'luxtts'
|
const modelName = `qwen-tts-${data.modelSize}`;
|
||||||
? 'luxtts'
|
const displayName = data.modelSize === '1.7B' ? 'Qwen TTS 1.7B' : 'Qwen TTS 0.6B';
|
||||||
: engine === 'chatterbox'
|
|
||||||
? 'chatterbox-tts'
|
|
||||||
: engine === 'chatterbox_turbo'
|
|
||||||
? 'chatterbox-turbo'
|
|
||||||
: `qwen-tts-${data.modelSize}`;
|
|
||||||
const displayName =
|
|
||||||
engine === 'luxtts'
|
|
||||||
? 'LuxTTS'
|
|
||||||
: engine === 'chatterbox'
|
|
||||||
? 'Chatterbox TTS'
|
|
||||||
: engine === 'chatterbox_turbo'
|
|
||||||
? 'Chatterbox Turbo'
|
|
||||||
: data.modelSize === '1.7B'
|
|
||||||
? 'Qwen TTS 1.7B'
|
|
||||||
: 'Qwen TTS 0.6B';
|
|
||||||
|
|
||||||
// Check if model needs downloading
|
|
||||||
try {
|
try {
|
||||||
const modelStatus = await apiClient.getModelStatus();
|
const modelStatus = await apiClient.getModelStatus();
|
||||||
const model = modelStatus.models.find((m) => m.model_name === modelName);
|
const model = modelStatus.models.find((m) => m.model_name === modelName);
|
||||||
@@ -104,35 +82,19 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
console.error('Failed to check model status:', error);
|
console.error('Failed to check model status:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isQwen = engine === 'qwen';
|
|
||||||
const effectsChain = options.getEffectsChain?.();
|
|
||||||
// This now returns immediately with status="generating"
|
|
||||||
const result = await generation.mutateAsync({
|
const result = await generation.mutateAsync({
|
||||||
profile_id: selectedProfileId,
|
profile_id: selectedProfileId,
|
||||||
text: data.text,
|
text: data.text,
|
||||||
language: data.language,
|
language: data.language,
|
||||||
seed: data.seed,
|
seed: data.seed,
|
||||||
model_size: isQwen ? data.modelSize : undefined,
|
model_size: data.modelSize,
|
||||||
engine,
|
instruct: data.instruct || undefined,
|
||||||
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
|
const audioUrl = apiClient.getAudioUrl(result.id);
|
||||||
addPendingGeneration(result.id);
|
setAudioWithAutoPlay(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
|
||||||
|
|
||||||
// Reset form immediately — user can start typing again
|
form.reset();
|
||||||
form.reset({
|
|
||||||
text: '',
|
|
||||||
language: data.language,
|
|
||||||
seed: undefined,
|
|
||||||
modelSize: data.modelSize,
|
|
||||||
instruct: '',
|
|
||||||
engine: data.engine,
|
|
||||||
});
|
|
||||||
options.onSuccess?.(result.id);
|
options.onSuccess?.(result.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
@@ -141,6 +103,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
|||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
setDownloadingModelName(null);
|
setDownloadingModelName(null);
|
||||||
setDownloadingDisplayName(null);
|
setDownloadingDisplayName(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
|
||||||
import { apiClient } from '@/lib/api/client';
|
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
|
||||||
import { usePlayerStore } from '@/stores/playerStore';
|
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
|
||||||
|
|
||||||
interface GenerationStatusEvent {
|
|
||||||
id: string;
|
|
||||||
status: 'loading_model' | 'generating' | 'completed' | 'failed' | 'not_found';
|
|
||||||
duration?: number;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribes to SSE for all pending generations. When a generation completes,
|
|
||||||
* invalidates the history query, removes it from pending, and auto-plays
|
|
||||||
* if the player is idle.
|
|
||||||
*/
|
|
||||||
export function useGenerationProgress() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const pendingIds = useGenerationStore((s) => s.pendingGenerationIds);
|
|
||||||
const removePendingGeneration = useGenerationStore((s) => s.removePendingGeneration);
|
|
||||||
const removePendingStoryAdd = useGenerationStore((s) => s.removePendingStoryAdd);
|
|
||||||
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
|
||||||
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
|
|
||||||
const autoplayOnGenerate = useServerStore((s) => s.autoplayOnGenerate);
|
|
||||||
|
|
||||||
// Keep refs to avoid stale closures in EventSource handlers
|
|
||||||
const isPlayingRef = useRef(isPlaying);
|
|
||||||
const autoplayRef = useRef(autoplayOnGenerate);
|
|
||||||
isPlayingRef.current = isPlaying;
|
|
||||||
autoplayRef.current = autoplayOnGenerate;
|
|
||||||
|
|
||||||
// Track active EventSource instances
|
|
||||||
const eventSourcesRef = useRef<Map<string, EventSource>>(new Map());
|
|
||||||
|
|
||||||
// Unmount-only cleanup — close all SSE connections when the hook is torn down
|
|
||||||
useEffect(() => {
|
|
||||||
const sources = eventSourcesRef.current;
|
|
||||||
return () => {
|
|
||||||
for (const source of sources.values()) {
|
|
||||||
source.close();
|
|
||||||
}
|
|
||||||
sources.clear();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const currentSources = eventSourcesRef.current;
|
|
||||||
|
|
||||||
// Close SSE connections for IDs no longer pending
|
|
||||||
for (const [id, source] of currentSources.entries()) {
|
|
||||||
if (!pendingIds.has(id)) {
|
|
||||||
source.close();
|
|
||||||
currentSources.delete(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open SSE connections for new pending IDs
|
|
||||||
for (const id of pendingIds) {
|
|
||||||
if (currentSources.has(id)) continue;
|
|
||||||
|
|
||||||
const url = apiClient.getGenerationStatusUrl(id);
|
|
||||||
const source = new EventSource(url);
|
|
||||||
|
|
||||||
source.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data: GenerationStatusEvent = JSON.parse(event.data);
|
|
||||||
|
|
||||||
if (data.status === 'completed') {
|
|
||||||
source.close();
|
|
||||||
currentSources.delete(id);
|
|
||||||
removePendingGeneration(id);
|
|
||||||
|
|
||||||
// Refetch history to pick up the completed generation
|
|
||||||
queryClient.refetchQueries({ 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.refetchQueries({ 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 = () => {
|
|
||||||
// SSE connection dropped — clean up and refresh history so any
|
|
||||||
// completed/failed generation still appears in the list
|
|
||||||
source.close();
|
|
||||||
currentSources.delete(id);
|
|
||||||
removePendingGeneration(id);
|
|
||||||
queryClient.refetchQueries({ queryKey: ['history'] });
|
|
||||||
};
|
|
||||||
|
|
||||||
currentSources.set(id, source);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
pendingIds,
|
|
||||||
removePendingGeneration,
|
|
||||||
removePendingStoryAdd,
|
|
||||||
queryClient,
|
|
||||||
toast,
|
|
||||||
setAudioWithAutoPlay,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
import { CancelCircleIcon, CheckmarkCircle02Icon } from '@hugeicons/core-free-icons';
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
@@ -10,7 +12,7 @@ interface UseModelDownloadToastOptions {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
onError?: (error: string) => void;
|
onError?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,7 +61,7 @@ export function useModelDownloadToast({
|
|||||||
title: displayName,
|
title: displayName,
|
||||||
description: (
|
description: (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||||
<span>Connecting to download...</span>
|
<span>Connecting to download...</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -96,19 +98,35 @@ export function useModelDownloadToast({
|
|||||||
|
|
||||||
switch (progress.status) {
|
switch (progress.status) {
|
||||||
case 'complete':
|
case 'complete':
|
||||||
statusIcon = <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
statusIcon = (
|
||||||
|
<HugeiconsIcon
|
||||||
|
icon={CheckmarkCircle02Icon}
|
||||||
|
size={16}
|
||||||
|
className="h-4 w-4 text-green-500"
|
||||||
|
/>
|
||||||
|
);
|
||||||
statusText = 'Download complete';
|
statusText = 'Download complete';
|
||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
statusIcon = <XCircle className="h-4 w-4 text-destructive" />;
|
statusIcon = (
|
||||||
statusText = 'Download failed. See Problems panel for details.';
|
<HugeiconsIcon
|
||||||
|
icon={CancelCircleIcon}
|
||||||
|
size={16}
|
||||||
|
className="h-4 w-4 text-destructive"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
statusText = `Error: ${progress.error || 'Unknown error'}`;
|
||||||
break;
|
break;
|
||||||
case 'downloading':
|
case 'downloading':
|
||||||
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
statusIcon = (
|
||||||
|
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||||
|
);
|
||||||
statusText = progress.filename || 'Downloading...';
|
statusText = progress.filename || 'Downloading...';
|
||||||
break;
|
break;
|
||||||
case 'extracting':
|
case 'extracting':
|
||||||
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
statusIcon = (
|
||||||
|
<Icon icon="svg-spinners:ring-resize" className="h-4 w-4 animate-spin" />
|
||||||
|
);
|
||||||
statusText = 'Extracting...';
|
statusText = 'Extracting...';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -131,7 +149,8 @@ export function useModelDownloadToast({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
duration: progress.status === 'complete' || progress.status === 'error' ? 5000 : Infinity,
|
duration: progress.status === 'complete' ? 5000 : Infinity,
|
||||||
|
variant: progress.status === 'error' ? 'destructive' : 'default',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close connection and dismiss toast on completion or error
|
// Close connection and dismiss toast on completion or error
|
||||||
@@ -153,7 +172,11 @@ export function useModelDownloadToast({
|
|||||||
toastUpdateRef.current({
|
toastUpdateRef.current({
|
||||||
title: (
|
title: (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
<HugeiconsIcon
|
||||||
|
icon={CheckmarkCircle02Icon}
|
||||||
|
size={16}
|
||||||
|
className="h-4 w-4 text-green-500"
|
||||||
|
/>
|
||||||
<span>{displayName}</span>
|
<span>{displayName}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -168,7 +191,7 @@ export function useModelDownloadToast({
|
|||||||
onComplete();
|
onComplete();
|
||||||
} else if (isError && onError) {
|
} else if (isError && onError) {
|
||||||
console.log('[useModelDownloadToast] Download error, calling onError callback');
|
console.log('[useModelDownloadToast] Download error, calling onError callback');
|
||||||
onError(progress.error || 'Unknown error');
|
onError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { ActiveDownloadTask } from '@/lib/api/types';
|
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
|
import type { ActiveDownloadTask } from '@/lib/api/types';
|
||||||
|
|
||||||
// Polling interval in milliseconds
|
// Polling interval in milliseconds
|
||||||
const POLL_INTERVAL = 30000;
|
const POLL_INTERVAL = 2000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to monitor active tasks (downloads and generations).
|
* Hook to monitor active tasks (downloads and generations).
|
||||||
* Polls the server periodically to catch downloads triggered from anywhere
|
* Polls the server periodically to catch downloads triggered from anywhere
|
||||||
* (transcription, generation, explicit download, etc.).
|
* (transcription, generation, explicit download, etc.).
|
||||||
*
|
*
|
||||||
* Returns the active downloads so components can render download toasts.
|
* Returns the active downloads so components can render download toasts.
|
||||||
*/
|
*/
|
||||||
export function useRestoreActiveTasks() {
|
export function useRestoreActiveTasks() {
|
||||||
const [activeDownloads, setActiveDownloads] = useState<ActiveDownloadTask[]>([]);
|
const [activeDownloads, setActiveDownloads] = useState<ActiveDownloadTask[]>([]);
|
||||||
|
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
|
||||||
const setActiveGenerationId = useGenerationStore((state) => state.setActiveGenerationId);
|
const setActiveGenerationId = useGenerationStore((state) => state.setActiveGenerationId);
|
||||||
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
|
|
||||||
|
|
||||||
// Track which downloads we've seen to detect new ones
|
// Track which downloads we've seen to detect new ones
|
||||||
const seenDownloadsRef = useRef<Set<string>>(new Set());
|
const seenDownloadsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
@@ -25,15 +25,15 @@ export function useRestoreActiveTasks() {
|
|||||||
try {
|
try {
|
||||||
const tasks = await apiClient.getActiveTasks();
|
const tasks = await apiClient.getActiveTasks();
|
||||||
|
|
||||||
// Restore pending generations (e.g., after page refresh)
|
// Update generation state
|
||||||
if (tasks.generations.length > 0) {
|
if (tasks.generations.length > 0) {
|
||||||
|
setIsGenerating(true);
|
||||||
setActiveGenerationId(tasks.generations[0].task_id);
|
setActiveGenerationId(tasks.generations[0].task_id);
|
||||||
for (const gen of tasks.generations) {
|
|
||||||
addPendingGeneration(gen.task_id);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
|
// Only clear if we were tracking a generation
|
||||||
const currentId = useGenerationStore.getState().activeGenerationId;
|
const currentId = useGenerationStore.getState().activeGenerationId;
|
||||||
if (currentId) {
|
if (currentId) {
|
||||||
|
setIsGenerating(false);
|
||||||
setActiveGenerationId(null);
|
setActiveGenerationId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,14 +41,14 @@ export function useRestoreActiveTasks() {
|
|||||||
// Update active downloads
|
// Update active downloads
|
||||||
// Keep track of all active downloads (including new ones)
|
// Keep track of all active downloads (including new ones)
|
||||||
const currentDownloadNames = new Set(tasks.downloads.map((d) => d.model_name));
|
const currentDownloadNames = new Set(tasks.downloads.map((d) => d.model_name));
|
||||||
|
|
||||||
// Remove completed downloads from our seen set
|
// Remove completed downloads from our seen set
|
||||||
for (const name of seenDownloadsRef.current) {
|
for (const name of seenDownloadsRef.current) {
|
||||||
if (!currentDownloadNames.has(name)) {
|
if (!currentDownloadNames.has(name)) {
|
||||||
seenDownloadsRef.current.delete(name);
|
seenDownloadsRef.current.delete(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new downloads to seen set
|
// Add new downloads to seen set
|
||||||
for (const download of tasks.downloads) {
|
for (const download of tasks.downloads) {
|
||||||
seenDownloadsRef.current.add(download.model_name);
|
seenDownloadsRef.current.add(download.model_name);
|
||||||
@@ -59,7 +59,7 @@ export function useRestoreActiveTasks() {
|
|||||||
// Silently fail - server might be temporarily unavailable
|
// Silently fail - server might be temporarily unavailable
|
||||||
console.debug('Failed to fetch active tasks:', error);
|
console.debug('Failed to fetch active tasks:', error);
|
||||||
}
|
}
|
||||||
}, [setActiveGenerationId, addPendingGeneration]);
|
}, [setIsGenerating, setActiveGenerationId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetch immediately on mount
|
// Fetch immediately on mount
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
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 {
|
import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder, StoryItemMove, StoryItemTrim, StoryItemSplit } from '@/lib/api/types';
|
||||||
StoryCreate,
|
|
||||||
StoryItemBatchUpdate,
|
|
||||||
StoryItemCreate,
|
|
||||||
StoryItemMove,
|
|
||||||
StoryItemReorder,
|
|
||||||
StoryItemSplit,
|
|
||||||
StoryItemTrim,
|
|
||||||
StoryItemVersionUpdate,
|
|
||||||
} from '@/lib/api/types';
|
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
export function useStories() {
|
export function useStories() {
|
||||||
@@ -118,15 +109,8 @@ export function useMoveStoryItem() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemMove }) =>
|
||||||
storyId,
|
apiClient.moveStoryItem(storyId, itemId, data),
|
||||||
itemId,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
storyId: string;
|
|
||||||
itemId: string;
|
|
||||||
data: StoryItemMove;
|
|
||||||
}) => apiClient.moveStoryItem(storyId, itemId, data),
|
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
@@ -138,15 +122,8 @@ export function useTrimStoryItem() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemTrim }) =>
|
||||||
storyId,
|
apiClient.trimStoryItem(storyId, itemId, data),
|
||||||
itemId,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
storyId: string;
|
|
||||||
itemId: string;
|
|
||||||
data: StoryItemTrim;
|
|
||||||
}) => apiClient.trimStoryItem(storyId, itemId, data),
|
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
@@ -158,15 +135,8 @@ export function useSplitStoryItem() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemSplit }) =>
|
||||||
storyId,
|
apiClient.splitStoryItem(storyId, itemId, data),
|
||||||
itemId,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
storyId: string;
|
|
||||||
itemId: string;
|
|
||||||
data: StoryItemSplit;
|
|
||||||
}) => apiClient.splitStoryItem(storyId, itemId, data),
|
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
||||||
@@ -187,26 +157,6 @@ export function useDuplicateStoryItem() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSetStoryItemVersion() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
storyId,
|
|
||||||
itemId,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
storyId: string;
|
|
||||||
itemId: string;
|
|
||||||
data: StoryItemVersionUpdate;
|
|
||||||
}) => apiClient.setStoryItemVersion(storyId, itemId, data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useExportStoryAudio() {
|
export function useExportStoryAudio() {
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
|
|
||||||
@@ -215,10 +165,7 @@ export function useExportStoryAudio() {
|
|||||||
const blob = await apiClient.exportStoryAudio(storyId);
|
const blob = await apiClient.exportStoryAudio(storyId);
|
||||||
|
|
||||||
// Create safe filename
|
// Create safe filename
|
||||||
const safeName = storyName
|
const safeName = storyName.substring(0, 50).replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||||
.substring(0, 50)
|
|
||||||
.replace(/[^a-z0-9]/gi, '-')
|
|
||||||
.toLowerCase();
|
|
||||||
const filename = `${safeName || 'story'}.wav`;
|
const filename = `${safeName || 'story'}.wav`;
|
||||||
|
|
||||||
await platform.filesystem.saveFile(filename, blob, [
|
await platform.filesystem.saveFile(filename, blob, [
|
||||||
|
|||||||
@@ -70,16 +70,6 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Resolve the audio buffer key and URL for an item.
|
|
||||||
// When a version_id is pinned, use that version's audio; otherwise use the generation default.
|
|
||||||
const getAudioKey = (item: StoryItemDetail) =>
|
|
||||||
item.version_id ? `v:${item.version_id}` : item.generation_id;
|
|
||||||
|
|
||||||
const getAudioUrlForItem = (item: StoryItemDetail) =>
|
|
||||||
item.version_id
|
|
||||||
? apiClient.getVersionAudioUrl(item.version_id)
|
|
||||||
: apiClient.getAudioUrl(item.generation_id);
|
|
||||||
|
|
||||||
// Preload audio files as AudioBuffers
|
// Preload audio files as AudioBuffers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!items || items.length === 0) {
|
if (!items || items.length === 0) {
|
||||||
@@ -88,12 +78,12 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentKeys = new Set(items.map(getAudioKey));
|
const currentIds = new Set(items.map((item) => item.generation_id));
|
||||||
const audioContext = getAudioContext();
|
const audioContext = getAudioContext();
|
||||||
|
|
||||||
// Remove buffers for items that no longer exist
|
// Remove buffers for items that no longer exist
|
||||||
for (const [id] of audioBuffersRef.current) {
|
for (const [id] of audioBuffersRef.current) {
|
||||||
if (!currentKeys.has(id)) {
|
if (!currentIds.has(id)) {
|
||||||
audioBuffersRef.current.delete(id);
|
audioBuffersRef.current.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,25 +91,24 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
|||||||
// Preload audio for new items
|
// Preload audio for new items
|
||||||
const preloadPromises: Promise<void>[] = [];
|
const preloadPromises: Promise<void>[] = [];
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const key = getAudioKey(item);
|
if (!audioBuffersRef.current.has(item.generation_id)) {
|
||||||
if (!audioBuffersRef.current.has(key)) {
|
const audioUrl = apiClient.getAudioUrl(item.generation_id);
|
||||||
const audioUrl = getAudioUrlForItem(item);
|
console.log('[StoryPlayback] Preloading audio buffer:', item.generation_id);
|
||||||
console.log('[StoryPlayback] Preloading audio buffer:', key);
|
|
||||||
|
|
||||||
const preloadPromise = fetch(audioUrl)
|
const preloadPromise = fetch(audioUrl)
|
||||||
.then((response) => response.arrayBuffer())
|
.then((response) => response.arrayBuffer())
|
||||||
.then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer))
|
.then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer))
|
||||||
.then((audioBuffer) => {
|
.then((audioBuffer) => {
|
||||||
audioBuffersRef.current.set(key, audioBuffer);
|
audioBuffersRef.current.set(item.generation_id, audioBuffer);
|
||||||
console.log(
|
console.log(
|
||||||
'[StoryPlayback] Preloaded buffer:',
|
'[StoryPlayback] Preloaded buffer:',
|
||||||
key,
|
item.generation_id,
|
||||||
'duration:',
|
'duration:',
|
||||||
audioBuffer.duration,
|
audioBuffer.duration,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('[StoryPlayback] Failed to preload audio:', key, err);
|
console.error('[StoryPlayback] Failed to preload audio:', item.generation_id, err);
|
||||||
});
|
});
|
||||||
|
|
||||||
preloadPromises.push(preloadPromise);
|
preloadPromises.push(preloadPromise);
|
||||||
@@ -227,16 +216,15 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
|
|||||||
// Schedule new sources for items that should be playing
|
// Schedule new sources for items that should be playing
|
||||||
for (const item of shouldBePlaying) {
|
for (const item of shouldBePlaying) {
|
||||||
if (!activeSourcesRef.current.has(item.id)) {
|
if (!activeSourcesRef.current.has(item.id)) {
|
||||||
const bufferKey = getAudioKey(item);
|
const buffer = audioBuffersRef.current.get(item.generation_id);
|
||||||
const buffer = audioBuffersRef.current.get(bufferKey);
|
|
||||||
if (!buffer) {
|
if (!buffer) {
|
||||||
console.warn('[StoryPlayback] Buffer not loaded for:', bufferKey);
|
console.warn('[StoryPlayback] Buffer not loaded for:', item.generation_id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate when this item should start in AudioContext time
|
// Calculate when this item should start in AudioContext time
|
||||||
const itemStartContextTime = storyTimeToContextTime(item.start_time_ms);
|
const itemStartContextTime = storyTimeToContextTime(item.start_time_ms);
|
||||||
|
|
||||||
// Calculate effective duration and trim offsets
|
// Calculate effective duration and trim offsets
|
||||||
const trimStartSec = (item.trim_start_ms || 0) / 1000;
|
const trimStartSec = (item.trim_start_ms || 0) / 1000;
|
||||||
const trimEndSec = (item.trim_end_ms || 0) / 1000;
|
const trimEndSec = (item.trim_end_ms || 0) / 1000;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
interface UseSystemAudioCaptureOptions {
|
interface UseSystemAudioCaptureOptions {
|
||||||
@@ -94,13 +94,15 @@ export function useSystemAudioCapture({
|
|||||||
const blob = await platform.audio.stopSystemAudioCapture();
|
const blob = await platform.audio.stopSystemAudioCapture();
|
||||||
|
|
||||||
// Pass the actual recorded duration
|
// Pass the actual recorded duration
|
||||||
const recordedDuration = startTimeRef.current
|
const recordedDuration = startTimeRef.current
|
||||||
? (Date.now() - startTimeRef.current) / 1000
|
? (Date.now() - startTimeRef.current) / 1000
|
||||||
: undefined;
|
: undefined;
|
||||||
onRecordingComplete?.(blob, recordedDuration);
|
onRecordingComplete?.(blob, recordedDuration);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
err instanceof Error ? err.message : 'Failed to stop system audio capture.';
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Failed to stop system audio capture.';
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
}
|
}
|
||||||
}, [isRecording, onRecordingComplete, platform]);
|
}, [isRecording, onRecordingComplete, platform]);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
|
export function useSystemFolders() {
|
||||||
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['system', 'folders', serverUrl],
|
||||||
|
queryFn: () => apiClient.getSystemFolders(),
|
||||||
|
staleTime: 60000, // Cache for 1 minute - folder paths don't change often
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,18 +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 { WhisperModelSize } from '@/lib/api/types';
|
|
||||||
import type { LanguageCode } from '@/lib/constants/languages';
|
import type { LanguageCode } from '@/lib/constants/languages';
|
||||||
|
|
||||||
export function useTranscription() {
|
export function useTranscription() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({ file, language }: { file: File; language?: LanguageCode }) =>
|
||||||
file,
|
apiClient.transcribeAudio(file, language),
|
||||||
language,
|
|
||||||
model,
|
|
||||||
}: {
|
|
||||||
file: File;
|
|
||||||
language?: LanguageCode;
|
|
||||||
model?: WhisperModelSize;
|
|
||||||
}) => apiClient.transcribeAudio(file, language, model),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-36
@@ -22,11 +22,6 @@ export function formatAudioDuration(seconds: number): string {
|
|||||||
* If the file has a recordedDuration property (from recording hooks),
|
* If the file has a recordedDuration property (from recording hooks),
|
||||||
* use that instead of trying to read metadata. This fixes issues on Windows
|
* use that instead of trying to read metadata. This fixes issues on Windows
|
||||||
* where WebM files from MediaRecorder don't have proper duration metadata.
|
* where WebM files from MediaRecorder don't have proper duration metadata.
|
||||||
*
|
|
||||||
* For uploaded files we use AudioContext.decodeAudioData which fully decodes
|
|
||||||
* the audio and returns the exact duration. This is more reliable than
|
|
||||||
* HTMLMediaElement.duration which can return incorrect large values for VBR
|
|
||||||
* MP3 files that lack a proper XING/VBRI header.
|
|
||||||
*/
|
*/
|
||||||
export async function getAudioDuration(
|
export async function getAudioDuration(
|
||||||
file: File & { recordedDuration?: number },
|
file: File & { recordedDuration?: number },
|
||||||
@@ -35,39 +30,26 @@ export async function getAudioDuration(
|
|||||||
return file.recordedDuration;
|
return file.recordedDuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use Web Audio API for accurate duration — avoids VBR MP3 metadata issues.
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
const audio = new Audio();
|
||||||
const audioContext = new AudioContext();
|
const url = URL.createObjectURL(file);
|
||||||
try {
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
|
||||||
return audioBuffer.duration;
|
|
||||||
} finally {
|
|
||||||
await audioContext.close();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fallback: read duration from the media element (less accurate but works for WAV).
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const audio = new Audio();
|
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
|
|
||||||
audio.addEventListener('loadedmetadata', () => {
|
audio.addEventListener('loadedmetadata', () => {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
||||||
resolve(audio.duration);
|
resolve(audio.duration);
|
||||||
} else {
|
} else {
|
||||||
reject(new Error('Audio file has invalid duration metadata'));
|
reject(new Error('Audio file has invalid duration metadata'));
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
audio.addEventListener('error', () => {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
reject(new Error('Failed to load audio file'));
|
|
||||||
});
|
|
||||||
|
|
||||||
audio.src = url;
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
audio.addEventListener('error', () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error('Failed to load audio file'));
|
||||||
|
});
|
||||||
|
|
||||||
|
audio.src = url;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -21,25 +21,10 @@ export function formatDate(date: string | Date): string {
|
|||||||
} else {
|
} else {
|
||||||
dateObj = date;
|
dateObj = date;
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatDistance(dateObj, new Date(), { addSuffix: true }).replace(/^about /i, '');
|
return formatDistance(dateObj, new Date(), { addSuffix: true }).replace(/^about /i, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ENGINE_DISPLAY_NAMES: Record<string, string> = {
|
|
||||||
qwen: 'Qwen',
|
|
||||||
luxtts: 'LuxTTS',
|
|
||||||
chatterbox: 'Chatterbox',
|
|
||||||
chatterbox_turbo: 'Chatterbox Turbo',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function formatEngineName(engine?: string, modelSize?: string): string {
|
|
||||||
const name = ENGINE_DISPLAY_NAMES[engine ?? 'qwen'] ?? engine ?? 'Qwen';
|
|
||||||
if (engine === 'qwen' && modelSize) {
|
|
||||||
return `${name} ${modelSize}`;
|
|
||||||
}
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatFileSize(bytes: number): string {
|
export function formatFileSize(bytes: number): string {
|
||||||
if (bytes === 0) return '0 Bytes';
|
if (bytes === 0) return '0 Bytes';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
export interface ChangelogEntry {
|
|
||||||
version: string;
|
|
||||||
date: string | null;
|
|
||||||
body: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses a Keep-a-Changelog style markdown string into structured entries.
|
|
||||||
*
|
|
||||||
* Splits on `## [version]` headings and extracts the version + date from each.
|
|
||||||
* The body is the raw markdown between headings (trimmed), with the leading
|
|
||||||
* `# Changelog` title and trailing link references stripped.
|
|
||||||
*/
|
|
||||||
export function parseChangelog(raw: string): ChangelogEntry[] {
|
|
||||||
const entries: ChangelogEntry[] = [];
|
|
||||||
|
|
||||||
// Strip trailing link reference definitions (e.g. [0.1.0]: https://...)
|
|
||||||
const cleaned = raw.replace(/^\[[\w.]+\]:.*$/gm, '').trimEnd();
|
|
||||||
|
|
||||||
// Match `## [version]` or `## [version] - date`
|
|
||||||
const headingRe = /^## \[(.+?)\](?:\s*-\s*(.+))?$/gm;
|
|
||||||
const matches = [...cleaned.matchAll(headingRe)];
|
|
||||||
|
|
||||||
for (let i = 0; i < matches.length; i++) {
|
|
||||||
const match = matches[i];
|
|
||||||
const version = match[1];
|
|
||||||
const date = match[2]?.trim() || null;
|
|
||||||
|
|
||||||
const start = match.index! + match[0].length;
|
|
||||||
const end = i + 1 < matches.length ? matches[i + 1].index! : cleaned.length;
|
|
||||||
const body = cleaned.slice(start, end).trim();
|
|
||||||
|
|
||||||
entries.push({ version, date, body });
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
@@ -10,8 +10,13 @@ export interface FileFilter {
|
|||||||
|
|
||||||
export interface PlatformFilesystem {
|
export interface PlatformFilesystem {
|
||||||
saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise<void>;
|
saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise<void>;
|
||||||
openPath(path: string): Promise<void>;
|
/**
|
||||||
pickDirectory(title: string): Promise<string | null>;
|
* Open a folder in the native file explorer.
|
||||||
|
* On web, this is a no-op since browsers cannot open folders.
|
||||||
|
* @param path - The absolute path to the folder to open
|
||||||
|
* @returns true if the folder was opened, false if not supported
|
||||||
|
*/
|
||||||
|
openFolder(path: string): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateStatus {
|
export interface UpdateStatus {
|
||||||
@@ -50,18 +55,11 @@ export interface PlatformAudio {
|
|||||||
stopPlayback(): void;
|
stopPlayback(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerLogEntry {
|
|
||||||
stream: 'stdout' | 'stderr';
|
|
||||||
line: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlatformLifecycle {
|
export interface PlatformLifecycle {
|
||||||
startServer(remote?: boolean, modelsDir?: string | null): Promise<string>;
|
startServer(remote?: boolean): Promise<string>;
|
||||||
stopServer(): Promise<void>;
|
stopServer(): Promise<void>;
|
||||||
restartServer(modelsDir?: string | null): Promise<string>;
|
|
||||||
setKeepServerRunning(keep: boolean): Promise<void>;
|
setKeepServerRunning(keep: boolean): Promise<void>;
|
||||||
setupWindowCloseHandler(): Promise<void>;
|
setupWindowCloseHandler(): Promise<void>;
|
||||||
subscribeToServerLogs(callback: (entry: ServerLogEntry) => void): () => void;
|
|
||||||
onServerReady?: () => void;
|
onServerReady?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-86
@@ -1,30 +1,15 @@
|
|||||||
import {
|
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router';
|
||||||
createRootRoute,
|
|
||||||
createRoute,
|
|
||||||
createRouter,
|
|
||||||
Outlet,
|
|
||||||
redirect,
|
|
||||||
} from '@tanstack/react-router';
|
|
||||||
import { AppFrame } from '@/components/AppFrame/AppFrame';
|
import { AppFrame } from '@/components/AppFrame/AppFrame';
|
||||||
import { AudioTab } from '@/components/AudioTab/AudioTab';
|
import { AudioTab } from '@/components/AudioTab/AudioTab';
|
||||||
import { EffectsTab } from '@/components/EffectsTab/EffectsTab';
|
|
||||||
import { MainEditor } from '@/components/MainEditor/MainEditor';
|
import { MainEditor } from '@/components/MainEditor/MainEditor';
|
||||||
import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
|
import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
|
||||||
import { AboutPage } from '@/components/ServerTab/AboutPage';
|
import { ServerTab } from '@/components/ServerTab/ServerTab';
|
||||||
import { ChangelogPage } from '@/components/ServerTab/ChangelogPage';
|
|
||||||
import { GeneralPage } from '@/components/ServerTab/GeneralPage';
|
|
||||||
import { GenerationPage } from '@/components/ServerTab/GenerationPage';
|
|
||||||
import { GpuPage } from '@/components/ServerTab/GpuPage';
|
|
||||||
import { LogsPage } from '@/components/ServerTab/LogsPage';
|
|
||||||
import { SettingsLayout } from '@/components/ServerTab/ServerTab';
|
|
||||||
import { Sidebar } from '@/components/Sidebar';
|
import { Sidebar } from '@/components/Sidebar';
|
||||||
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
|
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
|
||||||
import { Toaster } from '@/components/ui/toaster';
|
import { Toaster } from '@/components/ui/toaster';
|
||||||
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
|
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
|
||||||
import { useGenerationProgress } from '@/lib/hooks/useGenerationProgress';
|
|
||||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||||
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
|
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
|
||||||
|
|
||||||
// Simple platform check that works in both web and Tauri
|
// Simple platform check that works in both web and Tauri
|
||||||
const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
|
const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
|
||||||
|
|
||||||
@@ -33,9 +18,6 @@ function RootLayout() {
|
|||||||
// Monitor active downloads/generations and show toasts for them
|
// Monitor active downloads/generations and show toasts for them
|
||||||
const activeDownloads = useRestoreActiveTasks();
|
const activeDownloads = useRestoreActiveTasks();
|
||||||
|
|
||||||
// Subscribe to SSE for pending generations — handles completion, auto-play, and history refresh
|
|
||||||
useGenerationProgress();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppFrame>
|
<AppFrame>
|
||||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
@@ -118,13 +100,6 @@ const audioRoute = createRoute({
|
|||||||
component: AudioTab,
|
component: AudioTab,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Effects route
|
|
||||||
const effectsRoute = createRoute({
|
|
||||||
getParentRoute: () => rootRoute,
|
|
||||||
path: '/effects',
|
|
||||||
component: EffectsTab,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Models route
|
// Models route
|
||||||
const modelsRoute = createRoute({
|
const modelsRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
@@ -132,57 +107,11 @@ const modelsRoute = createRoute({
|
|||||||
component: ModelsTab,
|
component: ModelsTab,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Settings layout route (parent for sub-tabs)
|
// Server route
|
||||||
const settingsRoute = createRoute({
|
const serverRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
|
||||||
path: '/settings',
|
|
||||||
component: SettingsLayout,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Settings sub-routes
|
|
||||||
const settingsGeneralRoute = createRoute({
|
|
||||||
getParentRoute: () => settingsRoute,
|
|
||||||
path: '/',
|
|
||||||
component: GeneralPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const settingsGenerationRoute = createRoute({
|
|
||||||
getParentRoute: () => settingsRoute,
|
|
||||||
path: '/generation',
|
|
||||||
component: GenerationPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const settingsGpuRoute = createRoute({
|
|
||||||
getParentRoute: () => settingsRoute,
|
|
||||||
path: '/gpu',
|
|
||||||
component: GpuPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const settingsChangelogRoute = createRoute({
|
|
||||||
getParentRoute: () => settingsRoute,
|
|
||||||
path: '/changelog',
|
|
||||||
component: ChangelogPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const settingsLogsRoute = createRoute({
|
|
||||||
getParentRoute: () => settingsRoute,
|
|
||||||
path: '/logs',
|
|
||||||
component: LogsPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const settingsAboutRoute = createRoute({
|
|
||||||
getParentRoute: () => settingsRoute,
|
|
||||||
path: '/about',
|
|
||||||
component: AboutPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Redirect old /server path to /settings
|
|
||||||
const serverRedirectRoute = createRoute({
|
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/server',
|
path: '/server',
|
||||||
beforeLoad: () => {
|
component: ServerTab,
|
||||||
throw redirect({ to: '/settings' });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Route tree
|
// Route tree
|
||||||
@@ -191,17 +120,8 @@ const routeTree = rootRoute.addChildren([
|
|||||||
storiesRoute,
|
storiesRoute,
|
||||||
voicesRoute,
|
voicesRoute,
|
||||||
audioRoute,
|
audioRoute,
|
||||||
effectsRoute,
|
|
||||||
modelsRoute,
|
modelsRoute,
|
||||||
settingsRoute.addChildren([
|
serverRoute,
|
||||||
settingsGeneralRoute,
|
|
||||||
settingsGenerationRoute,
|
|
||||||
settingsGpuRoute,
|
|
||||||
settingsLogsRoute,
|
|
||||||
settingsChangelogRoute,
|
|
||||||
settingsAboutRoute,
|
|
||||||
]),
|
|
||||||
serverRedirectRoute,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Create router
|
// Create router
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
import type { EffectConfig } from '@/lib/api/types';
|
|
||||||
|
|
||||||
interface EffectsStore {
|
|
||||||
selectedPresetId: string | null;
|
|
||||||
setSelectedPresetId: (id: string | null) => void;
|
|
||||||
|
|
||||||
// Working chain for the detail panel (editing a preset or building a new one)
|
|
||||||
workingChain: EffectConfig[];
|
|
||||||
setWorkingChain: (chain: EffectConfig[]) => void;
|
|
||||||
|
|
||||||
// Track if editing an existing preset vs creating new
|
|
||||||
isCreatingNew: boolean;
|
|
||||||
setIsCreatingNew: (v: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useEffectsStore = create<EffectsStore>((set) => ({
|
|
||||||
selectedPresetId: null,
|
|
||||||
setSelectedPresetId: (id) => set({ selectedPresetId: id, isCreatingNew: false }),
|
|
||||||
|
|
||||||
workingChain: [],
|
|
||||||
setWorkingChain: (chain) => set({ workingChain: chain }),
|
|
||||||
|
|
||||||
isCreatingNew: false,
|
|
||||||
setIsCreatingNew: (v) => set({ isCreatingNew: v, ...(v && { selectedPresetId: null }) }),
|
|
||||||
}));
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user