mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-26 13:45:16 -07:00
Compare commits
179
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd116bcfe7 | ||
|
|
bfd6ec2f02 | ||
|
|
37b7110e52 | ||
|
|
c7a3ff5d65 | ||
|
|
fddcd61f4d | ||
|
|
6174cd64d6 | ||
|
|
696e927f6d | ||
|
|
91cdaa9162 | ||
|
|
7d7c0b59e5 | ||
|
|
46cf5803a3 | ||
|
|
901ffcc93b | ||
|
|
a3fa9a2784 | ||
|
|
cc9c905b55 | ||
|
|
7d4844fb10 | ||
|
|
309120ec89 | ||
|
|
29dadb5543 | ||
|
|
2a206cd09f | ||
|
|
dbd57dfb60 | ||
|
|
0f2032f058 | ||
|
|
46fe1c3608 | ||
|
|
7346c9e652 | ||
|
|
cbca21ac77 | ||
|
|
30db291b01 | ||
|
|
12ce7a4c35 | ||
|
|
71b51366bc | ||
|
|
3f4631c865 | ||
|
|
258b92c9c0 | ||
|
|
e001439c06 | ||
|
|
47d9ce908f | ||
|
|
e6cf50c7f7 | ||
|
|
b57cfed3ef | ||
|
|
cf173ae837 | ||
|
|
2dc3b075d5 | ||
|
|
05d90790f8 | ||
|
|
89d489f711 | ||
|
|
f7c08477a0 | ||
|
|
7d3f7b96d7 | ||
|
|
4a6b5da793 | ||
|
|
2c9d02af62 | ||
|
|
b680097dfb | ||
|
|
7e424a20a3 | ||
|
|
9b1beba3e1 | ||
|
|
0070c04bcf | ||
|
|
f2cf2a729d | ||
|
|
b542768429 | ||
|
|
e766c7cbfb | ||
|
|
c2282b256a | ||
|
|
cabef1bfe0 | ||
|
|
3835b63bd8 | ||
|
|
da79e37ef5 | ||
|
|
6e4989313c | ||
|
|
42b9cae216 | ||
|
|
b9bb2f075c | ||
|
|
e294b9c8f0 | ||
|
|
c1814a2870 | ||
|
|
7d9a384ee4 | ||
|
|
c6a59f4477 | ||
|
|
4f13123b95 | ||
|
|
21c7e373d3 | ||
|
|
45b64e0233 | ||
|
|
b35b90961d | ||
|
|
7df366d0c8 | ||
|
|
627d40b42d | ||
|
|
ed2eec591a | ||
|
|
d61e884104 | ||
|
|
74e004400f | ||
|
|
0047352df1 | ||
|
|
7e7feeac54 | ||
|
|
328bdca61c | ||
|
|
abb752d623 | ||
|
|
f0924d19d3 | ||
|
|
0f97300b4d | ||
|
|
6787f65701 | ||
|
|
a72ef81dc1 | ||
|
|
21dd3b8315 | ||
|
|
5aa1677a25 | ||
|
|
5964af5dea | ||
|
|
115de231d0 | ||
|
|
8929947c7a | ||
|
|
e3f7cd9d00 | ||
|
|
27a5a62581 | ||
|
|
d3a44338a2 | ||
|
|
28aa963b09 | ||
|
|
ae91aa9a88 | ||
|
|
da6070155e | ||
|
|
3c1e8512b9 | ||
|
|
bf58750447 | ||
|
|
2d56309bdd | ||
|
|
0445be295c | ||
|
|
8d550a5f7c | ||
|
|
795bd54381 | ||
|
|
a6ab5f3858 | ||
|
|
9d7e4a417e | ||
|
|
882cabc7d2 | ||
|
|
9c76b5de2c | ||
|
|
4560b7378a | ||
|
|
abd9943430 | ||
|
|
c8cb12f1bc | ||
|
|
54a3bf322e | ||
|
|
476abe07fc | ||
|
|
67bf8e906a | ||
|
|
625e1ba549 | ||
|
|
cfe6770639 | ||
|
|
00452b51a8 | ||
|
|
106aec46a8 | ||
|
|
c9e5c5d9a7 | ||
|
|
48cd1f369a | ||
|
|
2bfe400457 | ||
|
|
0aa19a9994 | ||
|
|
73170d0e92 | ||
|
|
0317626677 | ||
|
|
a5d5c780c2 | ||
|
|
7184a25e44 | ||
|
|
479bc7fc5e | ||
|
|
3e7727d1d2 | ||
|
|
be7c0cec12 | ||
|
|
c9d8142a78 | ||
|
|
13ba5f1aa6 | ||
|
|
9a3c307c75 | ||
|
|
1da16cfc57 | ||
|
|
a1807be04d | ||
|
|
fdba18e9ee | ||
|
|
07a845cece | ||
|
|
615d604ceb | ||
|
|
a383ff6863 | ||
|
|
75abbb02c3 | ||
|
|
b49f14a814 | ||
|
|
05686efbfd | ||
|
|
e2c03fef9a | ||
|
|
4347eaed4c | ||
|
|
60aac279ce | ||
|
|
8b1c7552be | ||
|
|
9a955a77d2 | ||
|
|
c18591c0c3 | ||
|
|
ea3469f2dc | ||
|
|
b108bb1cb1 | ||
|
|
8b796bc6b4 | ||
|
|
e6f419cd70 | ||
|
|
72c13fd3fc | ||
|
|
4e0c731db8 | ||
|
|
d70b878b71 | ||
|
|
a71011741d | ||
|
|
d6f48ace3e | ||
|
|
0fc2192204 | ||
|
|
9e726ad048 | ||
|
|
3584283d84 | ||
|
|
e4def9365f | ||
|
|
707046237c | ||
|
|
12ed2d51ce | ||
|
|
83ebababe7 | ||
|
|
eb5869e59f | ||
|
|
2e95b7c5d8 | ||
|
|
ffc1b54812 | ||
|
|
fc5ed1ff40 | ||
|
|
c9f38dd496 | ||
|
|
58b19e4e9f | ||
|
|
0245c31dba | ||
|
|
81864e831a | ||
|
|
7bd72ea9f7 | ||
|
|
f96eae2567 | ||
|
|
7d53699c96 | ||
|
|
28e91ce2c1 | ||
|
|
88be097b62 | ||
|
|
564d787927 | ||
|
|
2c1ee94891 | ||
|
|
e789c937ad | ||
|
|
273483ffcf | ||
|
|
5774a168a9 | ||
|
|
6bf40bd2d0 | ||
|
|
12cda2e090 | ||
|
|
7a90290a76 | ||
|
|
b02ce8e2f3 | ||
|
|
4e7772a21d | ||
|
|
51fb320b8c | ||
|
|
8ac202aa58 | ||
|
|
ac68052945 | ||
|
|
e601fd2ca4 | ||
|
|
7b25e0ba0b | ||
|
|
a6817cd082 |
@@ -0,0 +1,120 @@
|
||||
---
|
||||
name: add-tts-engine
|
||||
description: Use this skill to add a new TTS engine to Voicebox. It walks through dependency research, backend implementation, frontend wiring, PyInstaller bundling, and frozen-build testing. Always start with Phase 0 (dependency audit) before writing any code.
|
||||
---
|
||||
|
||||
# Add TTS Engine
|
||||
|
||||
## Goal
|
||||
|
||||
Integrate a new text-to-speech engine into Voicebox end-to-end: dependency research, backend protocol implementation, frontend UI wiring, PyInstaller bundling, and frozen-build verification. The user should only need to test the final build locally.
|
||||
|
||||
## Reference Doc
|
||||
|
||||
The full phased guide lives at `docs/content/docs/developer/tts-engines.mdx`. **Read this file in its entirety before starting.** It contains:
|
||||
|
||||
- Phase 0: Dependency research (mandatory before writing code)
|
||||
- Phase 1: Backend implementation (`TTSBackend` protocol)
|
||||
- Phase 2: Route and service integration (usually zero changes)
|
||||
- Phase 3: Frontend integration (5 files)
|
||||
- Phase 4: Dependencies (`requirements.txt`, justfile, CI, Docker)
|
||||
- Phase 5: PyInstaller bundling (`build_binary.py` + `server.py`)
|
||||
- Phase 6: Common upstream workarounds
|
||||
- Implementation checklist (gate between phases)
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Read the guide
|
||||
|
||||
```bash
|
||||
# Read the full TTS engines doc
|
||||
cat docs/content/docs/developer/tts-engines.mdx
|
||||
```
|
||||
|
||||
Internalize all phases, especially Phase 0 and Phase 5. The v0.2.3 release was three patch releases because Phase 0 was skipped.
|
||||
|
||||
### 2. Dependency research (Phase 0)
|
||||
|
||||
Clone the model library into a temporary directory and audit it. Do NOT skip this.
|
||||
|
||||
```bash
|
||||
mkdir /tmp/engine-research && cd /tmp/engine-research
|
||||
git clone <model-library-url>
|
||||
```
|
||||
|
||||
Run the grep searches from Phase 0.2 in the guide against the cloned source and its transitive dependencies. Produce a written dependency audit covering:
|
||||
|
||||
1. PyPI vs non-PyPI packages
|
||||
2. PyInstaller directives needed (`--collect-all`, `--copy-metadata`, `--hidden-import`)
|
||||
3. Runtime data files that must be bundled
|
||||
4. Native library paths that need env var overrides in frozen builds
|
||||
5. Monkey-patches needed (`torch.load`, float64, MPS, HF token)
|
||||
6. Sample rate
|
||||
7. Model download method (`from_pretrained` vs `snapshot_download` + `from_local`)
|
||||
|
||||
Test model loading and generation on CPU in the throwaway venv before proceeding.
|
||||
|
||||
### 3. Implement (Phases 1–4)
|
||||
|
||||
Follow the guide's phases in order. Key files to modify:
|
||||
|
||||
**Backend (Phase 1):**
|
||||
- Create `backend/backends/<engine>_backend.py`
|
||||
- Register in `backend/backends/__init__.py` (ModelConfig + TTS_ENGINES + factory)
|
||||
- Update regex in `backend/models.py`
|
||||
|
||||
**Frontend (Phase 3):**
|
||||
- `app/src/lib/api/types.ts` — engine union type
|
||||
- `app/src/lib/constants/languages.ts` — ENGINE_LANGUAGES
|
||||
- `app/src/components/Generation/EngineModelSelector.tsx` — ENGINE_OPTIONS, ENGINE_DESCRIPTIONS
|
||||
- `app/src/lib/hooks/useGenerationForm.ts` — Zod schema, model-name mapping
|
||||
- `app/src/components/ServerSettings/ModelManagement.tsx` — MODEL_DESCRIPTIONS
|
||||
|
||||
**Dependencies (Phase 4):**
|
||||
- `backend/requirements.txt`
|
||||
- `justfile` (setup-python, setup-python-release targets)
|
||||
- `.github/workflows/release.yml`
|
||||
- `Dockerfile` (if applicable)
|
||||
|
||||
### 4. PyInstaller bundling (Phase 5)
|
||||
|
||||
Register the engine in `backend/build_binary.py`:
|
||||
- `--hidden-import` for the backend module and model package
|
||||
- `--collect-all` for packages using `inspect.getsource`, shipping data files, or native libraries
|
||||
- `--copy-metadata` for packages using `importlib.metadata`
|
||||
|
||||
If the engine has native data paths, add `os.environ.setdefault()` in `backend/server.py` inside the `if getattr(sys, 'frozen', False):` block.
|
||||
|
||||
### 5. Verify in dev mode
|
||||
|
||||
```bash
|
||||
just dev
|
||||
```
|
||||
|
||||
Test the full chain: model download → load → generate → voice cloning.
|
||||
|
||||
### 6. Use the checklist
|
||||
|
||||
Walk through the Implementation Checklist at the bottom of `tts-engines.mdx`. Every item must be checked before handing the build to the user.
|
||||
|
||||
## Key Lessons (from v0.2.3)
|
||||
|
||||
These are the most common failure modes. Phase 0 research catches all of them:
|
||||
|
||||
| Pattern | Symptom in Frozen Build | Fix |
|
||||
|---------|------------------------|-----|
|
||||
| `@typechecked` / `inspect.getsource()` | "could not get source code" | `--collect-all <package>` |
|
||||
| Package ships pretrained model files | `FileNotFoundError` for `.pth.tar`, `.yaml` | `--collect-all <package>` |
|
||||
| C library with hardcoded system paths | `FileNotFoundError` for `/usr/share/...` | `--collect-all` + env var in `server.py` |
|
||||
| `importlib.metadata.version()` | "No package metadata found" | `--copy-metadata <package>` |
|
||||
| `torch.load` without `map_location` | CUDA device not available on CPU build | Monkey-patch `torch.load` |
|
||||
| `torch.from_numpy` on float64 data | dtype mismatch RuntimeError | Cast to `.float()` |
|
||||
| `token=True` in HF download calls | Auth failure without stored HF token | Use `snapshot_download(token=None)` + `from_local()` |
|
||||
|
||||
## Notes
|
||||
|
||||
- The route and service layers have zero per-engine dispatch points. `main.py` requires zero changes.
|
||||
- The model config registry in `backends/__init__.py` handles all dispatch automatically.
|
||||
- Use `get_torch_device()` and `model_load_progress()` from `backends/base.py` — don't reimplement device detection or progress tracking.
|
||||
- Always test with a **clean HuggingFace cache** (no pre-downloaded models from dev).
|
||||
- Do NOT push or create a release. Hand the build to the user for local testing.
|
||||
@@ -0,0 +1,299 @@
|
||||
---
|
||||
name: triage-prs
|
||||
description: Use this skill to triage the open PR queue before a release. Classifies every open PR into must-merge, candidate, superseded, or deferred; writes a working triage doc; and runs the merge loop end-to-end. Designed for the pre-release "PR speedrun" pass where a solo maintainer wants to clear the inbound backlog in a single session.
|
||||
---
|
||||
|
||||
# Triage PRs
|
||||
|
||||
## Goal
|
||||
|
||||
Turn a backlog of open PRs into a shipped set of merges in a single focused session. Produce a tracked, resumable plan (`<VERSION>_PR_TRIAGE.md`), then work it — rebasing where needed, merging in isolation-safe batches, applying post-merge follow-ups, and closing superseded or partially-applicable PRs with credit to their authors.
|
||||
|
||||
This skill pairs with `draft-release-notes` and `release-bump`: triage first, then draft notes against the new main, then cut the release.
|
||||
|
||||
## When to use
|
||||
|
||||
- Before a minor or major release when 10+ open PRs have accumulated
|
||||
- When you want to unblock merging without losing the narrative of what's landing
|
||||
- When you know you can't personally review every PR deeply, but need to land the critical subset fast
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `gh` CLI authenticated against the repo
|
||||
- A dedicated worktree for PR review (avoid contaminating `main` with checkouts of contributor branches)
|
||||
- Clarity on the target version — the triage doc is named after it (e.g. `0.4.0_PR_TRIAGE.md`)
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Set up an isolated PR-review worktree
|
||||
|
||||
```bash
|
||||
git worktree list # check for stale ones first
|
||||
git worktree prune
|
||||
git worktree add ../voicebox-pr-review -b pr-review-<VERSION> main
|
||||
```
|
||||
|
||||
Keep the main worktree for release-prep work (changelog drafts, direct-to-main follow-ups). Keep the review worktree for `gh pr checkout` — each checkout moves HEAD to a contributor branch, which you don't want to do in the main worktree.
|
||||
|
||||
### 2. Gather metadata for every open PR
|
||||
|
||||
```bash
|
||||
gh pr list --state open --limit 50 --json \
|
||||
number,title,author,isDraft,mergeable,mergeStateStatus,files,additions,deletions,reviewDecision,statusCheckRollup,maintainerCanModify \
|
||||
--jq '.[] | {num: .number, title, author: .author.login, mergeable, state: .mergeStateStatus, canModify: .maintainerCanModify, changes: "+\(.additions)/-\(.deletions)", files: [.files[].path]}'
|
||||
```
|
||||
|
||||
You want, for each PR:
|
||||
- Size (`+additions/-deletions`)
|
||||
- Mergeable state (`CLEAN`, `UNSTABLE`, `DIRTY` = conflicts, `UNKNOWN` = GitHub still computing)
|
||||
- Whether maintainer edits are allowed on the branch (needed later if you rebase for the author)
|
||||
- File paths touched (helps spot overlaps between PRs)
|
||||
|
||||
`UNKNOWN` is common right after a push to main — just try the merge and see.
|
||||
|
||||
### 3. Classify into tiers
|
||||
|
||||
Sort each PR into exactly one bucket:
|
||||
|
||||
**Tier 1 — Merge:** small, mergeable, fixes a real bug, clean CI, low review cost. One-liners, dependency relaxations, targeted safety hardening. These are the easy wins.
|
||||
|
||||
**Tier 2 — Candidate, review:** medium size (50-200 lines), touches more surface area, looks sound but needs a closer read. New user-facing features that fit the product direction.
|
||||
|
||||
**Supersede:** the fix or feature is already covered by something merged. Close with a comment pointing to the superseding PR. Check carefully — "similar title" isn't proof; compare the actual diffs.
|
||||
|
||||
**Defer to next release:** big features, dirty conflicts, draft PRs, anything touching the release pipeline in ways that would introduce risk. Don't merge these in a speedrun — they need dedicated focus.
|
||||
|
||||
### 4. Write the triage doc
|
||||
|
||||
Create `<VERSION>_PR_TRIAGE.md` in the PR-review worktree root. Structure:
|
||||
|
||||
```markdown
|
||||
# <Repo> <VERSION> — PR Triage
|
||||
|
||||
Working doc for tracking which open PRs land in <VERSION>. Delete after release cut.
|
||||
|
||||
Last updated: <DATE>
|
||||
|
||||
## Progress
|
||||
|
||||
**Tier 1: 0 / N merged**
|
||||
**Tier 2: 0 / M handled**
|
||||
**Supersede triage: pending**
|
||||
|
||||
---
|
||||
|
||||
## Merge for <VERSION> — critical bug fixes
|
||||
|
||||
| PR | Status | Size | What it fixes | Why must-have |
|
||||
|---|---|---|---|---|
|
||||
| [#123](url) | [ ] | +5/-0 | ... | ... |
|
||||
|
||||
## Strong candidate — needs a quick review
|
||||
|
||||
| PR | Status | Size | Summary |
|
||||
|---|---|---|---|
|
||||
|
||||
## Close as superseded
|
||||
|
||||
| PR | Status | Reason |
|
||||
|---|---|---|
|
||||
|
||||
## Defer to <NEXT_VERSION>
|
||||
|
||||
- [#xxx](url) ... — reason
|
||||
|
||||
---
|
||||
|
||||
## Order of attack
|
||||
|
||||
1. Close superseded PRs (one-liner comments)
|
||||
2. Merge tier-1 in dependency-free batches — check file paths don't overlap
|
||||
3. Review tier-2 individually
|
||||
4. Rerun `draft-release-notes` to pick up everything
|
||||
5. Run `release-bump`
|
||||
```
|
||||
|
||||
The **Progress** header is the most important part — it's your scoreboard and lets you resume cleanly if the session gets interrupted.
|
||||
|
||||
### 5. Work the loop — per PR
|
||||
|
||||
For each PR in the tier-1 / tier-2 list:
|
||||
|
||||
**a. Checkout in the review worktree:**
|
||||
```bash
|
||||
cd ../voicebox-pr-review
|
||||
git checkout pr-review-<VERSION> # reset to neutral base
|
||||
gh pr checkout <N>
|
||||
```
|
||||
|
||||
**b. Read the *actual* commit, not `main..HEAD`:**
|
||||
|
||||
```bash
|
||||
git show HEAD # the PR's actual changes
|
||||
git show --stat HEAD # files touched + line counts
|
||||
```
|
||||
|
||||
**Do NOT review via `git diff main..HEAD`** if the PR branch is older than main. That diff includes *every commit that landed on main after the PR was forked* as `-` (deletion) lines. A 3-line PR can look like a 700-line revert. This is the single easiest way to misjudge a PR.
|
||||
|
||||
**c. Evaluate concerns:** correctness, scope, interaction with already-merged work, version compatibility (e.g. can't use an API that requires a dependency version we don't yet pin).
|
||||
|
||||
**d. Rebase if the branch is behind main:**
|
||||
```bash
|
||||
git fetch origin main
|
||||
git rebase origin/main
|
||||
```
|
||||
|
||||
This is **essential** before squash-merging. GitHub's squash computes `diff(PR-head, merge-base)` — on a stale branch, that diff includes reverting every in-between commit. Rebasing moves the merge-base forward so the squash is clean.
|
||||
|
||||
**e. If maintainer edits are allowed, push the rebase back to the contributor's fork:**
|
||||
```bash
|
||||
git remote add <author> https://github.com/<author>/<repo>.git
|
||||
git fetch <author> <branch> # get their ref first
|
||||
git push <author> HEAD:<branch> --force-with-lease
|
||||
```
|
||||
|
||||
This keeps GitHub's PR UI in sync with the rebased state and makes the merge clean from the GitHub side.
|
||||
|
||||
**f. Merge:**
|
||||
```bash
|
||||
gh pr merge <N> --squash
|
||||
```
|
||||
|
||||
**g. Update the triage doc** — flip the checkbox to `✅ merged <sha>` (use the short SHA from `gh pr view <N> --json mergeCommit --jq '.mergeCommit.oid[0:7]'`). Update the Progress header.
|
||||
|
||||
### 6. Batch tiny fixes
|
||||
|
||||
PRs with ≤5 line changes, clean CI, non-overlapping file paths, and obviously-correct intent (e.g. one-line dependency relax, env var add, import path fix) can be merged in a single loop without the review-per-PR ceremony:
|
||||
|
||||
```bash
|
||||
for pr in 425 384 416 429; do
|
||||
echo "=== Merging PR $pr ==="
|
||||
gh pr merge $pr --squash
|
||||
done
|
||||
```
|
||||
|
||||
Verify afterward that each landed cleanly:
|
||||
```bash
|
||||
for pr in 425 384 416 429; do
|
||||
gh pr view $pr --json state,mergeCommit --jq "{pr: $pr, state, sha: .mergeCommit.oid[0:7]}"
|
||||
done
|
||||
```
|
||||
|
||||
### 7. Post-merge follow-ups
|
||||
|
||||
Sometimes a PR is worth merging despite a known minor issue (e.g. incomplete dtype map, stale sentinel cleanup). Don't block the merge; apply the follow-up as a normal branch + PR right after:
|
||||
|
||||
```bash
|
||||
cd <main-worktree>
|
||||
git pull --ff-only origin main
|
||||
git checkout -b fix/<short-name>
|
||||
# edit...
|
||||
git commit -m "fix(<area>): <one-liner>"
|
||||
git push -u origin fix/<short-name>
|
||||
gh pr create --title "..." --body "Follow-up to #<N>. ..."
|
||||
```
|
||||
|
||||
Record both SHAs in the triage doc (`✅ merged <pr-sha> + follow-up <pr>`).
|
||||
|
||||
**Direct-to-main exception:** only under an explicit, scoped policy (e.g. "release speedrun"). Don't default to it.
|
||||
|
||||
### 8. Supersede: close with a credit-pointing comment
|
||||
|
||||
```bash
|
||||
gh pr close <N> --comment "Closing — superseded by merged #<M> which landed <brief description>. Thanks!"
|
||||
```
|
||||
|
||||
Check the diffs first — "similar title" is not enough. If the PR is *partially* superseded (the diagnosis is right but only half the changes are still needed), do a partial-apply instead.
|
||||
|
||||
### 9. Partial-apply pattern
|
||||
|
||||
When a PR has both valuable and questionable changes bundled:
|
||||
|
||||
```bash
|
||||
cd <main-worktree>
|
||||
git pull --ff-only origin main
|
||||
|
||||
# Cherry-pick specific files from the PR branch
|
||||
git checkout <pr-commit-sha> -- <file1> <file2>
|
||||
|
||||
# Review the staged changes, adjust as needed
|
||||
git diff --cached
|
||||
|
||||
# Apply any surgical edits to files you don't want to bulk-replace
|
||||
# (e.g. the PR's file predates a recent main commit you need to preserve)
|
||||
|
||||
# Commit with a trailer crediting the original author
|
||||
git commit -m "$(cat <<'EOF'
|
||||
<subject>
|
||||
|
||||
<body explaining what was kept vs dropped>
|
||||
|
||||
Co-Authored-By: <author> <[email protected]>
|
||||
EOF
|
||||
)"
|
||||
git push ... # branch + PR, unless under the direct-to-main exception
|
||||
```
|
||||
|
||||
Then close the PR with a comment explaining what was applied and what was dropped, referencing the commit SHA.
|
||||
|
||||
### 10. Keep the doc current
|
||||
|
||||
Every merge, every close, every follow-up → update `<VERSION>_PR_TRIAGE.md`. The doc is your session log. If you're interrupted and resume tomorrow, the doc is the only source of truth for "where am I."
|
||||
|
||||
### 11. When triage is done
|
||||
|
||||
- Every PR in the doc has a terminal status (✅ merged / ✅ closed / deferred)
|
||||
- Progress header shows N/N for each tier
|
||||
- Next skill to run is `draft-release-notes` (to regenerate `[Unreleased]` against the new main), then `release-bump`
|
||||
|
||||
You can delete the triage doc after the release ships, or keep it in version history as a record.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`main..HEAD` on a stale branch lies.** It shows everything main gained since the branch split as deletions. Always review via `git show HEAD` for the PR's actual commit.
|
||||
- **Squash-merging an unrebased branch reverts in-between work.** The squash computes `diff(PR-head, merge-base)`. Rebase moves the merge-base forward.
|
||||
- **`mergeable=UNKNOWN`** is transient — GitHub is recomputing after a push. Just try the merge.
|
||||
- **Route ordering matters (FastAPI and similar):** `DELETE /history/failed` must be registered *before* `DELETE /history/{id}`, or the parameterized path will consume `"failed"` as an ID.
|
||||
- **Apple's `-weak_framework` overrides `-framework`** for the same framework, regardless of order — use it via `cargo:rustc-link-arg=-Wl,-weak_framework,Name` when a dependency hard-links something optional.
|
||||
- **Dependency version floors constrain what you can apply.** Before accepting a kwarg rename like `torch_dtype=` → `dtype=`, check the min-version pin supports it. Sometimes the right move is to cherry-pick half the PR.
|
||||
- **`cpal::Stream` and similar `!Send` audio types** can't cross `await` points or `spawn_blocking`. Sometimes a "not-ideal but correct" sync wait is the best available fix; flag but don't block.
|
||||
- **PyTorch nightly builds are not shippable for releases** — non-deterministic, can regress between runs. If a PR suggests switching to nightly to fix a GPU issue, prefer `TORCH_CUDA_ARCH_LIST=...+PTX` or wait for stable support instead.
|
||||
|
||||
## Canonical commands reference
|
||||
|
||||
```bash
|
||||
# Bulk PR metadata
|
||||
gh pr list --state open --limit 50 --json number,title,author,mergeable,mergeStateStatus,additions,deletions,maintainerCanModify,files
|
||||
|
||||
# Detailed single-PR view
|
||||
gh pr view <N> --json body,author,headRefName,baseRefName,mergeable,maintainerCanModify,files,statusCheckRollup
|
||||
|
||||
# The actual commit, not the branch-vs-main diff
|
||||
git show HEAD
|
||||
git show --stat HEAD
|
||||
gh pr diff <N>
|
||||
|
||||
# Rebase contributor branch onto current main
|
||||
git fetch origin main && git rebase origin/main
|
||||
|
||||
# Push rebase back to contributor fork (maintainerCanModify=true required)
|
||||
git remote add <author> https://github.com/<author>/<repo>.git
|
||||
git fetch <author> <branch>
|
||||
git push <author> HEAD:<branch> --force-with-lease
|
||||
|
||||
# Merge
|
||||
gh pr merge <N> --squash
|
||||
|
||||
# Confirm merge SHA for triage doc
|
||||
gh pr view <N> --json state,mergeCommit --jq '{state, sha: .mergeCommit.oid[0:7]}'
|
||||
|
||||
# Close superseded
|
||||
gh pr close <N> --comment "Closing — superseded by merged #<M>. Thanks!"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **Never review a stale branch via `main..HEAD`.** This is the single most important line in this skill.
|
||||
- **The triage doc is the session state.** Lose the doc, lose the session. Update it after every action.
|
||||
- **Credit contributors even on partial-applies.** Use `Co-Authored-By:` trailers and close comments that link to the applied commit.
|
||||
- **Don't let perfect be the enemy of shipped.** A fix that goes from "broken" to "works with a minor known issue" is a strict improvement. Flag the issue, file a follow-up, merge the fix.
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.2.3
|
||||
current_version = 0.5.0
|
||||
commit = True
|
||||
tag = True
|
||||
tag_name = v{new_version}
|
||||
|
||||
+2
-2
@@ -8,7 +8,8 @@ tauri/
|
||||
landing/
|
||||
docs/
|
||||
mlx-test/
|
||||
scripts/
|
||||
scripts/*
|
||||
!scripts/rocm-entrypoint.sh
|
||||
|
||||
# Dependencies & build artifacts (rebuilt in Docker)
|
||||
node_modules/
|
||||
@@ -38,7 +39,6 @@ biome.json
|
||||
.bumpversion.cfg
|
||||
.npmrc
|
||||
Makefile
|
||||
CHANGELOG.md
|
||||
CONTRIBUTING.md
|
||||
SECURITY.md
|
||||
LICENSE
|
||||
|
||||
@@ -29,11 +29,14 @@ jobs:
|
||||
run: |
|
||||
cd backend
|
||||
python build_binary.py
|
||||
python build_binary.py --shim
|
||||
|
||||
PLATFORM=$(rustc --print host-tuple)
|
||||
mkdir -p ../tauri/src-tauri/binaries
|
||||
cp dist/voicebox-server.exe ../tauri/src-tauri/binaries/voicebox-server-${PLATFORM}.exe
|
||||
cp dist/voicebox-mcp.exe ../tauri/src-tauri/binaries/voicebox-mcp-${PLATFORM}.exe
|
||||
echo "Built voicebox-server-${PLATFORM}.exe"
|
||||
echo "Built voicebox-mcp-${PLATFORM}.exe"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Typecheck app + web
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Build web
|
||||
run: bun run build:web
|
||||
|
||||
- name: Upload web build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: web-dist
|
||||
path: web/dist
|
||||
retention-days: 1
|
||||
|
||||
unit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
||||
|
||||
- name: Install Chromium
|
||||
run: bunx playwright install chromium --with-deps
|
||||
|
||||
- name: Vitest (unit + browser)
|
||||
run: bunx vitest run
|
||||
|
||||
e2e:
|
||||
# Informational while the suite beds in; flip to blocking once it has
|
||||
# a sustained green run.
|
||||
continue-on-error: true
|
||||
needs: quality
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: pip
|
||||
cache-dependency-path: backend/requirements-ci.txt
|
||||
|
||||
- name: Install backend (CPU)
|
||||
run: |
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r backend/requirements-ci.txt
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
||||
|
||||
- name: Install Chromium
|
||||
run: bunx playwright install chromium --with-deps
|
||||
|
||||
- name: Playwright E2E
|
||||
run: bunx playwright test -c e2e
|
||||
env:
|
||||
VOICEBOX_PYTHON: python
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
playwright-report
|
||||
test-results
|
||||
retention-days: 7
|
||||
|
||||
backend-tests:
|
||||
# Informational: 30 pre-existing pytest files that have never run in CI.
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: pip
|
||||
cache-dependency-path: backend/requirements-ci.txt
|
||||
|
||||
- name: Install backend (CPU)
|
||||
run: |
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r backend/requirements-ci.txt
|
||||
pip install pytest pytest-asyncio
|
||||
|
||||
- name: Pytest
|
||||
run: python -m pytest backend/tests -v --ignore=backend/tests/test_all_models_e2e.py
|
||||
+188
-15
@@ -32,6 +32,28 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Ubuntu runners ship with ~14 GB free; pip + PyInstaller + torch can
|
||||
# peak well above that during the build. Reclaim ~25 GB by pruning
|
||||
# preinstalled toolchains we don't use. This is what likely tripped
|
||||
# the March 2026 Linux release attempts (see commit 103e98b
|
||||
# "github runners suck") — not a code issue, a disk-pressure one.
|
||||
- name: Free up disk space (ubuntu)
|
||||
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
|
||||
# Pinned to v1.3.1 (SHA) — this job runs with contents: write and
|
||||
# handles signing secrets later, so we don't want a floating ref.
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
tool-cache: false
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
# large-packages: true would `apt-get remove '^llvm-.*'`, which
|
||||
# cascade-removes reverse deps that won't be pulled back in by the
|
||||
# `llvm-dev` install below. The other flags already free ~20 GB,
|
||||
# enough for the Python + torch + PyInstaller build.
|
||||
large-packages: false
|
||||
swap-storage: true
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
|
||||
run: |
|
||||
@@ -62,11 +84,23 @@ jobs:
|
||||
pip install pyinstaller
|
||||
pip install -r backend/requirements.txt
|
||||
pip install --no-deps chatterbox-tts
|
||||
pip install --no-deps hume-tada
|
||||
|
||||
- name: Install MLX dependencies (Apple Silicon only)
|
||||
if: matrix.backend == 'mlx'
|
||||
run: |
|
||||
pip install -r backend/requirements-mlx.txt
|
||||
# mlx-audio>=0.3.1 and mlx-lm>=0.31.1 both declare transformers>=5.x,
|
||||
# which conflicts with our 4.57.x cap. The runtime APIs we use work
|
||||
# fine on transformers 4.57.x in practice (verified in dev), so install
|
||||
# them --no-deps. mlx-audio's other runtime deps (huggingface_hub,
|
||||
# librosa, numpy, numba, pyloudnorm) are already in requirements.txt;
|
||||
# miniaudio is in requirements-mlx.txt (needed by mlx_audio.stt,
|
||||
# not transitively pulled by anything else — see issue #505); the
|
||||
# rest (sounddevice, protobuf, sentencepiece, pyyaml, jinja2) are
|
||||
# pulled in by other engines.
|
||||
pip install --no-deps mlx-lm==0.31.1
|
||||
pip install --no-deps mlx-audio==0.4.1
|
||||
|
||||
- name: Build Python server (Linux/macOS)
|
||||
if: matrix.platform != 'windows-latest'
|
||||
@@ -80,6 +114,7 @@ jobs:
|
||||
run: |
|
||||
cd backend
|
||||
python build_binary.py
|
||||
python build_binary.py --shim
|
||||
|
||||
# Get platform tuple
|
||||
PLATFORM=$(rustc --print host-tuple)
|
||||
@@ -89,7 +124,9 @@ jobs:
|
||||
|
||||
# Copy with platform suffix
|
||||
cp dist/voicebox-server.exe ../tauri/src-tauri/binaries/voicebox-server-${PLATFORM}.exe
|
||||
cp dist/voicebox-mcp.exe ../tauri/src-tauri/binaries/voicebox-mcp-${PLATFORM}.exe
|
||||
echo "Built voicebox-server-${PLATFORM}.exe"
|
||||
echo "Built voicebox-mcp-${PLATFORM}.exe"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
@@ -123,6 +160,21 @@ jobs:
|
||||
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Disk / environment snapshot (pre-bundle debug)
|
||||
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
|
||||
run: |
|
||||
echo "=== df -h ==="
|
||||
df -h
|
||||
echo "=== free -h ==="
|
||||
free -h
|
||||
echo "=== Rust / Cargo ==="
|
||||
rustc --version
|
||||
cargo --version
|
||||
echo "=== Bun ==="
|
||||
bun --version
|
||||
echo "=== Tauri CLI ==="
|
||||
cd tauri && bun run tauri --version
|
||||
|
||||
- name: Extract release notes from CHANGELOG.md
|
||||
id: changelog
|
||||
shell: bash
|
||||
@@ -146,7 +198,13 @@ jobs:
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Linux hang watchdog: previous releases silently wedged inside tauri
|
||||
# bundling (possibly linuxdeploy/AppImage download, possibly cargo link).
|
||||
# Cap the step at 30 min so we get logs instead of waiting out the 6hr
|
||||
# job timeout. Other platforms historically complete in ~25 min, so 45
|
||||
# is comfortable.
|
||||
- uses: tauri-apps/[email protected]
|
||||
timeout-minutes: ${{ (contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')) && 30 || 45 }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
@@ -158,6 +216,9 @@ jobs:
|
||||
APPLE_PROVIDER_SHORT_NAME: ${{ secrets.APPLE_PROVIDER_SHORT_NAME }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
# Stream subprocess stdout/stderr so the hang is visible in logs.
|
||||
CARGO_TERM_VERBOSE: "true"
|
||||
RUST_BACKTRACE: "1"
|
||||
with:
|
||||
projectPath: tauri
|
||||
tagName: v__VERSION__
|
||||
@@ -168,6 +229,46 @@ jobs:
|
||||
args: ${{ matrix.args }}
|
||||
includeUpdaterJson: true
|
||||
|
||||
# Tauri's bundler signs the .app and notarizes it, but the .dmg wrapper
|
||||
# ships unnotarized. Gatekeeper rejects that on macOS 15 Sequoia (caught
|
||||
# by Homebrew Cask CI) and causes "app isn't signed" dialogs on older
|
||||
# Intel Macs when Apple's notarization servers are slow (see issue #509).
|
||||
# Submit the .dmg to notarytool, staple the ticket, and overwrite the
|
||||
# release asset uploaded by tauri-action.
|
||||
- name: Notarize and staple DMG (macOS)
|
||||
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
|
||||
env:
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEY_PATH="$HOME/.appstoreconnect/private_keys/AuthKey_${APPLE_API_KEY_ID}.p8"
|
||||
TARGET=$(echo "${{ matrix.args }}" | sed -n 's/.*--target \([a-z0-9_-]*\).*/\1/p')
|
||||
DMG_DIR="tauri/src-tauri/target/${TARGET}/release/bundle/dmg"
|
||||
# Match the release tag tauri-action resolved from tauri.conf.json's
|
||||
# version field; GITHUB_REF_NAME is a branch name under workflow_dispatch.
|
||||
RELEASE_TAG="v$(jq -r '.version' tauri/src-tauri/tauri.conf.json)"
|
||||
shopt -s nullglob
|
||||
dmgs=("${DMG_DIR}"/*.dmg)
|
||||
if [ ${#dmgs[@]} -eq 0 ]; then
|
||||
echo "::error::No DMGs found in ${DMG_DIR} — tauri bundler output path may have changed"
|
||||
exit 1
|
||||
fi
|
||||
for dmg in "${dmgs[@]}"; do
|
||||
echo "::group::Notarize $(basename "$dmg")"
|
||||
xcrun notarytool submit "$dmg" \
|
||||
--key "$KEY_PATH" \
|
||||
--key-id "$APPLE_API_KEY_ID" \
|
||||
--issuer "$APPLE_API_ISSUER" \
|
||||
--wait --timeout 20m
|
||||
xcrun stapler staple "$dmg"
|
||||
spctl -a -t open --context context:primary-signature -vv "$dmg"
|
||||
gh release upload "${RELEASE_TAG}" "$dmg" --clobber \
|
||||
--repo "${GITHUB_REPOSITORY}"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
build-cuda-windows:
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
@@ -188,43 +289,115 @@ jobs:
|
||||
pip install pyinstaller
|
||||
pip install -r backend/requirements.txt
|
||||
pip install --no-deps chatterbox-tts
|
||||
pip install --no-deps hume-tada
|
||||
|
||||
- name: Install PyTorch with CUDA 12.6
|
||||
- name: Install PyTorch with CUDA 12.8
|
||||
run: |
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cu126 --force-reinstall --no-deps
|
||||
pip install torchaudio --index-url https://download.pytorch.org/whl/cu126 --force-reinstall --no-deps
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cu128 --force-reinstall --no-deps
|
||||
pip install torchaudio --index-url https://download.pytorch.org/whl/cu128 --force-reinstall --no-deps
|
||||
|
||||
- name: Verify CUDA support in torch
|
||||
run: |
|
||||
python -c "import torch; print(f'CUDA available in build: {torch.cuda.is_available()}'); print(f'CUDA version: {torch.version.cuda}')"
|
||||
|
||||
- name: Build CUDA server binary
|
||||
- name: Build CUDA server binary (onedir)
|
||||
shell: bash
|
||||
working-directory: backend
|
||||
env:
|
||||
# Include Blackwell (sm_120) via PTX forward compatibility.
|
||||
# Pre-built PyTorch cu128 wheels ship native kernels for sm_80/86/89/90
|
||||
# but not sm_120. Setting this env var causes torch.utils.cpp_extension
|
||||
# (and any JIT-compiled kernels) to target Blackwell GPUs as well.
|
||||
TORCH_CUDA_ARCH_LIST: "8.0;8.6;8.9;9.0;12.0+PTX"
|
||||
run: python build_binary.py --cuda
|
||||
|
||||
- name: Split binary for GitHub Releases
|
||||
- name: Package into server core + CUDA libs archives
|
||||
shell: bash
|
||||
run: |
|
||||
python scripts/split_binary.py \
|
||||
backend/dist/voicebox-server-cuda.exe \
|
||||
--output release-assets/
|
||||
python scripts/package_cuda.py \
|
||||
backend/dist/voicebox-server-cuda/ \
|
||||
--output release-assets/ \
|
||||
--cuda-libs-version cu128-v1 \
|
||||
--torch-compat ">=2.7.0,<2.11.0"
|
||||
|
||||
- name: Upload split parts to GitHub Release
|
||||
- name: Upload archives to GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
release-assets/voicebox-server-cuda.part*.exe
|
||||
release-assets/voicebox-server-cuda.sha256
|
||||
release-assets/voicebox-server-cuda.manifest
|
||||
release-assets/voicebox-server-cuda.tar.gz
|
||||
release-assets/voicebox-server-cuda.tar.gz.sha256
|
||||
release-assets/cuda-libs-cu128-v1.tar.gz
|
||||
release-assets/cuda-libs-cu128-v1.tar.gz.sha256
|
||||
release-assets/cuda-libs.json
|
||||
draft: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload binary as workflow artifact
|
||||
- name: Upload onedir as workflow artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: voicebox-server-cuda-windows
|
||||
path: backend/dist/voicebox-server-cuda.exe
|
||||
path: backend/dist/voicebox-server-cuda/
|
||||
retention-days: 7
|
||||
|
||||
build-rocm-windows:
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
# ROCm wheels are cp312-cp312-specific — build_binary.py --rocm enforces this.
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyinstaller
|
||||
pip install -r backend/requirements.txt
|
||||
pip install --no-deps chatterbox-tts
|
||||
pip install --no-deps hume-tada
|
||||
|
||||
- name: Build ROCm server binary (onedir)
|
||||
shell: bash
|
||||
working-directory: backend
|
||||
# build_binary.py --rocm pulls the official AMD Radeon torch + rocm_sdk
|
||||
# wheels (rocm-rel-7.2.1) itself when ROCm torch is not already present,
|
||||
# then restores the dev torch afterwards.
|
||||
run: python build_binary.py --rocm
|
||||
|
||||
- name: Package into server core + ROCm libs archives
|
||||
shell: bash
|
||||
run: |
|
||||
python scripts/package_rocm.py \
|
||||
backend/dist/voicebox-server-rocm/ \
|
||||
--output release-assets/ \
|
||||
--rocm-libs-version rocm7.2-v1 \
|
||||
--torch-compat ">=2.9.0,<2.10.0"
|
||||
|
||||
- name: Upload archives to GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
release-assets/voicebox-server-rocm.tar.gz
|
||||
release-assets/voicebox-server-rocm.tar.gz.sha256
|
||||
release-assets/rocm-libs-rocm7.2-v1.tar.gz
|
||||
release-assets/rocm-libs-rocm7.2-v1.tar.gz.sha256
|
||||
release-assets/rocm-libs.json
|
||||
draft: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload onedir as workflow artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: voicebox-server-rocm-windows
|
||||
path: backend/dist/voicebox-server-rocm/
|
||||
retention-days: 7
|
||||
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"voicebox": {
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:17493/mcp",
|
||||
"headers": {
|
||||
"X-Voicebox-Client-Id": "claude-code"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
22
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
+344
-3
@@ -7,9 +7,332 @@
|
||||
|
||||
## [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.
|
||||
### Linux
|
||||
|
||||
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.
|
||||
- **ROCm setup works on Linux AMD systems.** Docker ROCm builds now keep PyTorch
|
||||
on the ROCm wheel index during dependency installation, so later installs do
|
||||
not replace it with CUDA wheels. The ROCm compose overlay no longer assumes
|
||||
Ubuntu render/video group IDs; the container joins the groups that own the GPU
|
||||
device nodes at startup. Native Linux setup now picks ROCm wheels for AMD GPUs
|
||||
and CUDA wheels for NVIDIA GPUs before installing backend dependencies.
|
||||
|
||||
## [0.5.0] - 2026-04-22
|
||||
|
||||
**The Capture release.** Voicebox stops being just a voice-cloning studio and becomes a full AI voice studio. Hold a key anywhere on your machine, speak, release — the transcript lands in the focused text field. Flip the primitive around and any MCP-aware agent — Claude Code, Cursor, Spacebot — speaks back through an on-screen pill in one of your cloned voices. A local LLM sits between the two, so transcripts come out clean and voice profiles can carry a personality that reshapes what the agent says before it gets spoken.
|
||||
|
||||
### Dictation — speak anywhere, paste anywhere
|
||||
|
||||
- **Global hotkey capture.** Hold a customizable chord anywhere on your machine (defaults: right-Cmd + right-Option on macOS, right-Ctrl + right-Shift on Windows), speak, release. A floating on-screen pill walks through recording → transcribing → refining → done with a live elapsed timer. The transcript lands as clean text.
|
||||
- **Push-to-talk and toggle modes, each with its own chord.** The default toggle chord adds Space to the push-to-talk chord. Holding PTT and tapping Space mid-hold upgrades a hold into a hands-free session without a gap in the recording.
|
||||
- **Auto-paste into the focused app.** Once transcription finishes, Voicebox synthesizes a paste into whatever text field had focus when you started the chord — not wherever focus drifted while you were talking. Works across Dvorak / AZERTY layouts. Your clipboard is saved before and restored after.
|
||||
- **Chord picker UI.** Customize either chord from Settings → Captures by holding the keys you want. Left/right modifier badges show whether a key is the left or right variant.
|
||||
- **Defaults stay out of your way.** macOS defaults avoid left-hand Cmd+Option chords so the system shortcuts they collide with stay yours. Windows defaults route around AltGr collisions on German / French / Spanish layouts.
|
||||
- **Accessibility permission is scoped.** If macOS Accessibility isn't granted, dictation still runs and transcripts still land in the Captures tab — only synthetic paste is disabled. The permission prompt lives inline next to the auto-paste toggle, not as a global banner.
|
||||
|
||||
### Personality — voice profiles that speak for themselves
|
||||
|
||||
Voice profiles now carry an optional **personality** — a free-form description of who this voice is, up to 2000 characters. When set, two new controls appear next to the generate button, each powered by a new Qwen3 LLM running entirely locally:
|
||||
|
||||
- **Compose** — the shuffle button drops a fresh in-character line into the textarea. Click again for variety, edit before speaking.
|
||||
- **Speak in character** — the wand toggle runs your input through the personality LLM before TTS, preserving every idea but delivering it in the character's voice.
|
||||
|
||||
The same LLM doubles as the refinement model, so there's one local LLM in the app, not two.
|
||||
|
||||
**API surface.** `POST /generate`, `POST /speak`, and the MCP `voicebox.speak` tool accept `personality: bool`. `POST /profiles/{id}/compose` powers the shuffle button. MCP client bindings carry a `default_personality: bool` that applies when `personality` isn't passed explicitly.
|
||||
|
||||
### Agents — any MCP-aware agent gets a voice
|
||||
|
||||
Voicebox ships a built-in **Model Context Protocol** server at `http://127.0.0.1:17493/mcp` so Claude Code, Cursor, Windsurf, Cline, VS Code MCP extensions — any MCP-aware agent — can call into your local Voicebox install. Four tools ship with dotted names:
|
||||
|
||||
- **`voicebox.speak`** — speak text in any voice profile, with optional `personality: true` to run through the profile's personality LLM first
|
||||
- **`voicebox.transcribe`** — Whisper transcription of a base64 blob or an absolute local path. Path mode is restricted to loopback callers so a Voicebox bound on `0.0.0.0` doesn't double as an unauthenticated arbitrary-local-file read primitive.
|
||||
- **`voicebox.list_captures`** — recent captures with their transcripts
|
||||
- **`voicebox.list_profiles`** — available voice profiles (cloned + preset)
|
||||
|
||||
- **Streamable HTTP as primary transport.** Cursor / Windsurf / VS Code / Claude Code all support it out of the box — drop a `mcpServers` block with the URL and an `X-Voicebox-Client-Id` header.
|
||||
- **Stdio shim for clients that don't speak HTTP MCP.** A `voicebox-mcp` binary ships inside the app bundle as a Tauri sidecar. The Settings page renders the install snippet with the right absolute path pre-filled.
|
||||
- **Per-client voice binding.** Pin Claude Code to Morgan, Cursor to Scarlett, Cline to its own voice — the `X-Voicebox-Client-Id` header resolves to a bound voice whenever `speak` is called without an explicit `profile`. Managed in **Settings → MCP**.
|
||||
- **Profile resolution precedence.** Explicit `profile` arg (name or id, case-insensitive) → per-client binding → global default from `capture_settings.default_playback_voice_id` → error with a pointer to Settings.
|
||||
- **Speaking pill.** Agent-initiated speech surfaces the same on-screen pill as dictation, in a `speaking` state with the profile name and an elapsed timer. Silent background TTS is a trust hazard — the pill always shows what's coming out of your machine.
|
||||
- **`POST /speak` REST wrapper.** Same code path and voice resolution for shell scripts, ACP, A2A, GitHub Actions, or anything else that isn't MCP-native.
|
||||
|
||||
**Claude Code one-liner:**
|
||||
|
||||
```
|
||||
claude mcp add voicebox --transport http --url http://127.0.0.1:17493/mcp --header "X-Voicebox-Client-Id: claude-code"
|
||||
```
|
||||
|
||||
### Refinement
|
||||
|
||||
A clean transcript needs more than Whisper. Each capture flows through a small Qwen3 LLM that strips fillers, fixes punctuation, and optionally rewrites self-corrections — all on-device.
|
||||
|
||||
- **Loop-stripping before the LLM sees the transcript.** Whisper's "thanks for watching thanks for watching thanks for watching…" hallucination loops are collapsed at a six-identical-tokens threshold (case-insensitive) so a small refinement model can't echo them back. Coverage spans single-word runs, multi-word phrases, CJK character runs, and Japanese emphasis patterns; legitimate repetition ("no, no, no, no, no") doesn't cross the threshold.
|
||||
- **Per-capture flag snapshot.** `smart_cleanup`, `self_correction`, and `preserve_technical` are stored on each capture, so refinement can be re-run later with different flags without losing the raw transcript.
|
||||
- **Model picker** — Qwen3 0.6B (400 MB, very fast), 1.7B (1.1 GB, fast), 4B (2.5 GB, full quality). 0.6B is the default; 1.7B is the sweet spot for transcripts with code identifiers.
|
||||
|
||||
### Captures tab + settings
|
||||
|
||||
Settings → Captures is now the home for the whole dictation flow:
|
||||
|
||||
- **Dictation**: global shortcut toggle, push-to-talk chord picker, toggle chord picker, live pill preview, auto-paste into focused field (with inline accessibility prompt).
|
||||
- **Transcription**: model picker (Whisper Base / Small / Medium / Large / Turbo), language lock.
|
||||
- **Refinement**: auto-refine toggle, model picker, smart cleanup, remove self-corrections, preserve technical terms.
|
||||
- **Playback**: default voice for the Captures tab's "Play as" action — picking a voice from the split-button persists the choice across tab switches and restarts.
|
||||
- **Storage**: captures folder quick-open.
|
||||
|
||||
### Stories — timeline editor
|
||||
|
||||
The Stories tab graduates from a TTS sequencer into a real timeline editor. Same generation-row backing, but clips now compose with imported audio, per-clip levels, and a flexible track stack.
|
||||
|
||||
- **Import external audio.** Drag a music file onto the story content area or pick one from the new "Import audio" entry in the add-clip popover. Accepted formats: wav / mp3 / flac / ogg / m4a / aac / webm, capped at 200 MB. Imported clips show their filename instead of a profile name and skip the regenerate / version-picker controls — there's nothing to regenerate.
|
||||
- **Per-clip volume.** A `Volume2` icon in the clip-edit toolbar opens a 0–200% slider. Adjustments apply live and to exports. Split and duplicate carry the volume forward into the new clips.
|
||||
- **Regenerate** from both the clip's chat-list dropdown and the track-editor toolbar. Re-runs the underlying generation through the same path the History tab uses, with completion tracked in the global pending set.
|
||||
- **Add empty tracks above or below the timeline** via tiny `+` strips at the top of the topmost label cell and the bottom of the bottommost. Sticky in the label column so they follow horizontal scroll.
|
||||
- **Zoom bar tracks the project.** Min scope is 10 seconds visible (zoomed in cap), max is the entire project (zoomed out cap), default lands on 60 s. Both the +/− buttons and the scrollbar edge-drag handles clamp to those dynamic bounds.
|
||||
|
||||
### Interface
|
||||
|
||||
- **Theme selector.** Light / dark / system in **Settings → General**, persisted across sessions. System mode listens for OS-level appearance changes and flips live without a restart.
|
||||
- **Scrubbable waveform player on captures.** The capture detail card now embeds a WaveSurfer waveform with click-to-seek and a current / total timestamp pair, replacing the static duration label.
|
||||
- **Capture pill light mode.** The on-screen pill gets a dedicated light palette so it stays legible against bright windows.
|
||||
- **Readiness checklist in the Captures settings sidebar.** The same six-gate checklist the Captures empty state uses mirrors into Settings → Captures so a red gate can't hide behind a green toggle. Hidden once every gate is green. macOS-only rows (Input Monitoring, Accessibility) hide entirely on Windows and Linux.
|
||||
|
||||
### Windows parity
|
||||
|
||||
Same dictation flow on Windows. Right-hand default chord (Ctrl+Shift) avoids AltGr collisions on layouts where Ctrl+Alt is the compose key. Focus is captured at chord-start so paste lands in the original field even if focus drifts during transcribe/refine.
|
||||
|
||||
## [0.4.5] - 2026-04-22
|
||||
|
||||
Second hotfix for the "offline mode is enabled" crash on model load. 0.4.4 reverted the inference-path offline guards but kept the same trap on the load path, so users who updated to 0.4.4 kept hitting the exact error the release was supposed to fix ([#526](https://github.com/jamiepine/voicebox/issues/526)). This release removes the load-path guards and patches the transformers tokenizer load to be robust to HuggingFace metadata failures at the source, so the class of bug can't recur.
|
||||
|
||||
### Reliability
|
||||
|
||||
- **Load no longer fails with "offline mode is enabled"** ([#530](https://github.com/jamiepine/voicebox/pull/530), fixes [#526](https://github.com/jamiepine/voicebox/issues/526)). transformers 4.57.x added an unconditional `huggingface_hub.model_info()` call inside `AutoTokenizer.from_pretrained` (via `_patch_mistral_regex`) that runs for every non-local repo load, regardless of cache state or whether the target model is actually a Mistral variant. The load-time `HF_HUB_OFFLINE` guard from 0.4.2 turned that into a hard crash for cached online users the moment 0.4.4 removed the inference-path guard that had been masking the problem. Fix wraps `_patch_mistral_regex` so any exception from the HF metadata check is caught and the tokenizer is returned unchanged — matching the success-path behavior for non-Mistral repos. The wrapper installs at `backend.backends` import time so it covers Qwen Base, Qwen CustomVoice, TADA, and every other transformers-backed engine on Windows, Linux, and CUDA alike. The load-time `force_offline_if_cached` guards were removed — with the wrapper in place they provide zero value and only risk re-introducing the same failure mode.
|
||||
- **No more 30s pause when generating without a network.** The HuggingFace metadata timeout called out as a known caveat in 0.4.4 is covered by the same patch; offline users no longer wait for the check to time out before load completes.
|
||||
|
||||
## [0.4.4] - 2026-04-21
|
||||
|
||||
Hotfix for a regression in 0.4.3 where generation and transcription could fail outright with "offline mode is enabled" even when the user was online.
|
||||
|
||||
### Reliability
|
||||
|
||||
- **Inference no longer fails with "offline mode is enabled" while online** ([#524](https://github.com/jamiepine/voicebox/pull/524), reverts the inference-path guards from [#503](https://github.com/jamiepine/voicebox/pull/503)). 0.4.3 wrapped every inference body (`generate`, `transcribe`, `create_voice_clone_prompt`) with a process-wide `HF_HUB_OFFLINE` flip to stop lazy HuggingFace lookups from hanging when the network drops mid-inference ([#462](https://github.com/jamiepine/voicebox/issues/462)). That flag also blocks legitimate metadata calls (e.g. `HfApi().model_info` for revision resolution) so online users started seeing generation fail outright. Inference now runs with the process's default HF state. Load-time offline guards — which weren't the source of the regression — stay in place.
|
||||
|
||||
**Known caveat**: users generating without an internet connection may see brief pauses during inference while HuggingFace metadata lookups time out (typically ~30s, after which the library recovers). A proper offline-mode toggle is planned for 0.4.5.
|
||||
|
||||
## [0.4.3] - 2026-04-20
|
||||
|
||||
A patch focused on two user-impacting reliability fixes: macOS DMG notarization (unblocks `brew install voicebox` on macOS 15 Sequoia and fixes spurious "app isn't signed" Gatekeeper dialogs on older Intel Macs) and Kokoro Japanese voice initialization on fresh installs.
|
||||
|
||||
### macOS
|
||||
|
||||
- **DMGs are now notarized and stapled** ([#523](https://github.com/jamiepine/voicebox/pull/523)). Tauri's bundler notarizes the `.app` inside the DMG but ships the DMG wrapper itself unnotarized. Gatekeeper rejects that on macOS 15 Sequoia (confirmed by Homebrew Cask CI failing on both arm and intel Sequoia runners) and causes the "the app is not signed" dialog on older Intel Macs when Apple's notarization servers are slow or unreachable ([#509](https://github.com/jamiepine/voicebox/issues/509)). The release workflow now submits each DMG to `notarytool`, staples the ticket, verifies with `spctl`, and overwrites the draft-release asset `tauri-action` uploaded. Adds ~5-10 min per macOS job.
|
||||
|
||||
### Backend
|
||||
|
||||
- **Kokoro Japanese voices no longer crash on fresh installs** ([#521](https://github.com/jamiepine/voicebox/pull/521), fixes [#514](https://github.com/jamiepine/voicebox/issues/514)). `misaki[ja]` pulls in `fugashi`, which needs a MeCab dictionary on disk. The `unidic` package that was being installed ships no data and expects a ~526MB runtime download that `just setup` doesn't run (and which wouldn't survive PyInstaller anyway). Swapped to `unidic-lite`, which bundles a MeCab-compatible dict inside the wheel (~50MB). Collected in `build_binary.py` so frozen builds pick up `unidic_lite/dicdir/`.
|
||||
|
||||
## [0.4.2] - 2026-04-20
|
||||
|
||||
This release localizes the entire app. English, Simplified Chinese (zh-CN), Traditional Chinese (zh-TW), and Japanese (ja) are wired up end-to-end across every tab, modal, dialog, and toast — 559 translation keys per locale, parity verified. Plus a batch of reliability fixes: offline-mode now actually stays offline, Chatterbox accepts reference samples it used to reject, MLX Qwen 0.6B points at the right repo, and macOS system audio survives backgrounding.
|
||||
|
||||
### Internationalization ([#508](https://github.com/jamiepine/voicebox/pull/508))
|
||||
- **i18next foundation** with an in-app language switcher that re-renders the tree on change — lazy-loaded components were holding stale strings without an explicit key-bump on the React root.
|
||||
- **Four locales** at full coverage: English, Simplified Chinese, Traditional Chinese, Japanese. No partial/English-fallback surfaces.
|
||||
- **Every user-visible surface translated**: Stories (list, content editor, dialogs, toasts), Effects (list, detail, chain editor, built-in preset names), Voices (table, search, inspector, Create/Edit modal, audio sample panels), Audio Channels (list, dialogs, device picker), history + story dropdown menus, ProfileCard / ProfileList / HistoryTable, and the unsupported-model note.
|
||||
- **Relative dates** localize via `date-fns` locale objects (`3 days ago` → `3 天前` / `3 日前`) — `Intl.RelativeTimeFormat` doesn't produce the phrasing we use in the history table.
|
||||
- **Dev-build version suffix** (`v0.4.2 (dev)` / `(开发版)` / `(開發版)` / `(開発版)`) is now locale-aware.
|
||||
- **559 translation keys** across all four locales.
|
||||
|
||||
### Reliability
|
||||
- **`HF_HUB_OFFLINE` now guards every inference path** ([#503](https://github.com/jamiepine/voicebox/pull/503)) — some engines were still attempting a HuggingFace metadata roundtrip on first load when offline mode was enabled, causing hangs on airgapped or flaky networks.
|
||||
- **Chatterbox reference samples are preprocessed instead of rejected** ([#502](https://github.com/jamiepine/voicebox/pull/502)) — samples outside the expected sample rate or channel layout are resampled to match, rather than failing with an opaque error.
|
||||
- **MLX Qwen 0.6B repo path fixed** ([#501](https://github.com/jamiepine/voicebox/pull/501)) — now points at the published `mlx-community` repo so the model actually downloads on Apple Silicon.
|
||||
- **macOS system audio survives backgrounding** ([#486](https://github.com/jamiepine/voicebox/pull/486), closes [#41](https://github.com/jamiepine/voicebox/issues/41)) — WKWebView was tearing down the audio session when the app lost focus, silently killing system-audio capture.
|
||||
- **MLX backend `miniaudio` dependency pinned** ([#506](https://github.com/jamiepine/voicebox/pull/506)) — `mlx_audio.stt` needs it at runtime and nothing else transitively pulled it in, so `--no-deps` installs were breaking on first use.
|
||||
|
||||
### Landing / Docs
|
||||
- **New `/download` page** ([#487](https://github.com/jamiepine/voicebox/pull/487)) — no more dumping first-time visitors onto the GitHub releases list. The API example snippet on the landing page also got an accuracy pass.
|
||||
- **Download redirects work behind reverse proxies** ([#498](https://github.com/jamiepine/voicebox/pull/498)) — uses the public origin instead of `localhost` when resolving platform-specific installer URLs.
|
||||
- **MDX docs audited against the multi-engine backend** ([#484](https://github.com/jamiepine/voicebox/pull/484)) — stale single-engine assumptions removed.
|
||||
- **Three more tutorials + mobile navbar / hero CTA fixes** ([#483](https://github.com/jamiepine/voicebox/pull/483)).
|
||||
|
||||
### Linux
|
||||
- **Still not shipping.** The re-enable attempt ([#488](https://github.com/jamiepine/voicebox/pull/488)) landed on `main` but CI still hangs in the `tauri-action` bundler step on `ubuntu-22.04` — no output for 25+ minutes after `rpm` bundling, even with `createUpdaterArtifacts: false` and `--bundles deb,rpm`. The matrix entry is disabled again for 0.4.2; the ubuntu-specific setup steps stay in the workflow so re-enabling is a one-line change once we identify the hang. Next release will take another pass.
|
||||
|
||||
### New Contributors
|
||||
- [@shekharyv](https://github.com/shekharyv) — download redirects behind reverse proxies ([#498](https://github.com/jamiepine/voicebox/pull/498))
|
||||
|
||||
## [0.4.1] - 2026-04-18
|
||||
|
||||
A fast follow-up to 0.4.0 focused on making the new engines actually load in the production binary — plus generation cancellation, Linux system-audio capture, and the repo's first PR-time type check. Five first-time contributors shipped in this release.
|
||||
|
||||
0.4.0 introduced three new TTS engines, but the frozen PyInstaller binary tripped over several Python-ecosystem quirks that don't show up in the dev venv: `transformers` opening `.py` sources at runtime, `scipy.stats._distn_infrastructure` hitting a frozen-importer `NameError`, and `chatterbox-multilingual` failing to find its Chinese segmenter dictionary. This release patches all of those in one sweep.
|
||||
|
||||
### Frozen-Binary Reliability ([#438](https://github.com/jamiepine/voicebox/pull/438))
|
||||
- **Kokoro** now bundles `.py` sources alongside `.pyc` via `--collect-all kokoro` so `transformers`' `_can_set_attn_implementation` regex scan can read them — previously `FileNotFoundError: kokoro/modules.py` killed Kokoro loading in production builds
|
||||
- **Chatterbox Multilingual** now bundles `spacy_pkuseg/dicts/default.pkl` and the package's native `.so` extensions via `--collect-all spacy_pkuseg` — previously the Chinese word segmenter crashed with `FileNotFoundError` on first load
|
||||
- **scipy.stats._distn_infrastructure** — new runtime hook source-patches the trailing `del obj` (which raises `NameError` under PyInstaller's frozen importer because the preceding list comprehension evaluates empty) to `globals().pop('obj', None)`, unblocking `librosa` → `scipy.signal` → `scipy.stats` for every TTS engine that depends on librosa
|
||||
- **transformers.masking_utils** — same runtime hook forces `_is_torch_greater_or_equal_than_2_6 = False` so the older `sdpa_mask_older_torch` path is selected; the 2.6+ path uses `TransformGetItemToIndex()`, a real `torch._dynamo` graph transform our permissive stub can't reproduce
|
||||
- **torch._dynamo** — no-op stub replaces the real module before `transformers` imports it, preventing the `torch._numpy._ufuncs` import crash (`NameError: name 'name' is not defined`) that blocked Kokoro and every engine pulling in `flex_attention`
|
||||
- `.spec` paths are now repo-relative instead of absolute, so the generated spec is portable across machines and CI
|
||||
|
||||
### Generation
|
||||
- **Cancel queued or running generations** ([#444](https://github.com/jamiepine/voicebox/pull/444)) — new `/generate/{id}/cancel` endpoint and a Stop button on the history row while generating. The serial queue now tracks per-ID state (queued / running / cancelled) so queued jobs are skipped before the worker picks them up and running jobs are `.cancel()`-ed mid-flight; `run_generation` catches `CancelledError` and marks the row `failed` with a "cancelled" error.
|
||||
- **Legacy `data/` path prefix resolution** ([#440](https://github.com/jamiepine/voicebox/pull/440)) — generations stored with the old `data/` prefix under pre-0.4 installs now resolve correctly after the storage root moved, fixing 404s for historical audio.
|
||||
|
||||
### Model Migration
|
||||
- Migration dialog no longer hangs when the cache is empty ([#439](https://github.com/jamiepine/voicebox/pull/439)) — the backend now emits a completion SSE event even when zero models are moved.
|
||||
- Storage-change flow surfaces a toast when there's nothing to migrate ([#433](https://github.com/jamiepine/voicebox/pull/433)) instead of proceeding with a no-op move and restarting the server.
|
||||
- Deleting all generations from a voice profile now deletes the associated version files and DB rows too ([#447](https://github.com/jamiepine/voicebox/pull/447)) — previously orphaned versions accumulated in storage.
|
||||
|
||||
### Platform
|
||||
- **Linux system audio capture** ([#457](https://github.com/jamiepine/voicebox/pull/457)) — `cpal`'s ALSA backend doesn't expose PulseAudio/PipeWire monitor sources by name, so the previous device-name search never matched and silently fell back to the microphone. Detection now uses `pactl get-default-sink` + `pactl list short sources` and routes via `PULSE_SOURCE`, with the name-based search retained as a fallback when `pactl` is absent.
|
||||
|
||||
### Frontend CI
|
||||
- First PR-time quality gate ([#418](https://github.com/jamiepine/voicebox/pull/418)) — new `.github/workflows/ci.yml` runs `bun run typecheck` + `bun run build:web` on every PR. Fixed pre-existing type issues that were being suppressed with `@ts-expect-error`, cleaned up a dep-array typo (`[platform.metadata.isTauricheckOnMountcheckForUpdates]`) in `useAutoUpdater`, and removed 100+ lines of dead `ModelItem` code from `ModelManagement.tsx`.
|
||||
- Follow-up: widened `apiClient.migrateModels()` return type to include `moved` and `errors` so the storage-change handler typechecks against the real backend response ([#470](https://github.com/jamiepine/voicebox/pull/470)).
|
||||
|
||||
### Docs
|
||||
- Clarified in the Quick Start + README that paralinguistic tags (`[laugh]`, `[sigh]`) only work with Chatterbox Turbo; other engines read them as literal text ([#450](https://github.com/jamiepine/voicebox/pull/450)).
|
||||
|
||||
### New Contributors
|
||||
- [@Bortlesboat](https://github.com/Bortlesboat) — generation cancellation (#444)
|
||||
- [@gaojulong](https://github.com/gaojulong) — migration dialog hang fix (#439)
|
||||
- [@fuleinist](https://github.com/fuleinist) — migration no-op toast (#433)
|
||||
- [@erionjuniordeandrade-a11y](https://github.com/erionjuniordeandrade-a11y) — frontend CI + type hardening (#418)
|
||||
- [@estefrac](https://github.com/estefrac) — Linux pactl system-audio capture (#457)
|
||||
|
||||
## [0.4.0] - 2026-04-16
|
||||
|
||||
The biggest Voicebox release yet. Three new TTS engines bring the lineup to **seven** — HumeAI TADA, Kokoro 82M, and Qwen CustomVoice join Qwen3-TTS, LuxTTS, Chatterbox Multilingual, and Chatterbox Turbo. GPU support broadens to Intel Arc (XPU) and NVIDIA Blackwell (RTX 50-series), with runtime diagnostics that warn when your PyTorch build doesn't match your GPU. The CUDA backend is now split into independently versioned server and library archives, so upgrading no longer redownloads 4 GB of PyTorch/CUDA DLLs.
|
||||
|
||||
This release also marks a big community moment: **13 new contributors** shipped fixes and features in 0.4.0. Thirty-plus bug fixes target the most-reported issues in the tracker — numpy 2.x TTS crashes, Windows background-server reliability, macOS 11 launch failures, audio playback silence, Stories clip-splitting races, history status staleness, and more.
|
||||
|
||||
### New TTS Engines
|
||||
|
||||
#### HumeAI TADA — Expressive English & Multilingual ([#296](https://github.com/jamiepine/voicebox/pull/296))
|
||||
- Added `tada-1b` (English) and `tada-3b-ml` (multilingual) backends
|
||||
- Replaced `descript-audio-codec` with a lightweight DAC shim to cut dependencies
|
||||
- Switched audio decoding to `soundfile` to sidestep `torchcodec` bundling issues
|
||||
- Redirected gated Llama tokenizer lookups to an ungated mirror so model loading works out of the box
|
||||
- Fixed tokenizer patch that was corrupting `AutoTokenizer` for other engines
|
||||
- Fixed TorchScript error in frozen builds
|
||||
|
||||
#### Kokoro 82M — Fast Lightweight TTS ([#325](https://github.com/jamiepine/voicebox/pull/325))
|
||||
- Added Kokoro 82M engine with a new voice profile type system that distinguishes preset voices from cloned profiles
|
||||
- Profile grid now handles engine compatibility directly — removed redundant dropdown filtering
|
||||
- Tightened Kokoro profile handling so preset voices can't be edited like cloned profiles
|
||||
|
||||
#### Qwen CustomVoice ([#328](https://github.com/jamiepine/voicebox/pull/328))
|
||||
- Added `qwen-custom-voice` preset engine backed by Qwen3-TTS
|
||||
- Enforced preset/profile engine compatibility across the generation flow
|
||||
- Floating generator now shows all engines instead of silently filtering
|
||||
|
||||
### Voice Profile UX
|
||||
|
||||
Until 0.4, every engine in Voicebox was a cloning model, so every voice profile was usable with every engine and the profile grid just showed them all. Introducing Kokoro and Qwen CustomVoice — which work from preset voices rather than cloned samples — broke that assumption for the first time. An early cut on `main` filtered the grid by the selected engine, which left users running pre-release builds thinking their cloned voices had vanished whenever they switched to a preset-only engine.
|
||||
|
||||
This release ships the resolution before it ever reaches a tagged version:
|
||||
|
||||
- **Grey-out instead of filter** — all profiles are always visible; unsupported ones render dimmed with a compatibility hint at the bottom of the grid
|
||||
- **Auto-switch on selection** — clicking a greyed-out profile selects it AND switches the engine to a compatible one, instead of silently doing nothing
|
||||
- **Instruct toggle restored for Qwen CustomVoice** — the floating generate box now reveals a delivery-instructions input (tone, emotion, pace) when CustomVoice is selected. Hidden across the board while the new multi-engine lineup was stabilizing because most engines don't honor the kwarg; now conditionally exposed only for the one engine that was actually trained for instruction-based style control
|
||||
- Supported profiles sort first; the grid scrolls the selected profile into view after engine/sort changes
|
||||
- Fixed engine desync on tab navigation — the form now initializes its engine from the store
|
||||
- Fixed the disabled-and-selected card click edge case by bouncing selection to re-trigger the auto-switch
|
||||
- Cleaned up scroll effect timers (requestAnimationFrame + setTimeout) to prevent stale DOM writes on unmount or rapid selection changes
|
||||
|
||||
### GPU & Platform
|
||||
|
||||
#### Intel Arc (XPU) Support ([#320](https://github.com/jamiepine/voicebox/pull/320))
|
||||
- First-class Intel Arc support across all PyTorch-based backends
|
||||
- Device-aware seeding, XPU detection in the GPU status panel, and setup flow detection
|
||||
- Reports correct device name and VRAM in settings
|
||||
|
||||
#### Blackwell / RTX 50-series Support ([#316](https://github.com/jamiepine/voicebox/pull/316), [#401](https://github.com/jamiepine/voicebox/pull/401))
|
||||
- Upgraded the CUDA backend from cu126 → cu128 for RTX 50-series support
|
||||
- Added `sm_120+PTX` to the CUDA build via `TORCH_CUDA_ARCH_LIST` for forward-compatibility with Blackwell architectures (closes 5 open reports: #386, #395, #396, #399, #400)
|
||||
- GPU settings UI fixes around install/uninstall state
|
||||
|
||||
#### GPU Compatibility Diagnostics ([#367](https://github.com/jamiepine/voicebox/pull/367), adapted)
|
||||
- New `check_cuda_compatibility()` compares the current device's compute capability against the bundled PyTorch's architecture list
|
||||
- Health endpoint exposes a `gpu_compatibility_warning` field so the UI can surface mismatches
|
||||
- Startup logs a `WARN` when the installed PyTorch build doesn't support the detected GPU
|
||||
- GPU status label shows `[UNSUPPORTED - see logs]` — no more silent "no kernel image" failures
|
||||
|
||||
#### Split CUDA Backend ([#298](https://github.com/jamiepine/voicebox/pull/298))
|
||||
- CUDA backend now ships as two independently versioned archives: a small server binary and a large libs archive (the ~4 GB of PyTorch/CUDA DLLs)
|
||||
- Upgrading Voicebox no longer redownloads the libs archive when only the server binary changed
|
||||
- Added `asyncio.Lock` around `download_cuda_binary()` so auto-update and manual download can't race on the same temp file ([#428](https://github.com/jamiepine/voicebox/pull/428))
|
||||
- Updated `package_cuda.py` for PyInstaller 6.18 onedir layout
|
||||
- Temp archives are always cleaned up on failure, even when the install aborts mid-extract
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
#### Critical: TTS Generation
|
||||
- **numpy 2.x `torch.from_numpy` crash** ([#361](https://github.com/jamiepine/voicebox/pull/361)) — torch compiled against numpy 1.x ABI fails silently when paired with numpy 2.x, causing `RuntimeError: Numpy is not available` / `Unable to create tensor` on every TTS request in bundled macOS Intel / Rosetta builds. Pinned `numpy<2.0` in requirements and added a PyInstaller runtime hook with a `ctypes.memmove` fallback as belt-and-suspenders. Hardened afterward to raise on unknown dtypes instead of silently reinterpreting bytes as float32.
|
||||
|
||||
#### Platform Reliability
|
||||
- **Windows background server** ([#402](https://github.com/jamiepine/voicebox/pull/402)) — "keep server running after close" now actually keeps the server running. The HTTP `/watchdog/disable` request could lose the race against process exit on Windows; added a `.keep-running` sentinel file as a synchronous fallback, with stale-sentinel cleanup on startup to avoid orphan server processes
|
||||
- **macOS 11 launch crash** ([#424](https://github.com/jamiepine/voicebox/pull/424)) — weak-linked ScreenCaptureKit so the app can launch on macOS < 12.3 instead of crashing at dyld resolution. Gated system audio capture behind a real `sw_vers` version check so unsupported systems cleanly advertise "not available" rather than crashing at runtime
|
||||
- **macOS Intel (x86_64) setup** ([#416](https://github.com/jamiepine/voicebox/pull/416)) — relaxed `torch>=2.7.0` → `torch>=2.2.0`. PyTorch dropped pre-built x86_64 wheels after 2.2.2, so Intel Mac devs could no longer `pip install`. Now resolves to the latest compatible torch per platform
|
||||
- **Offline model loading** ([#318](https://github.com/jamiepine/voicebox/pull/318)) — Qwen TTS and Whisper force offline mode when loading cached models, so startup works without network access
|
||||
- **GUI startup with external server** ([#319](https://github.com/jamiepine/voicebox/pull/319)) — fixed GUI launch when pointed at a remote/external server, and added data refresh on server switch; hardened health validation and error handling
|
||||
- **Qwen3-TTS cache split on Windows** (adapted from [#218](https://github.com/jamiepine/voicebox/pull/218)) — route `Qwen3TTSModel.from_pretrained` through `hf_constants.HF_HUB_CACHE` so the speech tokenizer and `preprocessor_config.json` resolve from a single cache root
|
||||
- **Qwen3-TTS bundling** ([#305](https://github.com/jamiepine/voicebox/pull/305)) — bundle `qwen_tts` source files in the PyInstaller build to fix `inspect.getsource` errors in frozen builds
|
||||
- **Backend import paths** ([#345](https://github.com/jamiepine/voicebox/pull/345)) — moved lazy imports to top-level with absolute paths to resolve the "Failed to Save" preset error caused by `ModuleNotFoundError` in production builds
|
||||
- **Effects service import** ([#384](https://github.com/jamiepine/voicebox/pull/384)) — fixed `ModuleNotFoundError` on preset create/update by switching to relative imports (#349)
|
||||
|
||||
#### Audio & Playback
|
||||
- **cpal stream silent playback** ([#405](https://github.com/jamiepine/voicebox/pull/405)) — `cpal::Stream` was dropped on function return immediately after `play()`, causing every playback to fall silent. Now holds the stream until either the buffer drains or the stop flag fires (#404)
|
||||
|
||||
#### Stories & History
|
||||
- **Clip-splitting race** ([#403](https://github.com/jamiepine/voicebox/pull/403)) — rapid double-clicks on split could race through `split_story_item` with inconsistent state. Added `with_for_update()` row locking on the backend and an `isPending` guard on the frontend (#366)
|
||||
- **History `status` staleness** ([#394](https://github.com/jamiepine/voicebox/pull/394)) — `GET /history/{id}` was hardcoding `status="completed"` regardless of the DB row, breaking any client polling for job completion. Now returns `status`, `error`, `engine`, `model_size`, and `is_favorited` from the actual row
|
||||
- **"Clear failed" bulk button** ([#412](https://github.com/jamiepine/voicebox/pull/412)) — new `DELETE /history/failed` endpoint and a header strip showing `"N failed generations"` with a Clear button, complementing the per-row trash icon added in #321 (#410)
|
||||
- **Delete failed generations** ([#321](https://github.com/jamiepine/voicebox/pull/321)) — added a trash icon next to the retry button so failed entries can be cleaned up without having to retry first
|
||||
|
||||
#### Security & Safety
|
||||
- **Voice prompt cache hardening** ([#429](https://github.com/jamiepine/voicebox/pull/429)) — `torch.load(weights_only=True)` on cached voice prompts per PyTorch 2.6 recommendation; replaced string-based SPA path guard with `Path.is_relative_to()` for more robust path-traversal protection
|
||||
|
||||
#### Infrastructure & Docker
|
||||
- **Docker web build** ([#344](https://github.com/jamiepine/voicebox/pull/344)) — include `CHANGELOG.md` in the Docker web build so the in-app changelog page works in Docker deployments
|
||||
- **Docker numba cache** ([#425](https://github.com/jamiepine/voicebox/pull/425)) — set `NUMBA_CACHE_DIR` in docker-compose so numba can write its JIT cache in container runtime (#308)
|
||||
- **Relative media paths** ([#332](https://github.com/jamiepine/voicebox/pull/332)) — media paths now stored relative to the configured data dir rather than resolved against CWD, so the data directory is portable between installs
|
||||
|
||||
### Developer Tooling
|
||||
|
||||
- New `triage-prs` agent skill — encodes the end-to-end PR-speedrun workflow (classification → triage doc → rebase → squash-merge → follow-ups) so future release cycles can reproduce it
|
||||
- Rewrote the TTS engine guide with the patterns learned from adding TADA and Kokoro
|
||||
- Added the API refactor plan and CUDA libs addon design doc
|
||||
- Fixed broken links in the Get Started section ([#332](https://github.com/jamiepine/voicebox/pull/332))
|
||||
|
||||
### New Contributors
|
||||
|
||||
Huge thank you to everyone who contributed their first PR to Voicebox in this release:
|
||||
|
||||
[@liorshahverdi](https://github.com/liorshahverdi), [@nicoschtein](https://github.com/nicoschtein), [@ArfianID](https://github.com/ArfianID), [@aimaaaimaa](https://github.com/aimaaaimaa), [@maxmcoding](https://github.com/maxmcoding), [@Khalodddd](https://github.com/Khalodddd), [@LuisSambrano](https://github.com/LuisSambrano), [@shaun0927](https://github.com/shaun0927), [@malletfils](https://github.com/malletfils), [@mvanhorn](https://github.com/mvanhorn), [@kuishou68](https://github.com/kuishou68), [@txhno](https://github.com/txhno), [@MukundaKatta](https://github.com/MukundaKatta)
|
||||
|
||||
## [0.3.0] - 2026-03-17
|
||||
|
||||
This release rewrites the backend into a modular architecture, overhauls the settings UI into routed sub-pages, fixes audio player freezing, migrates documentation to Fumadocs, and ships a batch of bug fixes targeting the most-reported issues from the tracker.
|
||||
|
||||
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, settings have been split into dedicated routed pages with server logs, a changelog viewer, and an about page. The audio player no longer freezes mid-playback, and model loading status is now visible in the UI. Seven user-reported bugs have been fixed, including server crashes during sample uploads, generation list staleness, cryptic error messages, and CUDA support for RTX 50-series GPUs.
|
||||
|
||||
### Settings Overhaul ([#294](https://github.com/jamiepine/voicebox/pull/294))
|
||||
- Split settings into routed sub-tabs: General, Generation, GPU, Logs, Changelog, About
|
||||
- Added live server log viewer with auto-scroll
|
||||
- Added in-app changelog page that parses `CHANGELOG.md` at build time
|
||||
- Added About page with version info, license, and generation folder quick-open
|
||||
- Extracted reusable `SettingRow` component for consistent setting layouts
|
||||
|
||||
### Audio Player Fix ([#293](https://github.com/jamiepine/voicebox/pull/293))
|
||||
- Fixed audio player freezing during playback
|
||||
- Improved playback UX with better state management and listener cleanup
|
||||
- Fixed restart race condition during regeneration
|
||||
- Added stable keys for audio element re-rendering
|
||||
- Improved accessibility across player controls
|
||||
|
||||
### Backend Refactor ([#285](https://github.com/jamiepine/voicebox/pull/285))
|
||||
- Extracted all routes from `main.py` into 13 domain routers under `backend/routes/` — `main.py` dropped from ~3,100 lines to ~10
|
||||
@@ -40,6 +363,17 @@ The backend's 3,000-line monolith `main.py` has been decomposed into domain rout
|
||||
- Softened select focus indicator opacity
|
||||
- Addressed 4 critical and 12 major issues from CodeRabbit review
|
||||
|
||||
### Bug Fixes ([#295](https://github.com/jamiepine/voicebox/pull/295))
|
||||
- Fixed sample uploads crashing the server — audio decoding now runs in a thread pool instead of blocking the async event loop ([#278](https://github.com/jamiepine/voicebox/issues/278))
|
||||
- Fixed generation list not updating when a generation completes — switched to `refetchQueries` for reliable cache busting, added SSE error fallback, and page reset on completion ([#231](https://github.com/jamiepine/voicebox/issues/231))
|
||||
- Fixed error toasts showing `[object Object]` instead of the actual error message ([#290](https://github.com/jamiepine/voicebox/issues/290))
|
||||
- Added Whisper model selection (`base`, `small`, `medium`, `large`, `turbo`) and expanded language support to the `/transcribe` endpoint ([#233](https://github.com/jamiepine/voicebox/issues/233))
|
||||
- Upgraded CUDA backend build from cu121 to cu126 for RTX 50-series (Blackwell) GPU support ([#289](https://github.com/jamiepine/voicebox/issues/289))
|
||||
- Handled client disconnects in SSE and streaming endpoints to suppress `[Errno 32] Broken Pipe` errors ([#248](https://github.com/jamiepine/voicebox/issues/248))
|
||||
- Fixed Docker build failure from pip hash mismatch on Qwen3-TTS dependencies ([#286](https://github.com/jamiepine/voicebox/issues/286))
|
||||
- Added 50 MB upload size limit with chunked reads to prevent unbounded memory allocation on sample uploads
|
||||
- Eliminated redundant double audio decode in sample processing pipeline
|
||||
|
||||
### Platform Fixes
|
||||
- Replaced `netstat` with `TcpStream` + PowerShell for Windows port detection ([#277](https://github.com/jamiepine/voicebox/pull/277))
|
||||
- Fixed Docker frontend build and cleaned up Docker docs
|
||||
@@ -417,7 +751,14 @@ The first public release of Voicebox — an open-source voice synthesis studio p
|
||||
|
||||
Tauri v2, React, TypeScript, Tailwind CSS, FastAPI, Qwen3-TTS, Whisper, SQLite
|
||||
|
||||
[Unreleased]: https://github.com/jamiepine/voicebox/compare/v0.2.3...HEAD
|
||||
[0.5.0]: https://github.com/jamiepine/voicebox/compare/v0.4.5...v0.5.0
|
||||
[0.4.5]: https://github.com/jamiepine/voicebox/compare/v0.4.4...v0.4.5
|
||||
[0.4.4]: https://github.com/jamiepine/voicebox/compare/v0.4.3...v0.4.4
|
||||
[0.4.3]: https://github.com/jamiepine/voicebox/compare/v0.4.2...v0.4.3
|
||||
[0.4.2]: https://github.com/jamiepine/voicebox/compare/v0.4.1...v0.4.2
|
||||
[0.4.1]: https://github.com/jamiepine/voicebox/compare/v0.4.0...v0.4.1
|
||||
[0.4.0]: https://github.com/jamiepine/voicebox/compare/v0.3.0...v0.4.0
|
||||
[0.3.0]: https://github.com/jamiepine/voicebox/compare/v0.2.3...v0.3.0
|
||||
[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
|
||||
|
||||
+6
-5
@@ -91,7 +91,7 @@ On Windows, to build with CUDA support for local testing:
|
||||
just build-local # Build CPU + CUDA server binaries + Tauri installer
|
||||
```
|
||||
|
||||
This builds the CPU sidecar (bundled with the app), the CUDA binary (placed in `%APPDATA%/com.voicebox.app/backends/` for runtime GPU switching), and the installable Tauri app.
|
||||
This builds the CPU sidecar (bundled with the app), the CUDA binary (placed in `%APPDATA%/sh.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/`.
|
||||
|
||||
@@ -133,7 +133,7 @@ bun run convert:assets
|
||||
This script:
|
||||
- Converts PNG → WebP (better compression, same quality)
|
||||
- Converts MOV → WebM (VP9 codec, smaller file size)
|
||||
- Processes files in `landing/public/` and `docs/public/`
|
||||
- Processes files in `docs/public/`
|
||||
- **Deletes original files** after successful conversion
|
||||
|
||||
**Requirements:** Install `webp` and `ffmpeg`:
|
||||
@@ -260,7 +260,7 @@ voicebox/
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- Check the roadmap in README.md
|
||||
- Check the roadmap in README.md and the engineering status in [`docs/PROJECT_STATUS.md`](docs/PROJECT_STATUS.md) before proposing work — it lists prioritized tasks (Tier 1 → 3), known architectural bottlenecks, and candidate TTS engines already under evaluation (including why some have been backlogged)
|
||||
- Discuss major features in an issue first
|
||||
- Keep features focused and well-scoped
|
||||
|
||||
@@ -359,7 +359,7 @@ Releases are managed by maintainers:
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues and solutions.
|
||||
See [docs/content/docs/overview/troubleshooting.mdx](docs/content/docs/overview/troubleshooting.mdx) for common issues and solutions.
|
||||
|
||||
**Quick fixes:**
|
||||
|
||||
@@ -372,12 +372,13 @@ See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues and sol
|
||||
- Open an issue for bugs or feature requests
|
||||
- Check existing issues and discussions
|
||||
- Review the codebase to understand patterns
|
||||
- See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues
|
||||
- See [docs/content/docs/overview/troubleshooting.mdx](docs/content/docs/overview/troubleshooting.mdx) for common issues
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [README.md](README.md) - Project overview
|
||||
- [backend/README.md](backend/README.md) - API documentation
|
||||
- [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) - Living engineering roadmap: architecture, shipped vs in-flight work, prioritized open issues, candidate TTS engines under evaluation, architectural bottlenecks. Keep this updated when you ship significant features, close or backlog a model integration, or identify new bottlenecks.
|
||||
- [docs/AUTOUPDATER_QUICKSTART.md](docs/AUTOUPDATER_QUICKSTART.md) - Auto-updater setup
|
||||
- [SECURITY.md](SECURITY.md) - Security policy
|
||||
- [CHANGELOG.md](CHANGELOG.md) - Version history
|
||||
|
||||
+34
-9
@@ -1,20 +1,27 @@
|
||||
# ============================================================
|
||||
# Voicebox — Local TTS Server with Web UI (CPU)
|
||||
# Voicebox — Local TTS Server with Web UI
|
||||
# 3-stage build: Frontend → Python deps → Runtime
|
||||
#
|
||||
# Build variants:
|
||||
# CPU (default): docker compose up --build
|
||||
# ROCm (AMD GPU): docker compose -f docker-compose.yml -f docker-compose.rocm.yml up --build
|
||||
# ============================================================
|
||||
|
||||
# Top-level ARG so it is visible to all stages.
|
||||
ARG PYTORCH_VARIANT=cpu
|
||||
|
||||
# === Stage 1: Build frontend ===
|
||||
FROM oven/bun:1 AS frontend
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy workspace config and frontend source
|
||||
COPY package.json bun.lock ./
|
||||
COPY package.json bun.lock CHANGELOG.md ./
|
||||
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 && \
|
||||
RUN sed -i '/"tauri"/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)
|
||||
@@ -24,6 +31,9 @@ RUN cd web && bunx --bun vite build
|
||||
# === Stage 2: Build Python dependencies ===
|
||||
FROM python:3.11-slim AS backend-builder
|
||||
|
||||
# Re-declare ARG inside the stage (Docker scoping requirement).
|
||||
ARG PYTORCH_VARIANT=cpu
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
@@ -34,7 +44,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN pip install --no-cache-dir --upgrade pip
|
||||
|
||||
COPY backend/requirements.txt .
|
||||
|
||||
# ROCm wheel index. Default 6.3 (RDNA1/2/3); set ROCM_VERSION=7.2 for RDNA4.
|
||||
ARG ROCM_VERSION=6.3
|
||||
|
||||
# For ROCm, make the PyTorch ROCm index primary so every install below resolves
|
||||
# torch to ROCm wheels instead of the default CUDA build.
|
||||
RUN if [ "$PYTORCH_VARIANT" = "rocm" ]; then \
|
||||
pip install --no-cache-dir --prefix=/install \
|
||||
--index-url "https://download.pytorch.org/whl/rocm${ROCM_VERSION}" \
|
||||
torch torchaudio && \
|
||||
printf '[global]\nindex-url = https://download.pytorch.org/whl/rocm%s\nextra-index-url = https://pypi.org/simple\n' "$ROCM_VERSION" > /etc/pip.conf; \
|
||||
fi
|
||||
|
||||
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||
RUN pip install --no-cache-dir --prefix=/install --no-deps chatterbox-tts
|
||||
RUN pip install --no-cache-dir --prefix=/install --no-deps hume-tada
|
||||
RUN pip install --no-cache-dir --prefix=/install \
|
||||
git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
|
||||
@@ -42,16 +67,17 @@ RUN pip install --no-cache-dir --prefix=/install \
|
||||
# === Stage 3: Runtime ===
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Create non-root user for security
|
||||
# Create non-root user; the entrypoint joins GPU device groups at runtime.
|
||||
RUN groupadd -r voicebox && \
|
||||
useradd -r -g voicebox -m -s /bin/bash voicebox
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install only runtime system dependencies
|
||||
# Install only runtime system dependencies (gosu drops root in the entrypoint)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
curl \
|
||||
gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy installed Python packages from builder stage
|
||||
@@ -67,9 +93,6 @@ COPY --from=frontend --chown=voicebox:voicebox /build/web/dist /app/frontend/
|
||||
RUN mkdir -p /app/data/generations /app/data/profiles /app/data/cache \
|
||||
&& chown -R voicebox:voicebox /app/data
|
||||
|
||||
# Switch to non-root user
|
||||
USER voicebox
|
||||
|
||||
# Expose the API port
|
||||
EXPOSE 17493
|
||||
|
||||
@@ -77,5 +100,7 @@ EXPOSE 17493
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s \
|
||||
CMD curl -f http://localhost:17493/health || exit 1
|
||||
|
||||
# Start the FastAPI server
|
||||
# Entrypoint joins GPU groups then drops to the voicebox user
|
||||
COPY --chmod=755 scripts/rocm-entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "17493"]
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<h1 align="center">Voicebox</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>The open-source voice synthesis studio.</strong><br/>
|
||||
Clone voices. Generate speech. Apply effects. Build voice-powered apps.<br/>
|
||||
All running locally on your machine.
|
||||
<strong>The open-source AI voice studio.</strong><br/>
|
||||
Clone any voice. Generate speech. Dictate into any app. Talk to agents in voices you own.<br/>
|
||||
The full voice I/O stack, running locally on your machine.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -23,6 +23,13 @@
|
||||
<a href="https://github.com/jamiepine/voicebox/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/jamiepine/voicebox?style=flat" alt="License" />
|
||||
</a>
|
||||
<a href="https://deepwiki.com/jamiepine/voicebox">
|
||||
<img src="https://img.shields.io/static/v1?label=Ask&message=DeepWiki&color=5B6EF7" alt="Ask DeepWiki" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/21213" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21213" alt="jamiepine%2Fvoicebox | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -30,14 +37,15 @@
|
||||
<a href="https://docs.voicebox.sh">Docs</a> •
|
||||
<a href="#download">Download</a> •
|
||||
<a href="#features">Features</a> •
|
||||
<a href="#api">API</a>
|
||||
<a href="#api">API</a> •
|
||||
<a href="docs/content/docs/overview/troubleshooting.mdx">Troubleshooting</a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://voicebox.sh">
|
||||
<img src="landing/public/assets/app-screenshot-1.webp" alt="Voicebox App Screenshot" width="800" />
|
||||
<img src="docs/public/images/readme/app-screenshot-1.webp" alt="Voicebox App Screenshot" width="800" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -48,27 +56,33 @@
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
<img src="landing/public/assets/app-screenshot-2.webp" alt="Voicebox Screenshot 2" width="800" />
|
||||
<img src="docs/public/images/readme/app-screenshot-2.webp" alt="Voicebox Screenshot 2" width="800" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="landing/public/assets/app-screenshot-3.webp" alt="Voicebox Screenshot 3" width="800" />
|
||||
<img src="docs/public/images/readme/app-screenshot-3.webp" alt="Voicebox Screenshot 3" width="800" />
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
## 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 AI voice studio** — a free and open-source alternative to **ElevenLabs** and **WisprFlow** in one app. Clone voices from a few seconds of audio, generate speech in 23 languages across 7 TTS engines, dictate into any text field with a global hotkey, and give any MCP-aware AI agent a voice of your choosing.
|
||||
|
||||
- **Complete privacy** — models and voice data stay on your machine
|
||||
- **4 TTS engines** — Qwen3-TTS, LuxTTS, Chatterbox Multilingual, and Chatterbox Turbo
|
||||
The two cloud incumbents sit on opposite halves of the voice I/O loop — ElevenLabs on output, WisprFlow on input. Voicebox does both, bridges them with a bundled local LLM for refinement and per-profile personas, and runs the whole thing on your machine.
|
||||
|
||||
- **Complete privacy** — models, voice data, and captures never leave your machine
|
||||
- **7 TTS engines** — Qwen3-TTS, Qwen CustomVoice, LuxTTS, Chatterbox Multilingual, Chatterbox Turbo, HumeAI TADA, and Kokoro
|
||||
- **Voice cloning and preset voices** — zero-shot cloning from a reference sample, or 50+ curated preset voices via Kokoro and Qwen CustomVoice
|
||||
- **23 languages** — from English to Arabic, Japanese, Hindi, Swahili, and more
|
||||
- **Post-processing effects** — pitch shift, reverb, delay, chorus, compression, and filters
|
||||
- **Expressive speech** — paralinguistic tags like `[laugh]`, `[sigh]`, `[gasp]` via Chatterbox Turbo
|
||||
- **Expressive speech** — paralinguistic tags like `[laugh]`, `[sigh]`, `[gasp]` via Chatterbox Turbo; natural-language delivery control via Qwen CustomVoice
|
||||
- **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
|
||||
- **Voice input** — global dictation hotkey with push-to-talk and toggle modes, accessibility-verified auto-paste on macOS, in-app mic on every text field, Whisper-based STT
|
||||
- **Agent voice output** — one tool call (`voicebox.speak`) and any MCP-aware agent (Claude Code, Cursor, Cline) speaks to you in a voice you've cloned
|
||||
- **Voice personalities** — attach a free-form persona to any voice profile, then Compose, Rewrite, or Respond via a bundled local LLM — agents can invoke the same modes over MCP
|
||||
- **API-first** — REST API plus a built-in MCP server for integrating voice I/O into your own apps and agents
|
||||
- **Native performance** — built with Tauri (Rust), not Electron
|
||||
- **Runs everywhere** — macOS (MLX/Metal), Windows (CUDA), Linux, AMD ROCm, Intel Arc, Docker
|
||||
|
||||
@@ -87,24 +101,34 @@ Voicebox is a **local-first voice cloning studio** — a free and open-source al
|
||||
|
||||
> **Linux** — Pre-built binaries are not yet available. See [voicebox.sh/linux-install](https://voicebox.sh/linux-install) for build-from-source instructions.
|
||||
|
||||
> **Having trouble?** See the [Troubleshooting Guide](docs/content/docs/overview/troubleshooting.mdx) for common install, generation, model-download, and GPU issues.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Multi-Engine Voice Cloning
|
||||
|
||||
Four TTS engines with different strengths, switchable per-generation:
|
||||
Seven TTS engines with different strengths, switchable per-generation:
|
||||
|
||||
| Engine | Languages | Strengths |
|
||||
| --------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Qwen3-TTS** (0.6B / 1.7B) | 10 | High-quality multilingual cloning, delivery instructions ("speak slowly", "whisper") |
|
||||
| **Qwen CustomVoice** | 10 | 9 curated preset voices with natural-language delivery control — no reference audio required |
|
||||
| **LuxTTS** | English | Lightweight (~1GB VRAM), 48kHz output, 150x realtime on CPU |
|
||||
| **Chatterbox Multilingual** | 23 | Broadest language coverage — Arabic, Danish, Finnish, Greek, Hebrew, Hindi, Malay, Norwegian, Polish, Swahili, Swedish, Turkish and more |
|
||||
| **Chatterbox Turbo** | English | Fast 350M model with paralinguistic emotion/sound tags |
|
||||
| **TADA** (1B / 3B) | 10 | HumeAI speech-language model — 700s+ coherent audio, text-acoustic dual alignment |
|
||||
| **Kokoro** | 8 | 50 curated preset voices, tiny 82M model, fast CPU inference |
|
||||
|
||||
### Emotions & Paralinguistic Tags
|
||||
|
||||
Type `/` in the text input to insert expressive tags that the model synthesizes inline with speech (Chatterbox Turbo):
|
||||
Only **Chatterbox Turbo** interprets paralinguistic tags like `[laugh]` and
|
||||
`[sigh]`. Qwen3-TTS, LuxTTS, Chatterbox Multilingual, and HumeAI TADA read them
|
||||
literally as text.
|
||||
|
||||
With **Chatterbox Turbo** selected, type `/` in the text input to open the tag
|
||||
inserter and add expressive tags inline with speech:
|
||||
|
||||
`[laugh]` `[chuckle]` `[gasp]` `[cough]` `[sigh]` `[groan]` `[sniff]` `[shush]` `[clear throat]`
|
||||
|
||||
@@ -170,12 +194,69 @@ Multi-voice timeline editor for conversations, podcasts, and narratives.
|
||||
- Auto-playback with synchronized playhead
|
||||
- Version pinning per track clip
|
||||
|
||||
### Recording & Transcription
|
||||
### Global Dictation & Voice Input
|
||||
|
||||
- In-app recording with waveform visualization
|
||||
- System audio capture (macOS and Windows)
|
||||
- Automatic transcription powered by Whisper (including Whisper Turbo)
|
||||
- Export recordings in multiple formats
|
||||
The other half of the voice I/O loop. Hold a hotkey anywhere on your system, speak, release — on macOS the transcript pastes straight into the focused text field. Or hit the mic on any Voicebox text input and dictate directly into the app.
|
||||
|
||||
- **Configurable chord bindings** — hold-to-speak and tap-to-toggle chords, each rebindable in the in-app chord picker. Holding push-to-talk and tapping `Space` mid-hold upgrades into a toggle session without a gap in audio
|
||||
- **Target-aware paste (macOS)** — accessibility-verified injection into the focused text field, with atomic clipboard save/restore so your clipboard isn't clobbered
|
||||
- **First-run permissions UX** — in-app gates walk you through the macOS Accessibility and Input Monitoring grants with deep-links to System Settings
|
||||
- **In-app mic button** on every Voicebox text field — generation form, profile descriptions, story titles, anywhere you'd type
|
||||
- **LLM refinement** — optional cleanup of ums, stutters, and false starts before paste
|
||||
- **On-screen pill** — floating overlay surfacing `recording`, `transcribing`, `refining`, and `speaking` states. Same pill agents use when they speak to you, so there's one mental model for both directions of the loop
|
||||
|
||||
### Speech-to-Text
|
||||
|
||||
Voicebox runs OpenAI Whisper for transcription — the same model that backs dictation, the Captures tab, and the `/transcribe` API. Running on MLX (Apple Silicon) or PyTorch (CUDA / ROCm / DirectML / CPU) depending on your platform.
|
||||
|
||||
| Size | Notes |
|
||||
| ----------------------------- | -------------------------------------------------- |
|
||||
| Base / Small / Medium / Large | Standard Whisper quality ladder |
|
||||
| Turbo | ~8x faster than Whisper Large, minimal quality loss |
|
||||
|
||||
More engines (Parakeet v3, Qwen3-ASR) are planned — see [Roadmap](#roadmap).
|
||||
|
||||
### Captures
|
||||
|
||||
Every dictation, in-app recording, and uploaded audio file lands in the Captures tab — original audio paired with transcript, always preserved.
|
||||
|
||||
- **Replay, re-transcribe, refine** — rerun STT with any Whisper size, or re-run the raw transcript through the local LLM with different flags (filler cleanup, self-correction removal, technical-term preservation)
|
||||
- **Edit inline** — tweak the transcript and save on blur
|
||||
- **Play as voice profile** — turn any capture into speech with a cloned voice, one click
|
||||
- **Promote to voice sample** — use a capture's audio + transcript as a reference sample on any voice profile
|
||||
- **Local capture storage** — original audio and transcript stay in your Voicebox data directory, with a folder shortcut in Settings
|
||||
|
||||
### Agent Voice Output
|
||||
|
||||
Every agent gets a voice. One tool call and any MCP-aware agent can speak to you in a voice you've cloned — task completions, questions, notifications. The same pill that surfaces during dictation surfaces during agent speech, so you always see what's coming out of your machine.
|
||||
|
||||
```ts
|
||||
// In any MCP-aware agent:
|
||||
await voicebox.speak({
|
||||
text: "Deploy complete.",
|
||||
profile: "Morgan",
|
||||
});
|
||||
```
|
||||
|
||||
Also exposed as `POST /speak` for anything that doesn't speak MCP — ACP, A2A, shell scripts, custom harnesses.
|
||||
|
||||
- **Bidirectional pill** — `recording`, `transcribing`, `refining`, and `speaking` are all states of the same OS-level overlay, so dictation and agent speech share one surface
|
||||
- **Per-agent voice binding** — in **Settings → MCP**, pin Claude Code to Morgan and Cursor to Scarlett so you can tell which agent is talking without looking. Each client's `last_seen_at` timestamp confirms the install actually took
|
||||
- **Always visible** — no silent background TTS; every agent-initiated speak surfaces the pill with the voice profile name for the full duration
|
||||
- **HTTP + stdio transports** — install as a URL in Claude Code / Cursor / Windsurf / VS Code MCP, or point stdio-only clients at the bundled `voicebox-mcp` binary
|
||||
|
||||
### Voice Personalities
|
||||
|
||||
Attach a free-form personality to any voice profile — who this voice is, how they speak, what they care about. Two actions appear on the generate box when a personality is set, powered by a bundled Qwen3 LLM running entirely locally.
|
||||
|
||||
- **Compose** — a shuffle button that drops a fresh in-character line into the textarea; edit and speak, or click again for a different take
|
||||
- **Speak in character** — a toggle that routes your input text through the personality LLM to be rewritten in their voice before TTS
|
||||
|
||||
Agents can reach the same rewrite path over MCP by passing `personality: true` to `voicebox.speak`, turning the tool into a text-in → personality-LLM → TTS pipeline. The same LLM backs dictation's refinement step — one LLM in the app, one model cache, one GPU-memory footprint.
|
||||
|
||||
**Local LLM options:** Qwen3 0.6B / 1.7B / 4B, sharing the TTS runtime (MLX on Apple Silicon, PyTorch elsewhere).
|
||||
|
||||
Use cases: agent dev loops (dictate a question, hear the answer in a cloned voice), interactive characters for games and narrative tools, speech assistance for people who can't speak in their original voice.
|
||||
|
||||
### Model Management
|
||||
|
||||
@@ -189,7 +270,8 @@ Multi-voice timeline editor for conversations, podcasts, and narratives.
|
||||
| Platform | Backend | Notes |
|
||||
| ------------------------ | -------------- | ---------------------------------------------- |
|
||||
| macOS (Apple Silicon) | MLX (Metal) | 4-5x faster via Neural Engine |
|
||||
| Windows / Linux (NVIDIA) | PyTorch (CUDA) | Auto-downloads CUDA binary from within the app |
|
||||
| Windows (NVIDIA) | PyTorch (CUDA) | Auto-downloads CUDA binary from within the app |
|
||||
| Linux (NVIDIA) | PyTorch (CUDA) | Use a local/remote Python backend with CUDA PyTorch |
|
||||
| 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 |
|
||||
@@ -199,55 +281,123 @@ Multi-voice timeline editor for conversations, podcasts, and narratives.
|
||||
|
||||
## API
|
||||
|
||||
Voicebox exposes a full REST API for integrating voice synthesis into your own apps.
|
||||
Voicebox exposes a REST API for integrating voice I/O into your own apps and agents.
|
||||
|
||||
```bash
|
||||
# Generate speech
|
||||
curl -X POST http://localhost:17493/generate \
|
||||
curl -X POST http://127.0.0.1:17493/generate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"text": "Hello world", "profile_id": "abc123", "language": "en"}'
|
||||
|
||||
# List voice profiles
|
||||
curl http://localhost:17493/profiles
|
||||
|
||||
# Create a profile
|
||||
curl -X POST http://localhost:17493/profiles \
|
||||
# Agent voice output — any app or script can speak in a cloned voice
|
||||
curl -X POST http://127.0.0.1:17493/speak \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "My Voice", "language": "en"}'
|
||||
-H "X-Voicebox-Client-Id: my-script" \
|
||||
-d '{"text": "Deploy complete.", "profile": "Morgan"}'
|
||||
|
||||
# Transcribe an audio file
|
||||
curl -X POST http://127.0.0.1:17493/transcribe \
|
||||
-F "[email protected]" \
|
||||
-F "model=whisper-turbo"
|
||||
|
||||
# List voice profiles
|
||||
curl http://127.0.0.1:17493/profiles
|
||||
```
|
||||
|
||||
**Use cases:** game dialogue, podcast production, accessibility tools, voice assistants, content automation.
|
||||
`POST /speak` accepts `profile` as a name (case-insensitive) or id, and resolves via the same precedence as the MCP tool: explicit arg → per-client binding → `capture_settings.default_playback_voice_id`.
|
||||
|
||||
Full API documentation available at `http://localhost:17493/docs`.
|
||||
### MCP server
|
||||
|
||||
Voicebox ships a built-in **Model Context Protocol** server so any MCP-aware agent (Claude Code, Cursor, Windsurf, Cline, VS Code MCP extensions) can speak, transcribe, and browse captures and profiles.
|
||||
|
||||
**Claude Code one-liner:**
|
||||
|
||||
```
|
||||
claude mcp add voicebox \
|
||||
--transport http \
|
||||
--url http://127.0.0.1:17493/mcp \
|
||||
--header "X-Voicebox-Client-Id: claude-code"
|
||||
```
|
||||
|
||||
**Any HTTP MCP client** (Cursor, Windsurf, VS Code, etc.):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"voicebox": {
|
||||
"url": "http://127.0.0.1:17493/mcp",
|
||||
"headers": { "X-Voicebox-Client-Id": "cursor" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Stdio fallback** for clients that don't speak HTTP MCP — point at the bundled `voicebox-mcp` binary inside the app:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"voicebox": {
|
||||
"command": "/Applications/Voicebox.app/Contents/MacOS/voicebox-mcp",
|
||||
"env": { "VOICEBOX_CLIENT_ID": "claude-desktop" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Four tools ship: `voicebox.speak`, `voicebox.transcribe`, `voicebox.list_captures`, `voicebox.list_profiles`. Per-client voice bindings are managed in **Voicebox → Settings → MCP**. See the [full MCP guide](docs/content/docs/overview/mcp-server.mdx) for tool signatures, resolution precedence, the speaking-pill contract, and security notes.
|
||||
|
||||
```ts
|
||||
// In any MCP-aware agent:
|
||||
await voicebox.speak({
|
||||
text: "Tests passing. Ready to merge.",
|
||||
profile: "Morgan", // optional — falls back to the per-client binding
|
||||
personality: true, // optional — rewrites text through the profile's personality LLM first
|
||||
});
|
||||
```
|
||||
|
||||
**Use cases:** agent dev loops (voice in, voice out), game dialogue, podcast production, accessibility tools, voice assistants, content automation.
|
||||
|
||||
Full API documentation available at `http://127.0.0.1:17493/docs`.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
| ------------- | ------------------------------------------------- |
|
||||
| Desktop App | Tauri (Rust) |
|
||||
| Frontend | React, TypeScript, Tailwind CSS |
|
||||
| State | Zustand, React Query |
|
||||
| Backend | FastAPI (Python) |
|
||||
| TTS Engines | Qwen3-TTS, LuxTTS, Chatterbox, Chatterbox Turbo |
|
||||
| Effects | Pedalboard (Spotify) |
|
||||
| Transcription | Whisper / Whisper Turbo (PyTorch or MLX) |
|
||||
| Inference | MLX (Apple Silicon) / PyTorch (CUDA/ROCm/XPU/CPU) |
|
||||
| Database | SQLite |
|
||||
| Audio | WaveSurfer.js, librosa |
|
||||
| Layer | Technology |
|
||||
| ------------- | ------------------------------------------------------------------------------- |
|
||||
| Desktop App | Tauri (Rust) |
|
||||
| Frontend | React, TypeScript, Tailwind CSS |
|
||||
| State | Zustand, React Query |
|
||||
| Backend | FastAPI (Python) |
|
||||
| TTS Engines | Qwen3-TTS, Qwen CustomVoice, LuxTTS, Chatterbox, Chatterbox Turbo, TADA, Kokoro |
|
||||
| STT | Whisper / Whisper Turbo (PyTorch or MLX) |
|
||||
| Local LLM | Qwen3 (0.6B / 1.7B / 4B), shared runtime with TTS / STT |
|
||||
| MCP Server | FastMCP mounted at `/mcp` (Streamable HTTP) + bundled stdio shim binary |
|
||||
| Native Shim | Rust (inside Tauri) for global hotkey, paste injection, focus introspection |
|
||||
| Effects | Pedalboard (Spotify) |
|
||||
| Inference | MLX (Apple Silicon) / PyTorch (CUDA/ROCm/XPU/CPU) |
|
||||
| Database | SQLite |
|
||||
| Audio | WaveSurfer.js, librosa |
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
| Feature | Description |
|
||||
| ----------------------- | ---------------------------------------------- |
|
||||
| **Real-time Streaming** | Stream audio as it generates, word by word |
|
||||
| **Voice Design** | Create new voices from text descriptions |
|
||||
| **More Models** | XTTS, Bark, and other open-source voice models |
|
||||
| **Plugin Architecture** | Extend with custom models and effects |
|
||||
| **Mobile Companion** | Control Voicebox from your phone |
|
||||
| Feature | Description |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **Windows / Linux auto-paste** | Dictation paste parity — `SendInput` on Windows, `uinput` / AT-SPI on Linux |
|
||||
| **STT engine expansion** | Parakeet v3 and Qwen3-ASR joining Whisper — 50+ languages, better non-English quality |
|
||||
| **Pipeline routing** | Configurable source → transform → sink chains with webhook + MCP sinks and a preset editor |
|
||||
| **Streaming transcription** | WebSocket `/transcribe/stream` for partial transcripts as you speak |
|
||||
| **End-to-end speech LLMs** | Moshi, GLM-4-Voice, Qwen2.5 Omni — real voice-to-voice, no text between |
|
||||
| **Voice Design** | Create new voices from text descriptions |
|
||||
| **Long-form capture** | Dual-stream recorder (mic + system audio) with summary LLM transform |
|
||||
| **Platform sinks** | Apple Notes, Obsidian, and other opt-in integrations |
|
||||
| **Plugin architecture** | Extend with custom models, transforms, and sinks |
|
||||
| **Mobile companion** | Control Voicebox from your phone |
|
||||
|
||||
For the **full engineering status, open-issue triage, and prioritized work queue**, see [`docs/PROJECT_STATUS.md`](docs/PROJECT_STATUS.md) — a living document that tracks what's shipped, what's in-flight, candidate TTS engines under evaluation, and why we've accepted or backlogged specific integrations.
|
||||
|
||||
---
|
||||
|
||||
@@ -269,6 +419,8 @@ Install [just](https://github.com/casey/just): `brew install just` or `cargo ins
|
||||
|
||||
**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.
|
||||
|
||||
The repo ships a pre-wired `.mcp.json` at the root — running Claude Code inside this checkout picks up the Voicebox MCP tools automatically once the dev app is running.
|
||||
|
||||
### Building Locally
|
||||
|
||||
```bash
|
||||
@@ -276,6 +428,12 @@ just build # Build CPU server binary + Tauri app
|
||||
just build-local # (Windows) Build CPU + CUDA server binaries + Tauri app
|
||||
```
|
||||
|
||||
### Adding New Voice Models
|
||||
|
||||
The multi-engine architecture makes adding new TTS engines straightforward. A [step-by-step guide](docs/content/docs/developer/tts-engines.mdx) covers the full process: dependency research, backend protocol implementation, frontend wiring, and PyInstaller bundling.
|
||||
|
||||
The guide is optimized for AI coding agents. An [agent skill](.agents/skills/add-tts-engine/SKILL.md) can pick up a model name and handle the entire integration autonomously — you just test the build locally.
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
@@ -284,7 +442,6 @@ voicebox/
|
||||
├── tauri/ # Desktop app (Tauri + Rust)
|
||||
├── web/ # Web deployment
|
||||
├── backend/ # Python FastAPI server
|
||||
├── landing/ # Marketing website
|
||||
└── scripts/ # Build & release scripts
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Responsible Use
|
||||
|
||||
Voicebox is a local-first AI voice studio. It can clone voices from short audio samples, generate speech, and make AI agents speak through voice profiles. That capability is useful for accessibility, creative production, prototyping, game development, and personal tools, but it can also be misused.
|
||||
|
||||
Voicebox does not and cannot independently verify who owns a voice sample. You are responsible for making sure you have the right to use every voice you clone, import, or generate with.
|
||||
|
||||
## Allowed Uses
|
||||
|
||||
- Cloning your own voice.
|
||||
- Cloning a voice with explicit permission from the speaker.
|
||||
- Using licensed, public-domain, or otherwise legally authorized voice material.
|
||||
- Building accessibility tools, creative projects, games, podcasts, prototypes, and local workflows where the speaker's rights are respected.
|
||||
|
||||
## Prohibited Uses
|
||||
|
||||
- Impersonating someone without permission.
|
||||
- Fraud, scams, phishing, social engineering, or bypassing voice authentication.
|
||||
- Harassment, threats, intimidation, or non-consensual sexual content.
|
||||
- Misleading political, legal, financial, medical, or emergency communications.
|
||||
- Commercial use of a person's voice without the legal right to do so.
|
||||
- Removing or bypassing responsible-use acknowledgements in order to misuse the software.
|
||||
|
||||
## Disclosure And Compliance
|
||||
|
||||
If you publish or distribute synthetic audio, disclose that it is AI-generated where required by law, platform policy, or audience expectations. Developers building products on top of Voicebox should treat consent records, disclosure, and jurisdiction-specific requirements as part of their own application design.
|
||||
|
||||
Voicebox runs locally to protect user privacy. That privacy model does not remove your responsibility to respect other people's voices.
|
||||
+3
-3
@@ -6,8 +6,8 @@ We release patches for security vulnerabilities. Which versions are eligible for
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 0.1.x | :white_check_mark: |
|
||||
| < 0.1 | :x: |
|
||||
| 0.3.x | :white_check_mark: |
|
||||
| < 0.3 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
@@ -82,7 +82,7 @@ Timeline may vary based on severity and complexity.
|
||||
## Security Updates
|
||||
|
||||
Security updates will be:
|
||||
- Released as patch versions (e.g., 0.1.1)
|
||||
- Released as patch versions (e.g., 0.3.2)
|
||||
- Documented in CHANGELOG.md
|
||||
- Announced via GitHub releases
|
||||
- Automatically delivered via auto-updater
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>voicebox</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+5
-4
@@ -1,12 +1,10 @@
|
||||
{
|
||||
"name": "@voicebox/app",
|
||||
"version": "0.2.3",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"lint": "biome lint src",
|
||||
"lint:fix": "biome lint --write src",
|
||||
"format": "biome format --write src",
|
||||
@@ -43,11 +41,14 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"framer-motion": "^12.29.0",
|
||||
"i18next": "^26.0.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.454.0",
|
||||
"motion": "^12.29.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-sound-visualizer": "^1.4.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"wavesurfer.js": "^7.0.0",
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { mockIPC } from '@tauri-apps/api/mocks';
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest';
|
||||
import App from '@/App';
|
||||
import { createMockPlatform } from '@/test/mockPlatform';
|
||||
import { buildModelStatus, buildProfile } from '@/test/msw/fixtures';
|
||||
import {
|
||||
captureHandlers,
|
||||
effectsHandlers,
|
||||
historyHandlers,
|
||||
modelHandlers,
|
||||
profileHandlers,
|
||||
settingsHandlers,
|
||||
storyHandlers,
|
||||
taskHandlers,
|
||||
} from '@/test/msw/handlers';
|
||||
import { worker } from '@/test/msw/worker';
|
||||
import { renderWithProviders } from '@/test/render';
|
||||
|
||||
const originalUrl = window.location.href;
|
||||
|
||||
// useChordSync and the permission gates call the Tauri IPC modules directly,
|
||||
// outside the Platform abstraction. There is no Tauri runtime in the test
|
||||
// browser, so `invoke`/`listen` would reject with a TypeError that some
|
||||
// callers (e.g. useChordSync's `listen('dictate:warm-request')`) never get a
|
||||
// chance to handle, surfacing as unhandled rejections. mockIPC installs the
|
||||
// official in-memory IPC shim; `shouldMockEvents` covers listen/emit too.
|
||||
//
|
||||
// Reinstalled per test for a fresh listener map, but never cleared: the
|
||||
// harness unmounts components after this file's afterEach, and those unmount
|
||||
// cleanups still `unlisten` through the shim. The per-file iframe throws the
|
||||
// window state away anyway.
|
||||
beforeEach(() => {
|
||||
mockIPC(
|
||||
(cmd) => {
|
||||
// Permission checks treat the result as a trusted boolean — grant
|
||||
// them so no permission banners pop over the UI under test.
|
||||
if (cmd.startsWith('check_')) return true;
|
||||
return null;
|
||||
},
|
||||
{ shouldMockEvents: true },
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState(null, '', originalUrl);
|
||||
delete window.__voiceboxServerStartedByApp;
|
||||
});
|
||||
|
||||
/**
|
||||
* Everything the index route (MainEditor + app chrome) fetches on mount.
|
||||
* Unstubbed requests fail the test loudly, so this is the full route budget.
|
||||
*/
|
||||
function useHappyPathHandlers() {
|
||||
worker.use(
|
||||
...profileHandlers([buildProfile({ name: 'Ada Lovelace' })]),
|
||||
...historyHandlers([]),
|
||||
...captureHandlers([]),
|
||||
...settingsHandlers(),
|
||||
...modelHandlers([buildModelStatus()]),
|
||||
...storyHandlers([]),
|
||||
...effectsHandlers([]),
|
||||
...taskHandlers(),
|
||||
);
|
||||
}
|
||||
|
||||
// App reads window.location at render time: `?view=dictate` picks the pill
|
||||
// window, and the router matches the real browser path. Point the URL at the
|
||||
// state under test before mounting; afterEach restores the runner's URL.
|
||||
function setAppUrl(path: string) {
|
||||
window.history.replaceState(null, '', path);
|
||||
}
|
||||
|
||||
it('skips the startup gate outside Tauri and renders the router', async () => {
|
||||
useHappyPathHandlers();
|
||||
setAppUrl('/');
|
||||
|
||||
const screen = await renderWithProviders(<App />);
|
||||
|
||||
// Index route is MainEditor — the profile list proves the router mounted.
|
||||
await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
|
||||
|
||||
// Web mode assumes an external server: no lifecycle management at all.
|
||||
expect(screen.platform.lifecycle.startServer).not.toHaveBeenCalled();
|
||||
expect(screen.platform.lifecycle.setupWindowCloseHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips server auto-start in Tauri dev mode and still reaches the router', async () => {
|
||||
// App gates auto-start on `import.meta.env.PROD`, which is false under
|
||||
// vitest. The reachable Tauri branch is therefore the dev one: window
|
||||
// close handler installed, auto-start skipped, serverReady forced true.
|
||||
//
|
||||
// The PROD-only branches — `lifecycle.startServer`, the health-check
|
||||
// polling fallback, and the startup-error screen with its Retry button —
|
||||
// are unreachable here without mocking import.meta.env, so they are
|
||||
// intentionally not covered.
|
||||
useHappyPathHandlers();
|
||||
setAppUrl('/');
|
||||
const platform = createMockPlatform({ metadata: { isTauri: true } });
|
||||
|
||||
const screen = await renderWithProviders(<App />, { platform });
|
||||
|
||||
await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
|
||||
|
||||
expect(platform.lifecycle.startServer).not.toHaveBeenCalled();
|
||||
expect(platform.lifecycle.setupWindowCloseHandler).toHaveBeenCalled();
|
||||
// Startup syncs the keep-server-running setting into Rust.
|
||||
expect(platform.lifecycle.setKeepServerRunning).toHaveBeenCalledWith(expect.any(Boolean));
|
||||
// Auto-updater runs its mount check in Tauri.
|
||||
expect(platform.updater.checkForUpdates).toHaveBeenCalled();
|
||||
// Dev mode records that the app does not own the server process.
|
||||
expect(window.__voiceboxServerStartedByApp).toBe(false);
|
||||
});
|
||||
|
||||
it('renders the dictate pill window for ?view=dictate without booting the main app', async () => {
|
||||
// No route handlers on purpose: the dictate view must not touch any of the
|
||||
// main app's endpoints, and an unhandled request would fail the test.
|
||||
setAppUrl('/?view=dictate');
|
||||
|
||||
const screen = await renderWithProviders(<App />);
|
||||
|
||||
// DictateWindow forces the document transparent so the Tauri window takes
|
||||
// the pill's shape — the observable signal that it mounted without
|
||||
// throwing under the non-Tauri mock platform.
|
||||
await expect.poll(() => document.body.style.background).toBe('transparent');
|
||||
|
||||
// The pill starts hidden: the wrapper renders but contains no CapturePill.
|
||||
const wrapper = screen.container.firstElementChild as HTMLElement;
|
||||
expect(wrapper.className).toContain('h-screen');
|
||||
expect(wrapper.childElementCount).toBe(0);
|
||||
|
||||
// The startup gate never ran — no server lifecycle calls from this window.
|
||||
expect(screen.platform.lifecycle.startServer).not.toHaveBeenCalled();
|
||||
expect(screen.platform.lifecycle.setupWindowCloseHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
+133
-13
@@ -1,15 +1,56 @@
|
||||
import { RouterProvider } from '@tanstack/react-router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||
import { DictateWindow } from '@/components/DictateWindow/DictateWindow';
|
||||
import ShinyText from '@/components/ShinyText';
|
||||
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||
import { useThemeSync } from '@/hooks/useThemeSync';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { HealthResponse } from '@/lib/api/types';
|
||||
import { useChordSync } from '@/lib/hooks/useChordSync';
|
||||
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { router } from '@/router';
|
||||
import { useLogStore } from '@/stores/logStore';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import {
|
||||
getDefaultServerUrl,
|
||||
isLoopbackVoiceboxServerUrl,
|
||||
useServerStore,
|
||||
} from '@/stores/serverStore';
|
||||
|
||||
function isDictateView(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return new URLSearchParams(window.location.search).get('view') === 'dictate';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a health response has the expected Voicebox-specific shape.
|
||||
* Prevents misidentifying an unrelated service on the same port.
|
||||
*/
|
||||
function isVoiceboxHealthResponse(health: HealthResponse): boolean {
|
||||
return (
|
||||
health?.status === 'healthy' &&
|
||||
typeof health.model_loaded === 'boolean' &&
|
||||
typeof health.gpu_available === 'boolean'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a startup error indicates the port is occupied by an external
|
||||
* server (which we should try to reuse via health-check polling) vs. a real
|
||||
* failure (missing sidecar, signing issue, etc.) that should surface immediately.
|
||||
*/
|
||||
function isPortInUseError(error: unknown): boolean {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
msg.includes('already in use') ||
|
||||
msg.includes('port') ||
|
||||
msg.includes('EADDRINUSE') ||
|
||||
msg.includes('address already in use')
|
||||
);
|
||||
}
|
||||
|
||||
const LOADING_MESSAGES = [
|
||||
'Warming up tensors...',
|
||||
@@ -35,14 +76,32 @@ const LOADING_MESSAGES = [
|
||||
];
|
||||
|
||||
function App() {
|
||||
useThemeSync();
|
||||
|
||||
// The dictate window runs in a separate Tauri webview that must skip
|
||||
// server bootstrap (the main window owns that lifecycle) and render only
|
||||
// the floating recording surface. Split into a sibling component so the
|
||||
// main app's hooks are not called on the dictate path.
|
||||
if (isDictateView()) {
|
||||
return <DictateWindow />;
|
||||
}
|
||||
return <MainApp />;
|
||||
}
|
||||
|
||||
function MainApp() {
|
||||
const platform = usePlatform();
|
||||
const [serverReady, setServerReady] = useState(false);
|
||||
const [startupError, setStartupError] = useState<string | null>(null);
|
||||
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
||||
const serverStartingRef = useRef(false);
|
||||
|
||||
// Automatically check for app updates on startup and show toast notifications
|
||||
useAutoUpdater({ checkOnMount: true, showToast: true });
|
||||
|
||||
// Replay the saved chord into the Rust hotkey listener every time
|
||||
// capture_settings resolves or the user edits the chord.
|
||||
useChordSync();
|
||||
|
||||
// Sync stored setting to Rust on startup
|
||||
useEffect(() => {
|
||||
if (platform.metadata.isTauri) {
|
||||
@@ -75,6 +134,11 @@ function App() {
|
||||
// Setup window close handler and auto-start server when running in Tauri (production only)
|
||||
useEffect(() => {
|
||||
if (!platform.metadata.isTauri) {
|
||||
const serverUrl = getDefaultServerUrl();
|
||||
const currentServerUrl = useServerStore.getState().serverUrl;
|
||||
if (currentServerUrl !== serverUrl && isLoopbackVoiceboxServerUrl(currentServerUrl)) {
|
||||
useServerStore.getState().setServerUrl(serverUrl);
|
||||
}
|
||||
setServerReady(true); // Web assumes server is running
|
||||
return;
|
||||
}
|
||||
@@ -91,7 +155,6 @@ function App() {
|
||||
console.log('Dev mode: Skipping auto-start of server (run it separately)');
|
||||
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)
|
||||
// @ts-expect-error - adding property to window
|
||||
window.__voiceboxServerStartedByApp = false;
|
||||
return;
|
||||
}
|
||||
@@ -114,14 +177,52 @@ function App() {
|
||||
useServerStore.getState().setServerUrl(serverUrl);
|
||||
setServerReady(true);
|
||||
// Mark that we started the server (so we know to stop it on close)
|
||||
// @ts-expect-error - adding property to window
|
||||
window.__voiceboxServerStartedByApp = true;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to auto-start server:', error);
|
||||
serverStartingRef.current = false;
|
||||
// @ts-expect-error - adding property to window
|
||||
window.__voiceboxServerStartedByApp = false;
|
||||
|
||||
// Only fall back to health-check polling when the error indicates the
|
||||
// port is occupied (likely an external server). For real failures
|
||||
// (missing sidecar, signing issues, etc.) surface the error immediately.
|
||||
if (!isPortInUseError(error)) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error('Real startup failure — not polling:', msg);
|
||||
setStartupError(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to polling: the server may already be running externally
|
||||
// (e.g. started via python/uvicorn/Docker). Poll the health endpoint
|
||||
// until it responds with a valid Voicebox payload, then transition to
|
||||
// the main UI.
|
||||
console.log('Falling back to health-check polling...');
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const health = await apiClient.getHealth();
|
||||
if (!isVoiceboxHealthResponse(health)) {
|
||||
console.log('Health response is not from a Voicebox server, keep polling...');
|
||||
return;
|
||||
}
|
||||
console.log('External Voicebox server detected via health check');
|
||||
clearInterval(pollInterval);
|
||||
setServerReady(true);
|
||||
} catch {
|
||||
// Server not ready yet, keep polling
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// Stop polling after 2 minutes and surface the failure
|
||||
setTimeout(() => {
|
||||
clearInterval(pollInterval);
|
||||
serverStartingRef.current = false;
|
||||
setStartupError(
|
||||
'Could not connect to a Voicebox server within 2 minutes. ' +
|
||||
'Please check that the server is running and try again.',
|
||||
);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
// Cleanup: stop server on actual unmount (not StrictMode remount)
|
||||
@@ -168,15 +269,34 @@ function App() {
|
||||
className="w-48 h-48 object-contain animate-fade-in-scale relative z-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="animate-fade-in-delayed">
|
||||
<ShinyText
|
||||
text={LOADING_MESSAGES[loadingMessageIndex]}
|
||||
className="text-lg font-medium text-muted-foreground"
|
||||
speed={2}
|
||||
color="hsl(var(--muted-foreground))"
|
||||
shineColor="hsl(var(--foreground))"
|
||||
/>
|
||||
</div>
|
||||
{startupError ? (
|
||||
<div className="animate-fade-in-delayed max-w-md mx-auto space-y-3">
|
||||
<p className="text-lg font-medium text-destructive">Server startup failed</p>
|
||||
<p className="text-sm text-muted-foreground">{startupError}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
onClick={() => {
|
||||
setStartupError(null);
|
||||
serverStartingRef.current = false;
|
||||
// Trigger a re-mount of the effect by toggling state
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="animate-fade-in-delayed">
|
||||
<ShinyText
|
||||
text={LOADING_MESSAGES[loadingMessageIndex]}
|
||||
className="text-lg font-medium text-muted-foreground"
|
||||
speed={2}
|
||||
color="hsl(var(--muted-foreground))"
|
||||
shineColor="hsl(var(--foreground))"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 1180 320" xmlns="http://www.w3.org/2000/svg"><path d="m367.44 153.84c0 52.32 33.6 88.8 80.16 88.8s80.16-36.48 80.16-88.8-33.6-88.8-80.16-88.8-80.16 36.48-80.16 88.8zm129.6 0c0 37.44-20.4 61.68-49.44 61.68s-49.44-24.24-49.44-61.68 20.4-61.68 49.44-61.68 49.44 24.24 49.44 61.68z"/><path d="m614.27 242.64c35.28 0 55.44-29.76 55.44-65.52s-20.16-65.52-55.44-65.52c-16.32 0-28.32 6.48-36.24 15.84v-13.44h-28.8v169.2h28.8v-56.4c7.92 9.36 19.92 15.84 36.24 15.84zm-36.96-69.12c0-23.76 13.44-36.72 31.2-36.72 20.88 0 32.16 16.32 32.16 40.32s-11.28 40.32-32.16 40.32c-17.76 0-31.2-13.2-31.2-36.48z"/><path d="m747.65 242.64c25.2 0 45.12-13.2 54-35.28l-24.72-9.36c-3.84 12.96-15.12 20.16-29.28 20.16-18.48 0-31.44-13.2-33.6-34.8h88.32v-9.6c0-34.56-19.44-62.16-55.92-62.16s-60 28.56-60 65.52c0 38.88 25.2 65.52 61.2 65.52zm-1.44-106.8c18.24 0 26.88 12 27.12 25.92h-57.84c4.32-17.04 15.84-25.92 30.72-25.92z"/><path d="m823.98 240h28.8v-73.92c0-18 13.2-27.6 26.16-27.6 15.84 0 22.08 11.28 22.08 26.88v74.64h28.8v-83.04c0-27.12-15.84-45.36-42.24-45.36-16.32 0-27.6 7.44-34.8 15.84v-13.44h-28.8z"/><path d="m1014.17 67.68-65.28 172.32h30.48l14.64-39.36h74.4l14.88 39.36h30.96l-65.28-172.32zm16.8 34.08 27.36 72h-54.24z"/><path d="m1163.69 68.18h-30.72v172.32h30.72z"/><path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,126 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { AlertTriangle, ExternalLink } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
/**
|
||||
* Tracks macOS Accessibility permission state. Without this permission the
|
||||
* global chord can still record, but the synthetic-⌘V paste silently drops —
|
||||
* so callers can surface an inline prompt instead of relying on the
|
||||
* system-level permission dialog (which only fires once, the first time the
|
||||
* app tries to post a keystroke).
|
||||
*
|
||||
* Triggered on three signals:
|
||||
* - app mount in Tauri
|
||||
* - `system:accessibility-missing` event from the dictate window's paste
|
||||
* failure handler
|
||||
* - window focus (cheap way to re-check after the user flips the toggle in
|
||||
* System Settings and alt-tabs back)
|
||||
*/
|
||||
export function useAccessibilityPermission() {
|
||||
const platform = usePlatform();
|
||||
const [needsPermission, setNeedsPermission] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
const recheck = useCallback(async (): Promise<boolean> => {
|
||||
if (!platform.metadata.isTauri) return true;
|
||||
setChecking(true);
|
||||
try {
|
||||
const trusted = await invoke<boolean>('check_accessibility_permission');
|
||||
setNeedsPermission(!trusted);
|
||||
return trusted;
|
||||
} catch (err) {
|
||||
console.warn('[accessibility] check failed:', err);
|
||||
return false;
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}, [platform.metadata.isTauri]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!platform.metadata.isTauri) return;
|
||||
recheck();
|
||||
const onFocus = () => {
|
||||
recheck();
|
||||
};
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => window.removeEventListener('focus', onFocus);
|
||||
}, [platform.metadata.isTauri, recheck]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!platform.metadata.isTauri) return;
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
listen('system:accessibility-missing', () => {
|
||||
setNeedsPermission(true);
|
||||
})
|
||||
.then((fn) => {
|
||||
unlisten = fn;
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, [platform.metadata.isTauri]);
|
||||
|
||||
const openSettings = useCallback(async () => {
|
||||
try {
|
||||
await invoke('open_accessibility_settings');
|
||||
} catch (err) {
|
||||
console.warn('[accessibility] open settings failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { needsPermission, checking, recheck, openSettings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline notice rendered next to the auto-paste setting when macOS
|
||||
* Accessibility permission is missing. Returns null when the permission is
|
||||
* already granted.
|
||||
*/
|
||||
export function AccessibilityNotice() {
|
||||
const { t } = useTranslation();
|
||||
const { needsPermission, checking, recheck, openSettings } = useAccessibilityPermission();
|
||||
const [stillMissing, setStillMissing] = useState(false);
|
||||
|
||||
const handleRecheck = useCallback(async () => {
|
||||
setStillMissing(false);
|
||||
const trusted = await recheck();
|
||||
if (!trusted) setStillMissing(true);
|
||||
}, [recheck]);
|
||||
|
||||
if (!needsPermission) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3.5 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-amber-500" />
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t('captures.permissions.accessibility.title')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans i18nKey="captures.permissions.accessibility.body" components={{ path: <span /> }} />
|
||||
</p>
|
||||
<div className="flex items-center gap-2 pt-1.5">
|
||||
<Button size="sm" onClick={openSettings} className="gap-1.5">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t('captures.permissions.accessibility.openSettings')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleRecheck} disabled={checking}>
|
||||
{checking ? t('captures.permissions.accessibility.rechecking') : t('captures.permissions.accessibility.recheck')}
|
||||
</Button>
|
||||
</div>
|
||||
{stillMissing && !checking && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 pt-1">
|
||||
{t('captures.permissions.accessibility.stillMissing')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRouterState } from '@tanstack/react-router';
|
||||
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||
import { AudioKeepAlive } from '@/components/AudioPlayer/AudioKeepAlive';
|
||||
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
|
||||
import { StoryTrackEditor } from '@/components/StoriesTab/StoryTrackEditor';
|
||||
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
@@ -14,16 +15,19 @@ interface AppFrameProps {
|
||||
export function AppFrame({ children }: AppFrameProps) {
|
||||
const routerState = useRouterState();
|
||||
const isStoriesRoute = routerState.location.pathname === '/stories';
|
||||
|
||||
|
||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||
const { data: story } = useStory(selectedStoryId);
|
||||
|
||||
|
||||
// Show track editor when on stories route with a selected story that has items
|
||||
const showTrackEditor = isStoriesRoute && selectedStoryId && story && story.items.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cn('h-screen bg-background flex flex-col overflow-hidden', TOP_SAFE_AREA_PADDING)}>
|
||||
<div
|
||||
className={cn('h-screen bg-background flex flex-col overflow-hidden', TOP_SAFE_AREA_PADDING)}
|
||||
>
|
||||
<TitleBarDragRegion />
|
||||
<AudioKeepAlive />
|
||||
{children}
|
||||
{showTrackEditor ? (
|
||||
<StoryTrackEditor storyId={story.id} items={story.items} />
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export type AudioBarsMode = 'idle' | 'generating' | 'playing';
|
||||
|
||||
interface AudioBarsProps {
|
||||
mode: AudioBarsMode;
|
||||
className?: string;
|
||||
barClassName?: string;
|
||||
}
|
||||
|
||||
export function AudioBars({ mode, className, barClassName }: AudioBarsProps) {
|
||||
const activeColor = mode !== 'idle' ? 'bg-accent' : 'bg-muted-foreground/40';
|
||||
return (
|
||||
<div className={cn('flex items-center gap-[2px] h-5', className)}>
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<motion.div
|
||||
key={`${mode}-${i}`}
|
||||
className={cn('w-[3px] rounded-full', activeColor, barClassName)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { debug } from '@/lib/utils/debug';
|
||||
|
||||
// WKWebView tears down the app's CoreAudio output when idle for long enough,
|
||||
// and a JS-level reload (cmd+R) does NOT restore it — only relaunching the
|
||||
// Tauri app does. Keeping a silent <audio> element looping forever prevents
|
||||
// the OS audio session from ever going dormant.
|
||||
//
|
||||
// Real silence (zero PCM samples) at full volume is preferred over a muted
|
||||
// element: browsers/WebKit can optimize muted media away, which defeats the
|
||||
// purpose of holding the session open.
|
||||
|
||||
function buildSilentWavUrl(seconds = 1, sampleRate = 8000): string {
|
||||
const numSamples = seconds * sampleRate;
|
||||
const bytes = 44 + numSamples * 2;
|
||||
const buffer = new ArrayBuffer(bytes);
|
||||
const view = new DataView(buffer);
|
||||
const write = (offset: number, str: string) => {
|
||||
for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
|
||||
};
|
||||
write(0, 'RIFF');
|
||||
view.setUint32(4, bytes - 8, true);
|
||||
write(8, 'WAVE');
|
||||
write(12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 1, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * 2, true);
|
||||
view.setUint16(32, 2, true);
|
||||
view.setUint16(34, 16, true);
|
||||
write(36, 'data');
|
||||
view.setUint32(40, numSamples * 2, true);
|
||||
return URL.createObjectURL(new Blob([buffer], { type: 'audio/wav' }));
|
||||
}
|
||||
|
||||
export function AudioKeepAlive() {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const url = buildSilentWavUrl(1, 8000);
|
||||
const el = new Audio(url);
|
||||
el.loop = true;
|
||||
el.volume = 1;
|
||||
el.preload = 'auto';
|
||||
audioRef.current = el;
|
||||
|
||||
const tryPlay = () => {
|
||||
if (!audioRef.current) return;
|
||||
if (!audioRef.current.paused) return;
|
||||
audioRef.current.play().catch((err) => {
|
||||
debug.log('[AudioKeepAlive] play blocked (will retry on next gesture):', err);
|
||||
});
|
||||
};
|
||||
|
||||
tryPlay();
|
||||
|
||||
// Autoplay may be blocked until first user interaction — re-attempt then.
|
||||
const onGesture = () => tryPlay();
|
||||
window.addEventListener('pointerdown', onGesture, { once: false });
|
||||
window.addEventListener('keydown', onGesture, { once: false });
|
||||
|
||||
// If the webview ever pauses the element on background, resume on return.
|
||||
const onWake = () => {
|
||||
if (!document.hidden) tryPlay();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onWake);
|
||||
window.addEventListener('focus', onWake);
|
||||
window.addEventListener('pageshow', onWake);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', onGesture);
|
||||
window.removeEventListener('keydown', onGesture);
|
||||
document.removeEventListener('visibilitychange', onWake);
|
||||
window.removeEventListener('focus', onWake);
|
||||
window.removeEventListener('pageshow', onWake);
|
||||
el.pause();
|
||||
el.src = '';
|
||||
URL.revokeObjectURL(url);
|
||||
audioRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,675 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
|
||||
interface AudioDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export function AudioTab() {
|
||||
const platform = usePlatform();
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [editingChannel, setEditingChannel] = useState<string | null>(null);
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
const isPlayerVisible = !!audioUrl;
|
||||
|
||||
const { data: channels, isLoading: channelsLoading } = useQuery({
|
||||
queryKey: ['channels'],
|
||||
queryFn: () => apiClient.listChannels(),
|
||||
});
|
||||
|
||||
const { data: devices, isLoading: devicesLoading } = useQuery({
|
||||
queryKey: ['audio-devices'],
|
||||
queryFn: async () => {
|
||||
if (!platform.metadata.isTauri) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return await platform.audio.listOutputDevices();
|
||||
} catch (error) {
|
||||
console.error('Failed to list audio devices:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: platform.metadata.isTauri,
|
||||
});
|
||||
|
||||
const { data: profiles } = useQuery({
|
||||
queryKey: ['profiles'],
|
||||
queryFn: () => apiClient.listProfiles(),
|
||||
});
|
||||
|
||||
const createChannel = useMutation({
|
||||
mutationFn: (data: { name: string; device_ids: string[] }) => apiClient.createChannel(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] });
|
||||
setCreateDialogOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const updateChannel = useMutation({
|
||||
mutationFn: ({
|
||||
channelId,
|
||||
data,
|
||||
}: {
|
||||
channelId: string;
|
||||
data: { name?: string; device_ids?: string[] };
|
||||
}) => apiClient.updateChannel(channelId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
|
||||
setEditingChannel(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteChannel = useMutation({
|
||||
mutationFn: (channelId: string) => apiClient.deleteChannel(channelId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
|
||||
},
|
||||
});
|
||||
|
||||
const { data: channelVoices } = useQuery({
|
||||
queryKey: ['channel-voices', editingChannel],
|
||||
queryFn: async () => {
|
||||
if (!editingChannel) return { profile_ids: [] };
|
||||
return apiClient.getChannelVoices(editingChannel);
|
||||
},
|
||||
enabled: !!editingChannel,
|
||||
});
|
||||
|
||||
const setChannelVoices = useMutation({
|
||||
mutationFn: ({ channelId, profileIds }: { channelId: string; profileIds: string[] }) =>
|
||||
apiClient.setChannelVoices(channelId, profileIds),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['channel-voices'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
|
||||
},
|
||||
});
|
||||
|
||||
if (channelsLoading || devicesLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleChannelDelete = async (e, channelId) => {
|
||||
e.stopPropagation();
|
||||
if (await confirm('Delete this channel?')) {
|
||||
deleteChannel.mutate(channelId);
|
||||
}
|
||||
};
|
||||
|
||||
const allChannels = channels || [];
|
||||
const allDevices = devices || [];
|
||||
const selectedChannel = selectedChannelId
|
||||
? allChannels.find((c) => c.id === selectedChannelId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-6 shrink-0">
|
||||
<h2 className="text-2xl font-bold">Audio Channels</h2>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Channel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0">
|
||||
{/* Left Column - Channels */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col min-h-0 overflow-y-auto',
|
||||
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||
)}
|
||||
>
|
||||
{allChannels.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
||||
<Speaker className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No audio channels yet. Create your first channel to route voices to specific
|
||||
devices.
|
||||
</p>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Channel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{allChannels.map((channel) => {
|
||||
const isSelected = selectedChannelId === channel.id;
|
||||
return (
|
||||
<button
|
||||
key={channel.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'group border rounded-lg p-4 transition-colors cursor-pointer text-left w-full',
|
||||
isSelected && 'ring-2 ring-primary bg-primary/5 border-primary',
|
||||
)}
|
||||
onClick={() => setSelectedChannelId(isSelected ? null : channel.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||
<Speaker className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h3 className="font-semibold text-base truncate">{channel.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5 ml-10">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Output Devices
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{channel.device_ids.length > 0
|
||||
? channel.device_ids.map((deviceId) => {
|
||||
const device = allDevices.find((d) => d.id === deviceId);
|
||||
return (
|
||||
<Badge
|
||||
key={deviceId}
|
||||
variant="outline"
|
||||
className="text-xs font-normal"
|
||||
>
|
||||
{device?.name || deviceId}
|
||||
</Badge>
|
||||
);
|
||||
})
|
||||
: (() => {
|
||||
const defaultDevice = allDevices.find((d) => d.is_default);
|
||||
return defaultDevice ? (
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
{defaultDevice.name}
|
||||
</Badge>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Assigned Voices
|
||||
</div>
|
||||
<ChannelVoicesList channelId={channel.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!channel.is_default && (
|
||||
<div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingChannel(channel.id);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => handleChannelDelete(e, channel.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column - Available Devices */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col min-h-0 overflow-y-auto',
|
||||
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0 mb-4">
|
||||
<h3 className="text-lg font-semibold">Available Devices</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{selectedChannelId
|
||||
? selectedChannel?.is_default
|
||||
? 'Default channel uses system default device'
|
||||
: 'Click devices to add or remove them from the selected channel'
|
||||
: 'Select a channel to assign devices'}
|
||||
</p>
|
||||
</div>
|
||||
{allDevices.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{allDevices.map((device) => {
|
||||
const isConnected =
|
||||
selectedChannelId &&
|
||||
selectedChannel &&
|
||||
(selectedChannel.device_ids.length === 0
|
||||
? device.is_default
|
||||
: selectedChannel.device_ids.includes(device.id));
|
||||
const canToggle =
|
||||
selectedChannelId && selectedChannel && !selectedChannel.is_default;
|
||||
|
||||
const handleDeviceClick = () => {
|
||||
if (!canToggle || !selectedChannel) return;
|
||||
|
||||
const currentDeviceIds = selectedChannel.device_ids;
|
||||
const newDeviceIds = isConnected
|
||||
? currentDeviceIds.filter((id) => id !== device.id)
|
||||
: [...currentDeviceIds, device.id];
|
||||
|
||||
updateChannel.mutate({
|
||||
channelId: selectedChannelId,
|
||||
data: { device_ids: newDeviceIds },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
key={device.id}
|
||||
type="button"
|
||||
onClick={handleDeviceClick}
|
||||
disabled={!canToggle}
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm p-3 rounded-lg border transition-colors text-left w-full',
|
||||
isConnected
|
||||
? 'bg-primary/10 border-primary ring-1 ring-primary/20'
|
||||
: 'hover:bg-muted/50',
|
||||
!canToggle && 'cursor-default opacity-60',
|
||||
canToggle && 'cursor-pointer',
|
||||
)}
|
||||
>
|
||||
{canToggle ? (
|
||||
<div
|
||||
className={cn(
|
||||
'h-4 w-4 rounded border-2 flex items-center justify-center shrink-0',
|
||||
isConnected ? 'bg-accent border-accent' : 'border-muted-foreground/30',
|
||||
)}
|
||||
>
|
||||
{isConnected && <Check className="h-3 w-3 text-accent-foreground" />}
|
||||
</div>
|
||||
) : device.is_default ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-primary shrink-0" />
|
||||
) : null}
|
||||
<span className={cn('truncate flex-1', device.is_default && 'font-medium')}>
|
||||
{device.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
||||
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground text-center">
|
||||
{platform.metadata.isTauri
|
||||
? 'No audio devices found'
|
||||
: 'Audio device selection requires Tauri'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Channel Dialog */}
|
||||
<CreateChannelDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
devices={devices || []}
|
||||
onCreate={(name, deviceIds) => {
|
||||
createChannel.mutate({ name, device_ids: deviceIds });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Edit Channel Dialog */}
|
||||
{editingChannel &&
|
||||
(() => {
|
||||
const channel = channels?.find((c) => c.id === editingChannel);
|
||||
return channel ? (
|
||||
<EditChannelDialog
|
||||
open={!!editingChannel}
|
||||
onOpenChange={(open) => !open && setEditingChannel(null)}
|
||||
channel={channel}
|
||||
devices={devices || []}
|
||||
profiles={profiles || []}
|
||||
channelVoices={channelVoices?.profile_ids || []}
|
||||
onUpdate={(name, deviceIds) => {
|
||||
updateChannel.mutate({
|
||||
channelId: editingChannel,
|
||||
data: { name, device_ids: deviceIds },
|
||||
});
|
||||
}}
|
||||
onSetVoices={(profileIds) => {
|
||||
setChannelVoices.mutate({
|
||||
channelId: editingChannel,
|
||||
profileIds,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelVoicesList({ channelId }: { channelId: string }) {
|
||||
const { data: voices } = useQuery({
|
||||
queryKey: ['channel-voices', channelId],
|
||||
queryFn: () => apiClient.getChannelVoices(channelId),
|
||||
});
|
||||
|
||||
const { data: profiles } = useQuery({
|
||||
queryKey: ['profiles'],
|
||||
queryFn: () => apiClient.listProfiles(),
|
||||
});
|
||||
|
||||
const voiceNames =
|
||||
voices?.profile_ids.map((id) => profiles?.find((p) => p.id === id)?.name).filter(Boolean) || [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{voiceNames.length > 0 ? (
|
||||
voiceNames.map((name) => (
|
||||
<Badge key={name} variant="outline" className="text-xs font-normal">
|
||||
{name}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">No voices assigned</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateChannelDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
devices: AudioDevice[];
|
||||
onCreate: (name: string, deviceIds: string[]) => void;
|
||||
}
|
||||
|
||||
function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateChannelDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (name.trim()) {
|
||||
onCreate(name.trim(), selectedDevices);
|
||||
setName('');
|
||||
setSelectedDevices([]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Audio Channel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new audio channel (bus) to route voices to specific output devices.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="channel-name">Channel Name</Label>
|
||||
<Input
|
||||
id="channel-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Virtual Cable, Broadcast"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Output Devices</Label>
|
||||
<Select
|
||||
value={selectedDevices[0] || ''}
|
||||
onValueChange={(value) => {
|
||||
if (value && !selectedDevices.includes(value)) {
|
||||
setSelectedDevices([...selectedDevices, value]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select device" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{devices.map((device) => (
|
||||
<SelectItem key={device.id} value={device.id}>
|
||||
{device.name} {device.is_default && '(default)'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedDevices.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{selectedDevices.map((deviceId) => {
|
||||
const device = devices.find((d) => d.id === deviceId);
|
||||
return (
|
||||
<div
|
||||
key={deviceId}
|
||||
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
|
||||
>
|
||||
<span>{device?.name || deviceId}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!name.trim()}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditChannelDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
channel: {
|
||||
id: string;
|
||||
name: string;
|
||||
device_ids: string[];
|
||||
};
|
||||
devices: AudioDevice[];
|
||||
profiles: Array<{ id: string; name: string }>;
|
||||
channelVoices: string[];
|
||||
onUpdate: (name: string, deviceIds: string[]) => void;
|
||||
onSetVoices: (profileIds: string[]) => void;
|
||||
}
|
||||
|
||||
function EditChannelDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
channel,
|
||||
devices,
|
||||
profiles,
|
||||
channelVoices,
|
||||
onUpdate,
|
||||
onSetVoices,
|
||||
}: EditChannelDialogProps) {
|
||||
const [name, setName] = useState(channel.name);
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>(channel.device_ids);
|
||||
const [selectedVoices, setSelectedVoices] = useState<string[]>(channelVoices);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (name.trim()) {
|
||||
onUpdate(name.trim(), selectedDevices);
|
||||
onSetVoices(selectedVoices);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Channel</DialogTitle>
|
||||
<DialogDescription>Update channel settings and voice assignments.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="edit-channel-name">Channel Name</Label>
|
||||
<Input id="edit-channel-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Output Devices</Label>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(value) => {
|
||||
if (value && !selectedDevices.includes(value)) {
|
||||
setSelectedDevices([...selectedDevices, value]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Add device" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{devices.map((device) => (
|
||||
<SelectItem key={device.id} value={device.id}>
|
||||
{device.name} {device.is_default && '(default)'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedDevices.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{selectedDevices.map((deviceId) => {
|
||||
const device = devices.find((d) => d.id === deviceId);
|
||||
return (
|
||||
<div
|
||||
key={deviceId}
|
||||
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
|
||||
>
|
||||
<span>{device?.name || deviceId}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Assigned Voices</Label>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(value) => {
|
||||
if (value && !selectedVoices.includes(value)) {
|
||||
setSelectedVoices([...selectedVoices, value]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Add voice" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profiles.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id}>
|
||||
{profile.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedVoices.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{selectedVoices.map((profileId) => {
|
||||
const profile = profiles.find((p) => p.id === profileId);
|
||||
return (
|
||||
<div
|
||||
key={profileId}
|
||||
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
|
||||
>
|
||||
<span>{profile?.name || profileId}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setSelectedVoices(selectedVoices.filter((id) => id !== profileId))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!name.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
/**
|
||||
* Pill state machine shared between the settings preview and the live
|
||||
* recording pill in the Captures tab.
|
||||
*/
|
||||
export type PillState =
|
||||
| 'recording'
|
||||
| 'transcribing'
|
||||
| 'refining'
|
||||
| 'speaking'
|
||||
| 'completed'
|
||||
| 'rest'
|
||||
| 'error';
|
||||
|
||||
const PILL_LABEL_KEYS: Record<Exclude<PillState, 'rest' | 'error'>, string> = {
|
||||
recording: 'captures.pill.recording',
|
||||
transcribing: 'captures.pill.transcribing',
|
||||
refining: 'captures.pill.refining',
|
||||
speaking: 'captures.pill.speaking',
|
||||
completed: 'captures.pill.completed',
|
||||
};
|
||||
|
||||
function barModeFor(
|
||||
state: Exclude<PillState, 'error'>,
|
||||
): 'generating' | 'playing' | 'idle' {
|
||||
if (state === 'recording' || state === 'speaking') return 'playing';
|
||||
if (state === 'completed' || state === 'rest') return 'idle';
|
||||
return 'generating';
|
||||
}
|
||||
|
||||
export function PillAudioBars({ mode }: { mode: 'generating' | 'playing' | 'idle' }) {
|
||||
return (
|
||||
<div className="flex items-center gap-[2px] h-5 shrink-0">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<motion.div
|
||||
key={`${mode}-${i}`}
|
||||
className={cn('w-[3px] rounded-full', mode === 'idle' ? 'bg-accent/30' : 'bg-accent')}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
const total = Math.max(0, Math.floor(ms / 1000));
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating pill shown during capture. `state` drives the label, dot animation,
|
||||
* and bar motion; `elapsedMs` freezes at whatever the caller last passed in
|
||||
* (recording advances the timer, transcribing/refining hold the final value).
|
||||
* The ``error`` state renders a destructive variant — a clickable pill that
|
||||
* copies its message to the clipboard on press and calls ``onDismiss``.
|
||||
*/
|
||||
export function CapturePill({
|
||||
state,
|
||||
elapsedMs,
|
||||
onStop,
|
||||
errorMessage,
|
||||
onDismiss,
|
||||
className,
|
||||
}: {
|
||||
state: PillState;
|
||||
elapsedMs: number;
|
||||
onStop?: () => void;
|
||||
errorMessage?: string | null;
|
||||
onDismiss?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (state === 'error') {
|
||||
return (
|
||||
<ErrorPill
|
||||
message={errorMessage ?? t('captures.pill.errorFallback')}
|
||||
onDismiss={onDismiss}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const visible = state !== 'rest';
|
||||
const labelText = t(state === 'rest' ? PILL_LABEL_KEYS.recording : PILL_LABEL_KEYS[state]);
|
||||
const barMode = barModeFor(state);
|
||||
|
||||
const dot = (
|
||||
<span className="relative flex h-2 w-2 shrink-0">
|
||||
{state === 'recording' && (
|
||||
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-70" />
|
||||
)}
|
||||
<span className="relative rounded-full h-2 w-2 bg-accent" />
|
||||
</span>
|
||||
);
|
||||
|
||||
const stopButton = onStop && state === 'recording' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
aria-label={t('captures.pill.stopAria')}
|
||||
className="relative flex h-2 w-2 shrink-0 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
>
|
||||
{dot}
|
||||
</button>
|
||||
) : dot;
|
||||
|
||||
// Completed gets an inset accent stroke (via box-shadow, not Tailwind's
|
||||
// ring — ring utility doesn't compose with arbitrary shadow-[…]) to mark
|
||||
// the success moment without changing the pill's dimensions.
|
||||
const completedStroke =
|
||||
state === 'completed'
|
||||
? 'shadow-[inset_0_0_0_2px_hsl(var(--accent)/0.6)]'
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-3 px-4 h-10 rounded-full text-accent',
|
||||
'bg-white/80 ring-1 ring-black/5 shadow-lg backdrop-blur-xl',
|
||||
'dark:bg-black/55 dark:ring-0 dark:shadow-none dark:backdrop-blur-md',
|
||||
completedStroke,
|
||||
'transition-opacity duration-300 ease-out',
|
||||
visible ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{stopButton}
|
||||
<span className="text-sm font-medium shrink-0" style={{ minWidth: '104px' }}>
|
||||
{labelText}
|
||||
</span>
|
||||
<PillAudioBars mode={barMode} />
|
||||
<span className="text-xs tabular-nums text-accent/70 font-medium shrink-0 -ml-1">
|
||||
{formatElapsed(elapsedMs)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorPill({
|
||||
message,
|
||||
onDismiss,
|
||||
className,
|
||||
}: {
|
||||
message: string;
|
||||
onDismiss?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(message);
|
||||
} catch {
|
||||
// Clipboard access can be denied in rare webview configs — ignore,
|
||||
// we still want the dismiss to land.
|
||||
}
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
title={t('captures.pill.errorCopyTooltip')}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2.5 px-4 h-10 rounded-full',
|
||||
'bg-white/85 ring-1 ring-destructive/25 shadow-lg backdrop-blur-xl text-red-600 hover:bg-white',
|
||||
'dark:bg-black/65 dark:ring-0 dark:shadow-none dark:backdrop-blur-md dark:text-red-300 dark:hover:bg-black/80',
|
||||
'max-w-[380px] transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-red-400/50',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-sm font-medium truncate">{message}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Loader2, Pause, Play } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { debug } from '@/lib/utils/debug';
|
||||
|
||||
function formatDuration(ms?: number | null): string {
|
||||
if (!ms || ms < 0) return '0:00';
|
||||
const total = Math.round(ms / 1000);
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function CaptureInlinePlayer({
|
||||
audioUrl,
|
||||
fallbackDurationMs,
|
||||
className,
|
||||
}: {
|
||||
audioUrl: string;
|
||||
fallbackDurationMs?: number | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const waveformRef = useRef<HTMLDivElement>(null);
|
||||
const wavesurferRef = useRef<WaveSurfer | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = waveformRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
const cssHsla = (varName: string, alpha: number) => {
|
||||
const value = getComputedStyle(root).getPropertyValue(varName).trim();
|
||||
if (!value) return '';
|
||||
const [h, s, l] = value.split(/\s+/);
|
||||
if (!h || !s || !l) return '';
|
||||
return `hsla(${h}, ${s}, ${l}, ${alpha})`;
|
||||
};
|
||||
|
||||
const ws = WaveSurfer.create({
|
||||
container,
|
||||
waveColor: cssHsla('--muted-foreground', 1),
|
||||
progressColor: cssHsla('--accent', 1),
|
||||
cursorColor: 'transparent',
|
||||
barWidth: 2,
|
||||
barRadius: 2,
|
||||
barGap: 2,
|
||||
height: 40,
|
||||
normalize: true,
|
||||
interact: true,
|
||||
dragToSeek: { debounceTime: 0 },
|
||||
mediaControls: false,
|
||||
backend: 'WebAudio',
|
||||
});
|
||||
|
||||
ws.on('ready', () => {
|
||||
setDuration(ws.getDuration());
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
});
|
||||
ws.on('play', () => setIsPlaying(true));
|
||||
ws.on('pause', () => setIsPlaying(false));
|
||||
ws.on('finish', () => {
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(ws.getDuration());
|
||||
});
|
||||
ws.on('timeupdate', (t) => setCurrentTime(t));
|
||||
ws.on('seeking', (t) => setCurrentTime(t));
|
||||
ws.on('error', (err) => {
|
||||
debug.error('Inline waveform error', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
wavesurferRef.current = ws;
|
||||
|
||||
return () => {
|
||||
try {
|
||||
ws.destroy();
|
||||
} catch (err) {
|
||||
debug.error('Failed to destroy inline waveform', err);
|
||||
}
|
||||
wavesurferRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const ws = wavesurferRef.current;
|
||||
if (!ws) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setIsPlaying(false);
|
||||
try {
|
||||
if (ws.isPlaying()) ws.pause();
|
||||
ws.seekTo(0);
|
||||
} catch (err) {
|
||||
debug.error('Failed to reset inline waveform before load', err);
|
||||
}
|
||||
ws.load(audioUrl).catch((err) => {
|
||||
debug.error('Inline waveform load failed', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [audioUrl]);
|
||||
|
||||
const handlePlayPause = () => {
|
||||
const ws = wavesurferRef.current;
|
||||
if (!ws || isLoading) return;
|
||||
if (ws.isPlaying()) {
|
||||
ws.pause();
|
||||
} else {
|
||||
ws.play().catch((err) => {
|
||||
debug.error('Inline play failed', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const displayMs =
|
||||
duration > 0
|
||||
? Math.round((isPlaying || currentTime > 0 ? currentTime : duration) * 1000)
|
||||
: (fallbackDurationMs ?? 0);
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-4', className)}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-10 w-10 rounded-full shrink-0"
|
||||
onClick={handlePlayPause}
|
||||
disabled={isLoading || !!error}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : isPlaying ? (
|
||||
<Pause className="h-4 w-4 fill-current" />
|
||||
) : (
|
||||
<Play className="h-4 w-4 ml-0.5 fill-current" />
|
||||
)}
|
||||
</Button>
|
||||
<div ref={waveformRef} className="flex-1 min-w-0 h-10 select-none" />
|
||||
<span className="text-xs tabular-nums text-muted-foreground font-medium shrink-0">
|
||||
{error ? '—' : formatDuration(displayMs)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,925 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import {
|
||||
Captions,
|
||||
Check,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
Copy,
|
||||
Download,
|
||||
FileAudio,
|
||||
FileText,
|
||||
Loader2,
|
||||
Mic,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
Square,
|
||||
Trash2,
|
||||
Upload,
|
||||
Volume2,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AudioBars } from '@/components/AudioBars';
|
||||
import { CapturePill } from '@/components/CapturePill/CapturePill';
|
||||
import { CaptureInlinePlayer } from '@/components/CapturesTab/CaptureInlinePlayer';
|
||||
import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist';
|
||||
import {
|
||||
ListPane,
|
||||
ListPaneHeader,
|
||||
ListPaneScroll,
|
||||
ListPaneSearch,
|
||||
ListPaneTitle,
|
||||
ListPaneTitleRow,
|
||||
} from '@/components/ListPane';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type {
|
||||
CaptureListResponse,
|
||||
CaptureResponse,
|
||||
CaptureSource,
|
||||
VoiceProfileResponse,
|
||||
} from '@/lib/api/types';
|
||||
import type { LanguageCode } from '@/lib/constants/languages';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSession';
|
||||
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
|
||||
import { useCaptureSettings } from '@/lib/hooks/useSettings';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { formatAbsoluteDate, formatDate } from '@/lib/utils/format';
|
||||
import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
|
||||
const CAPTURE_AUDIO_MIME = 'audio/*,.wav,.mp3,.m4a,.flac,.ogg,.webm';
|
||||
|
||||
function formatDuration(ms?: number | null): string {
|
||||
if (!ms || ms < 0) return '0:00';
|
||||
const total = Math.round(ms / 1000);
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function ChordKeys({ keys }: { keys: string[] }) {
|
||||
if (keys.length === 0) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{keys.map((k) => {
|
||||
const side = modifierSideHint(k);
|
||||
return (
|
||||
<span
|
||||
key={k}
|
||||
className="relative inline-flex items-center justify-center h-6 min-w-[1.5rem] px-1.5 rounded-md border border-border bg-muted/60 font-mono text-[11px] font-medium shadow-sm text-foreground"
|
||||
>
|
||||
{displayLabelForKey(k)}
|
||||
{side ? (
|
||||
<span className="absolute -top-1 -right-1 h-3 min-w-[0.75rem] px-0.5 rounded-sm bg-accent text-[7px] font-bold leading-none flex items-center justify-center text-accent-foreground">
|
||||
{side}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source: CaptureSource }) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = source === 'dictation' ? Mic : source === 'recording' ? CircleDot : FileAudio;
|
||||
const label =
|
||||
source === 'dictation'
|
||||
? t('captures.source.dictation')
|
||||
: source === 'recording'
|
||||
? t('captures.source.recording')
|
||||
: t('captures.source.file');
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-muted/60 text-muted-foreground"
|
||||
>
|
||||
<Icon className="h-2.5 w-2.5" />
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
type PlaybackState = 'idle' | 'generating' | 'playing';
|
||||
|
||||
export function CapturesTab() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const platform = usePlatform();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const snippetOf = (capture: CaptureResponse): string => {
|
||||
const source = capture.transcript_refined || capture.transcript_raw || '';
|
||||
return source.trim() || t('captures.snippetEmpty');
|
||||
};
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [showRefined, setShowRefined] = useState(true);
|
||||
const [launchedPlayAsId, setLaunchedPlayAsId] = useState<string | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const audioUrl = usePlayerStore((s) => s.audioUrl);
|
||||
const playerAudioId = usePlayerStore((s) => s.audioId);
|
||||
const playerIsPlaying = usePlayerStore((s) => s.isPlaying);
|
||||
const isPlayerVisible = !!audioUrl;
|
||||
|
||||
const setIsPlaying = usePlayerStore((s) => s.setIsPlaying);
|
||||
|
||||
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
|
||||
const pendingGenerationIds = useGenerationStore((s) => s.pendingGenerationIds);
|
||||
|
||||
const { settings: captureSettings, update: updateCaptureSettings } = useCaptureSettings();
|
||||
const sttModel = captureSettings?.stt_model ?? 'turbo';
|
||||
const llmModel = captureSettings?.llm_model ?? '0.6B';
|
||||
const hotkeyEnabled = captureSettings?.hotkey_enabled ?? false;
|
||||
const pushToTalkKeys = captureSettings?.chord_push_to_talk_keys ?? [];
|
||||
const toggleToTalkKeys = captureSettings?.chord_toggle_to_talk_keys ?? [];
|
||||
const readiness = useDictationReadiness();
|
||||
|
||||
const session = useCaptureRecordingSession({
|
||||
onCaptureCreated: (capture) => setSelectedId(capture.id),
|
||||
});
|
||||
|
||||
const { data: capturesData, isLoading: capturesLoading } = useQuery({
|
||||
queryKey: ['captures'],
|
||||
queryFn: () => apiClient.listCaptures(200, 0),
|
||||
});
|
||||
|
||||
const { data: profiles } = useQuery({
|
||||
queryKey: ['profiles'],
|
||||
queryFn: () => apiClient.listProfiles(),
|
||||
});
|
||||
|
||||
const captures = capturesData?.items ?? [];
|
||||
|
||||
// Keep a selection. If the current selection disappears (e.g. deletion),
|
||||
// fall through to the first capture, then to null.
|
||||
useEffect(() => {
|
||||
if (!captures.length) {
|
||||
if (selectedId !== null) setSelectedId(null);
|
||||
return;
|
||||
}
|
||||
if (!selectedId || !captures.find((c) => c.id === selectedId)) {
|
||||
setSelectedId(captures[0].id);
|
||||
}
|
||||
}, [captures, selectedId]);
|
||||
|
||||
// Live sync from sibling Tauri webviews (the floating dictate window).
|
||||
// ``capture:created`` carries the full row so we can seed the cache before
|
||||
// the refetch lands and focus the new capture in one shot — without the
|
||||
// seed, the selection-guard effect would snap back to ``captures[0]`` in
|
||||
// the race window between ``setSelectedId(new)`` and the refetched list
|
||||
// actually containing the new row.
|
||||
useEffect(() => {
|
||||
if (!platform.metadata.isTauri) return;
|
||||
const unlistens: Promise<UnlistenFn>[] = [];
|
||||
unlistens.push(
|
||||
listen<{ capture: CaptureResponse }>('capture:created', (event) => {
|
||||
const capture = event.payload?.capture;
|
||||
if (capture) {
|
||||
queryClient.setQueryData<CaptureListResponse>(['captures'], (prev) => {
|
||||
if (!prev) return prev;
|
||||
if (prev.items.some((c) => c.id === capture.id)) return prev;
|
||||
return { ...prev, items: [capture, ...prev.items], total: prev.total + 1 };
|
||||
});
|
||||
setSelectedId(capture.id);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['captures'] });
|
||||
}),
|
||||
);
|
||||
unlistens.push(
|
||||
listen('capture:updated', () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['captures'] });
|
||||
}),
|
||||
);
|
||||
return () => {
|
||||
for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
|
||||
};
|
||||
}, [queryClient, platform.metadata.isTauri]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return captures;
|
||||
return captures.filter((c) => {
|
||||
const raw = (c.transcript_raw || '').toLowerCase();
|
||||
const refined = (c.transcript_refined || '').toLowerCase();
|
||||
return raw.includes(q) || refined.includes(q);
|
||||
});
|
||||
}, [search, captures]);
|
||||
|
||||
const selected = captures.find((c) => c.id === selectedId) ?? null;
|
||||
// Source of truth is capture_settings.default_playback_voice_id, shared
|
||||
// with Settings → Captures and the MCP global default. Stale ids (e.g.
|
||||
// referenced profile was deleted) fall through to the first profile.
|
||||
const storedVoiceId = captureSettings?.default_playback_voice_id ?? null;
|
||||
const playAsVoice =
|
||||
(storedVoiceId && profiles?.find((p) => p.id === storedVoiceId)) || profiles?.[0] || null;
|
||||
const playAsVoiceId = playAsVoice?.id ?? null;
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (captureId: string) => apiClient.deleteCapture(captureId),
|
||||
onSuccess: () => {
|
||||
setDeleteDialogOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['captures'] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
title: t('captures.toast.deleteFailed'),
|
||||
description: err.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const playAsMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
capture,
|
||||
voice,
|
||||
}: {
|
||||
capture: CaptureResponse;
|
||||
voice: VoiceProfileResponse;
|
||||
}) => {
|
||||
const text = capture.transcript_refined || capture.transcript_raw;
|
||||
if (!text.trim()) throw new Error(t('captures.noTranscriptError'));
|
||||
const language = (capture.language || voice.language) as LanguageCode;
|
||||
// Preset profiles (Kokoro etc.) reject the qwen default — honor the
|
||||
// profile's stored engine preference. Cloned profiles without an
|
||||
// override fall through to whatever the backend picks.
|
||||
const engine = voice.default_engine as
|
||||
| 'qwen'
|
||||
| 'qwen_custom_voice'
|
||||
| 'luxtts'
|
||||
| 'chatterbox'
|
||||
| 'chatterbox_turbo'
|
||||
| 'tada'
|
||||
| 'kokoro'
|
||||
| undefined;
|
||||
return apiClient.generateSpeech({
|
||||
profile_id: voice.id,
|
||||
text,
|
||||
language,
|
||||
engine,
|
||||
});
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
// /generate is queue-based — it returns a generating row with an empty
|
||||
// audio_path. Hand the id to the global SSE handler which polls
|
||||
// /generation/{id}/status and triggers autoplay on completion.
|
||||
setLaunchedPlayAsId(result.id);
|
||||
addPendingGeneration(result.id);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
title: t('captures.toast.playAsFailed'),
|
||||
description: err.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const playbackState: PlaybackState = playAsMutation.isPending
|
||||
? 'generating'
|
||||
: launchedPlayAsId && pendingGenerationIds.has(launchedPlayAsId)
|
||||
? 'generating'
|
||||
: launchedPlayAsId && playerAudioId === launchedPlayAsId && playerIsPlaying
|
||||
? 'playing'
|
||||
: 'idle';
|
||||
|
||||
const handleUploadClick = () => uploadInputRef.current?.click();
|
||||
|
||||
const handleUploadFile = (e: React.ChangeEvent<HTMLInputElement>, source: CaptureSource) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
session.uploadFile(file, source);
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!selected) return;
|
||||
const text = showRefined
|
||||
? selected.transcript_refined || selected.transcript_raw
|
||||
: selected.transcript_raw;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text || '');
|
||||
toast({ title: t('captures.toast.transcriptCopied') });
|
||||
} catch {
|
||||
toast({ title: t('captures.toast.copyFailed'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const exportToastSuccess = (path: string) => {
|
||||
const name = path.split(/[\\/]/).pop() ?? path;
|
||||
toast({ title: t('captures.toast.exportSuccess', { path: name }) });
|
||||
};
|
||||
|
||||
const exportToastError = (err: unknown) => {
|
||||
toast({
|
||||
title: t('captures.toast.exportFailed'),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportAudio = async () => {
|
||||
if (!selected) return;
|
||||
try {
|
||||
const res = await fetch(apiClient.getCaptureAudioUrl(selected.id));
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = new Blob([await res.arrayBuffer()], { type: 'audio/wav' });
|
||||
const dest = await platform.filesystem.saveFile(
|
||||
`capture_${selected.id.slice(0, 8)}.wav`,
|
||||
blob,
|
||||
[{ name: 'Audio', extensions: ['wav'] }],
|
||||
);
|
||||
if (dest) exportToastSuccess(dest);
|
||||
} catch (err) {
|
||||
exportToastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportTranscript = async () => {
|
||||
if (!selected) return;
|
||||
const text = (selected.transcript_refined || selected.transcript_raw || '').trim();
|
||||
if (!text) {
|
||||
toast({ title: t('captures.toast.exportEmpty'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const dest = await platform.filesystem.saveFile(
|
||||
`capture_${selected.id.slice(0, 8)}.txt`,
|
||||
new Blob([text], { type: 'text/plain' }),
|
||||
[{ name: 'Text', extensions: ['txt'] }],
|
||||
);
|
||||
if (dest) exportToastSuccess(dest);
|
||||
} catch (err) {
|
||||
exportToastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const buildCaptureMarkdown = (capture: CaptureResponse): string => {
|
||||
const lines: string[] = [];
|
||||
lines.push(`# Capture ${capture.id}`, '');
|
||||
lines.push(`- **Source:** ${capture.source}`);
|
||||
lines.push(`- **Created:** ${capture.created_at}`);
|
||||
if (capture.duration_ms != null)
|
||||
lines.push(`- **Duration:** ${formatDuration(capture.duration_ms)}`);
|
||||
if (capture.language) lines.push(`- **Language:** ${capture.language}`);
|
||||
if (capture.stt_model) lines.push(`- **STT model:** ${capture.stt_model}`);
|
||||
if (capture.llm_model) lines.push(`- **LLM model:** ${capture.llm_model}`);
|
||||
lines.push('');
|
||||
if (capture.transcript_refined?.trim()) {
|
||||
lines.push('## Refined transcript', '', capture.transcript_refined.trim(), '');
|
||||
}
|
||||
if (capture.transcript_raw?.trim()) {
|
||||
lines.push('## Raw transcript', '', capture.transcript_raw.trim(), '');
|
||||
}
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
const handleExportMarkdown = async () => {
|
||||
if (!selected) return;
|
||||
const hasContent = (selected.transcript_refined || selected.transcript_raw || '').trim();
|
||||
if (!hasContent) {
|
||||
toast({ title: t('captures.toast.exportEmpty'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const dest = await platform.filesystem.saveFile(
|
||||
`capture_${selected.id.slice(0, 8)}.md`,
|
||||
new Blob([buildCaptureMarkdown(selected)], { type: 'text/markdown' }),
|
||||
[{ name: 'Markdown', extensions: ['md'] }],
|
||||
);
|
||||
if (dest) exportToastSuccess(dest);
|
||||
} catch (err) {
|
||||
exportToastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayAs = (voice?: VoiceProfileResponse) => {
|
||||
if (!selected) return;
|
||||
// Stop the current playback when the button is in its 'playing' state
|
||||
// and the user clicked the main button without picking a new voice.
|
||||
if (!voice && playbackState === 'playing') {
|
||||
setIsPlaying(false);
|
||||
return;
|
||||
}
|
||||
const target = voice ?? playAsVoice;
|
||||
if (!target) {
|
||||
toast({
|
||||
title: t('captures.toast.noVoice'),
|
||||
description: t('captures.toast.noVoiceDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (voice && voice.id !== playAsVoiceId) {
|
||||
updateCaptureSettings({ default_playback_voice_id: voice.id });
|
||||
}
|
||||
playAsMutation.mutate({ capture: selected, voice: target });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex gap-0 overflow-hidden -mx-8">
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept={CAPTURE_AUDIO_MIME}
|
||||
onChange={(e) => handleUploadFile(e, 'file')}
|
||||
className="hidden"
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={CAPTURE_AUDIO_MIME}
|
||||
onChange={(e) => handleUploadFile(e, 'file')}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Left: capture list */}
|
||||
<div className="w-[340px] shrink-0">
|
||||
<ListPane>
|
||||
<ListPaneHeader>
|
||||
<ListPaneTitleRow>
|
||||
<ListPaneTitle>{t('captures.title')}</ListPaneTitle>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 -ml-2 text-[10px] font-medium text-accent bg-accent/10 border border-accent/20"
|
||||
>
|
||||
{t('captures.beta')}
|
||||
</Badge>
|
||||
</ListPaneTitleRow>
|
||||
<ListPaneSearch
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={t('captures.searchPlaceholder')}
|
||||
/>
|
||||
</ListPaneHeader>
|
||||
|
||||
<ListPaneScroll className={cn(isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}>
|
||||
<div className="px-4 pb-6 space-y-1">
|
||||
{capturesLoading ? (
|
||||
<div className="px-4 py-12 flex items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
{search ? (
|
||||
<p>{t('captures.empty.noMatches', { query: search })}</p>
|
||||
) : (
|
||||
<p>{t('captures.empty.none')}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((capture) => {
|
||||
const isActive = selectedId === capture.id;
|
||||
const refined = !!capture.transcript_refined;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={capture.id}
|
||||
onClick={() => setSelectedId(capture.id)}
|
||||
className={cn(
|
||||
'w-full text-left p-3 rounded-lg transition-colors block',
|
||||
isActive
|
||||
? 'bg-muted/70 border border-border'
|
||||
: 'border border-transparent hover:bg-muted/30',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-[11px] text-muted-foreground font-medium">
|
||||
{formatDate(capture.created_at)}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<span className="text-[10px] text-muted-foreground/70 tabular-nums">
|
||||
{formatDuration(capture.duration_ms)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[13px] text-foreground/90 line-clamp-2 leading-snug mb-2">
|
||||
{snippetOf(capture)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<SourceBadge source={capture.source} />
|
||||
{refined && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-accent/10 text-accent border border-accent/20"
|
||||
>
|
||||
<Sparkles className="h-2.5 w-2.5" />
|
||||
{t('captures.transcript.refined')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ListPaneScroll>
|
||||
</ListPane>
|
||||
</div>
|
||||
|
||||
{/* Right: capture detail */}
|
||||
<div className="flex-1 flex flex-col relative overflow-hidden min-w-0">
|
||||
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
|
||||
{/* Top action bar */}
|
||||
<div className="absolute top-0 left-0 right-0 z-20 px-8">
|
||||
<div className="flex items-center gap-3 py-4">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
<span>
|
||||
{t('captures.header.modelSummary', {
|
||||
stt: sttModel.charAt(0).toUpperCase() + sttModel.slice(1),
|
||||
llm: llmModel,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{session.pillState !== 'hidden' && (
|
||||
<CapturePill
|
||||
state={session.pillState}
|
||||
elapsedMs={session.pillElapsedMs}
|
||||
errorMessage={session.errorMessage}
|
||||
onDismiss={session.dismissError}
|
||||
onStop={session.isRecording ? session.stopRecording : undefined}
|
||||
/>
|
||||
)}
|
||||
{session.pillState === 'hidden' && (
|
||||
<>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/settings/captures">
|
||||
<Settings2 className="mr-2 h-4 w-4" />
|
||||
{t('captures.actions.configure')}
|
||||
</Link>
|
||||
</Button>
|
||||
{readiness.canRecord && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleUploadClick}
|
||||
disabled={session.isUploading}
|
||||
>
|
||||
{session.isUploading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{session.isUploading
|
||||
? t('captures.actions.importing')
|
||||
: t('captures.actions.import')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Hide Dictate when recording readiness fails so the user can't kick off
|
||||
a capture that has nowhere to land. Stop stays visible if a
|
||||
recording is somehow already in flight (e.g. a model was
|
||||
uninstalled mid-record) so the user can always cancel. */}
|
||||
{(readiness.canRecord || session.isRecording) && (
|
||||
<Button
|
||||
onClick={session.toggleRecording}
|
||||
disabled={session.isUploading && !session.isRecording}
|
||||
className="relative overflow-hidden transition-all bg-accent text-accent-foreground hover:bg-accent/90"
|
||||
>
|
||||
{session.isRecording ? (
|
||||
<>
|
||||
<Square className="h-4 w-4 mr-2 fill-current" />
|
||||
{t('captures.actions.stop')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mic className="h-4 w-4 mr-2" />
|
||||
{t('captures.actions.dictate')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 overflow-y-auto pt-20 px-8 pb-8',
|
||||
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||
)}
|
||||
>
|
||||
{/* Meta row */}
|
||||
<div className="flex items-center gap-3 mb-4 text-xs text-muted-foreground">
|
||||
<span>{formatAbsoluteDate(selected.created_at)}</span>
|
||||
{selected.language && (
|
||||
<>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<span>{selected.language.toUpperCase()}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<SourceBadge source={selected.source} />
|
||||
</div>
|
||||
|
||||
{/* Audio player card */}
|
||||
<div className="rounded-xl border border-border bg-muted/20 p-4 mb-6">
|
||||
<CaptureInlinePlayer
|
||||
audioUrl={apiClient.getCaptureAudioUrl(selected.id)}
|
||||
fallbackDurationMs={selected.duration_ms}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Transcript header */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="inline-flex rounded-md bg-muted/40 p-0.5 border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRefined(true)}
|
||||
disabled={!selected.transcript_refined}
|
||||
className={cn(
|
||||
'px-3 py-1 text-xs font-medium rounded transition-colors',
|
||||
showRefined && selected.transcript_refined
|
||||
? 'bg-background shadow-sm text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground disabled:opacity-40',
|
||||
)}
|
||||
>
|
||||
<Sparkles className="h-3 w-3 inline-block mr-1 -translate-y-px" />
|
||||
{t('captures.transcript.refined')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRefined(false)}
|
||||
className={cn(
|
||||
'px-3 py-1 text-xs font-medium rounded transition-colors',
|
||||
!showRefined || !selected.transcript_refined
|
||||
? 'bg-background shadow-sm text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Captions className="h-3 w-3 inline-block mr-1 -translate-y-px" />
|
||||
{t('captures.transcript.raw')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{showRefined && selected.transcript_refined
|
||||
? t('captures.transcript.refinedHint', { model: selected.llm_model ?? llmModel })
|
||||
: selected.stt_model
|
||||
? t('captures.transcript.rawHint', { model: selected.stt_model })
|
||||
: null}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Transcript body */}
|
||||
<div className="rounded-xl border border-border bg-muted/10">
|
||||
<Textarea
|
||||
key={`${selected.id}-${showRefined}`}
|
||||
defaultValue={
|
||||
showRefined && selected.transcript_refined
|
||||
? selected.transcript_refined
|
||||
: selected.transcript_raw
|
||||
}
|
||||
readOnly
|
||||
className="text-[15px] leading-relaxed min-h-[260px] border-0 bg-transparent resize-none focus-visible:ring-0 focus-visible:ring-offset-0 p-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bottom actions */}
|
||||
<div className="flex items-center gap-2 mt-4 flex-wrap">
|
||||
<div className="inline-flex">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePlayAs()}
|
||||
disabled={!playAsVoice || playAsMutation.isPending}
|
||||
className={cn(
|
||||
'gap-2 rounded-r-none border-r-0 pr-3 pl-2 transition-colors',
|
||||
playbackState !== 'idle' &&
|
||||
'border-accent/50 text-foreground bg-accent/10 hover:bg-accent/15 hover:text-foreground hover:border-accent/50',
|
||||
)}
|
||||
>
|
||||
{playbackState === 'generating' ? (
|
||||
<>
|
||||
<AudioBars mode="generating" className="h-3.5" />
|
||||
{t('captures.actions.playAsGenerating')}
|
||||
</>
|
||||
) : playbackState === 'playing' ? (
|
||||
<>
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
{playAsVoice
|
||||
? t('captures.actions.playAsStop', { name: playAsVoice.name })
|
||||
: t('captures.actions.playAsStopFallback')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Volume2 className="h-3.5 w-3.5" />
|
||||
{playAsVoice
|
||||
? t('captures.actions.playAs', { name: playAsVoice.name })
|
||||
: t('captures.actions.playAsFallback')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'rounded-l-none px-2 transition-colors',
|
||||
playbackState !== 'idle' &&
|
||||
'border-accent/50 bg-accent/10 hover:bg-accent/15 hover:text-foreground hover:border-accent/50',
|
||||
)}
|
||||
disabled={!profiles || !profiles.length}
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{t('captures.actions.playAsDropdownLabel')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{profiles?.map((v) => (
|
||||
<DropdownMenuItem key={v.id} onClick={() => handlePlayAs(v)} className="py-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{v.name}</div>
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
{v.description || v.language.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
{v.id === playAsVoiceId && (
|
||||
<Check className="h-3.5 w-3.5 text-accent shrink-0" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleCopy}>
|
||||
<Copy className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('captures.actions.copy')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => session.refine(selected.id)}
|
||||
disabled={session.isRefining}
|
||||
>
|
||||
{session.isRefining ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
{selected.transcript_refined
|
||||
? t('captures.actions.reRefine')
|
||||
: t('captures.actions.refine')}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('captures.actions.export')}
|
||||
<ChevronDown className="h-3.5 w-3.5 ml-1 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{t('captures.actions.exportDropdownLabel')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleExportAudio}>
|
||||
<FileAudio className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
|
||||
{t('captures.actions.exportAudio')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleExportTranscript}>
|
||||
<Captions className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
|
||||
{t('captures.actions.exportTranscript')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleExportMarkdown}>
|
||||
<FileText className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
|
||||
{t('captures.actions.exportMarkdown')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-muted-foreground "
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
{t('captures.actions.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground pt-20">
|
||||
{capturesLoading ? (
|
||||
<div className="text-center space-y-3">
|
||||
<Captions className="h-10 w-10 mx-auto opacity-40" />
|
||||
<p className="text-sm">{t('captures.empty.loading')}</p>
|
||||
</div>
|
||||
) : captures.length ? (
|
||||
<div className="text-center space-y-3">
|
||||
<Captions className="h-10 w-10 mx-auto opacity-40" />
|
||||
<p className="text-sm">{t('captures.empty.pickOne')}</p>
|
||||
</div>
|
||||
) : hotkeyEnabled && !readiness.canRecord ? (
|
||||
<DictationReadinessChecklist readiness={readiness} />
|
||||
) : hotkeyEnabled && (pushToTalkKeys.length || toggleToTalkKeys.length) ? (
|
||||
<div className="max-w-sm mx-auto text-center space-y-5">
|
||||
<div className="space-y-2">
|
||||
{pushToTalkKeys.length ? (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<ChordKeys keys={pushToTalkKeys} />
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
{t('captures.empty.holdToRecord')}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{toggleToTalkKeys.length ? (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<ChordKeys keys={toggleToTalkKeys} />
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
{t('captures.empty.toggleHandsFree')}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-sm">{t('captures.empty.pressShortcut')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-sm mx-auto text-center space-y-3">
|
||||
<Captions className="h-10 w-10 mx-auto opacity-40" />
|
||||
<p className="text-sm">{t('captures.empty.none')}</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t('captures.empty.turnOnShortcut')}
|
||||
</p>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/settings/captures">{t('captures.empty.openSettings')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('captures.deleteDialog.title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('captures.deleteDialog.description')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button
|
||||
onClick={() => selected && deleteMutation.mutate(selected.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteMutation.isPending
|
||||
? t('captures.deleteDialog.deleting')
|
||||
: t('common.delete')}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Accessibility,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Cpu,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Keyboard,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { ActiveDownloadTask } from '@/lib/api/types';
|
||||
import type { DictationReadiness, ReadinessGate } from '@/lib/hooks/useDictationReadiness';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface RowProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
ready: boolean;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
function ChecklistRow({ icon, title, description, ready, action }: RowProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border p-3.5 transition-colors',
|
||||
ready ? 'border-accent/20 bg-accent/5' : 'border-border bg-muted/20',
|
||||
)}
|
||||
>
|
||||
<div className="mt-0.5 shrink-0">
|
||||
{ready ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-accent" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5 text-muted-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<p className="text-sm font-medium text-foreground">{title}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{description}</p>
|
||||
{!ready && action ? <div className="pt-1.5">{action}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function progressPercent(task: ActiveDownloadTask | undefined): number | null {
|
||||
if (!task) return null;
|
||||
if (typeof task.progress === 'number')
|
||||
return Math.round(Math.max(0, Math.min(100, task.progress)));
|
||||
if (task.current && task.total) return Math.round((task.current / task.total) * 100);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one row per dictation-readiness gate. Each unmet gate gets an
|
||||
* inline action — Download for missing models, Open Settings for missing
|
||||
* TCC permissions — so the user can resolve everything without leaving
|
||||
* Captures.
|
||||
*
|
||||
* Download-in-progress state is sourced from ``/tasks/active`` (same query
|
||||
* the Models page uses) so it survives unmount: navigating away and back
|
||||
* still shows "Downloading…" instead of resetting to "Download".
|
||||
*
|
||||
* The chord stays disarmed until every row is green; this is what stops the
|
||||
* "stuck pill" failure mode of pressing the chord with a missing model.
|
||||
*
|
||||
* ``compact`` drops the centered title/subheading block and the
|
||||
* empty-state max-width so the checklist can be embedded in a narrow
|
||||
* sidebar alongside other settings. Callers own their own heading in
|
||||
* that mode (typically an ``<h3>`` that matches the surrounding sidebar
|
||||
* section style).
|
||||
*/
|
||||
export function DictationReadinessChecklist({
|
||||
readiness,
|
||||
compact = false,
|
||||
}: {
|
||||
readiness: DictationReadiness;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: activeTasks } = useQuery({
|
||||
queryKey: ['activeTasks'],
|
||||
queryFn: () => apiClient.getActiveTasks(),
|
||||
// Mirror ModelManagement's cadence: 1s while a download is in flight,
|
||||
// 5s otherwise. Keeps progress feeling live without hammering when idle.
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data;
|
||||
const hasActive = data?.downloads.some((d) => d.status === 'downloading');
|
||||
return hasActive ? 1000 : 5000;
|
||||
},
|
||||
});
|
||||
|
||||
// Memo so the Map identity is stable across renders that don't change
|
||||
// activeTasks — otherwise the cleanup effect below saw a fresh Map every
|
||||
// render and re-fired on every 1 s poll tick.
|
||||
const downloadByModel = useMemo(() => {
|
||||
const m = new Map<string, ActiveDownloadTask>();
|
||||
for (const dl of activeTasks?.downloads ?? []) {
|
||||
if (dl.status === 'downloading') m.set(dl.model_name, dl);
|
||||
}
|
||||
return m;
|
||||
}, [activeTasks]);
|
||||
|
||||
// When a download disappears from activeTasks, it just finished — refetch
|
||||
// readiness immediately so the row flips to ✓ instead of waiting up to 5s
|
||||
// for the next readiness poll.
|
||||
const prevActive = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
const current = new Set(downloadByModel.keys());
|
||||
for (const name of prevActive.current) {
|
||||
if (!current.has(name)) {
|
||||
queryClient.invalidateQueries({ queryKey: ['capture-readiness'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
||||
break;
|
||||
}
|
||||
}
|
||||
prevActive.current = current;
|
||||
}, [downloadByModel, queryClient]);
|
||||
|
||||
const downloadMutation = useMutation({
|
||||
mutationFn: async ({ modelName }: { gate: ReadinessGate; modelName: string }) =>
|
||||
apiClient.triggerModelDownload(modelName),
|
||||
onSuccess: (_data, vars) => {
|
||||
// Bump activeTasks so the row immediately shows "Downloading…" without
|
||||
// waiting for the next 5s poll. modelStatus + readiness invalidations
|
||||
// keep adjacent UI in sync.
|
||||
queryClient.invalidateQueries({ queryKey: ['activeTasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['capture-readiness'] });
|
||||
const displayName =
|
||||
vars.gate === 'stt' ? readiness.stt?.display_name : readiness.llm?.display_name;
|
||||
toast({
|
||||
title: t('captures.readiness.downloadStarted'),
|
||||
description: t('captures.readiness.downloadStartedDescription', { name: displayName }),
|
||||
});
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
title: t('captures.readiness.downloadFailed'),
|
||||
description: err.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const sttSize =
|
||||
readiness.stt?.size_mb != null ? `${(readiness.stt.size_mb / 1000).toFixed(1)} GB` : null;
|
||||
const llmSize =
|
||||
readiness.llm?.size_mb != null ? `${(readiness.llm.size_mb / 1000).toFixed(1)} GB` : null;
|
||||
|
||||
function modelDownloadButton(
|
||||
gate: 'stt' | 'llm',
|
||||
modelName: string,
|
||||
ready: boolean,
|
||||
): React.ReactNode {
|
||||
const task = downloadByModel.get(modelName);
|
||||
const downloading = !ready && !!task;
|
||||
const pct = progressPercent(task);
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => downloadMutation.mutate({ gate, modelName })}
|
||||
disabled={downloading || downloadMutation.isPending}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{downloading ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{pct != null
|
||||
? t('captures.readiness.downloadingPercent', { pct })
|
||||
: t('captures.readiness.downloading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{t('captures.readiness.downloadButton')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('w-full space-y-2.5', !compact && 'max-w-md mx-auto')}>
|
||||
{!compact && (
|
||||
<div className="text-center mb-5 space-y-1">
|
||||
<h2 className="text-base font-semibold text-foreground">
|
||||
{t('captures.readiness.title')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('captures.readiness.subheading')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{readiness.stt && (
|
||||
<ChecklistRow
|
||||
icon={<Cpu className="h-3.5 w-3.5" />}
|
||||
title={t('captures.readiness.stt.label', { name: readiness.stt.display_name })}
|
||||
description={
|
||||
readiness.stt.ready
|
||||
? t('captures.readiness.stt.ready')
|
||||
: sttSize
|
||||
? t('captures.readiness.stt.missingWithSize', { size: sttSize })
|
||||
: t('captures.readiness.stt.missing')
|
||||
}
|
||||
ready={readiness.stt.ready}
|
||||
action={modelDownloadButton('stt', readiness.stt.model_name, readiness.stt.ready)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{readiness.llm && (
|
||||
<ChecklistRow
|
||||
icon={<Cpu className="h-3.5 w-3.5" />}
|
||||
title={t('captures.readiness.llm.label', { name: readiness.llm.display_name })}
|
||||
description={
|
||||
readiness.llm.ready
|
||||
? t('captures.readiness.llm.ready')
|
||||
: llmSize
|
||||
? t('captures.readiness.llm.missingWithSize', { size: llmSize })
|
||||
: t('captures.readiness.llm.missing')
|
||||
}
|
||||
ready={readiness.llm.ready}
|
||||
action={modelDownloadButton('llm', readiness.llm.model_name, readiness.llm.ready)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Input Monitoring + Accessibility are macOS-only TCC permissions.
|
||||
The Rust stubs return true on Windows/Linux, so rendering these
|
||||
rows there would show permanent green checkmarks with copy
|
||||
that talks about macOS — noise. Hide on non-mac. */}
|
||||
{isMacOS && (
|
||||
<ChecklistRow
|
||||
icon={<Keyboard className="h-3.5 w-3.5" />}
|
||||
title={t('captures.readiness.inputMonitoring.label')}
|
||||
description={
|
||||
readiness.inputMonitoring
|
||||
? t('captures.readiness.inputMonitoring.ready')
|
||||
: t('captures.readiness.inputMonitoring.missing')
|
||||
}
|
||||
ready={readiness.inputMonitoring}
|
||||
action={
|
||||
<Button size="sm" onClick={readiness.openInputMonitoringSettings} className="gap-1.5">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t('captures.readiness.inputMonitoring.openSettings')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMacOS && (
|
||||
<ChecklistRow
|
||||
icon={<Accessibility className="h-3.5 w-3.5" />}
|
||||
title={t('captures.readiness.accessibility.label')}
|
||||
description={
|
||||
readiness.accessibility
|
||||
? t('captures.readiness.accessibility.ready')
|
||||
: t('captures.readiness.accessibility.missing')
|
||||
}
|
||||
ready={readiness.accessibility}
|
||||
action={
|
||||
<Button size="sm" onClick={readiness.openAccessibilitySettings} className="gap-1.5">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t('captures.readiness.accessibility.openSettings')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isMacOS =
|
||||
typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent);
|
||||
@@ -0,0 +1,113 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { ChordPicker } from '@/components/ChordPicker/ChordPicker';
|
||||
import { renderWithProviders } from '@/test/render';
|
||||
|
||||
// ChordPicker listens on window in the capture phase and canonicalizes via
|
||||
// `event.code`, so raw KeyboardEvents give exact control over which physical
|
||||
// keys the picker sees (userEvent would depend on the host keyboard layout).
|
||||
function press(code: string) {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { code, bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
function release(code: string) {
|
||||
window.dispatchEvent(new KeyboardEvent('keyup', { code, bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
async function renderPicker(initialKeys: string[] = []) {
|
||||
const onSave = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
const screen = await renderWithProviders(
|
||||
<ChordPicker
|
||||
open
|
||||
title="Push-to-talk shortcut"
|
||||
initialKeys={initialKeys}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
return { screen, onSave, onCancel };
|
||||
}
|
||||
|
||||
it('opens empty with save disabled and flags unsupported keys', async () => {
|
||||
const { screen } = await renderPicker();
|
||||
|
||||
await expect.element(screen.getByText('Press your shortcut')).toBeVisible();
|
||||
await expect.element(screen.getByText('No keys yet')).toBeVisible();
|
||||
await expect.element(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
|
||||
|
||||
// NumpadEnter has no canonical chord name — the picker refuses it and
|
||||
// stays empty instead of capturing garbage.
|
||||
press('NumpadEnter');
|
||||
await expect.element(screen.getByText(/isn't supported in chords/)).toBeVisible();
|
||||
await expect.element(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('captures the held keys and saves them after release', async () => {
|
||||
const { screen, onSave } = await renderPicker();
|
||||
|
||||
press('KeyJ');
|
||||
await expect.element(screen.getByText('Capturing…')).toBeVisible();
|
||||
await expect.element(screen.getByText('J', { exact: true })).toBeVisible();
|
||||
|
||||
press('KeyK');
|
||||
await expect.element(screen.getByText('K', { exact: true })).toBeVisible();
|
||||
|
||||
// Releasing everything freezes the peak so the user can save hands-free.
|
||||
release('KeyK');
|
||||
release('KeyJ');
|
||||
await expect.element(screen.getByText('Press your shortcut')).toBeVisible();
|
||||
await expect.element(screen.getByText('J', { exact: true })).toBeVisible();
|
||||
await expect.element(screen.getByText('K', { exact: true })).toBeVisible();
|
||||
|
||||
await screen.getByRole('button', { name: 'Save' }).click();
|
||||
expect(onSave).toHaveBeenCalledExactlyOnceWith(['KeyJ', 'KeyK']);
|
||||
});
|
||||
|
||||
it('keeps the peak set when a key is released mid-chord', async () => {
|
||||
const { screen, onSave } = await renderPicker();
|
||||
|
||||
press('KeyA');
|
||||
press('KeyB');
|
||||
press('KeyC');
|
||||
await expect.element(screen.getByText('B', { exact: true })).toBeVisible();
|
||||
|
||||
// Mid-chord the display tracks only the currently held keys...
|
||||
release('KeyB');
|
||||
await expect.element(screen.getByText('B', { exact: true })).not.toBeInTheDocument();
|
||||
await expect.element(screen.getByText('A', { exact: true })).toBeVisible();
|
||||
|
||||
// ...but the captured peak still includes the released key.
|
||||
release('KeyA');
|
||||
release('KeyC');
|
||||
await expect.element(screen.getByText('B', { exact: true })).toBeVisible();
|
||||
|
||||
await screen.getByRole('button', { name: 'Save' }).click();
|
||||
expect(onSave).toHaveBeenCalledExactlyOnceWith(['KeyA', 'KeyB', 'KeyC']);
|
||||
});
|
||||
|
||||
it('replaces a longer saved chord with a fresh shorter one', async () => {
|
||||
const { screen, onSave } = await renderPicker(['KeyA', 'KeyB', 'KeyC']);
|
||||
|
||||
await expect.element(screen.getByText('A', { exact: true })).toBeVisible();
|
||||
|
||||
// The first key of a new sequence resets the peak, so a single key can
|
||||
// beat the three-key seed.
|
||||
press('KeyZ');
|
||||
release('KeyZ');
|
||||
await expect.element(screen.getByText('Z', { exact: true })).toBeVisible();
|
||||
await expect.element(screen.getByText('A', { exact: true })).not.toBeInTheDocument();
|
||||
|
||||
await screen.getByRole('button', { name: 'Save' }).click();
|
||||
expect(onSave).toHaveBeenCalledExactlyOnceWith(['KeyZ']);
|
||||
});
|
||||
|
||||
it('cancel fires the cancel callback and never saves', async () => {
|
||||
const { screen, onSave, onCancel } = await renderPicker(['KeyA']);
|
||||
|
||||
press('KeyQ');
|
||||
release('KeyQ');
|
||||
await screen.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { Keyboard } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
canonicalKeyFromEvent,
|
||||
displayLabelForKey,
|
||||
modifierSideHint,
|
||||
sortChordKeys,
|
||||
} from '@/lib/utils/keyCodes';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface ChordPickerProps {
|
||||
open: boolean;
|
||||
/** Title shown in the modal — caller picks "push-to-talk" vs "toggle". */
|
||||
title: string;
|
||||
description?: string;
|
||||
/** The chord currently saved, shown as the starting state. */
|
||||
initialKeys: string[];
|
||||
onSave: (keys: string[]) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal that captures a key chord from the browser keyboard. Tracks the
|
||||
* peak set of keys held during the session so the user can release
|
||||
* before clicking Save (otherwise they'd be saving while still holding
|
||||
* the shortcut, which is awkward).
|
||||
*
|
||||
* Browser limitation: we can only capture keys while Voicebox has key
|
||||
* focus, so the picker pulls focus to a hidden capture surface inside
|
||||
* the dialog. The actual chord runs through the Rust global hook —
|
||||
* this picker only writes the configuration the hook reads.
|
||||
*/
|
||||
export function ChordPicker({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
initialKeys,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: ChordPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
// Currently held set, peak set captured this session, and "is the user
|
||||
// mid-chord?". We freeze the peak when they release everything so the
|
||||
// Save button can read a stable value.
|
||||
const [pressed, setPressed] = useState<Set<string>>(new Set());
|
||||
const [captured, setCaptured] = useState<string[]>(initialKeys);
|
||||
const [unsupportedAttempt, setUnsupportedAttempt] = useState<string | null>(null);
|
||||
const captureRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Reset every time the modal re-opens — otherwise the previous picker
|
||||
// session's peak set leaks into the next open and confuses the user.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPressed(new Set());
|
||||
setCaptured(initialKeys);
|
||||
setUnsupportedAttempt(null);
|
||||
// Defer focus to the next paint so the dialog is mounted.
|
||||
const timeoutId = window.setTimeout(() => captureRef.current?.focus(), 50);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}
|
||||
return;
|
||||
}, [open, initialKeys]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// Esc reaches the dialog's onOpenChange and closes the modal — let
|
||||
// it pass through unmodified.
|
||||
if (event.key === 'Escape') return;
|
||||
// Tab cycles focus inside the dialog; capturing it would trap the
|
||||
// user. Same for the dialog's own keyboard interactions.
|
||||
if (event.key === 'Tab') return;
|
||||
|
||||
const canonical = canonicalKeyFromEvent(event);
|
||||
if (!canonical) {
|
||||
setUnsupportedAttempt(event.code || event.key || 'unknown');
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setUnsupportedAttempt(null);
|
||||
|
||||
setPressed((prev) => {
|
||||
if (prev.has(canonical)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(canonical);
|
||||
setCaptured((prevCaptured) => {
|
||||
const candidate = sortChordKeys(Array.from(next));
|
||||
// First key in a fresh sequence replaces the peak — otherwise a
|
||||
// user trying to swap a longer saved chord for a shorter one is
|
||||
// stuck because their candidate never beats the seed length.
|
||||
if (prev.size === 0) return candidate;
|
||||
return candidate.length >= prevCaptured.length ? candidate : prevCaptured;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleKeyUp = useCallback((event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' || event.key === 'Tab') return;
|
||||
const canonical = canonicalKeyFromEvent(event);
|
||||
if (!canonical) return;
|
||||
event.preventDefault();
|
||||
setPressed((prev) => {
|
||||
if (!prev.has(canonical)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(canonical);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Wire global listeners only while open. Capture phase so Voicebox's
|
||||
// own command palette / global shortcuts don't swallow the chord first.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
window.addEventListener('keyup', handleKeyUp, true);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, true);
|
||||
window.removeEventListener('keyup', handleKeyUp, true);
|
||||
};
|
||||
}, [open, handleKeyDown, handleKeyUp]);
|
||||
|
||||
const displayKeys = pressed.size > 0
|
||||
? sortChordKeys(Array.from(pressed))
|
||||
: captured;
|
||||
|
||||
const canSave = captured.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => { if (!next) onCancel(); }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
ref={captureRef}
|
||||
tabIndex={-1}
|
||||
className="rounded-lg border border-border bg-muted/30 p-6 outline-none focus:ring-2 focus:ring-accent"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Keyboard className="h-3.5 w-3.5" />
|
||||
{pressed.size > 0 ? t('captures.chord.capturing') : t('captures.chord.pressShortcut')}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-1.5 min-h-[2.5rem]">
|
||||
{displayKeys.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground italic">
|
||||
{t('captures.chord.noKeys')}
|
||||
</span>
|
||||
) : (
|
||||
displayKeys.map((k) => <ChordKey key={k} name={k} />)
|
||||
)}
|
||||
</div>
|
||||
{unsupportedAttempt ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{t('captures.chord.unsupported', { key: unsupportedAttempt })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => onSave(captured)} disabled={!canSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ChordKey({ name }: { name: string }) {
|
||||
const side = modifierSideHint(name);
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center h-8 min-w-[2rem] px-2',
|
||||
'rounded-md border border-border bg-background font-mono text-sm font-medium',
|
||||
'shadow-sm text-foreground',
|
||||
)}
|
||||
>
|
||||
{displayLabelForKey(name)}
|
||||
{side ? (
|
||||
<span className="absolute -top-1 -right-1 h-3.5 min-w-[0.875rem] px-0.5 rounded-sm bg-accent text-[8px] font-bold leading-none flex items-center justify-center text-accent-foreground">
|
||||
{side}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { CapturePill } from '@/components/CapturePill/CapturePill';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { FocusSnapshot } from '@/lib/api/types';
|
||||
import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSession';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
/**
|
||||
* Floating dictate surface shown in a separate transparent Tauri window.
|
||||
* Mounted when the URL contains ``?view=dictate``. The main window bypasses
|
||||
* this branch and renders the full app shell.
|
||||
*
|
||||
* The pill surfaces for two independent cycles:
|
||||
* 1. User dictation — driven by ``dictate:start`` / ``dictate:stop``
|
||||
* from the Rust hotkey monitor.
|
||||
* 2. Agent speech — driven by ``dictate:speak-start`` / ``dictate:speak-end``
|
||||
* from the Rust ``speak_monitor`` (which owns the backend SSE stream).
|
||||
* On speak-start we subscribe to this single generation's status SSE,
|
||||
* then play ``/audio/{id}`` via a plain ``HTMLAudioElement`` when it
|
||||
* lands. When the audio element's ``ended`` fires, we emit
|
||||
* ``dictate:hide`` so Rust tucks the window away.
|
||||
*/
|
||||
export function DictateWindow() {
|
||||
const platform = usePlatform();
|
||||
const isTauri = platform.metadata.isTauri;
|
||||
|
||||
// Force the host document chrome to be transparent so the Tauri window
|
||||
// takes on the pill's own shape.
|
||||
useEffect(() => {
|
||||
const prevHtml = document.documentElement.style.background;
|
||||
const prevBody = document.body.style.background;
|
||||
document.documentElement.style.background = 'transparent';
|
||||
document.body.style.background = 'transparent';
|
||||
return () => {
|
||||
document.documentElement.style.background = prevHtml;
|
||||
document.body.style.background = prevBody;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Mirrored from the main window: true only when dictation is armed and the
|
||||
// user opted into keeping the microphone ready.
|
||||
const [micWarm, setMicWarm] = useState(false);
|
||||
|
||||
const session = useCaptureRecordingSession({
|
||||
keepMicWarm: micWarm,
|
||||
onFinalText: async (text, _capture, allowAutoPaste, context) => {
|
||||
// Focus is the snapshot taken at chord-start and threaded through as this
|
||||
// take's context, so it survives the 1–2 s transcribe + refine window and
|
||||
// overlapping dictations can't paste into each other's target.
|
||||
const focus = context as FocusSnapshot | null;
|
||||
if (!allowAutoPaste) return;
|
||||
if (!focus || !text.trim()) return;
|
||||
try {
|
||||
await invoke('paste_final_text', { text, focus });
|
||||
} catch (err) {
|
||||
// Surface accessibility failures to the main window so it can prompt
|
||||
// the user to grant permission. Other errors stay swallowed —
|
||||
// the transcription still landed in the captures list.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (/accessibility/i.test(msg)) {
|
||||
emit('system:accessibility-missing').catch(() => {});
|
||||
}
|
||||
console.warn('[dictate] paste_final_text failed:', err);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Route the chord events emitted from Rust into the session hook. Using a
|
||||
// ref so the `listen` effect only subscribes once — rebinding every render
|
||||
// would thrash the Tauri event bridge.
|
||||
const sessionRef = useRef(session);
|
||||
sessionRef.current = session;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri) return;
|
||||
let disposed = false;
|
||||
const unlistens: UnlistenFn[] = [];
|
||||
const registrations = [
|
||||
listen<{ focus: FocusSnapshot | null }>('dictate:start', (event) => {
|
||||
sessionRef.current.startRecording(event.payload?.focus ?? null);
|
||||
}),
|
||||
listen('dictate:stop', () => {
|
||||
// Forward stops that arrive while getUserMedia is still resolving.
|
||||
sessionRef.current.stopRecording();
|
||||
}),
|
||||
listen<boolean>('dictate:warm', (event) => {
|
||||
setMicWarm(Boolean(event.payload));
|
||||
}),
|
||||
];
|
||||
Promise.all(registrations)
|
||||
.then((registered) => {
|
||||
if (disposed) {
|
||||
for (const unlisten of registered) unlisten();
|
||||
return;
|
||||
}
|
||||
unlistens.push(...registered);
|
||||
emit('dictate:warm-request').catch(() => {});
|
||||
})
|
||||
.catch((err) => console.warn('[dictate] event listener registration failed:', err));
|
||||
return () => {
|
||||
disposed = true;
|
||||
for (const unlisten of unlistens) unlisten();
|
||||
};
|
||||
}, [isTauri]);
|
||||
|
||||
useEffect(() => {
|
||||
if (micWarm) void session.prewarm();
|
||||
else session.releaseWarm();
|
||||
}, [micWarm, session.prewarm, session.releaseWarm]);
|
||||
|
||||
// --- Agent-speak cycle ---------------------------------------------------
|
||||
|
||||
const [speaking, setSpeaking] = useState<{
|
||||
generationId: string;
|
||||
// Null while the backend is still generating audio; set to the
|
||||
// wall-clock timestamp when audio playback actually begins, so the
|
||||
// pill's elapsed counter only ticks while sound is coming out.
|
||||
startedAt: number | null;
|
||||
} | null>(null);
|
||||
const [speakElapsed, setSpeakElapsed] = useState(0);
|
||||
|
||||
// Refs so handlers inside long-lived `listen()` callbacks can read the
|
||||
// latest state without re-subscribing on every render.
|
||||
const speakingRef = useRef<typeof speaking>(null);
|
||||
speakingRef.current = speaking;
|
||||
const statusSourceRef = useRef<EventSource | null>(null);
|
||||
const statusTimeoutRef = useRef<number | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const clearStatusTimeout = () => {
|
||||
if (statusTimeoutRef.current !== null) {
|
||||
window.clearTimeout(statusTimeoutRef.current);
|
||||
statusTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const dismissSpeak = (id?: string) => {
|
||||
// Guard against a late dismiss targeting a stale cycle (a new speak
|
||||
// already started by the time audio.ended from the previous one fired).
|
||||
if (id && speakingRef.current && speakingRef.current.generationId !== id) return;
|
||||
statusSourceRef.current?.close();
|
||||
statusSourceRef.current = null;
|
||||
clearStatusTimeout();
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = '';
|
||||
audioRef.current = null;
|
||||
}
|
||||
setSpeaking(null);
|
||||
};
|
||||
|
||||
const startSpeakPlayback = (generationId: string) => {
|
||||
const audio = new Audio(apiClient.getAudioUrl(generationId));
|
||||
audio.onended = () => dismissSpeak(generationId);
|
||||
audio.onerror = () => dismissSpeak(generationId);
|
||||
// The pill window stays hidden through the ~1 s generation wait so the
|
||||
// user doesn't see a silent pill. We surface it the moment audio
|
||||
// actually starts playing, and that's also when the elapsed counter
|
||||
// arms.
|
||||
audio.onplaying = () => {
|
||||
emit('dictate:show').catch(() => {});
|
||||
setSpeaking((prev) =>
|
||||
prev && prev.generationId === generationId ? { ...prev, startedAt: Date.now() } : prev,
|
||||
);
|
||||
setSpeakElapsed(0);
|
||||
};
|
||||
audioRef.current = audio;
|
||||
audio.play().catch((err) => {
|
||||
console.warn('[dictate] audio.play failed:', err);
|
||||
dismissSpeak(generationId);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri) return;
|
||||
const unlistens: Promise<UnlistenFn>[] = [];
|
||||
|
||||
// Rust emits the SSE payload as a JSON *string* (not a parsed object);
|
||||
// the payload shape for speak-start is
|
||||
// {generation_id, profile_name, source, client_id}.
|
||||
unlistens.push(
|
||||
listen<string>('dictate:speak-start', (event) => {
|
||||
let parsed: { generation_id?: string } = {};
|
||||
try {
|
||||
parsed = typeof event.payload === 'string' ? JSON.parse(event.payload) : {};
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const id = parsed.generation_id;
|
||||
if (!id) return;
|
||||
|
||||
// Tear down any previous cycle — last speak wins.
|
||||
dismissSpeak();
|
||||
|
||||
setSpeaking({ generationId: id, startedAt: null });
|
||||
setSpeakElapsed(0);
|
||||
|
||||
// Subscribe to this one generation's status. When it completes, the
|
||||
// `/audio/{id}` endpoint will serve the WAV we need to play.
|
||||
const source = new EventSource(apiClient.getGenerationStatusUrl(id));
|
||||
statusSourceRef.current = source;
|
||||
// Hard cap on how long the pill can sit in the 'speaking' state
|
||||
// without ever hearing back from the backend. Covers the case where
|
||||
// the gen row is deleted mid-flight (SSE 404s and EventSource silently
|
||||
// retries) or the backend goes away while a request is in flight.
|
||||
// Clears as soon as a real status event lands.
|
||||
clearStatusTimeout();
|
||||
statusTimeoutRef.current = window.setTimeout(() => {
|
||||
statusTimeoutRef.current = null;
|
||||
if (speakingRef.current?.generationId === id && !audioRef.current) {
|
||||
dismissSpeak(id);
|
||||
}
|
||||
}, 60_000);
|
||||
source.onmessage = (msg) => {
|
||||
try {
|
||||
const data = JSON.parse(msg.data) as { status?: string };
|
||||
if (data.status === 'completed') {
|
||||
clearStatusTimeout();
|
||||
source.close();
|
||||
if (statusSourceRef.current === source) statusSourceRef.current = null;
|
||||
startSpeakPlayback(id);
|
||||
} else if (data.status === 'failed' || data.status === 'not_found') {
|
||||
clearStatusTimeout();
|
||||
source.close();
|
||||
dismissSpeak(id);
|
||||
}
|
||||
} catch {
|
||||
// heartbeats / junk — ignore.
|
||||
}
|
||||
};
|
||||
source.onerror = () => {
|
||||
// EventSource auto-reconnects on transient drops; the timeout above
|
||||
// is the backstop for the case where it never recovers.
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Speak-end from the backend is advisory: the authoritative dismiss is
|
||||
// `audio.ended`. But if generation failed or nothing ever triggered
|
||||
// playback, a short grace window followed by forced dismiss avoids a
|
||||
// stuck-visible pill.
|
||||
unlistens.push(
|
||||
listen<string>('dictate:speak-end', (event) => {
|
||||
let parsed: { generation_id?: string; status?: string } = {};
|
||||
try {
|
||||
parsed = typeof event.payload === 'string' ? JSON.parse(event.payload) : {};
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (parsed.status && parsed.status !== 'completed') {
|
||||
// Failed / cancelled — dismiss immediately.
|
||||
if (parsed.generation_id) dismissSpeak(parsed.generation_id);
|
||||
return;
|
||||
}
|
||||
// Completed: if audio never started (shouldn't happen, but guard),
|
||||
// auto-dismiss after 15 s so the pill never stays forever.
|
||||
const id = parsed.generation_id;
|
||||
window.setTimeout(() => {
|
||||
if (speakingRef.current?.generationId === id && !audioRef.current) {
|
||||
dismissSpeak(id);
|
||||
}
|
||||
}, 15_000);
|
||||
}),
|
||||
);
|
||||
|
||||
return () => {
|
||||
for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
|
||||
dismissSpeak();
|
||||
};
|
||||
}, [isTauri]);
|
||||
|
||||
// Advance the pill's elapsed-time label while audio is playing. Paused
|
||||
// during the pre-playback generation window (startedAt is null) so the
|
||||
// counter stays at 0:00 until sound actually starts.
|
||||
useEffect(() => {
|
||||
if (!speaking?.startedAt) return;
|
||||
const anchor = speaking.startedAt;
|
||||
const iv = window.setInterval(() => {
|
||||
setSpeakElapsed(Date.now() - anchor);
|
||||
}, 250);
|
||||
return () => window.clearInterval(iv);
|
||||
}, [speaking?.generationId, speaking?.startedAt]);
|
||||
|
||||
// --- Effective pill state -----------------------------------------------
|
||||
|
||||
const isSpeaking = Boolean(speaking);
|
||||
const effectiveState = isSpeaking ? 'speaking' : session.pillState;
|
||||
const effectiveElapsed = isSpeaking ? speakElapsed : session.pillElapsedMs;
|
||||
|
||||
// When the pill cycle ends (no capture AND no speak), tell Rust to tuck
|
||||
// the window away. Rust owns the hide + park-off-screen + click-through
|
||||
// combo because calling hide() directly from JS has been unreliable for
|
||||
// transparent always-on-top windows on macOS.
|
||||
useEffect(() => {
|
||||
if (effectiveState === 'hidden') {
|
||||
emit('dictate:hide').catch(() => {});
|
||||
}
|
||||
}, [effectiveState]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-screen w-screen flex items-center justify-center px-3"
|
||||
style={{ background: 'transparent' }}
|
||||
>
|
||||
{effectiveState !== 'hidden' ? (
|
||||
<CapturePill
|
||||
state={effectiveState}
|
||||
elapsedMs={effectiveElapsed}
|
||||
errorMessage={session.errorMessage}
|
||||
onDismiss={session.dismissError}
|
||||
onStop={session.isRecording ? session.stopRecording : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ 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 { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
@@ -55,6 +56,7 @@ export function EffectsChainEditor({
|
||||
compact = false,
|
||||
showPresets = true,
|
||||
}: EffectsChainEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
// Maintain stable IDs for each effect across renders.
|
||||
@@ -177,17 +179,27 @@ export function EffectsChainEditor({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-xs focus:ring-0 focus:ring-offset-0">
|
||||
<SelectValue placeholder="Load preset..." />
|
||||
<SelectValue placeholder={t('effects.chain.loadPreset')} />
|
||||
</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>
|
||||
))}
|
||||
{presets?.map((p) => {
|
||||
const name = p.is_builtin
|
||||
? t(`effects.builtinPresets.${p.name}.name`, { defaultValue: p.name })
|
||||
: p.name;
|
||||
const description = p.is_builtin
|
||||
? t(`effects.builtinPresets.${p.name}.description`, {
|
||||
defaultValue: p.description ?? '',
|
||||
})
|
||||
: p.description;
|
||||
return (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{name}
|
||||
{description && (
|
||||
<span className="ml-1 text-muted-foreground">- {description}</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -198,7 +210,7 @@ export function EffectsChainEditor({
|
||||
className="h-8 px-2 text-xs text-muted-foreground"
|
||||
onClick={clearAll}
|
||||
>
|
||||
Clear
|
||||
{t('effects.chain.clear')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -229,12 +241,12 @@ export function EffectsChainEditor({
|
||||
<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..." />
|
||||
<SelectValue placeholder={t('effects.chain.addEffect')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableEffects.effects.map((e) => (
|
||||
<SelectItem key={e.type} value={e.type}>
|
||||
{e.label}
|
||||
{t(`effects.types.${e.type}.label`, { defaultValue: e.label })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -270,6 +282,7 @@ function SortableEffectItem({
|
||||
onToggleEnabled,
|
||||
onUpdateParam,
|
||||
}: SortableEffectItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id,
|
||||
});
|
||||
@@ -280,7 +293,9 @@ function SortableEffectItem({
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
};
|
||||
|
||||
const label = effectDef?.label ?? effect.type;
|
||||
const label = t(`effects.types.${effect.type}.label`, {
|
||||
defaultValue: effectDef?.label ?? effect.type,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -328,16 +343,16 @@ function SortableEffectItem({
|
||||
effect.enabled ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={onToggleEnabled}
|
||||
title={effect.enabled ? 'Disable' : 'Enable'}
|
||||
title={effect.enabled ? t('effects.chain.disable') : t('effects.chain.enable')}
|
||||
>
|
||||
<Power className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 text-muted-foreground hover:text-destructive"
|
||||
className="p-0.5 text-muted-foreground "
|
||||
onClick={onRemove}
|
||||
title="Remove"
|
||||
title={t('effects.chain.remove')}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -352,7 +367,9 @@ function SortableEffectItem({
|
||||
<div key={paramName} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[11px] text-muted-foreground">
|
||||
{paramDef.description}
|
||||
{t(`effects.types.${effect.type}.params.${paramName}`, {
|
||||
defaultValue: paramDef.description,
|
||||
})}
|
||||
</Label>
|
||||
<span className="text-[11px] font-mono tabular-nums text-foreground">
|
||||
{currentValue.toFixed(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Play, Save, Trash2, Wand2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||
import { GenerationPicker } from '@/components/Effects/GenerationPicker';
|
||||
@@ -25,6 +26,7 @@ import { useEffectsStore } from '@/stores/effectsStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
|
||||
export function EffectsDetail() {
|
||||
const { t } = useTranslation();
|
||||
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
|
||||
const isCreatingNew = useEffectsStore((s) => s.isCreatingNew);
|
||||
const workingChain = useEffectsStore((s) => s.workingChain);
|
||||
@@ -95,6 +97,18 @@ export function EffectsDetail() {
|
||||
|
||||
const isEditing = !!selectedPresetId || isCreatingNew;
|
||||
const isBuiltIn = preset?.is_builtin ?? false;
|
||||
const presetName = preset
|
||||
? preset.is_builtin
|
||||
? t(`effects.builtinPresets.${preset.name}.name`, { defaultValue: preset.name })
|
||||
: preset.name
|
||||
: '';
|
||||
const presetDescription = preset
|
||||
? preset.is_builtin
|
||||
? t(`effects.builtinPresets.${preset.name}.description`, {
|
||||
defaultValue: preset.description ?? '',
|
||||
})
|
||||
: preset.description
|
||||
: '';
|
||||
|
||||
async function handlePreview() {
|
||||
if (!previewGenId || workingChain.length === 0) return;
|
||||
@@ -115,8 +129,8 @@ export function EffectsDetail() {
|
||||
setAudioWithAutoPlay(url, `preview-${Date.now()}`, null, 'Effects Preview');
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Preview failed',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
title: t('effects.toast.previewFailed'),
|
||||
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
@@ -130,7 +144,7 @@ export function EffectsDetail() {
|
||||
|
||||
async function handleSaveNew() {
|
||||
if (!name.trim()) {
|
||||
toast({ title: 'Name required', variant: 'destructive' });
|
||||
toast({ title: t('effects.toast.nameRequired'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
@@ -143,11 +157,14 @@ export function EffectsDetail() {
|
||||
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||
setIsCreatingNew(false);
|
||||
setSelectedPresetId(created.id);
|
||||
toast({ title: 'Preset saved', description: `"${created.name}" has been created.` });
|
||||
toast({
|
||||
title: t('effects.toast.saved'),
|
||||
description: t('effects.toast.createdDescription', { name: created.name }),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to save',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
title: t('effects.toast.saveFailed'),
|
||||
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
@@ -166,11 +183,11 @@ export function EffectsDetail() {
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['effect-preset', selectedPresetId] });
|
||||
toast({ title: 'Preset updated' });
|
||||
toast({ title: t('effects.toast.updated') });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to save',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
title: t('effects.toast.saveFailed'),
|
||||
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
@@ -179,15 +196,15 @@ export function EffectsDetail() {
|
||||
}
|
||||
|
||||
function handleSaveAsNew() {
|
||||
// Open the dialog with a suggested name based on the current preset
|
||||
setSaveAsName(`${name} (Copy)`);
|
||||
const sourceName = isBuiltIn ? presetName : name;
|
||||
setSaveAsName(t('effects.saveAs.suggestedName', { name: sourceName }));
|
||||
setSaveAsDescription(description);
|
||||
setSaveAsDialogOpen(true);
|
||||
}
|
||||
|
||||
async function handleSaveAsConfirm() {
|
||||
if (!saveAsName.trim()) {
|
||||
toast({ title: 'Name required', variant: 'destructive' });
|
||||
toast({ title: t('effects.toast.nameRequired'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
@@ -200,11 +217,14 @@ export function EffectsDetail() {
|
||||
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||
setSaveAsDialogOpen(false);
|
||||
setSelectedPresetId(created.id);
|
||||
toast({ title: 'Preset saved', description: `"${created.name}" has been created.` });
|
||||
toast({
|
||||
title: t('effects.toast.saved'),
|
||||
description: t('effects.toast.createdDescription', { name: created.name }),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to save',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
title: t('effects.toast.saveFailed'),
|
||||
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
@@ -220,11 +240,11 @@ export function EffectsDetail() {
|
||||
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||
setSelectedPresetId(null);
|
||||
setWorkingChain([]);
|
||||
toast({ title: 'Preset deleted' });
|
||||
toast({ title: t('effects.toast.deleted') });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to delete',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
title: t('effects.toast.deleteFailed'),
|
||||
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
@@ -237,7 +257,7 @@ export function EffectsDetail() {
|
||||
<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>
|
||||
<p className="text-sm">{t('effects.placeholder')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -245,10 +265,13 @@ export function EffectsDetail() {
|
||||
|
||||
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'}
|
||||
{isCreatingNew
|
||||
? t('effects.detail.newTitle')
|
||||
: isBuiltIn
|
||||
? presetName
|
||||
: t('effects.detail.editTitle')}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isBuiltIn && !isCreatingNew && (
|
||||
@@ -256,12 +279,12 @@ export function EffectsDetail() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-destructive hover:text-destructive gap-1.5"
|
||||
className="h-8 text-destructive gap-1.5"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
{deleting ? t('effects.detail.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -270,7 +293,7 @@ export function EffectsDetail() {
|
||||
disabled={saving || workingChain.length === 0}
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
{saving ? t('effects.detail.saving') : t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -282,7 +305,7 @@ export function EffectsDetail() {
|
||||
disabled={saving || workingChain.length === 0}
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{saving ? 'Saving...' : 'Save Preset'}
|
||||
{saving ? t('effects.detail.saving') : t('effects.detail.savePreset')}
|
||||
</Button>
|
||||
)}
|
||||
{isBuiltIn && (
|
||||
@@ -294,51 +317,46 @@ export function EffectsDetail() {
|
||||
disabled={saving}
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{saving ? 'Saving...' : 'Save as Custom'}
|
||||
{saving ? t('effects.detail.saving') : t('effects.detail.saveAsCustom')}
|
||||
</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>
|
||||
<Label className="text-xs">{t('effects.fields.name')}</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My preset..."
|
||||
placeholder={t('effects.fields.namePlaceholder')}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Description</Label>
|
||||
<Label className="text-xs">{t('effects.fields.description')}</Label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe what this preset does..."
|
||||
placeholder={t('effects.fields.descriptionPlaceholder')}
|
||||
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>
|
||||
{isBuiltIn && presetDescription && (
|
||||
<p className="text-sm text-muted-foreground">{presetDescription}</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>
|
||||
<Label className="text-xs">{t('effects.preview.label')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<GenerationPicker
|
||||
selectedId={previewGenId}
|
||||
@@ -355,38 +373,33 @@ export function EffectsDetail() {
|
||||
{previewLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Processing...
|
||||
{t('effects.preview.processing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
Preview
|
||||
{t('effects.preview.button')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Preview applies effects to the clean version without saving.
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">{t('effects.preview.hint')}</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>
|
||||
<DialogTitle>{t('effects.saveAs.title')}</DialogTitle>
|
||||
<DialogDescription>{t('effects.saveAs.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Name</Label>
|
||||
<Label className="text-xs">{t('effects.fields.name')}</Label>
|
||||
<Input
|
||||
value={saveAsName}
|
||||
onChange={(e) => setSaveAsName(e.target.value)}
|
||||
placeholder="My preset..."
|
||||
placeholder={t('effects.fields.namePlaceholder')}
|
||||
className="h-9"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
@@ -397,22 +410,22 @@ export function EffectsDetail() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Description</Label>
|
||||
<Label className="text-xs">{t('effects.fields.description')}</Label>
|
||||
<Textarea
|
||||
value={saveAsDescription}
|
||||
onChange={(e) => setSaveAsDescription(e.target.value)}
|
||||
placeholder="Describe what this preset does..."
|
||||
placeholder={t('effects.fields.descriptionPlaceholder')}
|
||||
className="min-h-[60px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSaveAsDialogOpen(false)} disabled={saving}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSaveAsConfirm} disabled={saving || !saveAsName.trim()}>
|
||||
<Save className="h-3.5 w-3.5 mr-1.5" />
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
{saving ? t('effects.detail.saving') : t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2, Plus, Sparkles, Wand2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ListPane,
|
||||
ListPaneActions,
|
||||
ListPaneHeader,
|
||||
ListPaneScroll,
|
||||
ListPaneTitle,
|
||||
ListPaneTitleRow,
|
||||
} from '@/components/ListPane';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { EffectPresetResponse } from '@/lib/api/types';
|
||||
@@ -7,6 +16,7 @@ import { cn } from '@/lib/utils/cn';
|
||||
import { useEffectsStore } from '@/stores/effectsStore';
|
||||
|
||||
export function EffectsList() {
|
||||
const { t } = useTranslation();
|
||||
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
|
||||
const setSelectedPresetId = useEffectsStore((s) => s.setSelectedPresetId);
|
||||
const setWorkingChain = useEffectsStore((s) => s.setWorkingChain);
|
||||
@@ -41,75 +51,74 @@ export function EffectsList() {
|
||||
}
|
||||
|
||||
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>
|
||||
<ListPane>
|
||||
<ListPaneHeader>
|
||||
<ListPaneTitleRow>
|
||||
<ListPaneTitle>{t('effects.title')}</ListPaneTitle>
|
||||
<ListPaneActions>
|
||||
<Button onClick={handleCreateNew} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('effects.newPreset')}
|
||||
</Button>
|
||||
</ListPaneActions>
|
||||
</ListPaneTitleRow>
|
||||
</ListPaneHeader>
|
||||
|
||||
{/* 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>
|
||||
<ListPaneScroll className="pt-16">
|
||||
<div className="px-4 pb-6 space-y-4">
|
||||
{builtIn.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||
{t('effects.sections.builtin')}
|
||||
</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>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Configure effects in the panel on the right.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userPresets.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||
{t('effects.sections.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>
|
||||
)}
|
||||
|
||||
{isCreatingNew && (
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||
{t('effects.sections.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">{t('effects.unsaved.title')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('effects.unsaved.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ListPaneScroll>
|
||||
</ListPane>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,7 +131,16 @@ function PresetCard({
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const effectCount = preset.effects_chain.length;
|
||||
const name = preset.is_builtin
|
||||
? t(`effects.builtinPresets.${preset.name}.name`, { defaultValue: preset.name })
|
||||
: preset.name;
|
||||
const description = preset.is_builtin
|
||||
? t(`effects.builtinPresets.${preset.name}.description`, {
|
||||
defaultValue: preset.description ?? '',
|
||||
})
|
||||
: preset.description;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -139,19 +157,19 @@ function PresetCard({
|
||||
<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>
|
||||
<span className="text-sm font-medium truncate">{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
|
||||
{t('effects.badge.builtin')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-1 pl-6">
|
||||
{preset.description || 'No description'}
|
||||
{description || t('effects.noDescription')}
|
||||
</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' : ''}
|
||||
{t('effects.effectCount', { count: effectCount })}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">
|
||||
{preset.effects_chain
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import {EffectsDetail} from "./EffectsDetail";
|
||||
import {EffectsList} from "./EffectsList";
|
||||
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>
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden -mx-8">
|
||||
<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>
|
||||
);
|
||||
{/* Right - Detail / editor */}
|
||||
<div className="flex-1 min-h-0 flex flex-col pr-8">
|
||||
<EffectsDetail />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
import { FormControl } from '@/components/ui/form';
|
||||
import {
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||
import { getLanguageOptionsForEngine } from '@/lib/constants/languages';
|
||||
import type { GenerationFormValues } from '@/lib/hooks/useGenerationForm';
|
||||
|
||||
@@ -15,30 +17,57 @@ import type { GenerationFormValues } from '@/lib/hooks/useGenerationForm';
|
||||
* 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' },
|
||||
{ value: 'qwen:1.7B', label: 'Qwen3-TTS 1.7B', engine: 'qwen' },
|
||||
{ value: 'qwen:0.6B', label: 'Qwen3-TTS 0.6B', engine: 'qwen' },
|
||||
{ value: 'qwen_custom_voice:1.7B', label: 'Qwen CustomVoice 1.7B', engine: 'qwen_custom_voice' },
|
||||
{ value: 'qwen_custom_voice:0.6B', label: 'Qwen CustomVoice 0.6B', engine: 'qwen_custom_voice' },
|
||||
{ value: 'luxtts', label: 'LuxTTS', engine: 'luxtts' },
|
||||
{ value: 'chatterbox', label: 'Chatterbox', engine: 'chatterbox' },
|
||||
{ value: 'chatterbox_turbo', label: 'Chatterbox Turbo', engine: 'chatterbox_turbo' },
|
||||
{ value: 'tada:1B', label: 'TADA 1B', engine: 'tada' },
|
||||
{ value: 'tada:3B', label: 'TADA 3B Multilingual', engine: 'tada' },
|
||||
{ value: 'kokoro', label: 'Kokoro 82M', engine: 'kokoro' },
|
||||
] as const;
|
||||
|
||||
const ENGINE_DESCRIPTIONS: Record<string, string> = {
|
||||
qwen: 'Multi-language, two sizes',
|
||||
qwen_custom_voice: '9 preset voices, instruct control',
|
||||
luxtts: 'Fast, English-focused',
|
||||
chatterbox: '23 languages, incl. Hebrew',
|
||||
chatterbox_turbo: 'English, [laugh] [cough] tags',
|
||||
tada: 'HumeAI, 700s+ coherent audio',
|
||||
kokoro: '82M params, CPU realtime, 8 langs',
|
||||
};
|
||||
|
||||
/** Engines that only support English and should force language to 'en' on select. */
|
||||
const ENGLISH_ONLY_ENGINES = new Set(['luxtts', 'chatterbox_turbo']);
|
||||
|
||||
/** Engines that support cloned (reference audio) profiles. */
|
||||
const CLONING_ENGINES = new Set(['qwen', 'luxtts', 'chatterbox', 'chatterbox_turbo', 'tada']);
|
||||
|
||||
function getAvailableOptions(selectedProfile?: VoiceProfileResponse | null) {
|
||||
if (!selectedProfile) return ENGINE_OPTIONS;
|
||||
return ENGINE_OPTIONS.filter((opt) => isProfileCompatibleWithEngine(selectedProfile, opt.engine));
|
||||
}
|
||||
|
||||
function getSelectValue(engine: string, modelSize?: string): string {
|
||||
if (engine === 'qwen') return `qwen:${modelSize || '1.7B'}`;
|
||||
if (engine === 'qwen_custom_voice') return `qwen_custom_voice:${modelSize || '1.7B'}`;
|
||||
if (engine === 'tada') return `tada:${modelSize || '1B'}`;
|
||||
return engine;
|
||||
}
|
||||
|
||||
function handleEngineChange(form: UseFormReturn<GenerationFormValues>, value: string) {
|
||||
if (value.startsWith('qwen:')) {
|
||||
export function applyEngineSelection(form: UseFormReturn<GenerationFormValues>, value: string) {
|
||||
if (value.startsWith('qwen_custom_voice:')) {
|
||||
const [, modelSize] = value.split(':');
|
||||
form.setValue('engine', 'qwen_custom_voice');
|
||||
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
|
||||
const currentLang = form.getValues('language');
|
||||
const available = getLanguageOptionsForEngine('qwen_custom_voice');
|
||||
if (!available.some((l) => l.value === currentLang)) {
|
||||
form.setValue('language', available[0]?.value ?? 'en');
|
||||
}
|
||||
} else if (value.startsWith('qwen:')) {
|
||||
const [, modelSize] = value.split(':');
|
||||
form.setValue('engine', 'qwen');
|
||||
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
|
||||
@@ -48,6 +77,20 @@ function handleEngineChange(form: UseFormReturn<GenerationFormValues>, value: st
|
||||
if (!available.some((l) => l.value === currentLang)) {
|
||||
form.setValue('language', available[0]?.value ?? 'en');
|
||||
}
|
||||
} else if (value.startsWith('tada:')) {
|
||||
const [, modelSize] = value.split(':');
|
||||
form.setValue('engine', 'tada');
|
||||
form.setValue('modelSize', modelSize as '1B' | '3B');
|
||||
// TADA 1B is English-only; 3B is multilingual
|
||||
if (modelSize === '1B') {
|
||||
form.setValue('language', 'en');
|
||||
} else {
|
||||
const currentLang = form.getValues('language');
|
||||
const available = getLanguageOptionsForEngine('tada');
|
||||
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');
|
||||
@@ -67,12 +110,22 @@ function handleEngineChange(form: UseFormReturn<GenerationFormValues>, value: st
|
||||
interface EngineModelSelectorProps {
|
||||
form: UseFormReturn<GenerationFormValues>;
|
||||
compact?: boolean;
|
||||
selectedProfile?: VoiceProfileResponse | null;
|
||||
}
|
||||
|
||||
export function EngineModelSelector({ form, compact }: EngineModelSelectorProps) {
|
||||
export function EngineModelSelector({ form, compact, selectedProfile }: EngineModelSelectorProps) {
|
||||
const engine = form.watch('engine') || 'qwen';
|
||||
const modelSize = form.watch('modelSize');
|
||||
const selectValue = getSelectValue(engine, modelSize);
|
||||
const availableOptions = getAvailableOptions(selectedProfile);
|
||||
|
||||
const currentEngineAvailable = availableOptions.some((opt) => opt.value === selectValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentEngineAvailable && availableOptions.length > 0) {
|
||||
applyEngineSelection(form, availableOptions[0].value);
|
||||
}
|
||||
}, [availableOptions, currentEngineAvailable, form]);
|
||||
|
||||
const itemClass = compact ? 'text-xs text-muted-foreground' : undefined;
|
||||
const triggerClass = compact
|
||||
@@ -80,14 +133,14 @@ export function EngineModelSelector({ form, compact }: EngineModelSelectorProps)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Select value={selectValue} onValueChange={(v) => handleEngineChange(form, v)}>
|
||||
<Select value={selectValue} onValueChange={(v) => applyEngineSelection(form, v)}>
|
||||
<FormControl>
|
||||
<SelectTrigger className={triggerClass}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{ENGINE_OPTIONS.map((opt) => (
|
||||
{availableOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value} className={itemClass}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
@@ -101,3 +154,17 @@ export function EngineModelSelector({ form, compact }: EngineModelSelectorProps)
|
||||
export function getEngineDescription(engine: string): string {
|
||||
return ENGINE_DESCRIPTIONS[engine] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a profile is compatible with the currently selected engine.
|
||||
* Useful for UI hints.
|
||||
*/
|
||||
export function isProfileCompatibleWithEngine(
|
||||
profile: VoiceProfileResponse,
|
||||
engine: string,
|
||||
): boolean {
|
||||
const voiceType = profile.voice_type || 'cloned';
|
||||
if (voiceType === 'preset') return profile.preset_engine === engine;
|
||||
if (voiceType === 'cloned') return CLONING_ENGINES.has(engine);
|
||||
return true; // designed — future
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { expect, it } from 'vitest';
|
||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { buildGeneration, buildModelStatus, buildProfile } from '@/test/msw/fixtures';
|
||||
import {
|
||||
captureHandlers,
|
||||
effectsHandlers,
|
||||
historyHandlers,
|
||||
modelHandlers,
|
||||
profileHandlers,
|
||||
settingsHandlers,
|
||||
storyHandlers,
|
||||
taskHandlers,
|
||||
} from '@/test/msw/handlers';
|
||||
import { worker } from '@/test/msw/worker';
|
||||
import { renderRoute } from '@/test/render';
|
||||
import { sseController } from '@/test/sse';
|
||||
|
||||
/**
|
||||
* FloatingGenerateBox calls useMatchRoute, so it needs router context; the
|
||||
* SSE completion loop (useGenerationProgress) lives in the router's root
|
||||
* layout. Mounting the index route exercises the real wiring for both.
|
||||
* History handlers are registered per test so requests can be counted.
|
||||
*/
|
||||
function stubAppRequests(profiles: VoiceProfileResponse[]) {
|
||||
worker.use(
|
||||
...profileHandlers(profiles),
|
||||
...captureHandlers([]),
|
||||
...settingsHandlers(),
|
||||
...modelHandlers([buildModelStatus()]),
|
||||
...storyHandlers([]),
|
||||
...effectsHandlers([]),
|
||||
...taskHandlers(),
|
||||
);
|
||||
}
|
||||
|
||||
it('renders the generate box wired to the selected profile', async () => {
|
||||
const profile = buildProfile({ name: 'Ada Lovelace' });
|
||||
stubAppRequests([profile]);
|
||||
worker.use(...historyHandlers([]));
|
||||
useUIStore.getState().setSelectedProfileId(profile.id);
|
||||
|
||||
const screen = await renderRoute('/');
|
||||
|
||||
await expect
|
||||
.element(screen.getByPlaceholder('Generate speech using Ada Lovelace…'))
|
||||
.toBeVisible();
|
||||
await expect.element(screen.getByRole('button', { name: 'Generate speech' })).toBeEnabled();
|
||||
expect(useUIStore.getState().selectedProfileId).toBe(profile.id);
|
||||
});
|
||||
|
||||
it('posts to /generate on submit and tracks the pending generation', async () => {
|
||||
const profile = buildProfile({ name: 'Ada Lovelace' });
|
||||
const generation = buildGeneration({
|
||||
profile_id: profile.id,
|
||||
status: 'generating',
|
||||
audio_path: undefined,
|
||||
});
|
||||
const generateBodies: unknown[] = [];
|
||||
const sse = sseController();
|
||||
stubAppRequests([profile]);
|
||||
worker.use(
|
||||
...historyHandlers([]),
|
||||
http.post('*/generate', async ({ request }) => {
|
||||
generateBodies.push(await request.json());
|
||||
return HttpResponse.json(generation);
|
||||
}),
|
||||
http.get('*/generate/:id/status', () => sse.response()),
|
||||
);
|
||||
useUIStore.getState().setSelectedProfileId(profile.id);
|
||||
|
||||
const screen = await renderRoute('/');
|
||||
|
||||
const input = screen.getByPlaceholder('Generate speech using Ada Lovelace…');
|
||||
await input.fill('Hello from the browser test');
|
||||
await screen.getByRole('button', { name: 'Generate speech' }).click();
|
||||
|
||||
await expect.poll(() => generateBodies.length).toBe(1);
|
||||
expect(generateBodies[0]).toMatchObject({
|
||||
profile_id: profile.id,
|
||||
text: 'Hello from the browser test',
|
||||
language: 'en',
|
||||
engine: 'qwen',
|
||||
});
|
||||
await expect
|
||||
.poll(() => useGenerationStore.getState().pendingGenerationIds.has(generation.id))
|
||||
.toBe(true);
|
||||
// The form resets as soon as the request is accepted.
|
||||
await expect.element(input).toHaveValue('');
|
||||
sse.close();
|
||||
});
|
||||
|
||||
it('clears pending state and refetches history when SSE reports completion', async () => {
|
||||
const profile = buildProfile({ name: 'Ada Lovelace' });
|
||||
const generation = buildGeneration({
|
||||
profile_id: profile.id,
|
||||
status: 'generating',
|
||||
audio_path: undefined,
|
||||
});
|
||||
const sse = sseController();
|
||||
let sseConnections = 0;
|
||||
let historyGets = 0;
|
||||
stubAppRequests([profile]);
|
||||
worker.use(
|
||||
http.get('*/history', () => {
|
||||
historyGets += 1;
|
||||
return HttpResponse.json({ items: [], total: 0 });
|
||||
}),
|
||||
http.post('*/generate', () => HttpResponse.json(generation)),
|
||||
http.get('*/generate/:id/status', () => {
|
||||
sseConnections += 1;
|
||||
return sse.response();
|
||||
}),
|
||||
// Autoplay is off via settingsHandlers, but keep audio stubbed so a
|
||||
// completion-triggered player fetch could never fail the run loudly.
|
||||
http.get(
|
||||
'*/audio/:id',
|
||||
() =>
|
||||
new HttpResponse(new Blob([new Uint8Array(64)]), {
|
||||
headers: { 'Content-Type': 'audio/wav' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
useUIStore.getState().setSelectedProfileId(profile.id);
|
||||
|
||||
const screen = await renderRoute('/');
|
||||
|
||||
await screen.getByPlaceholder('Generate speech using Ada Lovelace…').fill('Progress please');
|
||||
await screen.getByRole('button', { name: 'Generate speech' }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => useGenerationStore.getState().pendingGenerationIds.has(generation.id))
|
||||
.toBe(true);
|
||||
await expect.poll(() => sseConnections).toBe(1);
|
||||
// Initial mount fetch + post-submit invalidation — wait for both so the
|
||||
// final count increase can only come from the SSE completion refetch.
|
||||
await expect.poll(() => historyGets).toBe(2);
|
||||
|
||||
sse.push({ data: { id: generation.id, status: 'generating' } });
|
||||
sse.push({ data: { id: generation.id, status: 'completed', duration: 1.5 } });
|
||||
|
||||
await expect.poll(() => useGenerationStore.getState().pendingGenerationIds.size).toBe(0);
|
||||
await expect.poll(() => historyGets).toBe(3);
|
||||
sse.close();
|
||||
});
|
||||
|
||||
it('disables the input and generate button when no profile is selected', async () => {
|
||||
stubAppRequests([]);
|
||||
worker.use(...historyHandlers([]));
|
||||
|
||||
const screen = await renderRoute('/');
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole('button', { name: 'Select a voice profile first' }))
|
||||
.toBeDisabled();
|
||||
await expect.element(screen.getByPlaceholder('Select a voice profile above…')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not post to /generate when the text is empty', async () => {
|
||||
const profile = buildProfile({ name: 'Ada Lovelace' });
|
||||
let generateCalls = 0;
|
||||
stubAppRequests([profile]);
|
||||
worker.use(
|
||||
...historyHandlers([]),
|
||||
http.post('*/generate', () => {
|
||||
generateCalls += 1;
|
||||
return HttpResponse.json(buildGeneration());
|
||||
}),
|
||||
);
|
||||
useUIStore.getState().setSelectedProfileId(profile.id);
|
||||
|
||||
const screen = await renderRoute('/');
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Generate speech' });
|
||||
await expect.element(button).toBeEnabled();
|
||||
await button.click();
|
||||
|
||||
// Validation rejects empty text before any request is made — give a
|
||||
// would-be submission ample time to surface, then assert it never did.
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(generateCalls).toBe(0);
|
||||
expect(useGenerationStore.getState().pendingGenerationIds.size).toBe(0);
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useMatchRoute } from '@tanstack/react-router';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Loader2, Sparkles } from 'lucide-react';
|
||||
import { Dices, Loader2, SlidersHorizontal, Sparkles, Wand2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||
import {
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
|
||||
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
||||
@@ -34,11 +36,14 @@ export function FloatingGenerateBox({
|
||||
isPlayerOpen = false,
|
||||
showVoiceSelector = false,
|
||||
}: FloatingGenerateBoxProps) {
|
||||
const { t } = useTranslation();
|
||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
|
||||
const setSelectedEngine = useUIStore((state) => state.setSelectedEngine);
|
||||
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
||||
const { data: profiles } = useProfiles();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isInstructExpanded, setIsInstructExpanded] = useState(false);
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
@@ -48,6 +53,21 @@ export function FloatingGenerateBox({
|
||||
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||
const { data: currentStory } = useStory(selectedStoryId);
|
||||
const addPendingStoryAdd = useGenerationStore((s) => s.addPendingStoryAdd);
|
||||
const { toast } = useToast();
|
||||
|
||||
const composeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedProfileId) throw new Error('No profile selected');
|
||||
return apiClient.composeWithPersonality(selectedProfileId);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
title: t('generation.compose.failedTitle'),
|
||||
description: err.message || t('generation.compose.failedDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch effect presets for the dropdown
|
||||
const { data: effectPresets } = useQuery({
|
||||
@@ -67,7 +87,12 @@ export function FloatingGenerateBox({
|
||||
}
|
||||
},
|
||||
getEffectsChain: () => {
|
||||
if (!selectedPresetId || !effectPresets) return undefined;
|
||||
if (!selectedPresetId) return undefined;
|
||||
// Profile's own effects chain (no matching preset)
|
||||
if (selectedPresetId === '_profile') {
|
||||
return selectedProfile?.effects_chain ?? undefined;
|
||||
}
|
||||
if (!effectPresets) return undefined;
|
||||
const preset = effectPresets.find((p) => p.id === selectedPresetId);
|
||||
return preset?.effects_chain;
|
||||
},
|
||||
@@ -110,12 +135,67 @@ export function FloatingGenerateBox({
|
||||
}
|
||||
}, [selectedProfileId, profiles, setSelectedProfileId]);
|
||||
|
||||
// Sync generation form language with selected profile's language
|
||||
// Sync engine selection to global store so ProfileList can filter
|
||||
const watchedEngine = form.watch('engine');
|
||||
useEffect(() => {
|
||||
if (watchedEngine) {
|
||||
setSelectedEngine(watchedEngine);
|
||||
}
|
||||
}, [watchedEngine, setSelectedEngine]);
|
||||
|
||||
// Sync generation form language, engine, and effects with selected profile
|
||||
type EngineValue =
|
||||
| 'qwen'
|
||||
| 'luxtts'
|
||||
| 'chatterbox'
|
||||
| 'chatterbox_turbo'
|
||||
| 'tada'
|
||||
| 'kokoro'
|
||||
| 'qwen_custom_voice';
|
||||
useEffect(() => {
|
||||
if (selectedProfile?.language) {
|
||||
form.setValue('language', selectedProfile.language as LanguageCode);
|
||||
}
|
||||
}, [selectedProfile, form]);
|
||||
// Auto-switch engine to match the profile
|
||||
const engine = selectedProfile?.default_engine ?? selectedProfile?.preset_engine;
|
||||
if (engine) {
|
||||
form.setValue('engine', engine as EngineValue);
|
||||
} else if (selectedProfile && selectedProfile.voice_type !== 'preset') {
|
||||
// Cloned/designed profile with no default — ensure a compatible (non-preset) engine
|
||||
const currentEngine = form.getValues('engine');
|
||||
const presetEngines = new Set(['kokoro', 'qwen_custom_voice']);
|
||||
if (currentEngine && presetEngines.has(currentEngine)) {
|
||||
form.setValue('engine', 'qwen');
|
||||
}
|
||||
}
|
||||
// Pre-fill effects from profile defaults
|
||||
if (
|
||||
selectedProfile?.effects_chain &&
|
||||
selectedProfile.effects_chain.length > 0 &&
|
||||
effectPresets
|
||||
) {
|
||||
// Try to match against a known preset
|
||||
const profileChainJson = JSON.stringify(selectedProfile.effects_chain);
|
||||
const matchingPreset = effectPresets.find(
|
||||
(p) => JSON.stringify(p.effects_chain) === profileChainJson,
|
||||
);
|
||||
if (matchingPreset) {
|
||||
setSelectedPresetId(matchingPreset.id);
|
||||
} else {
|
||||
// No matching preset — use special value to pass profile chain directly
|
||||
setSelectedPresetId('_profile');
|
||||
}
|
||||
} else if (
|
||||
selectedProfile &&
|
||||
(!selectedProfile.effects_chain || selectedProfile.effects_chain.length === 0)
|
||||
) {
|
||||
setSelectedPresetId(null);
|
||||
}
|
||||
// Persona toggle only applies when the profile has a personality prompt.
|
||||
if (selectedProfile && !selectedProfile.personality?.trim()) {
|
||||
form.setValue('personality', false);
|
||||
}
|
||||
}, [selectedProfile, effectPresets, form]);
|
||||
|
||||
// Auto-resize textarea based on content (only when expanded)
|
||||
useEffect(() => {
|
||||
@@ -175,10 +255,10 @@ export function FloatingGenerateBox({
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'fixed right-auto',
|
||||
'fixed',
|
||||
isStoriesRoute
|
||||
? // Position aligned with story list: after sidebar + padding, width 360px
|
||||
'left-[calc(5rem+2rem)] w-[360px]'
|
||||
? // Aligned with StoryContent: sidebar + list width + gap (tab bleeds with -mx-8)
|
||||
'left-[calc(5rem+360px+1.5rem)] right-8'
|
||||
: 'left-[calc(5rem+2rem)] right-8 lg:right-auto lg:w-[calc((100%-5rem-4rem)/2-1rem)]',
|
||||
)}
|
||||
style={{
|
||||
@@ -218,10 +298,12 @@ export function FloatingGenerateBox({
|
||||
onChange={field.onChange}
|
||||
placeholder={
|
||||
isStoriesRoute && currentStory
|
||||
? `Generate speech for "${currentStory.name}"... (type / for effects)`
|
||||
? t('generation.placeholder.storyWithEffects', {
|
||||
name: currentStory.name,
|
||||
})
|
||||
: selectedProfile
|
||||
? `Type / for effects like [laugh], [sigh]...`
|
||||
: 'Select a voice profile above...'
|
||||
? t('generation.placeholder.effectsHint')
|
||||
: t('generation.placeholder.selectVoice')
|
||||
}
|
||||
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={{
|
||||
@@ -244,10 +326,12 @@ export function FloatingGenerateBox({
|
||||
}}
|
||||
placeholder={
|
||||
isStoriesRoute && currentStory
|
||||
? `Generate speech for "${currentStory.name}"...`
|
||||
? t('generation.placeholder.story', { name: currentStory.name })
|
||||
: selectedProfile
|
||||
? `Generate speech using ${selectedProfile.name}...`
|
||||
: 'Select a voice profile above...'
|
||||
? t('generation.placeholder.profile', {
|
||||
name: selectedProfile.name,
|
||||
})
|
||||
: t('generation.placeholder.selectVoice')
|
||||
}
|
||||
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={{
|
||||
@@ -267,7 +351,129 @@ export function FloatingGenerateBox({
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<div className="relative shrink-0">
|
||||
<div className="flex items-start gap-2 shrink-0">
|
||||
{/* Compose — fills the textarea with a fresh in-character line. */}
|
||||
<AnimatePresence>
|
||||
{selectedProfile?.personality?.trim() && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="group relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={composeMutation.isPending || !selectedProfileId}
|
||||
onClick={async () => {
|
||||
const result = await composeMutation.mutateAsync();
|
||||
form.setValue('text', result.text, { shouldDirty: true });
|
||||
setIsExpanded(true);
|
||||
}}
|
||||
className="h-10 w-10 rounded-full bg-card border border-border hover:bg-background/50 transition-all duration-200"
|
||||
aria-label={t('generation.compose.ariaLabel')}
|
||||
>
|
||||
{composeMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Dices 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]">
|
||||
{t('generation.compose.tooltip')}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Persona — rewrite input through the profile's personality LLM before TTS. */}
|
||||
<AnimatePresence>
|
||||
{selectedProfile?.personality?.trim() && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="personality"
|
||||
render={({ field }) => {
|
||||
const active = !!field.value;
|
||||
return (
|
||||
<FormItem className="space-y-0">
|
||||
<FormControl>
|
||||
<div className="group relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => field.onChange(!active)}
|
||||
className={cn(
|
||||
'h-10 w-10 rounded-full transition-all duration-200',
|
||||
active
|
||||
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
|
||||
: 'bg-card border border-border hover:bg-background/50',
|
||||
)}
|
||||
aria-label={active ? t('generation.persona.ariaLabelActive') : t('generation.persona.ariaLabelInactive')}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<Wand2 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]">
|
||||
{active ? t('generation.persona.tooltipActive') : t('generation.persona.tooltipInactive')}
|
||||
</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Instruct toggle — only for Qwen CustomVoice, which actually honors the kwarg */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && form.watch('engine') === 'qwen_custom_voice' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="group relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsInstructExpanded((prev) => !prev)}
|
||||
className={cn(
|
||||
'h-10 w-10 rounded-full transition-all duration-200',
|
||||
isInstructExpanded
|
||||
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
|
||||
: 'bg-card border border-border hover:bg-background/50',
|
||||
)}
|
||||
aria-label={
|
||||
isInstructExpanded
|
||||
? t('generation.instruct.hide')
|
||||
: t('generation.instruct.show')
|
||||
}
|
||||
aria-pressed={isInstructExpanded}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
|
||||
{t('generation.instruct.tooltip')}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="group relative">
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -276,10 +482,10 @@ export function FloatingGenerateBox({
|
||||
size="icon"
|
||||
aria-label={
|
||||
isPending
|
||||
? 'Generating...'
|
||||
? t('generation.button.generating')
|
||||
: !selectedProfileId
|
||||
? 'Select a voice profile first'
|
||||
: 'Generate speech'
|
||||
? t('generation.button.selectFirst')
|
||||
: t('generation.button.generate')
|
||||
}
|
||||
>
|
||||
{isPending ? (
|
||||
@@ -290,15 +496,47 @@ export function FloatingGenerateBox({
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
|
||||
{isPending
|
||||
? 'Generating...'
|
||||
? t('generation.button.generating')
|
||||
: !selectedProfileId
|
||||
? 'Select a voice profile first'
|
||||
: 'Generate speech'}
|
||||
? t('generation.button.selectFirst')
|
||||
: t('generation.button.generate')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additive instruct textarea — shown below main text when toggle is on and engine supports it */}
|
||||
<AnimatePresence>
|
||||
{isInstructExpanded && form.watch('engine') === 'qwen_custom_voice' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="instruct"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-2">
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder={t('generation.instruct.placeholder')}
|
||||
className="resize-none bg-transparent border border-accent/20 focus-visible:ring-1 focus-visible:ring-accent/40 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full px-3 py-2"
|
||||
style={{ minHeight: '60px', maxHeight: '160px' }}
|
||||
maxLength={500}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
@@ -315,7 +553,7 @@ export function FloatingGenerateBox({
|
||||
onValueChange={(value) => setSelectedProfileId(value || null)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all w-full">
|
||||
<SelectValue placeholder="Select a voice..." />
|
||||
<SelectValue placeholder={t('generation.voiceSelector.placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profiles?.map((profile) => (
|
||||
@@ -328,6 +566,7 @@ export function FloatingGenerateBox({
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
@@ -369,12 +608,18 @@ export function FloatingGenerateBox({
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
||||
<SelectValue placeholder="No effects" />
|
||||
<SelectValue placeholder={t('generation.effects.none')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none" className="text-xs">
|
||||
No effects
|
||||
{t('generation.effects.none')}
|
||||
</SelectItem>
|
||||
{selectedProfile?.effects_chain &&
|
||||
selectedProfile.effects_chain.length > 0 && (
|
||||
<SelectItem value="_profile" className="text-xs">
|
||||
{t('generation.effects.profileDefault')}
|
||||
</SelectItem>
|
||||
)}
|
||||
{effectPresets?.map((preset) => (
|
||||
<SelectItem key={preset.id} value={preset.id} className="text-xs">
|
||||
{preset.name}
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import { Loader2, Mic } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
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 { getLanguageOptionsForEngine } from '@/lib/constants/languages';
|
||||
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
||||
import { useProfile } from '@/lib/hooks/useProfiles';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { EngineModelSelector, getEngineDescription } from './EngineModelSelector';
|
||||
import { ParalinguisticInput } from './ParalinguisticInput';
|
||||
|
||||
export function GenerationForm() {
|
||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
||||
|
||||
const { form, handleSubmit, isPending } = useGenerationForm();
|
||||
|
||||
async function onSubmit(data: Parameters<typeof handleSubmit>[0]) {
|
||||
await handleSubmit(data, selectedProfileId);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Generate Speech</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<FormLabel>Voice Profile</FormLabel>
|
||||
{selectedProfile ? (
|
||||
<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" />
|
||||
<span className="font-medium">{selectedProfile.name}</span>
|
||||
<span className="text-sm text-muted-foreground">{selectedProfile.language}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 p-3 border border-dashed rounded-md text-sm text-muted-foreground">
|
||||
Click on a profile card above to select a voice profile
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="text"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Text to Speak</FormLabel>
|
||||
<FormControl>
|
||||
{form.watch('engine') === 'chatterbox_turbo' ? (
|
||||
<ParalinguisticInput
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="Enter text... type / for effects like [laugh], [sigh]"
|
||||
className="min-h-[150px] rounded-md border border-input bg-background px-3 py-2"
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
placeholder="Enter the text you want to generate..."
|
||||
className="min-h-[150px]"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{form.watch('engine') === 'chatterbox_turbo'
|
||||
? 'Max 5000 characters. Type / to insert sound effects.'
|
||||
: 'Max 5000 characters'}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch('engine') === 'qwen' && (
|
||||
<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>
|
||||
<FormDescription>
|
||||
Natural language instructions to control speech delivery (tone, emotion,
|
||||
pace). Max 500 characters
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<FormItem>
|
||||
<FormLabel>Model</FormLabel>
|
||||
<EngineModelSelector form={form} />
|
||||
<FormDescription>
|
||||
{getEngineDescription(form.watch('engine') || 'qwen')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => {
|
||||
const engineLangs = getLanguageOptionsForEngine(form.watch('engine') || 'qwen');
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Language</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{engineLangs.map((lang) => (
|
||||
<SelectItem key={lang.value} value={lang.value}>
|
||||
{lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="seed"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Seed (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Random"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(e.target.value ? parseInt(e.target.value, 10) : undefined)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>For reproducible results</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isPending || !selectedProfileId}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
'Generate Speech'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { HistoryTable } from '@/components/History/HistoryTable';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { buildHistoryItem } from '@/test/msw/fixtures';
|
||||
import { historyHandlers } from '@/test/msw/handlers';
|
||||
import { worker } from '@/test/msw/worker';
|
||||
import { renderWithProviders } from '@/test/render';
|
||||
|
||||
it('renders history rows with profile names and transcripts', async () => {
|
||||
const ada = buildHistoryItem({
|
||||
profile_name: 'Ada Lovelace',
|
||||
text: 'The analytical engine speaks.',
|
||||
});
|
||||
const grace = buildHistoryItem({
|
||||
profile_name: 'Grace Hopper',
|
||||
text: 'A compiler for the spoken word.',
|
||||
});
|
||||
worker.use(...historyHandlers([ada, grace]));
|
||||
|
||||
const screen = await renderWithProviders(<HistoryTable />);
|
||||
|
||||
await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
|
||||
await expect.element(screen.getByText('Grace Hopper')).toBeVisible();
|
||||
await expect
|
||||
.element(screen.getByRole('textbox', { name: /Transcript for sample from Ada Lovelace/ }))
|
||||
.toHaveValue('The analytical engine speaks.');
|
||||
await expect
|
||||
.element(screen.getByRole('textbox', { name: /Transcript for sample from Grace Hopper/ }))
|
||||
.toHaveValue('A compiler for the spoken word.');
|
||||
});
|
||||
|
||||
it('shows the empty state when there is no history', async () => {
|
||||
worker.use(...historyHandlers([]));
|
||||
|
||||
const screen = await renderWithProviders(<HistoryTable />);
|
||||
|
||||
await expect.element(screen.getByText('No voice generations', { exact: false })).toBeVisible();
|
||||
});
|
||||
|
||||
it('loads a clicked row into the player store with auto-play intent', async () => {
|
||||
const item = buildHistoryItem({ profile_name: 'Ada Lovelace', text: 'Play me back.' });
|
||||
worker.use(...historyHandlers([item]));
|
||||
|
||||
const screen = await renderWithProviders(<HistoryTable />);
|
||||
|
||||
// Click the profile-name cell — the row's mousedown handler ignores clicks
|
||||
// that land on the transcript textarea.
|
||||
await screen.getByText('Ada Lovelace').click();
|
||||
|
||||
await expect.poll(() => usePlayerStore.getState().audioId).toBe(item.id);
|
||||
const player = usePlayerStore.getState();
|
||||
expect(player.audioUrl).toContain(`/audio/${item.id}`);
|
||||
expect(player.profileId).toBe(item.profile_id);
|
||||
expect(player.shouldAutoPlay).toBe(true);
|
||||
// isPlaying flips only once the AudioPlayer (not mounted here) starts playback.
|
||||
expect(player.isPlaying).toBe(false);
|
||||
});
|
||||
|
||||
it('toggles favorite via POST and reflects the refetched state', async () => {
|
||||
const item = buildHistoryItem({ profile_name: 'Ada Lovelace' });
|
||||
let favorited = false;
|
||||
const favoriteRequests: string[] = [];
|
||||
worker.use(
|
||||
http.get('*/history', () =>
|
||||
HttpResponse.json({ items: [{ ...item, is_favorited: favorited }], total: 1 }),
|
||||
),
|
||||
http.post('*/history/:id/favorite', ({ params }) => {
|
||||
favoriteRequests.push(params.id as string);
|
||||
favorited = true;
|
||||
return HttpResponse.json({ is_favorited: favorited });
|
||||
}),
|
||||
);
|
||||
|
||||
const screen = await renderWithProviders(<HistoryTable />);
|
||||
|
||||
await screen.getByRole('button', { name: 'Favorite' }).click();
|
||||
|
||||
await expect.poll(() => favoriteRequests).toEqual([item.id]);
|
||||
// History was invalidated and refetched — the star now reads as favorited.
|
||||
await expect.element(screen.getByRole('button', { name: 'Unfavorite' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('deletes a generation after confirming the dialog', async () => {
|
||||
const item = buildHistoryItem({ profile_name: 'Ada Lovelace' });
|
||||
let items = [item];
|
||||
const deleteRequests: string[] = [];
|
||||
worker.use(
|
||||
http.get('*/history', () => HttpResponse.json({ items, total: items.length })),
|
||||
http.delete('*/history/:id', ({ params }) => {
|
||||
deleteRequests.push(params.id as string);
|
||||
items = items.filter((i) => i.id !== params.id);
|
||||
return HttpResponse.json({ status: 'deleted' });
|
||||
}),
|
||||
);
|
||||
|
||||
const screen = await renderWithProviders(<HistoryTable />);
|
||||
|
||||
await screen.getByRole('button', { name: 'Actions' }).click();
|
||||
await screen.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await expect.element(screen.getByText('Delete Generation')).toBeVisible();
|
||||
await screen.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expect.poll(() => deleteRequests).toEqual([item.id]);
|
||||
// The refetched (now empty) list replaces the row.
|
||||
await expect.element(screen.getByText('No voice generations', { exact: false })).toBeVisible();
|
||||
});
|
||||
|
||||
it('exports audio through platform.filesystem.saveFile', async () => {
|
||||
const item = buildHistoryItem({ profile_name: 'Ada Lovelace', text: 'Export me please' });
|
||||
worker.use(
|
||||
...historyHandlers([item]),
|
||||
http.get(
|
||||
'*/history/:id/export-audio',
|
||||
() =>
|
||||
new HttpResponse(new Blob([new Uint8Array(64)]), {
|
||||
headers: { 'Content-Type': 'audio/wav' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const screen = await renderWithProviders(<HistoryTable />);
|
||||
|
||||
await screen.getByRole('button', { name: 'Actions' }).click();
|
||||
await screen.getByRole('menuitem', { name: 'Export Audio' }).click();
|
||||
|
||||
const saveFile = vi.mocked(screen.platform.filesystem.saveFile);
|
||||
await expect.poll(() => saveFile.mock.calls.length).toBe(1);
|
||||
const [filename, blob, filters] = saveFile.mock.calls[0];
|
||||
expect(filename).toBe('export-me-please.wav');
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
expect(filters).toEqual([{ name: 'Audio File', extensions: ['wav'] }]);
|
||||
});
|
||||
@@ -1,21 +1,22 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
AlignCenter,
|
||||
AudioLines,
|
||||
AudioWaveform,
|
||||
Download,
|
||||
FileArchive,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Square,
|
||||
Star,
|
||||
Trash2,
|
||||
Wand2,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { AudioBars } from '@/components/AudioBars';
|
||||
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -45,6 +46,7 @@ import { apiClient } from '@/lib/api/client';
|
||||
import type { EffectConfig, GenerationVersionResponse, HistoryResponse } from '@/lib/api/types';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import {
|
||||
useClearFailedGenerations,
|
||||
useDeleteGeneration,
|
||||
useExportGeneration,
|
||||
useExportGenerationAudio,
|
||||
@@ -56,38 +58,8 @@ import { formatDate, formatDuration, formatEngineName } from '@/lib/utils/format
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
|
||||
// ─── Audio Bars ─────────────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
export function HistoryTable() {
|
||||
const { t } = useTranslation();
|
||||
const [page, setPage] = useState(0);
|
||||
const [allHistory, setAllHistory] = useState<HistoryResponse[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -124,9 +96,28 @@ export function HistoryTable() {
|
||||
});
|
||||
|
||||
const deleteGeneration = useDeleteGeneration();
|
||||
const clearFailed = useClearFailedGenerations();
|
||||
const [clearFailedDialogOpen, setClearFailedDialogOpen] = useState(false);
|
||||
const exportGeneration = useExportGeneration();
|
||||
const exportGenerationAudio = useExportGenerationAudio();
|
||||
const importGeneration = useImportGeneration();
|
||||
const cancelGeneration = useMutation({
|
||||
mutationFn: (generationId: string) => apiClient.cancelGeneration(generationId),
|
||||
onSuccess: async (data) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||
toast({
|
||||
title: 'Cancelling generation',
|
||||
description: data.message,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Cancel failed',
|
||||
description: error instanceof Error ? error.message : 'Could not cancel generation',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
|
||||
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
||||
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
|
||||
@@ -157,11 +148,11 @@ export function HistoryTable() {
|
||||
const pendingCount = useGenerationStore((state) => state.pendingGenerationIds.size);
|
||||
const prevPendingCountRef = useRef(pendingCount);
|
||||
useEffect(() => {
|
||||
if (deleteGeneration.isSuccess || importGeneration.isSuccess) {
|
||||
if (deleteGeneration.isSuccess || importGeneration.isSuccess || clearFailed.isSuccess) {
|
||||
setPage(0);
|
||||
setAllHistory([]);
|
||||
}
|
||||
}, [deleteGeneration.isSuccess, importGeneration.isSuccess]);
|
||||
}, [deleteGeneration.isSuccess, importGeneration.isSuccess, clearFailed.isSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
// A generation finished (pending count decreased) — scroll back to show it
|
||||
@@ -415,15 +406,53 @@ export function HistoryTable() {
|
||||
|
||||
const history = allHistory;
|
||||
const hasMore = allHistory.length < total;
|
||||
const failedCount = history.filter((g) => g.status === 'failed').length;
|
||||
|
||||
const handleClearFailedConfirm = () => {
|
||||
clearFailed.mutate(undefined, {
|
||||
onSuccess: (data) => {
|
||||
setClearFailedDialogOpen(false);
|
||||
toast({
|
||||
title: 'Cleared failed generations',
|
||||
description: `${data.deleted} failed ${data.deleted === 1 ? 'generation' : 'generations'} removed.`,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setClearFailedDialogOpen(false);
|
||||
toast({
|
||||
title: 'Failed to clear',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 relative">
|
||||
{history.length === 0 ? (
|
||||
<div className="text-center py-12 px-5 border-2 border-dashed mb-5 border-muted rounded-md text-muted-foreground flex-1 flex items-center justify-center">
|
||||
No voice generations, yet...
|
||||
{t('history.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{failedCount > 0 && (
|
||||
<div className="flex items-center justify-between px-1 pb-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{failedCount} failed {failedCount === 1 ? 'generation' : 'generations'}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-muted-foreground"
|
||||
onClick={() => setClearFailedDialogOpen(true)}
|
||||
disabled={clearFailed.isPending}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-1.5" />
|
||||
{clearFailed.isPending ? 'Clearing...' : 'Clear failed'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isScrolled && (
|
||||
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
)}
|
||||
@@ -442,6 +471,8 @@ export function HistoryTable() {
|
||||
const isPlayable = !isGenerating && !isFailed;
|
||||
const hasVersions = gen.versions && gen.versions.length > 1;
|
||||
const isVersionsExpanded = expandedVersionsId === gen.id;
|
||||
const isCancelling =
|
||||
cancelGeneration.isPending && cancelGeneration.variables === gen.id;
|
||||
return (
|
||||
<div
|
||||
key={gen.id}
|
||||
@@ -569,69 +600,93 @@ export function HistoryTable() {
|
||||
)}
|
||||
|
||||
{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>
|
||||
<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="Delete generation"
|
||||
disabled={deleteGeneration.isPending}
|
||||
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
||||
>
|
||||
<Trash2 className="h-2 w-2" />
|
||||
</Button>
|
||||
</>
|
||||
) : isGenerating ? (
|
||||
<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)}
|
||||
aria-label="Cancel generation"
|
||||
disabled={isCancelling}
|
||||
onClick={() => cancelGeneration.mutate(gen.id)}
|
||||
>
|
||||
<RotateCcw className="h-2 w-2" />
|
||||
{isCancelling ? (
|
||||
<Loader2 className="h-2 w-2 animate-spin" />
|
||||
) : (
|
||||
<Square 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>
|
||||
</>
|
||||
<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={t('history.actions.menu')}
|
||||
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" />
|
||||
{t('history.actions.play')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDownloadAudio(gen.id, gen.text)}
|
||||
disabled={exportGenerationAudio.isPending}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('history.actions.exportAudio')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExportPackage(gen.id, gen.text)}
|
||||
disabled={exportGeneration.isPending}
|
||||
>
|
||||
<FileArchive className="mr-2 h-4 w-4" />
|
||||
{t('history.actions.exportPackage')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleApplyEffects(gen.id)}>
|
||||
<Wand2 className="mr-2 h-4 w-4" />
|
||||
{t('history.actions.applyEffects')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleRegenerate(gen.id)}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t('history.actions.regenerate')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
||||
disabled={deleteGeneration.isPending}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t('common.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -720,10 +775,9 @@ export function HistoryTable() {
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Generation</DialogTitle>
|
||||
<DialogTitle>{t('history.deleteDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this generation from "{generationToDelete?.name}"?
|
||||
This action cannot be undone.
|
||||
{t('history.deleteDialog.body', { name: generationToDelete?.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -734,14 +788,39 @@ export function HistoryTable() {
|
||||
setGenerationToDelete(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteGeneration.isPending}
|
||||
>
|
||||
{deleteGeneration.isPending ? 'Deleting...' : 'Delete'}
|
||||
{deleteGeneration.isPending ? t('history.deleteDialog.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={clearFailedDialogOpen} onOpenChange={setClearFailedDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('history.clearFailedDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('history.clearFailedDialog.body', { count: failedCount })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setClearFailedDialogOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleClearFailedConfirm}
|
||||
disabled={clearFailed.isPending}
|
||||
>
|
||||
{clearFailed.isPending
|
||||
? t('history.clearFailedDialog.clearing')
|
||||
: t('history.clearFailedDialog.clearAll')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -750,9 +829,9 @@ export function HistoryTable() {
|
||||
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Generation</DialogTitle>
|
||||
<DialogTitle>{t('history.importDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Import the generation from "{selectedFile?.name}". This will add it to your history.
|
||||
{t('history.importDialog.body', { name: selectedFile?.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -766,13 +845,15 @@ export function HistoryTable() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImportConfirm}
|
||||
disabled={importGeneration.isPending || !selectedFile}
|
||||
>
|
||||
{importGeneration.isPending ? 'Importing...' : 'Import'}
|
||||
{importGeneration.isPending
|
||||
? t('history.importDialog.importing')
|
||||
: t('history.importDialog.action')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -781,21 +862,20 @@ export function HistoryTable() {
|
||||
<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>
|
||||
<DialogTitle>{t('history.effectsDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('history.effectsDialog.body')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{effectsTargetVersions.length > 1 && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Source</label>
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t('history.effectsDialog.sourceLabel')}
|
||||
</label>
|
||||
<Select
|
||||
value={effectsSourceVersionId ?? ''}
|
||||
onValueChange={(val) => setEffectsSourceVersionId(val || null)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Select source version" />
|
||||
<SelectValue placeholder={t('history.effectsDialog.sourcePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{effectsTargetVersions.map((v) => (
|
||||
@@ -817,13 +897,15 @@ export function HistoryTable() {
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEffectsDialogOpen(false)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApplyEffectsConfirm}
|
||||
disabled={applyingEffects || effectsChain.length === 0}
|
||||
>
|
||||
{applyingEffects ? 'Applying...' : 'Apply'}
|
||||
{applyingEffects
|
||||
? t('history.effectsDialog.applying')
|
||||
: t('history.effectsDialog.apply')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { AlertTriangle, ExternalLink } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
/**
|
||||
* Tracks macOS Input Monitoring permission state. Without it, `rdev::listen`
|
||||
* sees no key events and the chord engine never fires — but neither does
|
||||
* anything error-out visibly, so we surface an inline prompt next to the
|
||||
* hotkey toggle instead of leaving the user wondering why the shortcut is
|
||||
* dead.
|
||||
*
|
||||
* Re-checked on mount and on window focus (cheap way to pick up the user
|
||||
* flipping the toggle in System Settings and alt-tabbing back).
|
||||
*/
|
||||
export function useInputMonitoringPermission() {
|
||||
const platform = usePlatform();
|
||||
const [needsPermission, setNeedsPermission] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
const recheck = useCallback(async (): Promise<boolean> => {
|
||||
if (!platform.metadata.isTauri) return true;
|
||||
setChecking(true);
|
||||
try {
|
||||
const trusted = await invoke<boolean>('check_input_monitoring_permission');
|
||||
setNeedsPermission(!trusted);
|
||||
return trusted;
|
||||
} catch (err) {
|
||||
console.warn('[input-monitoring] check failed:', err);
|
||||
return false;
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}, [platform.metadata.isTauri]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!platform.metadata.isTauri) return;
|
||||
recheck();
|
||||
const onFocus = () => {
|
||||
recheck();
|
||||
};
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => window.removeEventListener('focus', onFocus);
|
||||
}, [platform.metadata.isTauri, recheck]);
|
||||
|
||||
const openSettings = useCallback(async () => {
|
||||
try {
|
||||
await invoke('open_input_monitoring_settings');
|
||||
} catch (err) {
|
||||
console.warn('[input-monitoring] open settings failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { needsPermission, checking, recheck, openSettings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline notice rendered under the global-shortcut toggle when the user has
|
||||
* opted in but macOS Input Monitoring is not granted. Returns null when the
|
||||
* permission is present (or when the toggle is off and the notice would just
|
||||
* be noise).
|
||||
*/
|
||||
export function InputMonitoringNotice({ enabled }: { enabled: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { needsPermission, checking, recheck, openSettings } =
|
||||
useInputMonitoringPermission();
|
||||
const [stillMissing, setStillMissing] = useState(false);
|
||||
|
||||
const handleRecheck = useCallback(async () => {
|
||||
setStillMissing(false);
|
||||
const trusted = await recheck();
|
||||
if (!trusted) setStillMissing(true);
|
||||
}, [recheck]);
|
||||
|
||||
if (!enabled || !needsPermission) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3.5 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-amber-500" />
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t('captures.permissions.inputMonitoring.title')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans i18nKey="captures.permissions.inputMonitoring.body" components={{ path: <span /> }} />
|
||||
</p>
|
||||
<div className="flex items-center gap-2 pt-1.5">
|
||||
<Button size="sm" onClick={openSettings} className="gap-1.5">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t('captures.permissions.inputMonitoring.openSettings')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleRecheck} disabled={checking}>
|
||||
{checking ? t('captures.permissions.inputMonitoring.rechecking') : t('captures.permissions.inputMonitoring.recheck')}
|
||||
</Button>
|
||||
</div>
|
||||
{stillMissing && !checking && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 pt-1">
|
||||
{t('captures.permissions.inputMonitoring.stillMissing')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface ListPaneProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ListPane({ className, children }: ListPaneProps) {
|
||||
return (
|
||||
<div className={cn('h-full flex flex-col relative overflow-hidden', className)}>
|
||||
<div
|
||||
className="absolute top-0 right-0 bottom-0 w-px bg-border pointer-events-none z-30"
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to bottom, transparent 0, black 50px)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, transparent 0, black 50px)',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListPaneHeaderProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ListPaneHeader({ className, children }: ListPaneHeaderProps) {
|
||||
return (
|
||||
<div className={cn('absolute top-0 left-0 right-0 z-20 px-4', className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListPaneTitleRowProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ListPaneTitleRow({ className, children }: ListPaneTitleRowProps) {
|
||||
return <div className={cn('flex items-center mb-2', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
interface ListPaneTitleProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ListPaneTitle({ className, children }: ListPaneTitleProps) {
|
||||
return <h2 className={cn('text-2xl px-4 font-bold truncate', className)}>{children}</h2>;
|
||||
}
|
||||
|
||||
interface ListPaneActionsProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ListPaneActions({ className, children }: ListPaneActionsProps) {
|
||||
return <div className={cn('ml-auto flex items-center gap-2', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
interface ListPaneSearchProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ListPaneSearch({ value, onChange, placeholder, className }: ListPaneSearchProps) {
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListPaneScrollProps {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ListPaneScroll({ className, style, children }: ListPaneScrollProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex-1 overflow-y-auto overflow-x-hidden pt-24', className)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Sparkles, Upload } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
||||
import { HistoryTable } from '@/components/History/HistoryTable';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -20,6 +21,7 @@ import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
|
||||
export function MainEditor() {
|
||||
const { t } = useTranslation();
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
const isPlayerVisible = !!audioUrl;
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -39,8 +41,8 @@ export function MainEditor() {
|
||||
if (file) {
|
||||
if (!file.name.endsWith('.voicebox.zip')) {
|
||||
toast({
|
||||
title: 'Invalid file type',
|
||||
description: 'Please select a valid .voicebox.zip file',
|
||||
title: t('main.import.invalidTitle'),
|
||||
description: t('main.import.invalidDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -60,13 +62,13 @@ export function MainEditor() {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
toast({
|
||||
title: 'Profile imported',
|
||||
description: 'Voice profile imported successfully',
|
||||
title: t('main.import.successTitle'),
|
||||
description: t('main.import.successDescription'),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to import profile',
|
||||
title: t('main.import.failedTitle'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -76,21 +78,17 @@ export function MainEditor() {
|
||||
};
|
||||
|
||||
return (
|
||||
// Main view: Profiles top left, Generator bottom left, History right
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-6 h-full min-h-0 overflow-hidden relative">
|
||||
{/* Left Column */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden relative lg:overflow-hidden">
|
||||
{/* Scroll Mask - Always visible, behind content */}
|
||||
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />
|
||||
|
||||
{/* Fixed Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-10">
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<h2 className="text-2xl font-bold">Voicebox</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleImportClick}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Import Voice
|
||||
{t('main.importVoice')}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -101,13 +99,12 @@ export function MainEditor() {
|
||||
/>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Create Voice
|
||||
{t('main.createVoice')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn('flex-1 min-h-0 overflow-y-auto pt-14 pb-4', isPlayerVisible && 'lg:pb-32')}
|
||||
@@ -120,25 +117,18 @@ export function MainEditor() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider - single column only */}
|
||||
{/* <div className="border-t border-border -my-3 lg:hidden" /> */}
|
||||
|
||||
{/* Right Column - History */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||
<HistoryTable />
|
||||
</div>
|
||||
|
||||
{/* Floating Generate Box */}
|
||||
<FloatingGenerateBox isPlayerOpen={!!audioUrl} />
|
||||
|
||||
{/* Import Dialog */}
|
||||
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Profile</DialogTitle>
|
||||
<DialogTitle>{t('main.import.dialogTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Import the profile from "{selectedFile?.name}". This will create a new profile with
|
||||
all samples.
|
||||
{t('main.import.dialogDescription', { name: selectedFile?.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -152,13 +142,13 @@ export function MainEditor() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImportConfirm}
|
||||
disabled={importProfile.isPending || !selectedFile}
|
||||
>
|
||||
{importProfile.isPending ? 'Importing...' : 'Import'}
|
||||
{importProfile.isPending ? t('main.import.importing') : t('main.import.action')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ 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 type { CudaDownloadProgress, RocmDownloadProgress } from '@/lib/api/types';
|
||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
@@ -21,6 +21,9 @@ export function GpuAcceleration() {
|
||||
const [restartPhase, setRestartPhase] = useState<RestartPhase>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [downloadProgress, setDownloadProgress] = useState<CudaDownloadProgress | null>(null);
|
||||
const [rocmDownloadProgress, setRocmDownloadProgress] = useState<RocmDownloadProgress | null>(
|
||||
null,
|
||||
);
|
||||
const healthPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Query CUDA backend status
|
||||
@@ -36,10 +39,26 @@ export function GpuAcceleration() {
|
||||
enabled: !!health, // Only fetch when backend is reachable
|
||||
});
|
||||
|
||||
// Query ROCm backend status
|
||||
const {
|
||||
data: rocmStatus,
|
||||
isLoading: _rocmStatusLoading,
|
||||
refetch: refetchRocmStatus,
|
||||
} = useQuery({
|
||||
queryKey: ['rocm-status', serverUrl],
|
||||
queryFn: () => apiClient.getRocmStatus(),
|
||||
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 isCurrentlyRocm = health?.backend_variant === 'rocm';
|
||||
const cudaAvailable = cudaStatus?.available ?? false;
|
||||
const cudaDownloading = cudaStatus?.downloading ?? false;
|
||||
const rocmAvailable = rocmStatus?.available ?? false;
|
||||
const rocmDownloading = rocmStatus?.downloading ?? false;
|
||||
|
||||
// Clean up health poll on unmount
|
||||
useEffect(() => {
|
||||
@@ -51,7 +70,7 @@ export function GpuAcceleration() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// SSE progress tracking during download
|
||||
// SSE progress tracking during CUDA download
|
||||
useEffect(() => {
|
||||
if (!cudaDownloading || !serverUrl) {
|
||||
return;
|
||||
@@ -88,6 +107,43 @@ export function GpuAcceleration() {
|
||||
};
|
||||
}, [cudaDownloading, serverUrl, refetchCudaStatus]);
|
||||
|
||||
// SSE progress tracking during ROCm download
|
||||
useEffect(() => {
|
||||
if (!rocmDownloading || !serverUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventSource = new EventSource(`${serverUrl}/backend/rocm-progress`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as RocmDownloadProgress;
|
||||
setRocmDownloadProgress(data);
|
||||
|
||||
if (data.status === 'complete') {
|
||||
eventSource.close();
|
||||
setRocmDownloadProgress(null);
|
||||
refetchRocmStatus();
|
||||
} else if (data.status === 'error') {
|
||||
eventSource.close();
|
||||
setError(data.error || 'Download failed');
|
||||
setRocmDownloadProgress(null);
|
||||
refetchRocmStatus();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing ROCm progress event:', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [rocmDownloading, serverUrl, refetchRocmStatus]);
|
||||
|
||||
// Start aggressive health polling during restart
|
||||
const startHealthPolling = useCallback(() => {
|
||||
if (healthPollRef.current) return;
|
||||
@@ -113,7 +169,7 @@ export function GpuAcceleration() {
|
||||
}, 1000);
|
||||
}, [queryClient]);
|
||||
|
||||
const handleDownload = async () => {
|
||||
const handleDownloadCuda = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.downloadCudaBackend();
|
||||
@@ -128,6 +184,21 @@ export function GpuAcceleration() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadRocm = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.downloadRocmBackend();
|
||||
refetchRocmStatus();
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to start download';
|
||||
if (msg.includes('already downloaded')) {
|
||||
refetchRocmStatus();
|
||||
} else {
|
||||
setError(msg);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = async () => {
|
||||
setError(null);
|
||||
setRestartPhase('stopping');
|
||||
@@ -154,18 +225,17 @@ export function GpuAcceleration() {
|
||||
}
|
||||
};
|
||||
|
||||
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.
|
||||
const handleSwitchToCpuFromCuda = async () => {
|
||||
setError(null);
|
||||
setRestartPhase('stopping');
|
||||
|
||||
try {
|
||||
await apiClient.deleteCudaBackend();
|
||||
// Tell Rust launcher to skip GPU binary detection on next start.
|
||||
// We cannot delete an active .exe on Windows, so we override instead.
|
||||
await platform.lifecycle.setBackendOverride('cpu');
|
||||
setRestartPhase('waiting');
|
||||
startHealthPolling();
|
||||
await platform.lifecycle.restartServer();
|
||||
// Invoke resolved — server is likely ready
|
||||
if (healthPollRef.current) {
|
||||
clearInterval(healthPollRef.current);
|
||||
healthPollRef.current = null;
|
||||
@@ -184,7 +254,36 @@ export function GpuAcceleration() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const handleSwitchToCpuFromRocm = async () => {
|
||||
setError(null);
|
||||
setRestartPhase('stopping');
|
||||
|
||||
try {
|
||||
// Tell Rust launcher to skip GPU binary detection on next start.
|
||||
// We cannot delete an active .exe on Windows, so we override instead.
|
||||
await platform.lifecycle.setBackendOverride('cpu');
|
||||
setRestartPhase('waiting');
|
||||
startHealthPolling();
|
||||
await platform.lifecycle.restartServer();
|
||||
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');
|
||||
refetchRocmStatus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCuda = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.deleteCudaBackend();
|
||||
@@ -194,6 +293,16 @@ export function GpuAcceleration() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRocm = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.deleteRocmBackend();
|
||||
refetchRocmStatus();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to delete ROCm backend');
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
@@ -205,7 +314,7 @@ export function GpuAcceleration() {
|
||||
// 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
|
||||
// If the system already has native GPU (MPS, ROCm active, etc.), only show info - no download needed
|
||||
const hasNativeGpu =
|
||||
health.gpu_available &&
|
||||
!isCurrentlyCuda &&
|
||||
@@ -241,41 +350,245 @@ export function GpuAcceleration() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Native GPU detected - no CUDA download needed */}
|
||||
|
||||
{/* CUDA download section - only show when no GPU is active (native or CUDA) */}
|
||||
{!hasNativeGpu && !isCurrentlyCuda && (
|
||||
{/* Currently running CUDA - show switch back to CPU */}
|
||||
{isCurrentlyCuda && platform.metadata.isTauri && (
|
||||
<>
|
||||
{/* 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>
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
) : (
|
||||
<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={handleSwitchToCpuFromCuda}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
>
|
||||
<RotateCw className="h-4 w-4 mr-2" />
|
||||
Switch to CPU Backend
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Currently running ROCm - show switch back to CPU */}
|
||||
{isCurrentlyRocm && platform.metadata.isTauri && (
|
||||
<>
|
||||
{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>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Running with ROCm GPU acceleration for AMD. Switch back to CPU if needed (you can
|
||||
re-download later).
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleSwitchToCpuFromRocm}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
>
|
||||
<RotateCw className="h-4 w-4 mr-2" />
|
||||
Switch to CPU Backend
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Backend download/manage sections - show when no native GPU and not currently running GPU */}
|
||||
{!hasNativeGpu && !isCurrentlyCuda && !isCurrentlyRocm && (
|
||||
<>
|
||||
{/* CUDA Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">NVIDIA (CUDA)</div>
|
||||
|
||||
{/* CUDA Download progress */}
|
||||
{cudaDownloading && downloadProgress && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>
|
||||
{downloadProgress.filename ||
|
||||
(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>
|
||||
)}
|
||||
|
||||
{/* CUDA Actions */}
|
||||
{restartPhase === 'idle' && !cudaDownloading && (
|
||||
<div className="space-y-2">
|
||||
{!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={handleDownloadCuda} className="w-full" size="sm">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download CUDA Backend
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cudaAvailable && 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>
|
||||
)}
|
||||
|
||||
{cudaAvailable && (
|
||||
<Button
|
||||
onClick={handleDeleteCuda}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t" />
|
||||
|
||||
{/* ROCm Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">AMD (ROCm)</div>
|
||||
|
||||
{/* ROCm Download progress */}
|
||||
{rocmDownloading && rocmDownloadProgress && (
|
||||
<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>
|
||||
{rocmDownloadProgress.filename ||
|
||||
(rocmAvailable
|
||||
? 'Updating ROCm backend...'
|
||||
: 'Downloading ROCm backend...')}
|
||||
</span>
|
||||
</div>
|
||||
{rocmDownloadProgress.total > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
{rocmDownloadProgress.progress.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{rocmDownloadProgress.total > 0 && (
|
||||
<>
|
||||
<Progress value={rocmDownloadProgress.progress} className="h-2" />
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatBytes(rocmDownloadProgress.current)} /{' '}
|
||||
{formatBytes(rocmDownloadProgress.total)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ROCm Actions */}
|
||||
{restartPhase === 'idle' && !rocmDownloading && (
|
||||
<div className="space-y-2">
|
||||
{!rocmAvailable && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Download the ROCm backend (~2-3 GB) for AMD GPU acceleration. Requires an
|
||||
AMD Radeon GPU with ROCm support.
|
||||
</p>
|
||||
<Button onClick={handleDownloadRocm} className="w-full" size="sm">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download AMD ROCm Backend
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rocmAvailable && platform.metadata.isTauri && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
ROCm backend is downloaded and ready. Restart the server to enable AMD GPU
|
||||
acceleration.
|
||||
</p>
|
||||
<Button onClick={handleRestart} className="w-full" size="sm">
|
||||
<RotateCw className="h-4 w-4 mr-2" />
|
||||
Switch to ROCm Backend
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rocmAvailable && (
|
||||
<Button
|
||||
onClick={handleDeleteRocm}
|
||||
variant="ghost"
|
||||
className="w-full text-muted-foreground hover:text-destructive"
|
||||
size="sm"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Remove ROCm Backend
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Restart in progress */}
|
||||
{restartPhase !== 'idle' && (
|
||||
@@ -296,71 +609,6 @@ export function GpuAcceleration() {
|
||||
<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>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -62,6 +63,16 @@ const MODEL_DESCRIPTIONS: Record<string, string> = {
|
||||
'Production-grade open source TTS by Resemble AI. Supports 23 languages with voice cloning and emotion exaggeration control.',
|
||||
'chatterbox-turbo':
|
||||
'Streamlined 350M parameter TTS by Resemble AI. High-quality English speech with less compute and VRAM than larger models.',
|
||||
'tada-1b':
|
||||
'HumeAI TADA 1B — English speech-language model built on Llama 3.2 1B. Generates 700s+ of coherent audio with synchronized text-acoustic alignment.',
|
||||
'tada-3b-ml':
|
||||
'HumeAI TADA 3B Multilingual — built on Llama 3.2 3B. Supports 10 languages with high-fidelity voice cloning via text-acoustic dual alignment.',
|
||||
kokoro:
|
||||
'Kokoro 82M by hexgrad. Tiny 82M-parameter TTS that runs at CPU realtime. Supports 8 languages with pre-built voice styles. Apache 2.0 licensed.',
|
||||
'qwen-custom-voice-1.7B':
|
||||
'Qwen3-TTS CustomVoice 1.7B by Alibaba. 9 premium preset voices with instruct-based style control for tone, emotion, and prosody. Supports 10 languages.',
|
||||
'qwen-custom-voice-0.6B':
|
||||
'Qwen3-TTS CustomVoice 0.6B by Alibaba. Lightweight version with the same 9 preset voices and instruct control. Faster inference for lower-end hardware.',
|
||||
'whisper-base':
|
||||
'Smallest Whisper model (74M parameters). Fast transcription with moderate accuracy.',
|
||||
'whisper-small':
|
||||
@@ -72,6 +83,12 @@ const MODEL_DESCRIPTIONS: Record<string, string> = {
|
||||
'Whisper Large (1.5B parameters). Best accuracy for speech-to-text across multiple languages.',
|
||||
'whisper-turbo':
|
||||
'Whisper Large v3 Turbo. Pruned for significantly faster inference while maintaining near-large accuracy.',
|
||||
'qwen3-0.6b':
|
||||
'Qwen3 0.6B — smallest of the Qwen3 instruct family. Very fast on CPU, runs at ~400 MB quantized on Apple Silicon. Good for dictation refinement and short completions.',
|
||||
'qwen3-1.7b':
|
||||
'Qwen3 1.7B — balanced size and quality. Handles subtle self-corrections and technical vocabulary better than the 0.6B. Runs at ~1.1 GB quantized on Apple Silicon.',
|
||||
'qwen3-4b':
|
||||
'Qwen3 4B — highest quality local refinement and longer-form reasoning. ~2.5 GB quantized on Apple Silicon, ~8 GB at full precision on PyTorch.',
|
||||
};
|
||||
|
||||
function formatDownloads(n: number): string {
|
||||
@@ -109,6 +126,7 @@ function formatBytes(bytes: number): string {
|
||||
}
|
||||
|
||||
export function ModelManagement() {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const platform = usePlatform();
|
||||
@@ -260,8 +278,8 @@ export function ModelManagement() {
|
||||
setDownloadingModel(null);
|
||||
setDownloadingDisplayName(null);
|
||||
toast({
|
||||
title: 'Download failed',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
title: t('models.toast.downloadFailed'),
|
||||
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -299,8 +317,8 @@ export function ModelManagement() {
|
||||
setDownloadingModel(prevDownloadingModel);
|
||||
setDownloadingDisplayName(prevDownloadingDisplayName);
|
||||
toast({
|
||||
title: 'Cancel failed',
|
||||
description: 'Could not cancel the download task.',
|
||||
title: t('models.toast.cancelFailed'),
|
||||
description: t('models.toast.cancelFailedDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
@@ -326,8 +344,10 @@ export function ModelManagement() {
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast({
|
||||
title: 'Model deleted',
|
||||
description: `${modelToDelete?.displayName || 'Model'} has been deleted successfully.`,
|
||||
title: t('models.toast.deleted'),
|
||||
description: t('models.toast.deletedDescription', {
|
||||
name: modelToDelete?.displayName || t('models.defaultName'),
|
||||
}),
|
||||
});
|
||||
setDeleteDialogOpen(false);
|
||||
setModelToDelete(null);
|
||||
@@ -338,7 +358,7 @@ export function ModelManagement() {
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Delete failed',
|
||||
title: t('models.toast.deleteFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -351,15 +371,15 @@ export function ModelManagement() {
|
||||
},
|
||||
onSuccess: async (_data, modelName) => {
|
||||
toast({
|
||||
title: 'Model unloaded',
|
||||
description: `${modelName} has been unloaded from memory.`,
|
||||
title: t('models.toast.unloaded'),
|
||||
description: t('models.toast.unloadedDescription', { name: modelName }),
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['modelStatus'], refetchType: 'all' });
|
||||
await queryClient.refetchQueries({ queryKey: ['modelStatus'] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Unload failed',
|
||||
title: t('models.toast.unloadFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -367,7 +387,7 @@ export function ModelManagement() {
|
||||
});
|
||||
|
||||
const formatSize = (sizeMb?: number): string => {
|
||||
if (!sizeMb) return 'Unknown size';
|
||||
if (!sizeMb) return t('models.unknownSize');
|
||||
if (sizeMb < 1024) return `${sizeMb.toFixed(1)} MB`;
|
||||
return `${(sizeMb / 1024).toFixed(2)} GB`;
|
||||
};
|
||||
@@ -390,15 +410,20 @@ export function ModelManagement() {
|
||||
modelStatus?.models.filter(
|
||||
(m) =>
|
||||
m.model_name.startsWith('qwen-tts') ||
|
||||
m.model_name.startsWith('qwen-custom-voice') ||
|
||||
m.model_name.startsWith('luxtts') ||
|
||||
m.model_name.startsWith('chatterbox'),
|
||||
m.model_name.startsWith('chatterbox') ||
|
||||
m.model_name.startsWith('tada') ||
|
||||
m.model_name.startsWith('kokoro'),
|
||||
) ?? [];
|
||||
const whisperModels = modelStatus?.models.filter((m) => m.model_name.startsWith('whisper')) ?? [];
|
||||
const llmModels = modelStatus?.models.filter((m) => m.model_name.startsWith('qwen3-')) ?? [];
|
||||
|
||||
// Build sections
|
||||
const sections: { label: string; models: ModelStatus[] }[] = [
|
||||
{ label: 'Voice Generation', models: voiceModels },
|
||||
{ label: 'Transcription', models: whisperModels },
|
||||
{ label: t('models.sections.voiceGeneration'), models: voiceModels },
|
||||
{ label: t('models.sections.transcription'), models: whisperModels },
|
||||
{ label: t('models.sections.languageModels'), models: llmModels },
|
||||
];
|
||||
|
||||
// Get detail modal state for selected model
|
||||
@@ -414,16 +439,14 @@ export function ModelManagement() {
|
||||
// Derive license from HF data
|
||||
const license =
|
||||
hfModelInfo?.cardData?.license ||
|
||||
hfModelInfo?.tags?.find((t) => t.startsWith('license:'))?.replace('license:', '');
|
||||
hfModelInfo?.tags?.find((tag) => tag.startsWith('license:'))?.replace('license:', '');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 pb-4">
|
||||
<h1 className="text-lg font-semibold">Models</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Download and manage AI models for voice generation and transcription
|
||||
</p>
|
||||
<h1 className="text-lg font-semibold">{t('models.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t('models.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Model storage location */}
|
||||
@@ -431,7 +454,7 @@ export function ModelManagement() {
|
||||
<div className="shrink-0 pb-4 border-b mb-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs text-muted-foreground">Storage location</span>
|
||||
<span className="text-xs text-muted-foreground">{t('models.storage.location')}</span>
|
||||
<p
|
||||
className="text-xs font-mono text-muted-foreground/70 truncate"
|
||||
title={cacheDir.path}
|
||||
@@ -448,12 +471,12 @@ export function ModelManagement() {
|
||||
try {
|
||||
await platform.filesystem.openPath(cacheDir.path);
|
||||
} catch {
|
||||
toast({ title: 'Failed to open model folder', variant: 'destructive' });
|
||||
toast({ title: t('models.toast.openFolderFailed'), variant: 'destructive' });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="h-3 w-3" />
|
||||
Open
|
||||
{t('models.storage.open')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -462,12 +485,12 @@ export function ModelManagement() {
|
||||
onClick={async () => {
|
||||
try {
|
||||
const newDir = await platform.filesystem.pickDirectory(
|
||||
'Choose model storage folder',
|
||||
t('models.storage.pickerTitle'),
|
||||
);
|
||||
if (!newDir) return;
|
||||
setPendingMigrateDir(newDir);
|
||||
} catch {
|
||||
toast({ title: 'Failed to open folder picker', variant: 'destructive' });
|
||||
toast({ title: t('models.toast.pickerFailed'), variant: 'destructive' });
|
||||
}
|
||||
}}
|
||||
disabled={migrating}
|
||||
@@ -477,7 +500,7 @@ export function ModelManagement() {
|
||||
) : (
|
||||
<FolderOpen className="h-3 w-3" />
|
||||
)}
|
||||
{migrating ? 'Migrating...' : 'Change'}
|
||||
{migrating ? t('models.storage.migrating') : t('models.storage.change')}
|
||||
</Button>
|
||||
{customModelsDir && (
|
||||
<Button
|
||||
@@ -487,13 +510,13 @@ export function ModelManagement() {
|
||||
disabled={migrating}
|
||||
onClick={async () => {
|
||||
setCustomModelsDir(null);
|
||||
toast({ title: 'Reset to default location. Restarting server...' });
|
||||
toast({ title: t('models.toast.resetToDefault') });
|
||||
await platform.lifecycle.restartServer('');
|
||||
queryClient.invalidateQueries();
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Reset
|
||||
{t('models.storage.reset')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -507,7 +530,7 @@ export function ModelManagement() {
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : modelStatus ? (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-6">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-6 pb-6">
|
||||
{sections.map((section) => (
|
||||
<div key={section.label}>
|
||||
<h2 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">
|
||||
@@ -552,7 +575,7 @@ export function ModelManagement() {
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{hasProgress
|
||||
? `${formatBytes(dl.current ?? 0)} / ${formatBytes(dl.total!)} (${pct.toFixed(0)}%)`
|
||||
: dl?.filename || 'Connecting...'}
|
||||
: dl?.filename || t('models.progress.connecting')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -563,12 +586,12 @@ export function ModelManagement() {
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{hasError && (
|
||||
<Badge variant="destructive" className="text-[10px] h-5">
|
||||
Error
|
||||
{t('common.error')}
|
||||
</Badge>
|
||||
)}
|
||||
{model.loaded && (
|
||||
<Badge className="text-[10px] h-5 bg-accent/15 text-accent border-accent/30 hover:bg-accent/15">
|
||||
Loaded
|
||||
{t('models.status.loaded')}
|
||||
</Badge>
|
||||
)}
|
||||
{model.downloaded && !isDownloading && !hasError && (
|
||||
@@ -600,7 +623,7 @@ export function ModelManagement() {
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>Problems</span>
|
||||
<span>{t('models.problems.title')}</span>
|
||||
<Badge variant="destructive" className="text-[10px] h-4 px-1.5 rounded-full">
|
||||
{errorCount}
|
||||
</Badge>
|
||||
@@ -613,7 +636,7 @@ export function ModelManagement() {
|
||||
disabled={clearAllMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
Clear All
|
||||
{t('models.problems.clearAll')}
|
||||
</Button>
|
||||
</div>
|
||||
{consoleOpen && (
|
||||
@@ -632,13 +655,13 @@ export function ModelManagement() {
|
||||
) : (
|
||||
<>
|
||||
{': '}
|
||||
<span className="text-[#808080]">
|
||||
No error details available. Try downloading again.
|
||||
</span>
|
||||
<span className="text-[#808080]">{t('models.problems.noDetails')}</span>
|
||||
</>
|
||||
)}
|
||||
<div className="text-[#6a9955] mt-0.5">
|
||||
started at {new Date(dl.started_at).toLocaleString()}
|
||||
{t('models.problems.startedAt', {
|
||||
time: new Date(dl.started_at).toLocaleString(),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -679,13 +702,13 @@ export function ModelManagement() {
|
||||
{freshSelectedModel.loaded && (
|
||||
<Badge className="text-xs bg-accent/15 text-accent border-accent/30 hover:bg-accent/15">
|
||||
<CircleCheck className="h-3 w-3 mr-1" />
|
||||
Loaded
|
||||
{t('models.status.loaded')}
|
||||
</Badge>
|
||||
)}
|
||||
{selectedState?.hasError && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<CircleX className="h-3 w-3 mr-1" />
|
||||
Error
|
||||
{t('common.error')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -694,7 +717,7 @@ export function ModelManagement() {
|
||||
{hfLoading && freshSelectedModel.hf_repo_id && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Loading model info...
|
||||
{t('models.detail.loadingInfo')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -721,23 +744,29 @@ export function ModelManagement() {
|
||||
)}
|
||||
{hfModelInfo.author && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
by {hfModelInfo.author}
|
||||
{t('models.detail.byAuthor', { author: hfModelInfo.author })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1" title="Downloads">
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
title={t('models.detail.downloads')}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{formatDownloads(hfModelInfo.downloads)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1" title="Likes">
|
||||
<span className="flex items-center gap-1" title={t('models.detail.likes')}>
|
||||
<Heart className="h-3.5 w-3.5" />
|
||||
{formatDownloads(hfModelInfo.likes)}
|
||||
</span>
|
||||
{license && (
|
||||
<span className="flex items-center gap-1" title="License">
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
title={t('models.detail.license')}
|
||||
>
|
||||
<Scale className="h-3.5 w-3.5" />
|
||||
{formatLicense(license)}
|
||||
</span>
|
||||
@@ -749,8 +778,12 @@ export function ModelManagement() {
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{hfModelInfo.cardData.language.length > 10
|
||||
? `${hfModelInfo.cardData.language.length} languages supported`
|
||||
: `Languages: ${hfModelInfo.cardData.language.join(', ')}`}
|
||||
? t('models.detail.languagesCount', {
|
||||
count: hfModelInfo.cardData.language.length,
|
||||
})
|
||||
: t('models.detail.languagesList', {
|
||||
list: hfModelInfo.cardData.language.join(', '),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -761,7 +794,9 @@ export function ModelManagement() {
|
||||
{freshSelectedModel.downloaded && freshSelectedModel.size_mb && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<HardDrive className="h-3.5 w-3.5" />
|
||||
<span>{formatSize(freshSelectedModel.size_mb)} on disk</span>
|
||||
<span>
|
||||
{t('models.detail.onDisk', { size: formatSize(freshSelectedModel.size_mb) })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -783,7 +818,7 @@ export function ModelManagement() {
|
||||
className="flex-1"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Retry Download
|
||||
{t('models.actions.retry')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -812,7 +847,7 @@ export function ModelManagement() {
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{hasProgress
|
||||
? `${formatBytes(dl.current ?? 0)} / ${formatBytes(dl.total!)} (${pct.toFixed(1)}%)`
|
||||
: dl?.filename || 'Connecting to HuggingFace...'}
|
||||
: dl?.filename || t('models.progress.connectingHf')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -845,7 +880,9 @@ export function ModelManagement() {
|
||||
) : (
|
||||
<Unplug className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{unloadMutation.isPending ? 'Unloading...' : 'Unload'}
|
||||
{unloadMutation.isPending
|
||||
? t('models.actions.unloading')
|
||||
: t('models.actions.unload')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -862,13 +899,13 @@ export function ModelManagement() {
|
||||
disabled={freshSelectedModel.loaded}
|
||||
title={
|
||||
freshSelectedModel.loaded
|
||||
? 'Unload model before deleting'
|
||||
: 'Delete model'
|
||||
? t('models.actions.unloadFirst')
|
||||
: t('models.actions.deleteModel')
|
||||
}
|
||||
className="flex-1"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Model
|
||||
{t('models.actions.deleteModel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -878,7 +915,7 @@ export function ModelManagement() {
|
||||
className="flex-1"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
{t('models.actions.download')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -892,20 +929,23 @@ export function ModelManagement() {
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Model</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('models.deleteDialog.title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete <strong>{modelToDelete?.displayName}</strong>?
|
||||
<Trans
|
||||
i18nKey="models.deleteDialog.body"
|
||||
values={{ name: modelToDelete?.displayName }}
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
{modelToDelete?.sizeMb && (
|
||||
<>
|
||||
{' '}
|
||||
This will free up {formatSize(modelToDelete.sizeMb)} of disk space. The model will
|
||||
need to be re-downloaded if you want to use it again.
|
||||
{t('models.deleteDialog.sizeNote', { size: formatSize(modelToDelete.sizeMb) })}
|
||||
</>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
if (modelToDelete) {
|
||||
@@ -918,10 +958,10 @@ export function ModelManagement() {
|
||||
{deleteMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Deleting...
|
||||
{t('models.deleteDialog.deleting')}
|
||||
</>
|
||||
) : (
|
||||
'Delete'
|
||||
t('common.delete')
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
@@ -935,11 +975,8 @@ export function ModelManagement() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Move models to new location?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The server will shut down while models are being moved to the new folder. It will
|
||||
restart automatically once the migration is complete.
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogTitle>{t('models.migrateDialog.title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t('models.migrateDialog.description')}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div
|
||||
className="text-xs font-mono text-muted-foreground bg-muted/50 rounded px-3 py-2 truncate"
|
||||
@@ -948,7 +985,7 @@ export function ModelManagement() {
|
||||
{pendingMigrateDir}
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
if (!pendingMigrateDir) return;
|
||||
@@ -960,11 +997,23 @@ export function ModelManagement() {
|
||||
total: 0,
|
||||
progress: 0,
|
||||
status: 'downloading',
|
||||
filename: 'Preparing...',
|
||||
filename: t('models.migrateDialog.preparing'),
|
||||
});
|
||||
try {
|
||||
// Start the migration (background task)
|
||||
await apiClient.migrateModels(newDir);
|
||||
const migrationResult = await apiClient.migrateModels(newDir);
|
||||
|
||||
// If no models to migrate, warn user and skip the change
|
||||
if (migrationResult.moved === 0) {
|
||||
setMigrating(false);
|
||||
setMigrationProgress(null);
|
||||
toast({
|
||||
title: t('models.toast.noModelsToMigrate'),
|
||||
description: t('models.toast.noModelsToMigrateDescription'),
|
||||
});
|
||||
setPendingMigrateDir(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect to SSE for progress
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -978,7 +1027,7 @@ export function ModelManagement() {
|
||||
resolve();
|
||||
} else if (data.status === 'error') {
|
||||
es.close();
|
||||
reject(new Error(data.error || 'Migration failed'));
|
||||
reject(new Error(data.error || t('models.toast.migrationFailed')));
|
||||
}
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
@@ -986,7 +1035,7 @@ export function ModelManagement() {
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
reject(new Error('Lost connection during migration'));
|
||||
reject(new Error(t('models.toast.migrationConnectionLost')));
|
||||
};
|
||||
});
|
||||
|
||||
@@ -996,15 +1045,16 @@ export function ModelManagement() {
|
||||
total: 1,
|
||||
progress: 100,
|
||||
status: 'complete',
|
||||
filename: 'Restarting server...',
|
||||
filename: t('models.migrateDialog.restartingServer'),
|
||||
});
|
||||
await platform.lifecycle.restartServer(newDir);
|
||||
queryClient.invalidateQueries();
|
||||
toast({ title: 'Models moved successfully' });
|
||||
toast({ title: t('models.toast.migrated') });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Migration failed',
|
||||
description: e instanceof Error ? e.message : 'Failed to migrate models',
|
||||
title: t('models.toast.migrationFailed'),
|
||||
description:
|
||||
e instanceof Error ? e.message : t('models.toast.migrationFailedGeneric'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
@@ -1013,7 +1063,7 @@ export function ModelManagement() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
Move Models
|
||||
{t('models.migrateDialog.action')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -1025,11 +1075,11 @@ export function ModelManagement() {
|
||||
<div className="w-full max-w-md px-8 space-y-6 text-center">
|
||||
<div className="space-y-2">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold">Moving models</h2>
|
||||
<h2 className="text-lg font-semibold">{t('models.migrate.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{migrationProgress.status === 'complete'
|
||||
? 'Restarting server...'
|
||||
: 'The server is offline while models are being moved.'}
|
||||
? t('models.migrateDialog.restartingServer')
|
||||
: t('models.migrate.offline')}
|
||||
</p>
|
||||
</div>
|
||||
{migrationProgress.total > 0 && (
|
||||
@@ -1050,106 +1100,3 @@ export function ModelManagement() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelItemProps {
|
||||
model: {
|
||||
model_name: string;
|
||||
display_name: string;
|
||||
downloaded: boolean;
|
||||
downloading?: boolean; // From server - true if download in progress
|
||||
size_mb?: number;
|
||||
loaded: boolean;
|
||||
};
|
||||
onDownload: () => void;
|
||||
onDelete: () => void;
|
||||
isDownloading: boolean; // Local state - true if user just clicked download
|
||||
formatSize: (sizeMb?: number) => string;
|
||||
}
|
||||
|
||||
function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: ModelItemProps) {
|
||||
// Use server's downloading state OR local state (for immediate feedback before server updates)
|
||||
const showDownloading = model.downloading || isDownloading;
|
||||
|
||||
const statusText = model.loaded
|
||||
? 'Loaded'
|
||||
: showDownloading
|
||||
? 'Downloading'
|
||||
: model.downloaded
|
||||
? 'Downloaded'
|
||||
: 'Not downloaded';
|
||||
const sizeText =
|
||||
model.downloaded && model.size_mb && !showDownloading ? `, ${formatSize(model.size_mb)}` : '';
|
||||
const rowLabel = `${model.display_name}, ${statusText}${sizeText}. Use Tab to reach Download or Delete.`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
role="group"
|
||||
tabIndex={0}
|
||||
aria-label={rowLabel}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{model.display_name}</span>
|
||||
{model.loaded && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Loaded
|
||||
</Badge>
|
||||
)}
|
||||
{/* Only show Downloaded if actually downloaded AND not downloading */}
|
||||
{model.downloaded && !model.loaded && !showDownloading && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Downloaded
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{model.downloaded && model.size_mb && !showDownloading && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Size: {formatSize(model.size_mb)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{model.downloaded && !showDownloading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<span>Ready</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
variant="outline"
|
||||
disabled={model.loaded}
|
||||
title={model.loaded ? 'Unload model before deleting' : 'Delete model'}
|
||||
aria-label={
|
||||
model.loaded ? 'Unload model before deleting' : `Delete ${model.display_name}`
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : showDownloading ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
aria-label={`${model.display_name} downloading`}
|
||||
>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Downloading...
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onDownload}
|
||||
variant="outline"
|
||||
aria-label={`Download ${model.display_name}`}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ interface ModelProgressProps {
|
||||
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 serverUrl = useServerStore((state) => state.serverUrl);
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { AboutPage } from '@/components/ServerTab/AboutPage';
|
||||
import { createMockPlatform } from '@/test/mockPlatform';
|
||||
import { renderWithProviders } from '@/test/render';
|
||||
|
||||
it('renders and shows the platform version', async () => {
|
||||
const platform = createMockPlatform({
|
||||
metadata: { getVersion: async () => '9.9.9-test', isTauri: false },
|
||||
});
|
||||
|
||||
const screen = await renderWithProviders(<AboutPage />, { platform });
|
||||
|
||||
await expect.element(screen.getByAltText('Voicebox')).toBeVisible();
|
||||
await expect.element(screen.getByText('9.9.9-test', { exact: false })).toBeVisible();
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
@@ -16,6 +17,7 @@ function FadeIn({ delay = 0, children }: { delay?: number; children: ReactNode }
|
||||
}
|
||||
|
||||
export function AboutPage() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const [version, setVersion] = useState('');
|
||||
|
||||
@@ -57,14 +59,13 @@ export function AboutPage() {
|
||||
|
||||
<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.
|
||||
{t('settings.about.tagline')}
|
||||
</p>
|
||||
</FadeIn>
|
||||
|
||||
<FadeIn delay={240}>
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<span>Created by</span>
|
||||
<span>{t('settings.about.createdBy')}</span>
|
||||
<a
|
||||
href="https://github.com/jamiepine"
|
||||
target="_blank"
|
||||
@@ -92,7 +93,7 @@ export function AboutPage() {
|
||||
>
|
||||
<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
|
||||
{t('settings.about.buyCoffee')}
|
||||
<ArrowUpRight className="h-3.5 w-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
||||
</a>
|
||||
<a
|
||||
@@ -115,17 +116,22 @@ export function AboutPage() {
|
||||
</div>
|
||||
</FadeIn>
|
||||
|
||||
<FadeIn delay={400}>
|
||||
<FadeIn delay={480}>
|
||||
<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>
|
||||
<Trans
|
||||
i18nKey="settings.about.license"
|
||||
components={{
|
||||
link: (
|
||||
// biome-ignore lint/a11y/useAnchorContent: Trans fills content at runtime
|
||||
<a
|
||||
href="https://github.com/jamiepine/voicebox/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-muted-foreground/60 transition-colors"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</FadeIn>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
import { Check, ChevronDown, FolderOpen, Info, Keyboard, Laptop, Lock, Volume2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AccessibilityNotice } from '@/components/AccessibilityGate/AccessibilityGate';
|
||||
import { InputMonitoringNotice } from '@/components/InputMonitoringGate/InputMonitoringGate';
|
||||
import { CapturePill, type PillState } from '@/components/CapturePill/CapturePill';
|
||||
import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist';
|
||||
import { ChordPicker } from '@/components/ChordPicker/ChordPicker';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Toggle } from '@/components/ui/toggle';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
|
||||
import { useCaptureSettings } from '@/lib/hooks/useSettings';
|
||||
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { defaultChordKeys, displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
|
||||
import type { Qwen3ModelSize, VoiceProfileResponse, WhisperModelSize } from '@/lib/api/types';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
|
||||
function ChordPreview({ keys }: { keys: string[] }) {
|
||||
const { t } = useTranslation();
|
||||
if (keys.length === 0) {
|
||||
return <span className="text-xs text-muted-foreground italic">{t('captures.chord.notSet')}</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{keys.map((k) => {
|
||||
const side = modifierSideHint(k);
|
||||
return (
|
||||
<span
|
||||
key={k}
|
||||
className="relative inline-flex items-center justify-center h-6 min-w-[1.5rem] px-1.5 rounded-md border border-border bg-muted/60 font-mono text-[11px] font-medium shadow-sm text-foreground"
|
||||
>
|
||||
{displayLabelForKey(k)}
|
||||
{side ? (
|
||||
<span className="absolute -top-1 -right-1 h-3 min-w-[0.75rem] px-0.5 rounded-sm bg-accent text-[7px] font-bold leading-none flex items-center justify-center text-accent-foreground">
|
||||
{side}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isWindows =
|
||||
typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows');
|
||||
|
||||
const PILL_SEQUENCE: PillState[] = ['recording', 'transcribing', 'refining', 'rest'];
|
||||
const PILL_DURATIONS: Partial<Record<PillState, number>> = {
|
||||
recording: 2600,
|
||||
transcribing: 1500,
|
||||
refining: 1500,
|
||||
rest: 900,
|
||||
};
|
||||
|
||||
function HotkeyPillPreview({ enabled }: { enabled: boolean }) {
|
||||
const [state, setState] = useState<PillState>('recording');
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
// Cycle recording → transcribing → refining → rest → …
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => {
|
||||
const next = PILL_SEQUENCE[(PILL_SEQUENCE.indexOf(state) + 1) % PILL_SEQUENCE.length];
|
||||
setState(next);
|
||||
}, PILL_DURATIONS[state] ?? 1000);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [state]);
|
||||
|
||||
// Timer only advances while recording; holds its final value through
|
||||
// transcribing and refining so users see the duration of the clip being
|
||||
// processed.
|
||||
useEffect(() => {
|
||||
if (state !== 'recording') return;
|
||||
setTick(0);
|
||||
const iv = window.setInterval(() => setTick((n) => n + 1), 90);
|
||||
return () => window.clearInterval(iv);
|
||||
}, [state]);
|
||||
|
||||
const elapsedMs = tick * 90;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-xl border overflow-hidden transition-opacity',
|
||||
'bg-muted/30',
|
||||
'aspect-[6/1]',
|
||||
enabled ? 'border-border' : 'border-border/50 opacity-50',
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, hsl(var(--foreground) / 0.06) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, hsl(var(--foreground) / 0.06) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '22px 22px',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<CapturePill state={state} elapsedMs={elapsedMs} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CapturesPage() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const { settings, update } = useCaptureSettings();
|
||||
const { data: profiles } = useProfiles();
|
||||
const { toast } = useToast();
|
||||
const readiness = useDictationReadiness();
|
||||
const sttModel = settings?.stt_model ?? 'turbo';
|
||||
const language = settings?.language ?? 'auto';
|
||||
const autoRefine = settings?.auto_refine ?? true;
|
||||
const llmModel = settings?.llm_model ?? '0.6B';
|
||||
const smartCleanup = settings?.smart_cleanup ?? true;
|
||||
const selfCorrection = settings?.self_correction ?? true;
|
||||
const preserveTechnical = settings?.preserve_technical ?? true;
|
||||
const allowAutoPaste = settings?.allow_auto_paste ?? true;
|
||||
const defaultVoiceId = settings?.default_playback_voice_id ?? null;
|
||||
const hotkeyEnabled = settings?.hotkey_enabled ?? false;
|
||||
const keepMicWarm = settings?.keep_mic_warm ?? false;
|
||||
const pushToTalkKeys = settings?.chord_push_to_talk_keys ?? defaultChordKeys('push');
|
||||
const toggleToTalkKeys = settings?.chord_toggle_to_talk_keys ?? defaultChordKeys('toggle');
|
||||
|
||||
const [chordEditor, setChordEditor] = useState<'push' | 'toggle' | null>(null);
|
||||
const [opening, setOpening] = useState(false);
|
||||
const [capturesPath, setCapturesPath] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${serverUrl}/health/filesystem`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const dir = data.directories?.find((d: { path: string }) =>
|
||||
d.path.includes('captures'),
|
||||
);
|
||||
if (dir?.path) setCapturesPath(dir.path);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [serverUrl]);
|
||||
|
||||
const openCapturesFolder = useCallback(async () => {
|
||||
if (!capturesPath) return;
|
||||
setOpening(true);
|
||||
try {
|
||||
await platform.filesystem.openPath(capturesPath);
|
||||
} catch (e) {
|
||||
console.error('Failed to open captures folder:', e);
|
||||
} finally {
|
||||
setOpening(false);
|
||||
}
|
||||
}, [platform, capturesPath]);
|
||||
|
||||
const voices: VoiceProfileResponse[] = profiles ?? [];
|
||||
const defaultVoice =
|
||||
voices.find((v) => v.id === defaultVoiceId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex gap-8 items-start max-w-5xl">
|
||||
<div className="flex-1 min-w-0 max-w-2xl space-y-10">
|
||||
<SettingSection
|
||||
title={t('settings.captures.dictation.title')}
|
||||
description={t('settings.captures.dictation.description')}
|
||||
>
|
||||
<div>
|
||||
<SettingRow
|
||||
title={t('settings.captures.dictation.globalShortcut.title')}
|
||||
description={t('settings.captures.dictation.globalShortcut.description')}
|
||||
htmlFor="hotkeyEnabled"
|
||||
action={
|
||||
<Toggle
|
||||
id="hotkeyEnabled"
|
||||
checked={hotkeyEnabled}
|
||||
onCheckedChange={(v) => {
|
||||
update({ hotkey_enabled: v });
|
||||
// Surface model-readiness blocks at the toggle. The
|
||||
// InputMonitoringNotice below already covers TCC, but
|
||||
// missing models would otherwise be invisible from this
|
||||
// page — the user toggles on, presses the chord, and
|
||||
// nothing happens because useChordSync gates on readiness.
|
||||
if (!v) return;
|
||||
const missingModels = readiness.missing.filter(
|
||||
(g) => g === 'stt' || g === 'llm',
|
||||
);
|
||||
if (missingModels.length === 0) return;
|
||||
const names = [
|
||||
missingModels.includes('stt') ? readiness.stt?.display_name : null,
|
||||
missingModels.includes('llm') ? readiness.llm?.display_name : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' and ');
|
||||
toast({
|
||||
title: t('captures.toast.shortcutNotArmed'),
|
||||
description: t('captures.toast.shortcutNotArmedDescription', {
|
||||
names,
|
||||
count: missingModels.length,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputMonitoringNotice enabled={hotkeyEnabled} />
|
||||
</div>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.captures.dictation.keepMicWarm.title')}
|
||||
description={t('settings.captures.dictation.keepMicWarm.description')}
|
||||
htmlFor="keepMicWarm"
|
||||
action={
|
||||
<Toggle
|
||||
id="keepMicWarm"
|
||||
checked={keepMicWarm}
|
||||
disabled={!hotkeyEnabled}
|
||||
onCheckedChange={(v) => {
|
||||
update({ keep_mic_warm: v });
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.captures.dictation.pushToTalk.title')}
|
||||
description={t('settings.captures.dictation.pushToTalk.description')}
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<ChordPreview keys={pushToTalkKeys} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hotkeyEnabled}
|
||||
onClick={() => setChordEditor('push')}
|
||||
>
|
||||
<Keyboard className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.captures.dictation.pushToTalk.change')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.captures.dictation.toggle.title')}
|
||||
description={t('settings.captures.dictation.toggle.description')}
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<ChordPreview keys={toggleToTalkKeys} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hotkeyEnabled}
|
||||
onClick={() => setChordEditor('toggle')}
|
||||
>
|
||||
<Keyboard className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.captures.dictation.toggle.change')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<ChordPicker
|
||||
open={chordEditor === 'push'}
|
||||
title={t('settings.captures.dictation.chordPicker.pttTitle')}
|
||||
description={t('settings.captures.dictation.chordPicker.pttDescription')}
|
||||
initialKeys={pushToTalkKeys}
|
||||
onCancel={() => setChordEditor(null)}
|
||||
onSave={(keys) => {
|
||||
update({ chord_push_to_talk_keys: keys });
|
||||
setChordEditor(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChordPicker
|
||||
open={chordEditor === 'toggle'}
|
||||
title={t('settings.captures.dictation.chordPicker.toggleTitle')}
|
||||
description={t('settings.captures.dictation.chordPicker.toggleDescription')}
|
||||
initialKeys={toggleToTalkKeys}
|
||||
onCancel={() => setChordEditor(null)}
|
||||
onSave={(keys) => {
|
||||
update({ chord_toggle_to_talk_keys: keys });
|
||||
setChordEditor(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.captures.dictation.preview.title')}
|
||||
description={t('settings.captures.dictation.preview.description')}
|
||||
>
|
||||
<HotkeyPillPreview enabled={hotkeyEnabled} />
|
||||
</SettingRow>
|
||||
|
||||
<div>
|
||||
<SettingRow
|
||||
title={t('settings.captures.dictation.autoPaste.title')}
|
||||
description={t('settings.captures.dictation.autoPaste.description')}
|
||||
htmlFor="autoPaste"
|
||||
action={
|
||||
<Toggle
|
||||
id="autoPaste"
|
||||
checked={allowAutoPaste}
|
||||
onCheckedChange={(v) => update({ allow_auto_paste: v })}
|
||||
disabled={!hotkeyEnabled}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<AccessibilityNotice />
|
||||
</div>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection
|
||||
title={t('settings.captures.transcription.title')}
|
||||
description={t('settings.captures.transcription.description')}
|
||||
>
|
||||
<SettingRow
|
||||
title={t('settings.captures.transcription.model.title')}
|
||||
description={t('settings.captures.transcription.model.description')}
|
||||
action={
|
||||
<Select
|
||||
value={sttModel}
|
||||
onValueChange={(v) => update({ stt_model: v as WhisperModelSize })}
|
||||
>
|
||||
<SelectTrigger className="w-[300px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="base">
|
||||
{t('settings.captures.transcription.model.base', { tail: t('settings.captures.transcription.model.tail.fast') })}
|
||||
</SelectItem>
|
||||
<SelectItem value="small">
|
||||
{t('settings.captures.transcription.model.small', { tail: t('settings.captures.transcription.model.tail.balanced') })}
|
||||
</SelectItem>
|
||||
<SelectItem value="medium">
|
||||
{t('settings.captures.transcription.model.medium', { tail: t('settings.captures.transcription.model.tail.higher') })}
|
||||
</SelectItem>
|
||||
<SelectItem value="large">
|
||||
{t('settings.captures.transcription.model.large', { tail: t('settings.captures.transcription.model.tail.best') })}
|
||||
</SelectItem>
|
||||
<SelectItem value="turbo">
|
||||
{t('settings.captures.transcription.model.turbo', { tail: t('settings.captures.transcription.model.tail.nearBest') })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.captures.transcription.language.title')}
|
||||
description={t('settings.captures.transcription.language.description')}
|
||||
action={
|
||||
<Select value={language} onValueChange={(v) => update({ language: v })}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t('settings.captures.transcription.language.auto')}</SelectItem>
|
||||
<SelectItem value="en">{t('settings.captures.transcription.language.en')}</SelectItem>
|
||||
<SelectItem value="es">{t('settings.captures.transcription.language.es')}</SelectItem>
|
||||
<SelectItem value="fr">{t('settings.captures.transcription.language.fr')}</SelectItem>
|
||||
<SelectItem value="de">{t('settings.captures.transcription.language.de')}</SelectItem>
|
||||
<SelectItem value="ja">{t('settings.captures.transcription.language.ja')}</SelectItem>
|
||||
<SelectItem value="zh">{t('settings.captures.transcription.language.zh')}</SelectItem>
|
||||
<SelectItem value="hi">{t('settings.captures.transcription.language.hi')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection
|
||||
title={t('settings.captures.refinement.title')}
|
||||
description={t('settings.captures.refinement.description')}
|
||||
>
|
||||
<SettingRow
|
||||
title={t('settings.captures.refinement.auto.title')}
|
||||
description={t('settings.captures.refinement.auto.description')}
|
||||
htmlFor="autoRefine"
|
||||
action={
|
||||
<Toggle
|
||||
id="autoRefine"
|
||||
checked={autoRefine}
|
||||
onCheckedChange={(v) => update({ auto_refine: v })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.captures.refinement.model.title')}
|
||||
description={t('settings.captures.refinement.model.description')}
|
||||
action={
|
||||
<Select
|
||||
value={llmModel}
|
||||
onValueChange={(v) => update({ llm_model: v as Qwen3ModelSize })}
|
||||
disabled={!autoRefine}
|
||||
>
|
||||
<SelectTrigger className="w-[260px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0.6B">
|
||||
{t('settings.captures.refinement.model.size06', { tail: t('settings.captures.refinement.model.tail.veryFast') })}
|
||||
</SelectItem>
|
||||
<SelectItem value="1.7B">
|
||||
{t('settings.captures.refinement.model.size17', { tail: t('settings.captures.refinement.model.tail.fast') })}
|
||||
</SelectItem>
|
||||
<SelectItem value="4B">
|
||||
{t('settings.captures.refinement.model.size40', { tail: t('settings.captures.refinement.model.tail.fullQuality') })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.captures.refinement.smartCleanup.title')}
|
||||
description={t('settings.captures.refinement.smartCleanup.description')}
|
||||
htmlFor="smartCleanup"
|
||||
action={
|
||||
<Toggle
|
||||
id="smartCleanup"
|
||||
checked={smartCleanup}
|
||||
onCheckedChange={(v) => update({ smart_cleanup: v })}
|
||||
disabled={!autoRefine}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.captures.refinement.selfCorrection.title')}
|
||||
description={t('settings.captures.refinement.selfCorrection.description')}
|
||||
htmlFor="selfCorrection"
|
||||
action={
|
||||
<Toggle
|
||||
id="selfCorrection"
|
||||
checked={selfCorrection}
|
||||
onCheckedChange={(v) => update({ self_correction: v })}
|
||||
disabled={!autoRefine}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.captures.refinement.preserveTechnical.title')}
|
||||
description={t('settings.captures.refinement.preserveTechnical.description')}
|
||||
htmlFor="preserveTechnical"
|
||||
action={
|
||||
<Toggle
|
||||
id="preserveTechnical"
|
||||
checked={preserveTechnical}
|
||||
onCheckedChange={(v) => update({ preserve_technical: v })}
|
||||
disabled={!autoRefine}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection
|
||||
title={t('settings.captures.playback.title')}
|
||||
description={t('settings.captures.playback.description')}
|
||||
>
|
||||
<SettingRow
|
||||
title={t('settings.captures.playback.defaultVoice.title')}
|
||||
description={t('settings.captures.playback.defaultVoice.description')}
|
||||
action={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 min-w-[220px] justify-between"
|
||||
disabled={voices.length === 0}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{defaultVoice ? (
|
||||
<span className="truncate">{defaultVoice.name}</span>
|
||||
) : (
|
||||
<span className="truncate text-muted-foreground">
|
||||
{voices.length === 0
|
||||
? t('settings.captures.playback.defaultVoice.noClonedVoices')
|
||||
: t('settings.captures.playback.defaultVoice.noneSelected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60 shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{t('settings.captures.playback.defaultVoice.clonedVoices')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{voices.map((v) => (
|
||||
<DropdownMenuItem
|
||||
key={v.id}
|
||||
onClick={() => update({ default_playback_voice_id: v.id })}
|
||||
className="gap-2.5 py-2"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{v.name}</div>
|
||||
{v.description ? (
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
{v.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{v.id === defaultVoiceId && <Check className="h-3.5 w-3.5 text-accent shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection
|
||||
title={t('settings.captures.storage.title')}
|
||||
description={t('settings.captures.storage.description')}
|
||||
>
|
||||
<SettingRow
|
||||
title={t('settings.captures.storage.folder.title')}
|
||||
description={capturesPath ?? t('settings.captures.storage.folder.description')}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openCapturesFolder}
|
||||
disabled={opening || !capturesPath}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.captures.storage.folder.open')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SettingSection>
|
||||
</div>
|
||||
|
||||
<aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">{t('settings.captures.sidebar.aboutTitle')}</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t('settings.captures.sidebar.aboutBody')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">{t('settings.captures.sidebar.differencesTitle')}</h3>
|
||||
<ul className="space-y-3 text-sm text-muted-foreground">
|
||||
<li className="flex gap-2.5">
|
||||
<Lock className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
|
||||
<span className="leading-relaxed">
|
||||
<span className="text-foreground font-medium">{t('settings.captures.sidebar.local.title')}</span>{' '}
|
||||
{t('settings.captures.sidebar.local.body')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-2.5">
|
||||
<Volume2 className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
|
||||
<span className="leading-relaxed">
|
||||
<span className="text-foreground font-medium">
|
||||
{t('settings.captures.sidebar.playAs.title')}
|
||||
</span>{' '}
|
||||
{t('settings.captures.sidebar.playAs.body')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-2.5">
|
||||
<Laptop className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
|
||||
<span className="leading-relaxed">
|
||||
<span className="text-foreground font-medium">
|
||||
{t('settings.captures.sidebar.crossPlatform.title')}
|
||||
</span>{' '}
|
||||
{t('settings.captures.sidebar.crossPlatform.body')}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
{isWindows && (
|
||||
<div className="rounded-lg border border-accent/20 bg-accent/5 px-3 py-2.5">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Info className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t('settings.captures.sidebar.windowsCaveat.title')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t('settings.captures.sidebar.windowsCaveat.body')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Same six-gate checklist the CapturesTab empty state uses.
|
||||
Surfaces missing models / permissions persistently while
|
||||
users configure this page, so a red gate can't hide behind
|
||||
a green toggle. Hidden once every gate is green — no value
|
||||
in real estate full of checkmarks. */}
|
||||
{!readiness.allReady && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">{t('captures.readiness.title')}</h3>
|
||||
<DictationReadinessChecklist readiness={readiness} compact />
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import changelogRaw from 'virtual:changelog';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { type ChangelogEntry, parseChangelog } from '@/lib/utils/parseChangelog';
|
||||
|
||||
@@ -176,16 +177,19 @@ function inlineMarkdown(text: string): React.ReactNode {
|
||||
}
|
||||
|
||||
function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
|
||||
const { t } = useTranslation();
|
||||
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>
|
||||
<div className="flex items-baseline gap-3 mb-3">
|
||||
<h3 className="text-xl font-semibold tracking-tight">{entry.version}</h3>
|
||||
{entry.date && <span className="text-xs text-muted-foreground">{entry.date}</span>}
|
||||
{entry.version === 'Unreleased' && <Badge variant="outline">dev</Badge>}
|
||||
{entry.version === 'Unreleased' && (
|
||||
<Badge variant="outline">{t('settings.changelog.devBadge')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={isLong && !expanded ? 'max-h-48 overflow-hidden relative' : ''}>
|
||||
@@ -200,7 +204,7 @@ function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-xs text-accent hover:underline mt-2"
|
||||
>
|
||||
{expanded ? 'Show less' : 'Show more'}
|
||||
{expanded ? t('settings.changelog.showLess') : t('settings.changelog.showMore')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Cloud, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
|
||||
// "Log in with browser" device pairing. The backend opens the system browser
|
||||
// and completes the code exchange; here we just kick it off and poll status
|
||||
// until the link goes live. The API key never touches the frontend.
|
||||
export function CloudSection() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [polling, setPolling] = useState(false);
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ['cloud-status'],
|
||||
queryFn: () => apiClient.getCloudStatus(),
|
||||
refetchInterval: polling ? 2000 : false,
|
||||
});
|
||||
|
||||
const connected = status?.connected ?? false;
|
||||
|
||||
// Once the browser flow completes, stop polling and celebrate.
|
||||
useEffect(() => {
|
||||
if (connected && polling) {
|
||||
setPolling(false);
|
||||
toast({
|
||||
title: 'Connected to Voicebox Cloud',
|
||||
description: `Linked as ${status?.device_name ?? 'this device'}.`,
|
||||
});
|
||||
}
|
||||
}, [connected, polling, status?.device_name, toast]);
|
||||
|
||||
// Give up after two minutes so an abandoned browser flow doesn't leave the
|
||||
// button stuck on "Waiting for browser…". The backend state stays valid for
|
||||
// ten, so the user can simply start again.
|
||||
useEffect(() => {
|
||||
if (!polling) return;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setPolling(false);
|
||||
toast({
|
||||
title: 'Sign-in timed out',
|
||||
description: 'The browser sign-in was not completed. Try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}, 120_000);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [polling, toast]);
|
||||
|
||||
const startLogin = useMutation({
|
||||
mutationFn: () => apiClient.startCloudLogin(),
|
||||
onSuccess: () => {
|
||||
setPolling(true);
|
||||
toast({
|
||||
title: 'Continue in your browser',
|
||||
description: 'Authorize this device, then return here.',
|
||||
});
|
||||
},
|
||||
onError: (error: Error) =>
|
||||
toast({
|
||||
title: 'Could not start sign-in',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
}),
|
||||
});
|
||||
|
||||
const disconnect = useMutation({
|
||||
mutationFn: () => apiClient.disconnectCloud(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-status'] });
|
||||
toast({
|
||||
title: 'Disconnected',
|
||||
description:
|
||||
'This device is no longer linked. The key stays valid until revoked in your account.',
|
||||
});
|
||||
},
|
||||
onError: (error: Error) =>
|
||||
toast({ title: 'Could not disconnect', description: error.message, variant: 'destructive' }),
|
||||
});
|
||||
|
||||
const busy = startLogin.isPending || polling;
|
||||
|
||||
return (
|
||||
<SettingSection
|
||||
title="Voicebox Cloud"
|
||||
description="End-to-end encrypted backup & sync across your devices."
|
||||
>
|
||||
<SettingRow
|
||||
title={connected ? 'Connected' : 'Account'}
|
||||
description={
|
||||
connected
|
||||
? `Linked as ${status?.device_name ?? 'this device'}${
|
||||
status?.key_prefix ? ` · ${status.key_prefix}…` : ''
|
||||
}`
|
||||
: 'Log in to back up and sync your captures and generations.'
|
||||
}
|
||||
action={
|
||||
connected ? (
|
||||
<Button
|
||||
disabled={disconnect.isPending}
|
||||
onClick={() => disconnect.mutate()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{disconnect.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
Disconnecting…
|
||||
</>
|
||||
) : (
|
||||
'Disconnect'
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled={busy} onClick={() => startLogin.mutate()} size="sm">
|
||||
{busy ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
{polling ? 'Waiting for browser…' : 'Opening…'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Cloud className="h-3.5 w-3.5 mr-1.5" />
|
||||
Log in with browser
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{connected && (
|
||||
<SettingRow
|
||||
title="Manage"
|
||||
description="Revoke this device, add API keys, or manage billing from your account."
|
||||
>
|
||||
<a
|
||||
className="text-sm text-accent hover:underline"
|
||||
href={status?.dashboard_url ?? 'https://voicebox.sh/account'}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Open account dashboard ↗
|
||||
</a>
|
||||
</SettingRow>
|
||||
)}
|
||||
</SettingSection>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { AlertCircle, ArrowUpRight, Book, Download, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||
@@ -13,15 +14,21 @@ import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { CloudSection } from './CloudSection';
|
||||
import { LanguageSelect } from './LanguageSelect';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
import { ThemeSelect } from './ThemeSelect';
|
||||
|
||||
const connectionSchema = z.object({
|
||||
serverUrl: z.string().url('Please enter a valid URL'),
|
||||
});
|
||||
function makeConnectionSchema(invalidUrl: string) {
|
||||
return z.object({
|
||||
serverUrl: z.string().url(invalidUrl),
|
||||
});
|
||||
}
|
||||
|
||||
type ConnectionFormValues = z.infer<typeof connectionSchema>;
|
||||
type ConnectionFormValues = { serverUrl: string };
|
||||
|
||||
export function GeneralPage() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const setServerUrl = useServerStore((state) => state.setServerUrl);
|
||||
@@ -32,8 +39,12 @@ export function GeneralPage() {
|
||||
const { toast } = useToast();
|
||||
const { data: health, isLoading, error: healthError } = useServerHealth();
|
||||
|
||||
const resolver = useMemo(
|
||||
() => zodResolver(makeConnectionSchema(t('settings.general.serverUrl.invalidUrl'))),
|
||||
[t],
|
||||
);
|
||||
const form = useForm<ConnectionFormValues>({
|
||||
resolver: zodResolver(connectionSchema),
|
||||
resolver,
|
||||
defaultValues: { serverUrl },
|
||||
});
|
||||
|
||||
@@ -41,14 +52,21 @@ export function GeneralPage() {
|
||||
form.reset({ serverUrl });
|
||||
}, [serverUrl, form]);
|
||||
|
||||
// Re-run validation when the locale changes so existing error messages retranslate.
|
||||
useEffect(() => {
|
||||
if (form.formState.errors.serverUrl) {
|
||||
form.trigger('serverUrl');
|
||||
}
|
||||
}, [t, 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}`,
|
||||
title: t('settings.general.serverUrl.updatedTitle'),
|
||||
description: t('settings.general.serverUrl.updatedDescription', { url: data.serverUrl }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,7 +81,7 @@ export function GeneralPage() {
|
||||
>
|
||||
<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-sm font-medium">{t('settings.general.docs.title')}</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" />
|
||||
@@ -83,8 +101,10 @@ export function GeneralPage() {
|
||||
<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 className="text-sm font-medium">{t('settings.general.discord.title')}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('settings.general.discord.subtitle')}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowUpRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
||||
</a>
|
||||
@@ -92,8 +112,8 @@ export function GeneralPage() {
|
||||
|
||||
<SettingSection>
|
||||
<SettingRow
|
||||
title="Server URL"
|
||||
description="The address of your voicebox backend server."
|
||||
title={t('settings.general.serverUrl.title')}
|
||||
description={t('settings.general.serverUrl.description')}
|
||||
action={
|
||||
<ConnectionStatus health={health} isLoading={isLoading} healthError={healthError} />
|
||||
}
|
||||
@@ -114,7 +134,7 @@ export function GeneralPage() {
|
||||
/>
|
||||
{isDirty && (
|
||||
<Button type="submit" size="sm">
|
||||
Save
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
@@ -122,8 +142,8 @@ export function GeneralPage() {
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Keep server running when app closes"
|
||||
description="The server will continue running in the background after closing the app."
|
||||
title={t('settings.general.keepServerRunning.title')}
|
||||
description={t('settings.general.keepServerRunning.description')}
|
||||
htmlFor="keepServerRunning"
|
||||
action={
|
||||
<Toggle
|
||||
@@ -135,17 +155,17 @@ export function GeneralPage() {
|
||||
console.error('Failed to sync setting to Rust:', error);
|
||||
setKeepServerRunningOnClose(!checked);
|
||||
toast({
|
||||
title: 'Failed to update setting',
|
||||
description: 'Could not sync setting to backend.',
|
||||
title: t('settings.general.keepServerRunning.failedTitle'),
|
||||
description: t('settings.general.keepServerRunning.failedDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
});
|
||||
toast({
|
||||
title: 'Setting updated',
|
||||
title: t('settings.general.keepServerRunning.updatedTitle'),
|
||||
description: checked
|
||||
? 'Server will continue running when app closes'
|
||||
: 'Server will stop when app closes',
|
||||
? t('settings.general.keepServerRunning.runningDescription')
|
||||
: t('settings.general.keepServerRunning.stoppedDescription'),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
@@ -154,8 +174,8 @@ export function GeneralPage() {
|
||||
|
||||
{platform.metadata.isTauri && (
|
||||
<SettingRow
|
||||
title="Allow network access"
|
||||
description="Makes the server accessible from other devices on your network. Restart the app after changing."
|
||||
title={t('settings.general.networkAccess.title')}
|
||||
description={t('settings.general.networkAccess.description')}
|
||||
htmlFor="allowNetworkAccess"
|
||||
action={
|
||||
<Toggle
|
||||
@@ -164,18 +184,32 @@ export function GeneralPage() {
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
setMode(checked ? 'remote' : 'local');
|
||||
toast({
|
||||
title: 'Setting updated',
|
||||
title: t('settings.general.networkAccess.updatedTitle'),
|
||||
description: checked
|
||||
? 'Network access enabled. Restart the app to apply.'
|
||||
: 'Network access disabled. Restart the app to apply.',
|
||||
? t('settings.general.networkAccess.enabled')
|
||||
: t('settings.general.networkAccess.disabled'),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.language.label')}
|
||||
description={t('settings.language.description')}
|
||||
action={<LanguageSelect />}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.theme.label')}
|
||||
description={t('settings.theme.description')}
|
||||
action={<ThemeSelect />}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
<CloudSection />
|
||||
|
||||
<ApiReferenceCard serverUrl={serverUrl} />
|
||||
|
||||
{platform.metadata.isTauri && <UpdatesSection />}
|
||||
@@ -192,11 +226,14 @@ function ConnectionStatus({
|
||||
isLoading: boolean;
|
||||
healthError: ReturnType<typeof useServerHealth>['error'];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
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>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('settings.general.connection.connecting')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -207,7 +244,7 @@ function ConnectionStatus({
|
||||
<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>
|
||||
<span className="text-xs text-destructive">{t('settings.general.connection.offline')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -218,7 +255,9 @@ function ConnectionStatus({
|
||||
<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>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('settings.general.connection.online')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -226,35 +265,41 @@ function ConnectionStatus({
|
||||
}
|
||||
|
||||
function UpdatesSection() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
|
||||
const [currentVersion, setCurrentVersion] = useState<string>('');
|
||||
const [currentVersion, setCurrentVersion] = useState<string | null>('');
|
||||
const isDev = !import.meta.env?.PROD;
|
||||
|
||||
useEffect(() => {
|
||||
platform.metadata
|
||||
.getVersion()
|
||||
.then(setCurrentVersion)
|
||||
.catch(() => setCurrentVersion('Unknown'));
|
||||
.catch(() => setCurrentVersion(null));
|
||||
}, [platform]);
|
||||
|
||||
const versionLabel = currentVersion ?? t('common.unknown');
|
||||
|
||||
return (
|
||||
<SettingSection title="App Updates" description={`v${currentVersion}${isDev ? ' (dev)' : ''}`}>
|
||||
<SettingSection
|
||||
title={t('settings.general.updates.title')}
|
||||
description={`v${versionLabel}${isDev ? t('settings.general.updates.devSuffix') : ''}`}
|
||||
>
|
||||
{isDev ? (
|
||||
<SettingRow
|
||||
title="Development mode"
|
||||
description="Auto-updates are disabled in development mode."
|
||||
title={t('settings.general.updates.devMode.title')}
|
||||
description={t('settings.general.updates.devMode.description')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<SettingRow
|
||||
title="Check for updates"
|
||||
title={t('settings.general.updates.check.title')}
|
||||
description={
|
||||
status.available
|
||||
? `Version ${status.version} available`
|
||||
? t('settings.general.updates.check.available', { version: status.version })
|
||||
: status.checking
|
||||
? 'Checking...'
|
||||
: "You're up to date"
|
||||
? t('settings.general.updates.check.checking')
|
||||
: t('settings.general.updates.check.upToDate')
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
@@ -266,13 +311,13 @@ function UpdatesSection() {
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 mr-1.5 ${status.checking ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Check
|
||||
{t('settings.general.updates.check.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{status.error && (
|
||||
<SettingRow title="Update error">
|
||||
<SettingRow title={t('settings.general.updates.error')}>
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{status.error}
|
||||
@@ -282,19 +327,19 @@ function UpdatesSection() {
|
||||
|
||||
{status.available && !status.downloading && !status.readyToInstall && (
|
||||
<SettingRow
|
||||
title={`Update to ${status.version}`}
|
||||
description="Download and install the latest version."
|
||||
title={t('settings.general.updates.download.title', { version: status.version })}
|
||||
description={t('settings.general.updates.download.description')}
|
||||
action={
|
||||
<Button onClick={downloadAndInstall} size="sm">
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
Download
|
||||
{t('settings.general.updates.download.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status.downloading && (
|
||||
<SettingRow title="Downloading update...">
|
||||
<SettingRow title={t('settings.general.updates.downloading')}>
|
||||
<div className="space-y-1.5">
|
||||
<Progress value={status.downloadProgress} />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
@@ -316,12 +361,14 @@ function UpdatesSection() {
|
||||
|
||||
{status.readyToInstall && (
|
||||
<SettingRow
|
||||
title="Update ready to install"
|
||||
description={`Version ${status.version} has been downloaded. Restart to complete.`}
|
||||
title={t('settings.general.updates.ready.title')}
|
||||
description={t('settings.general.updates.ready.description', {
|
||||
version: status.version,
|
||||
})}
|
||||
action={
|
||||
<Button onClick={restartAndInstall} size="sm">
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
Restart Now
|
||||
{t('settings.general.updates.ready.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -332,25 +379,31 @@ function UpdatesSection() {
|
||||
);
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const { t } = useTranslation();
|
||||
const endpoints = [
|
||||
{ method: 'POST', path: '/generate', label: t('settings.general.api.endpoints.generate') },
|
||||
{ method: 'GET', path: '/health', label: t('settings.general.api.endpoints.health') },
|
||||
{ method: 'GET', path: '/profiles', label: t('settings.general.api.endpoints.profiles') },
|
||||
{ method: 'GET', path: '/history', label: t('settings.general.api.endpoints.history') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">API Access</h3>
|
||||
<h3 className="text-sm font-medium">{t('settings.general.api.title')}</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>
|
||||
<Trans
|
||||
i18nKey="settings.general.api.description"
|
||||
values={{ url: serverUrl }}
|
||||
components={{
|
||||
code: <code className="text-xs bg-muted px-1 py-0.5 rounded font-mono" />,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{API_ENDPOINTS.map((ep) => (
|
||||
{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 ${
|
||||
@@ -371,7 +424,7 @@ function ApiReferenceCard({ serverUrl }: { serverUrl: string }) {
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
View the full API reference
|
||||
{t('settings.general.api.viewReference')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { FolderOpen, Languages, Mic, Zap } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Toggle } from '@/components/ui/toggle';
|
||||
import { useGenerationSettings } from '@/lib/hooks/useSettings';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
|
||||
export function GenerationPage() {
|
||||
const { t } = useTranslation();
|
||||
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 { settings, update } = useGenerationSettings();
|
||||
const persistedMaxChunkChars = settings?.max_chunk_chars ?? 800;
|
||||
const persistedCrossfadeMs = settings?.crossfade_ms ?? 50;
|
||||
const normalizeAudio = settings?.normalize_audio ?? true;
|
||||
const autoplayOnGenerate = settings?.autoplay_on_generate ?? true;
|
||||
// Slider mirrors persist on commit (pointer-up / keyboard-release) only —
|
||||
// onValueChange would fire a PATCH for every pointer-move pixel and round-
|
||||
// trip mid-drag failures could leave persisted state out of sync with UI.
|
||||
const [maxChunkChars, setMaxChunkChars] = useState(persistedMaxChunkChars);
|
||||
const [crossfadeMs, setCrossfadeMs] = useState(persistedCrossfadeMs);
|
||||
useEffect(() => setMaxChunkChars(persistedMaxChunkChars), [persistedMaxChunkChars]);
|
||||
useEffect(() => setCrossfadeMs(persistedCrossfadeMs), [persistedCrossfadeMs]);
|
||||
const [opening, setOpening] = useState(false);
|
||||
const [generationsPath, setGenerationsPath] = useState<string | null>(null);
|
||||
|
||||
@@ -46,17 +53,18 @@ export function GenerationPage() {
|
||||
}, [platform, generationsPath]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
<div className="flex gap-8 items-start max-w-5xl">
|
||||
<div className="flex-1 min-w-0 max-w-2xl space-y-8">
|
||||
<SettingSection
|
||||
title="Generation"
|
||||
description="Controls for long text generation. These settings apply to all engines."
|
||||
title={t('settings.generation.title')}
|
||||
description={t('settings.generation.description')}
|
||||
>
|
||||
<SettingRow
|
||||
title="Auto-chunking limit"
|
||||
description="Long text is split into chunks at sentence boundaries. Lower values can improve quality for long outputs."
|
||||
title={t('settings.generation.chunkLimit.title')}
|
||||
description={t('settings.generation.chunkLimit.description')}
|
||||
action={
|
||||
<span className="text-sm tabular-nums text-muted-foreground">
|
||||
{maxChunkChars} chars
|
||||
{t('settings.generation.chunkLimit.value', { chars: maxChunkChars })}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
@@ -64,19 +72,22 @@ export function GenerationPage() {
|
||||
id="maxChunkChars"
|
||||
value={[maxChunkChars]}
|
||||
onValueChange={([value]) => setMaxChunkChars(value)}
|
||||
onValueCommit={([value]) => update({ max_chunk_chars: value })}
|
||||
min={100}
|
||||
max={5000}
|
||||
step={50}
|
||||
aria-label="Auto-chunking character limit"
|
||||
aria-label={t('settings.generation.chunkLimit.title')}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Chunk crossfade"
|
||||
description="Blends audio between chunks to smooth transitions. Set to 0 for a hard cut."
|
||||
title={t('settings.generation.crossfade.title')}
|
||||
description={t('settings.generation.crossfade.description')}
|
||||
action={
|
||||
<span className="text-sm tabular-nums text-muted-foreground">
|
||||
{crossfadeMs === 0 ? 'Cut' : `${crossfadeMs}ms`}
|
||||
{crossfadeMs === 0
|
||||
? t('settings.generation.crossfade.cut')
|
||||
: t('settings.generation.crossfade.ms', { ms: crossfadeMs })}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
@@ -84,42 +95,43 @@ export function GenerationPage() {
|
||||
id="crossfadeMs"
|
||||
value={[crossfadeMs]}
|
||||
onValueChange={([value]) => setCrossfadeMs(value)}
|
||||
onValueCommit={([value]) => update({ crossfade_ms: value })}
|
||||
min={0}
|
||||
max={200}
|
||||
step={10}
|
||||
aria-label="Chunk crossfade duration"
|
||||
aria-label={t('settings.generation.crossfade.title')}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Normalize audio"
|
||||
description="Adjusts output volume to a consistent level across generations."
|
||||
title={t('settings.generation.normalize.title')}
|
||||
description={t('settings.generation.normalize.description')}
|
||||
htmlFor="normalizeAudio"
|
||||
action={
|
||||
<Toggle
|
||||
id="normalizeAudio"
|
||||
checked={normalizeAudio}
|
||||
onCheckedChange={setNormalizeAudio}
|
||||
onCheckedChange={(v) => update({ normalize_audio: v })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title="Autoplay on generate"
|
||||
description="Automatically play audio when a generation completes."
|
||||
title={t('settings.generation.autoplay.title')}
|
||||
description={t('settings.generation.autoplay.description')}
|
||||
htmlFor="autoplayOnGenerate"
|
||||
action={
|
||||
<Toggle
|
||||
id="autoplayOnGenerate"
|
||||
checked={autoplayOnGenerate}
|
||||
onCheckedChange={setAutoplayOnGenerate}
|
||||
onCheckedChange={(v) => update({ autoplay_on_generate: v })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title="Generations folder"
|
||||
description={generationsPath ?? 'Where generated audio files are stored on disk.'}
|
||||
title={t('settings.generation.folder.title')}
|
||||
description={generationsPath ?? t('settings.generation.folder.description')}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -128,11 +140,52 @@ export function GenerationPage() {
|
||||
disabled={opening || !generationsPath}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5 mr-1.5" />
|
||||
Open
|
||||
{t('settings.generation.folder.open')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SettingSection>
|
||||
</div>
|
||||
|
||||
<aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">{t('settings.generation.sidebar.aboutTitle')}</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t('settings.generation.sidebar.aboutBody')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">{t('settings.generation.sidebar.differencesTitle')}</h3>
|
||||
<ul className="space-y-3 text-sm text-muted-foreground">
|
||||
<li className="flex gap-2.5">
|
||||
<Mic className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
|
||||
<span className="leading-relaxed">
|
||||
<span className="text-foreground font-medium">
|
||||
{t('settings.generation.sidebar.clone.title')}
|
||||
</span>{' '}
|
||||
{t('settings.generation.sidebar.clone.body')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-2.5">
|
||||
<Languages className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
|
||||
<span className="leading-relaxed">
|
||||
<span className="text-foreground font-medium">
|
||||
{t('settings.generation.sidebar.engines.title')}
|
||||
</span>{' '}
|
||||
{t('settings.generation.sidebar.engines.body')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-2.5">
|
||||
<Zap className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
|
||||
<span className="leading-relaxed">
|
||||
<span className="text-foreground font-medium">{t('settings.generation.sidebar.agentReady.title')}</span>{' '}
|
||||
{t('settings.generation.sidebar.agentReady.body')}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
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 type { CudaDownloadProgress, RocmDownloadProgress, HealthResponse } from '@/lib/api/types';
|
||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
@@ -40,16 +41,19 @@ function GpuIcon({ className }: { className?: string }) {
|
||||
}
|
||||
|
||||
function GpuInfoCard({ health }: { health: HealthResponse }) {
|
||||
const { t } = useTranslation();
|
||||
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';
|
||||
const showBackendVariant =
|
||||
health.backend_variant &&
|
||||
health.backend_variant !== 'cpu' &&
|
||||
health.backend_variant.toLowerCase() !== gpuBackend?.toLowerCase();
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 p-4">
|
||||
@@ -64,7 +68,7 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
|
||||
<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="text-sm font-medium">{hasGpu ? gpuName : t('settings.gpu.cpuOnly')}</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{hasGpu ? (
|
||||
<>
|
||||
@@ -78,12 +82,14 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
|
||||
{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>
|
||||
{t('settings.gpu.vramUsed', { mb: health.vram_used_mb.toFixed(0) })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span>No GPU acceleration detected</span>
|
||||
<span>{t('settings.gpu.noAcceleration')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,7 +99,9 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
|
||||
<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>
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
{t('settings.gpu.active')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -102,6 +110,7 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
|
||||
}
|
||||
|
||||
export function GpuPage() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const queryClient = useQueryClient();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
@@ -109,9 +118,19 @@ export function GpuPage() {
|
||||
|
||||
const [restartPhase, setRestartPhase] = useState<RestartPhase>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cudaStreaming, setCudaStreaming] = useState(false);
|
||||
const [rocmStreaming, setRocmStreaming] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState<CudaDownloadProgress | null>(null);
|
||||
const [rocmDownloadProgress, setRocmDownloadProgress] = useState<RocmDownloadProgress | null>(
|
||||
null,
|
||||
);
|
||||
const healthPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const tRef = useRef(t);
|
||||
useEffect(() => {
|
||||
tRef.current = t;
|
||||
}, [t]);
|
||||
|
||||
const {
|
||||
data: cudaStatus,
|
||||
isLoading: _cudaStatusLoading,
|
||||
@@ -124,9 +143,27 @@ export function GpuPage() {
|
||||
enabled: !!health,
|
||||
});
|
||||
|
||||
const {
|
||||
data: rocmStatus,
|
||||
isLoading: _rocmStatusLoading,
|
||||
refetch: refetchRocmStatus,
|
||||
} = useQuery({
|
||||
queryKey: ['rocm-status', serverUrl],
|
||||
queryFn: () => apiClient.getRocmStatus(),
|
||||
refetchInterval: (query) => (query.state.status === 'pending' ? false : 10000),
|
||||
retry: 1,
|
||||
enabled: !!health,
|
||||
});
|
||||
|
||||
const isCurrentlyCuda = health?.backend_variant === 'cuda';
|
||||
const isCurrentlyRocm = health?.backend_variant === 'rocm';
|
||||
const cudaAvailable = cudaStatus?.available ?? false;
|
||||
const cudaDownloading = cudaStatus?.downloading ?? false;
|
||||
const rocmAvailable = rocmStatus?.available ?? false;
|
||||
const rocmDownloading = rocmStatus?.downloading ?? false;
|
||||
// The ROCm backend only applies to AMD GPUs on Windows. Show the section when
|
||||
// the backend detects applicable hardware, or it is already downloaded/active.
|
||||
const supportsRocm = (health?.supports_rocm ?? false) || rocmAvailable || isCurrentlyRocm;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -138,7 +175,7 @@ export function GpuPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cudaDownloading || !serverUrl) return;
|
||||
if ((!cudaDownloading && !cudaStreaming) || !serverUrl) return;
|
||||
|
||||
const eventSource = new EventSource(`${serverUrl}/backend/cuda-progress`);
|
||||
|
||||
@@ -150,11 +187,13 @@ export function GpuPage() {
|
||||
if (data.status === 'complete') {
|
||||
eventSource.close();
|
||||
setDownloadProgress(null);
|
||||
setCudaStreaming(false);
|
||||
refetchCudaStatus();
|
||||
} else if (data.status === 'error') {
|
||||
eventSource.close();
|
||||
setError(data.error || 'Download failed');
|
||||
setError(data.error || tRef.current('settings.gpu.errors.downloadFailed'));
|
||||
setDownloadProgress(null);
|
||||
setCudaStreaming(false);
|
||||
refetchCudaStatus();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -164,12 +203,50 @@ export function GpuPage() {
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource.close();
|
||||
setCudaStreaming(false);
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [cudaDownloading, serverUrl, refetchCudaStatus]);
|
||||
}, [cudaDownloading, cudaStreaming, serverUrl, refetchCudaStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if ((!rocmDownloading && !rocmStreaming) || !serverUrl) return;
|
||||
|
||||
const eventSource = new EventSource(`${serverUrl}/backend/rocm-progress`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as RocmDownloadProgress;
|
||||
setRocmDownloadProgress(data);
|
||||
|
||||
if (data.status === 'complete') {
|
||||
eventSource.close();
|
||||
setRocmDownloadProgress(null);
|
||||
setRocmStreaming(false);
|
||||
refetchRocmStatus();
|
||||
} else if (data.status === 'error') {
|
||||
eventSource.close();
|
||||
setError(data.error || tRef.current('settings.gpu.errors.downloadFailed'));
|
||||
setRocmDownloadProgress(null);
|
||||
setRocmStreaming(false);
|
||||
refetchRocmStatus();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing ROCm progress event:', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource.close();
|
||||
setRocmStreaming(false);
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [rocmDownloading, rocmStreaming, serverUrl, refetchRocmStatus]);
|
||||
|
||||
const clearHealthPolling = useCallback(() => {
|
||||
if (healthPollRef.current) {
|
||||
@@ -212,13 +289,14 @@ export function GpuPage() {
|
||||
[platform, startHealthPolling, clearHealthPolling],
|
||||
);
|
||||
|
||||
const handleDownload = async () => {
|
||||
const handleDownloadCuda = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.downloadCudaBackend();
|
||||
setCudaStreaming(true);
|
||||
refetchCudaStatus();
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to start download';
|
||||
const msg = e instanceof Error ? e.message : t('settings.gpu.errors.downloadStart');
|
||||
if (msg.includes('already downloaded')) {
|
||||
refetchCudaStatus();
|
||||
} else {
|
||||
@@ -227,34 +305,80 @@ export function GpuPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = async () => {
|
||||
const handleDownloadRocm = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await restartServerWithPolling('Restart failed');
|
||||
await apiClient.downloadRocmBackend();
|
||||
setRocmStreaming(true);
|
||||
refetchRocmStatus();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Restart failed');
|
||||
const msg = e instanceof Error ? e.message : t('settings.gpu.errors.downloadStart');
|
||||
if (msg.includes('already downloaded')) {
|
||||
refetchRocmStatus();
|
||||
} else {
|
||||
setError(msg);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleSwitchToCpu = async () => {
|
||||
setError(null);
|
||||
setRestartPhase('stopping');
|
||||
try {
|
||||
await apiClient.deleteCudaBackend();
|
||||
await restartServerWithPolling('Failed to switch to CPU');
|
||||
await platform.lifecycle.setBackendOverride('cpu');
|
||||
await restartServerWithPolling(t('settings.gpu.errors.switchCpu'));
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to switch to CPU');
|
||||
setRestartPhase('idle');
|
||||
setError(e instanceof Error ? e.message : t('settings.gpu.errors.switchCpu'));
|
||||
refetchCudaStatus();
|
||||
refetchRocmStatus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchToCuda = async () => {
|
||||
setError(null);
|
||||
setRestartPhase('stopping');
|
||||
try {
|
||||
await platform.lifecycle.setBackendOverride('cuda');
|
||||
await restartServerWithPolling(t('settings.gpu.errors.restartFailed'));
|
||||
} catch (e: unknown) {
|
||||
setRestartPhase('idle');
|
||||
setError(e instanceof Error ? e.message : t('settings.gpu.errors.restartFailed'));
|
||||
refetchCudaStatus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const handleSwitchToRocm = async () => {
|
||||
setError(null);
|
||||
setRestartPhase('stopping');
|
||||
try {
|
||||
await platform.lifecycle.setBackendOverride('rocm');
|
||||
await restartServerWithPolling(t('settings.gpu.errors.restartFailed'));
|
||||
} catch (e: unknown) {
|
||||
setRestartPhase('idle');
|
||||
setError(e instanceof Error ? e.message : t('settings.gpu.errors.restartFailed'));
|
||||
refetchRocmStatus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCuda = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.deleteCudaBackend();
|
||||
refetchCudaStatus();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to delete CUDA backend');
|
||||
setError(e instanceof Error ? e.message : t('settings.gpu.errors.deleteCuda'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRocm = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.deleteRocmBackend();
|
||||
refetchRocmStatus();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : t('settings.gpu.errors.deleteRocm'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -271,6 +395,7 @@ export function GpuPage() {
|
||||
const hasNativeGpu =
|
||||
health.gpu_available &&
|
||||
!isCurrentlyCuda &&
|
||||
!isCurrentlyRocm &&
|
||||
health.gpu_type &&
|
||||
!health.gpu_type.includes('CUDA');
|
||||
|
||||
@@ -278,128 +403,222 @@ export function GpuPage() {
|
||||
<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>
|
||||
{!hasNativeGpu && !isCurrentlyCuda && !isCurrentlyRocm && (
|
||||
<>
|
||||
<SettingSection
|
||||
title={t('settings.gpu.cuda.title')}
|
||||
description={t('settings.gpu.cuda.description')}
|
||||
>
|
||||
{cudaDownloading && downloadProgress && (
|
||||
<SettingRow title={t('settings.gpu.cuda.downloading')}>
|
||||
<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
|
||||
? t('settings.gpu.cuda.updating')
|
||||
: t('settings.gpu.cuda.downloadingShort'))}
|
||||
</span>
|
||||
<span>
|
||||
{downloadProgress.total > 0
|
||||
? `${formatBytes(downloadProgress.current)} / ${formatBytes(downloadProgress.total)}`
|
||||
: `${downloadProgress.progress.toFixed(1)}%`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{/* Restart in progress */}
|
||||
{restartPhase !== 'idle' && (
|
||||
{restartPhase !== 'idle' && (
|
||||
<SettingRow
|
||||
title={
|
||||
restartPhase === 'ready'
|
||||
? t('settings.gpu.restart.ready')
|
||||
: restartPhase === 'waiting'
|
||||
? t('settings.gpu.restart.waiting')
|
||||
: t('settings.gpu.restart.stopping')
|
||||
}
|
||||
action={<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<SettingRow title={t('common.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>
|
||||
)}
|
||||
|
||||
{restartPhase === 'idle' && !cudaDownloading && (
|
||||
<>
|
||||
{!cudaAvailable && !isCurrentlyCuda && (
|
||||
<SettingRow
|
||||
title={t('settings.gpu.download.title')}
|
||||
description={t('settings.gpu.download.description')}
|
||||
action={
|
||||
<Button onClick={handleDownloadCuda} size="sm">
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.gpu.download.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && (
|
||||
<SettingRow
|
||||
title={t('settings.gpu.switchToCuda.title')}
|
||||
description={t('settings.gpu.switchToCuda.description')}
|
||||
action={
|
||||
<Button onClick={handleSwitchToCuda} size="sm">
|
||||
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.gpu.switchToCuda.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{cudaAvailable && !isCurrentlyCuda && (
|
||||
<SettingRow
|
||||
title={t('settings.gpu.remove.title')}
|
||||
description={t('settings.gpu.remove.description')}
|
||||
action={
|
||||
<Button
|
||||
onClick={handleDeleteCuda}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.gpu.remove.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingSection>
|
||||
|
||||
{supportsRocm && (
|
||||
<SettingSection
|
||||
title={t('settings.gpu.rocm.title')}
|
||||
description={t('settings.gpu.rocm.description')}
|
||||
>
|
||||
{rocmDownloading && rocmDownloadProgress && (
|
||||
<SettingRow title={t('settings.gpu.rocm.downloading')}>
|
||||
<div className="space-y-1.5">
|
||||
<Progress value={rocmDownloadProgress.progress} className="h-2" />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{rocmDownloadProgress.filename ||
|
||||
(rocmAvailable
|
||||
? t('settings.gpu.rocm.updating')
|
||||
: t('settings.gpu.rocm.downloadingShort'))}
|
||||
</span>
|
||||
<span>
|
||||
{rocmDownloadProgress.total > 0
|
||||
? `${formatBytes(rocmDownloadProgress.current)} / ${formatBytes(rocmDownloadProgress.total)}`
|
||||
: `${rocmDownloadProgress.progress.toFixed(1)}%`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{restartPhase === 'idle' && !rocmDownloading && (
|
||||
<>
|
||||
{!rocmAvailable && !isCurrentlyRocm && (
|
||||
<SettingRow
|
||||
title={t('settings.gpu.downloadRocm.title')}
|
||||
description={t('settings.gpu.downloadRocm.description')}
|
||||
action={
|
||||
<Button onClick={handleDownloadRocm} size="sm">
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.gpu.downloadRocm.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rocmAvailable && !isCurrentlyRocm && platform.metadata.isTauri && (
|
||||
<SettingRow
|
||||
title={t('settings.gpu.switchToRocm.title')}
|
||||
description={t('settings.gpu.switchToRocm.description')}
|
||||
action={
|
||||
<Button onClick={handleSwitchToRocm} size="sm">
|
||||
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.gpu.switchToRocm.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rocmAvailable && !isCurrentlyRocm && (
|
||||
<SettingRow
|
||||
title={t('settings.gpu.removeRocm.title')}
|
||||
description={t('settings.gpu.removeRocm.description')}
|
||||
action={
|
||||
<Button
|
||||
onClick={handleDeleteRocm}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.gpu.removeRocm.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingSection>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(isCurrentlyCuda || isCurrentlyRocm) && platform.metadata.isTauri && (
|
||||
<SettingSection
|
||||
title={isCurrentlyCuda ? t('settings.gpu.cuda.activeTitle') : t('settings.gpu.rocm.activeTitle')}
|
||||
description={t('settings.gpu.activeBackend.description')}
|
||||
>
|
||||
{restartPhase !== 'idle' ? (
|
||||
<SettingRow
|
||||
title={
|
||||
restartPhase === 'ready'
|
||||
? 'Server restarted successfully'
|
||||
? t('settings.gpu.restart.ready')
|
||||
: restartPhase === 'waiting'
|
||||
? 'Restarting server...'
|
||||
: 'Stopping server...'
|
||||
? t('settings.gpu.restart.waiting')
|
||||
: t('settings.gpu.restart.stopping')
|
||||
}
|
||||
action={<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
|
||||
/>
|
||||
) : (
|
||||
<SettingRow
|
||||
title={t('settings.gpu.switchToCpu.title')}
|
||||
description={t('settings.gpu.switchToCpu.description')}
|
||||
action={
|
||||
<Button onClick={handleSwitchToCpu} variant="outline" size="sm">
|
||||
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.gpu.switchToCpu.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<SettingRow title="Error">
|
||||
<SettingRow title={t('common.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>
|
||||
<p className="text-xs text-muted-foreground/60 leading-relaxed">{t('settings.gpu.footer')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { type LanguageCode, SUPPORTED_LANGUAGES } from '@/i18n';
|
||||
|
||||
export function LanguageSelect() {
|
||||
const { i18n } = useTranslation();
|
||||
const current = SUPPORTED_LANGUAGES.find((l) => l.code === i18n.language)?.code ?? 'en';
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={current}
|
||||
onValueChange={(value) => {
|
||||
void i18n.changeLanguage(value as LanguageCode);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { type LogEntry, useLogStore } from '@/stores/logStore';
|
||||
@@ -32,6 +33,7 @@ function LogLine({ entry }: { entry: LogEntry }) {
|
||||
}
|
||||
|
||||
export function LogsPage() {
|
||||
const { t } = useTranslation();
|
||||
const entries = useLogStore((s) => s.entries);
|
||||
const clear = useLogStore((s) => s.clear);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -56,9 +58,9 @@ export function LogsPage() {
|
||||
<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>
|
||||
<h3 className="text-sm font-medium">{t('settings.logs.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{entries.length} {entries.length === 1 ? 'line' : 'lines'}
|
||||
{t('settings.logs.lineCount', { count: entries.length })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -71,11 +73,11 @@ export function LogsPage() {
|
||||
containerRef.current?.scrollTo({ top: containerRef.current.scrollHeight });
|
||||
}}
|
||||
>
|
||||
Scroll to bottom
|
||||
{t('settings.logs.scrollToBottom')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={clear}>
|
||||
Clear
|
||||
{t('settings.logs.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,13 +89,8 @@ export function LogsPage() {
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<p>{t('settings.logs.empty')}</p>
|
||||
{!import.meta.env?.PROD && <p>{t('settings.logs.devHint')}</p>}
|
||||
</div>
|
||||
) : (
|
||||
entries.map((entry) => <LogLine key={entry.id} entry={entry} />)
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import { Check, Copy, Plug, Trash2, Waypoints } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useMCPBindings } from '@/lib/hooks/useMCPBindings';
|
||||
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||
import { useCaptureSettings } from '@/lib/hooks/useSettings';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { formatDate } from '@/lib/utils/format';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
|
||||
function getStdioShimCommand(): string {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return '/Applications/Voicebox.app/Contents/MacOS/voicebox-mcp';
|
||||
}
|
||||
|
||||
const platform = `${navigator.platform} ${navigator.userAgent}`.toLowerCase();
|
||||
if (platform.includes('win')) {
|
||||
return 'C:\\Program Files\\Voicebox\\voicebox-mcp.exe';
|
||||
}
|
||||
if (platform.includes('linux')) {
|
||||
return '/opt/voicebox/voicebox-mcp';
|
||||
}
|
||||
return '/Applications/Voicebox.app/Contents/MacOS/voicebox-mcp';
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → MCP — configure per-agent voice binding and show copy-paste
|
||||
* install snippets for major MCP clients. Backend runs at /mcp on the
|
||||
* existing Voicebox server; this page is the agent-onboarding surface.
|
||||
*/
|
||||
export function MCPPage() {
|
||||
const { t } = useTranslation();
|
||||
const serverUrl = useServerStore((s) => s.serverUrl);
|
||||
const { bindings, upsertAsync, remove } = useMCPBindings();
|
||||
const { data: profiles } = useProfiles();
|
||||
const { settings: captureSettings, update: updateCapture } = useCaptureSettings();
|
||||
|
||||
const defaultProfileId = captureSettings?.default_playback_voice_id ?? '';
|
||||
const mcpUrl = `${serverUrl}/mcp`;
|
||||
const stdioShimCommand = getStdioShimCommand();
|
||||
|
||||
const [newClientId, setNewClientId] = useState('');
|
||||
const [newLabel, setNewLabel] = useState('');
|
||||
const [newProfileId, setNewProfileId] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newClientId.trim()) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
await upsertAsync({
|
||||
client_id: newClientId.trim(),
|
||||
label: newLabel.trim() || null,
|
||||
profile_id: newProfileId || null,
|
||||
});
|
||||
setNewClientId('');
|
||||
setNewLabel('');
|
||||
setNewProfileId('');
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-8 items-start max-w-5xl">
|
||||
<div className="flex-1 min-w-0 max-w-2xl space-y-8">
|
||||
<SettingSection
|
||||
title={t('settings.mcp.install.title')}
|
||||
description={t('settings.mcp.install.description')}
|
||||
>
|
||||
<SnippetRow
|
||||
title={t('settings.mcp.install.http.title')}
|
||||
description={t('settings.mcp.install.http.description')}
|
||||
snippet={JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
voicebox: {
|
||||
url: mcpUrl,
|
||||
headers: { 'X-Voicebox-Client-Id': 'claude-code' },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
/>
|
||||
<SnippetRow
|
||||
title={t('settings.mcp.install.claudeCode.title')}
|
||||
description={t('settings.mcp.install.claudeCode.description')}
|
||||
snippet={`claude mcp add voicebox --transport http --url ${mcpUrl} --header "X-Voicebox-Client-Id: claude-code"`}
|
||||
/>
|
||||
<SnippetRow
|
||||
title={t('settings.mcp.install.stdio.title')}
|
||||
description={t('settings.mcp.install.stdio.description')}
|
||||
snippet={JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
voicebox: {
|
||||
command: stdioShimCommand,
|
||||
env: { VOICEBOX_CLIENT_ID: 'claude-code' },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection
|
||||
title={t('settings.mcp.defaultVoice.title')}
|
||||
description={t('settings.mcp.defaultVoice.description')}
|
||||
>
|
||||
<SettingRow
|
||||
title={t('settings.mcp.defaultVoice.label')}
|
||||
description={t('settings.mcp.defaultVoice.labelHint')}
|
||||
action={
|
||||
<Select
|
||||
value={defaultProfileId || '__default__'}
|
||||
onValueChange={(v) =>
|
||||
updateCapture({
|
||||
default_playback_voice_id: v === '__default__' ? null : v,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[220px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">
|
||||
{t('settings.mcp.defaultVoice.none')}
|
||||
</SelectItem>
|
||||
{(profiles ?? []).map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection
|
||||
title={t('settings.mcp.bindings.title')}
|
||||
description={t('settings.mcp.bindings.description')}
|
||||
>
|
||||
{bindings.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 italic">
|
||||
<Trans i18nKey="settings.mcp.bindings.empty" components={{ code: <code /> }} />
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border/60">
|
||||
{bindings.map((b) => (
|
||||
<div
|
||||
key={b.client_id}
|
||||
className="py-3 grid grid-cols-[1fr_auto_auto] gap-4 items-center"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm truncate">
|
||||
{b.label || b.client_id}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
<code className="text-[11px]">{b.client_id}</code>
|
||||
{' · '}
|
||||
{b.last_seen_at ? (
|
||||
<span title={t('settings.mcp.bindings.lastSeenTitle', { when: b.last_seen_at })}>
|
||||
<Plug className="inline h-3 w-3 text-emerald-500" />{' '}
|
||||
{t('settings.mcp.bindings.lastSeen', { when: formatDate(b.last_seen_at) })}
|
||||
</span>
|
||||
) : (
|
||||
<span>{t('settings.mcp.bindings.neverConnected')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={b.profile_id ?? '__default__'}
|
||||
onValueChange={(v) =>
|
||||
upsertAsync({
|
||||
client_id: b.client_id,
|
||||
label: b.label,
|
||||
profile_id: v === '__default__' ? null : v,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">
|
||||
{t('settings.mcp.bindings.defaultOption')}
|
||||
</SelectItem>
|
||||
{(profiles ?? []).map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => remove(b.client_id)}
|
||||
aria-label={t('settings.mcp.bindings.removeAria', { client: b.client_id })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4 space-y-2">
|
||||
<div className="text-sm font-medium">{t('settings.mcp.bindings.add.title')}</div>
|
||||
<div className="grid grid-cols-[1fr_1fr_auto] gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('settings.mcp.bindings.add.clientIdPlaceholder')}
|
||||
value={newClientId}
|
||||
onChange={(e) => setNewClientId(e.target.value)}
|
||||
className="h-9 px-3 rounded-md border bg-background text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('settings.mcp.bindings.add.labelPlaceholder')}
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
className="h-9 px-3 rounded-md border bg-background text-sm"
|
||||
/>
|
||||
<Select
|
||||
value={newProfileId || '__default__'}
|
||||
onValueChange={(v) => setNewProfileId(v === '__default__' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="h-9 min-w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">
|
||||
{t('settings.mcp.bindings.defaultOption')}
|
||||
</SelectItem>
|
||||
{(profiles ?? []).map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
disabled={!newClientId.trim() || adding}
|
||||
>
|
||||
{t('settings.mcp.bindings.add.action')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingSection>
|
||||
</div>
|
||||
|
||||
<aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">{t('settings.mcp.sidebar.aboutTitle')}</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t('settings.mcp.sidebar.aboutBody')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">{t('settings.mcp.sidebar.toolsTitle')}</h3>
|
||||
<ul className="text-sm text-muted-foreground space-y-1.5 leading-relaxed">
|
||||
<li>
|
||||
<code className="text-accent">voicebox.speak</code>
|
||||
<div>{t('settings.mcp.sidebar.tools.speak')}</div>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-accent">voicebox.transcribe</code>
|
||||
<div>{t('settings.mcp.sidebar.tools.transcribe')}</div>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-accent">voicebox.list_captures</code>
|
||||
<div>{t('settings.mcp.sidebar.tools.listCaptures')}</div>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-accent">voicebox.list_profiles</code>
|
||||
<div>{t('settings.mcp.sidebar.tools.listProfiles')}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Waypoints className="h-3.5 w-3.5 text-accent" />
|
||||
<span>
|
||||
<Trans i18nKey="settings.mcp.sidebar.postSpeak" components={{ code: <code /> }} />
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SnippetRow({
|
||||
title,
|
||||
description,
|
||||
snippet,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
snippet: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(snippet);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// ignore; user can still select-and-copy the pre content
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
<div className="text-xs text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={copy}>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.mcp.install.copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.mcp.install.copy')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-[11px] font-mono p-3 rounded-md bg-muted/50 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{snippet}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import { Link, Outlet, useMatchRoute } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
|
||||
interface SettingsTab {
|
||||
label: string;
|
||||
labelKey?: string;
|
||||
label?: string;
|
||||
path:
|
||||
| '/settings'
|
||||
| '/settings/generation'
|
||||
| '/settings/captures'
|
||||
| '/settings/mcp'
|
||||
| '/settings/gpu'
|
||||
| '/settings/logs'
|
||||
| '/settings/changelog'
|
||||
@@ -17,15 +21,18 @@ interface SettingsTab {
|
||||
}
|
||||
|
||||
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' },
|
||||
{ labelKey: 'settings.tabs.general', path: '/settings' },
|
||||
{ labelKey: 'settings.tabs.generation', path: '/settings/generation' },
|
||||
{ labelKey: 'settings.tabs.captures', path: '/settings/captures' },
|
||||
{ labelKey: 'settings.tabs.mcp', path: '/settings/mcp' },
|
||||
{ labelKey: 'settings.tabs.gpu', path: '/settings/gpu', tauriOnly: true },
|
||||
{ labelKey: 'settings.tabs.logs', path: '/settings/logs', tauriOnly: true },
|
||||
{ labelKey: 'settings.tabs.changelog', path: '/settings/changelog' },
|
||||
{ labelKey: 'settings.tabs.about', path: '/settings/about' },
|
||||
];
|
||||
|
||||
export function SettingsLayout() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const isPlayerVisible = !!usePlayerStore((state) => state.audioUrl);
|
||||
const matchRoute = useMatchRoute();
|
||||
@@ -52,7 +59,7 @@ export function SettingsLayout() {
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.label ?? (tab.labelKey ? t(tab.labelKey) : '')}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -14,7 +14,7 @@ export function SettingSection({
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{title && <h3 className="text-sm font-medium">{title}</h3>}
|
||||
{title && <h3 className="text-lg font-semibold">{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}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { type Theme, useUIStore } from '@/stores/uiStore';
|
||||
|
||||
export function ThemeSelect() {
|
||||
const { t } = useTranslation();
|
||||
const theme = useUIStore((s) => s.theme);
|
||||
const setTheme = useUIStore((s) => s.setTheme);
|
||||
|
||||
return (
|
||||
<Select value={theme} onValueChange={(value) => setTheme(value as Theme)}>
|
||||
<SelectTrigger className="h-9 w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="system">{t('settings.theme.options.system')}</SelectItem>
|
||||
<SelectItem value="light">{t('settings.theme.options.light')}</SelectItem>
|
||||
<SelectItem value="dark">{t('settings.theme.options.dark')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -1,6 +1,7 @@
|
||||
import { Link, useMatchRoute } from '@tanstack/react-router';
|
||||
import { AudioLines, Box, Mic, Settings, Speaker, Volume2, Wand2 } from 'lucide-react';
|
||||
import { AudioLines, Box, Captions, type LucideIcon, Mic, Settings, Volume2, Wand2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
@@ -12,17 +13,24 @@ interface SidebarProps {
|
||||
isMacOS?: boolean;
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'main', path: '/', icon: Volume2, label: 'Generate' },
|
||||
{ id: 'stories', path: '/stories', icon: AudioLines, label: 'Stories' },
|
||||
{ id: 'voices', path: '/voices', icon: Mic, label: 'Voices' },
|
||||
{ id: 'effects', path: '/effects', icon: Wand2, label: 'Effects' },
|
||||
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
|
||||
{ id: 'models', path: '/models', icon: Box, label: 'Models' },
|
||||
{ id: 'settings', path: '/settings', icon: Settings, label: 'Settings' },
|
||||
const tabs: Array<{
|
||||
id: string;
|
||||
path: string;
|
||||
icon: LucideIcon;
|
||||
labelKey?: string;
|
||||
label?: string;
|
||||
}> = [
|
||||
{ id: 'main', path: '/', icon: Volume2, labelKey: 'nav.generate' },
|
||||
{ id: 'stories', path: '/stories', icon: AudioLines, labelKey: 'nav.stories' },
|
||||
{ id: 'captures', path: '/captures', icon: Captions, labelKey: 'nav.captures' },
|
||||
{ id: 'voices', path: '/voices', icon: Mic, labelKey: 'nav.voices' },
|
||||
{ id: 'effects', path: '/effects', icon: Wand2, labelKey: 'nav.effects' },
|
||||
{ id: 'models', path: '/models', icon: Box, labelKey: 'nav.models' },
|
||||
{ id: 'settings', path: '/settings', icon: Settings, labelKey: 'nav.settings' },
|
||||
];
|
||||
|
||||
export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const matchRoute = useMatchRoute();
|
||||
const isPlayerOpen = !!usePlayerStore((s) => s.audioUrl);
|
||||
const platform = usePlatform();
|
||||
@@ -39,15 +47,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="mb-2">
|
||||
<img
|
||||
src={voiceboxLogo}
|
||||
alt="Voicebox"
|
||||
className="w-12 h-12 object-contain"
|
||||
style={{
|
||||
filter:
|
||||
'drop-shadow(0 0 6px hsl(var(--accent) / 0.5)) drop-shadow(0 0 14px hsl(var(--accent) / 0.35)) drop-shadow(0 0 28px hsl(var(--accent) / 0.2))',
|
||||
}}
|
||||
/>
|
||||
<img src={voiceboxLogo} alt="Voicebox" className="sidebar-logo w-12 h-12 object-contain" />
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
@@ -72,8 +72,8 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
? 'bg-white/[0.07] text-foreground shadow-lg backdrop-blur-sm border border-white/[0.08]'
|
||||
: 'text-muted-foreground hover:bg-muted/50',
|
||||
)}
|
||||
title={tab.label}
|
||||
aria-label={tab.label}
|
||||
title={tab.label ?? (tab.labelKey ? t(tab.labelKey) : tab.id)}
|
||||
aria-label={tab.label ?? (tab.labelKey ? t(tab.labelKey) : tab.id)}
|
||||
>
|
||||
{isActive && (
|
||||
<div
|
||||
@@ -102,7 +102,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
to="/settings"
|
||||
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
|
||||
{t('nav.updateBadge')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ export function StoriesTab() {
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
|
||||
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 -mx-8">
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden relative">
|
||||
{/* Left Column - Story List */}
|
||||
@@ -16,7 +16,7 @@ export function StoriesTab() {
|
||||
</div>
|
||||
|
||||
{/* Right Column - Story Content */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden flex-1">
|
||||
<div className="flex flex-col min-h-0 overflow-hidden flex-1 pr-8">
|
||||
<StoryContent />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical, Mic, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
||||
import { GripVertical, Mic, MoreHorizontal, Music, Play, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -20,6 +21,7 @@ interface StoryChatItemProps {
|
||||
storyId: string;
|
||||
index: number;
|
||||
onRemove: () => void;
|
||||
onRegenerate?: () => void;
|
||||
currentTimeMs: number;
|
||||
isPlaying: boolean;
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLButtonElement>;
|
||||
@@ -29,11 +31,13 @@ interface StoryChatItemProps {
|
||||
export function StoryChatItem({
|
||||
item,
|
||||
onRemove,
|
||||
onRegenerate,
|
||||
currentTimeMs,
|
||||
isPlaying,
|
||||
dragHandleProps,
|
||||
isDragging,
|
||||
}: StoryChatItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const seek = useStoryStore((state) => state.seek);
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
@@ -81,13 +85,15 @@ export function StoryChatItem({
|
||||
{/* Voice Avatar */}
|
||||
<div className="shrink-0">
|
||||
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center overflow-hidden">
|
||||
{!avatarError ? (
|
||||
{item.engine === 'import' ? (
|
||||
<Music className="h-5 w-5 text-muted-foreground" />
|
||||
) : !avatarError ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={`${item.profile_name} avatar`}
|
||||
className={cn(
|
||||
'h-full w-full object-cover transition-all duration-200',
|
||||
!isCurrentlyPlaying && 'grayscale'
|
||||
!isCurrentlyPlaying && 'grayscale',
|
||||
)}
|
||||
onError={() => setAvatarError(true)}
|
||||
/>
|
||||
@@ -100,36 +106,56 @@ export function StoryChatItem({
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-medium text-sm">{item.profile_name}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.language}</span>
|
||||
<span className="font-medium text-sm truncate">
|
||||
{item.engine === 'import' ? item.text : item.profile_name}
|
||||
</span>
|
||||
{item.engine !== 'import' && (
|
||||
<span className="text-xs text-muted-foreground">{item.language}</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground tabular-nums ml-auto">
|
||||
{formatTime(itemStartMs)}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={item.text}
|
||||
className="flex-1 resize-none text-sm text-muted-foreground select-text bg-card cursor-text"
|
||||
readOnly
|
||||
onDoubleClick={handlePlay}
|
||||
/>
|
||||
{item.engine === 'import' ? null : (
|
||||
<Textarea
|
||||
value={item.text}
|
||||
className="flex-1 resize-none text-sm text-muted-foreground select-text bg-card cursor-text"
|
||||
readOnly
|
||||
onDoubleClick={handlePlay}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label={t('history.actions.menu')}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handlePlay}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Play from here
|
||||
{t('storyContent.itemActions.playFromHere')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onRemove} className="text-destructive focus:text-destructive">
|
||||
{onRegenerate && (
|
||||
<DropdownMenuItem onClick={onRegenerate}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t('storyContent.itemActions.regenerate')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={onRemove}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Remove from Story
|
||||
{t('storyContent.itemActions.removeFromStory')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -139,15 +165,12 @@ export function StoryChatItem({
|
||||
}
|
||||
|
||||
// Sortable wrapper component
|
||||
export function SortableStoryChatItem(props: Omit<StoryChatItemProps, 'dragHandleProps' | 'isDragging'>) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: props.item.generation_id });
|
||||
export function SortableStoryChatItem(
|
||||
props: Omit<StoryChatItemProps, 'dragHandleProps' | 'isDragging'>,
|
||||
) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: props.item.generation_id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -156,11 +179,7 @@ export function SortableStoryChatItem(props: Omit<StoryChatItemProps, 'dragHandl
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
<StoryChatItem
|
||||
{...props}
|
||||
dragHandleProps={listeners}
|
||||
isDragging={isDragging}
|
||||
/>
|
||||
<StoryChatItem {...props} dragHandleProps={listeners} isDragging={isDragging} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,13 +15,15 @@ import {
|
||||
} from '@dnd-kit/sortable';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Download, Plus } from 'lucide-react';
|
||||
import { Download, Music, Plus, Upload } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Loader from 'react-loaders';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useHistory } from '@/lib/hooks/useHistory';
|
||||
import {
|
||||
useAddStoryItem,
|
||||
@@ -36,6 +38,7 @@ import { useStoryStore } from '@/stores/storyStore';
|
||||
import { SortableStoryChatItem } from './StoryChatItem';
|
||||
|
||||
export function StoryContent() {
|
||||
const { t } = useTranslation();
|
||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||
const { data: story, isLoading } = useStory(selectedStoryId);
|
||||
const removeItem = useRemoveStoryItem();
|
||||
@@ -44,7 +47,12 @@ export function StoryContent() {
|
||||
const addStoryItem = useAddStoryItem();
|
||||
const { toast } = useToast();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
|
||||
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
|
||||
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const dragDepthRef = useRef(0);
|
||||
|
||||
// Add generation popover state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -70,8 +78,12 @@ export function StoryContent() {
|
||||
// Track editor is shown when story has items
|
||||
const hasBottomBar = story && story.items.length > 0;
|
||||
|
||||
// Calculate dynamic bottom padding: track editor + gap
|
||||
const bottomPadding = hasBottomBar ? trackEditorHeight + 24 : 0;
|
||||
// Clear the floating generate box (always visible on this route) and the
|
||||
// track editor bar when it's showing.
|
||||
const FLOATING_BOX_CLEARANCE = 140;
|
||||
const bottomPadding = hasBottomBar
|
||||
? trackEditorHeight + FLOATING_BOX_CLEARANCE
|
||||
: FLOATING_BOX_CLEARANCE;
|
||||
|
||||
// Drag and drop sensors
|
||||
const sensors = useSensors(
|
||||
@@ -136,6 +148,19 @@ export function StoryContent() {
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
const handleRegenerate = async (generationId: string) => {
|
||||
try {
|
||||
await apiClient.regenerateGeneration(generationId);
|
||||
addPendingGeneration(generationId);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('storyContent.toast.regenerateFailed'),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveItem = (itemId: string) => {
|
||||
if (!story) return;
|
||||
|
||||
@@ -147,7 +172,7 @@ export function StoryContent() {
|
||||
{
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to remove item',
|
||||
title: t('storyContent.toast.removeFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -179,7 +204,7 @@ export function StoryContent() {
|
||||
{
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to reorder items',
|
||||
title: t('storyContent.toast.reorderFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -199,7 +224,7 @@ export function StoryContent() {
|
||||
{
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to export audio',
|
||||
title: t('storyContent.toast.exportFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -208,6 +233,33 @@ export function StoryContent() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleImportAudio = async (file: File) => {
|
||||
if (!story) return;
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const generation = await apiClient.importAudio(file);
|
||||
await addStoryItem.mutateAsync({
|
||||
storyId: story.id,
|
||||
data: { generation_id: generation.id },
|
||||
});
|
||||
setIsAddOpen(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('storyContent.toast.importFailed'),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportFiles = async (files: FileList | File[]) => {
|
||||
for (const file of Array.from(files)) {
|
||||
await handleImportAudio(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddGeneration = (generationId: string) => {
|
||||
if (!story) return;
|
||||
|
||||
@@ -223,7 +275,7 @@ export function StoryContent() {
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to add generation',
|
||||
title: t('storyContent.toast.addFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -236,8 +288,8 @@ export function StoryContent() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-medium mb-2">Select a story</p>
|
||||
<p className="text-sm">Choose a story from the list to view its content</p>
|
||||
<p className="text-lg font-medium mb-2">{t('storyContent.selectStory.title')}</p>
|
||||
<p className="text-sm">{t('storyContent.selectStory.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -246,7 +298,7 @@ export function StoryContent() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-muted-foreground">Loading story...</div>
|
||||
<div className="text-muted-foreground">{t('storyContent.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -255,17 +307,62 @@ export function StoryContent() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-medium mb-2">Story not found</p>
|
||||
<p className="text-sm">The selected story could not be loaded</p>
|
||||
<p className="text-lg font-medium mb-2">{t('storyContent.notFound.title')}</p>
|
||||
<p className="text-sm">{t('storyContent.notFound.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div
|
||||
className="flex flex-col h-full min-h-0 relative overflow-hidden"
|
||||
onDragEnter={(e) => {
|
||||
if (!e.dataTransfer?.types.includes('Files')) return;
|
||||
e.preventDefault();
|
||||
dragDepthRef.current += 1;
|
||||
setIsDraggingFile(true);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer?.types.includes('Files')) e.preventDefault();
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.dataTransfer?.types.includes('Files')) return;
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) setIsDraggingFile(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!e.dataTransfer?.files?.length) return;
|
||||
e.preventDefault();
|
||||
dragDepthRef.current = 0;
|
||||
setIsDraggingFile(false);
|
||||
handleImportFiles(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept="audio/*,.wav,.mp3,.flac,.ogg,.m4a,.aac,.webm"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) handleImportFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{isDraggingFile && (
|
||||
<div className="absolute inset-0 z-30 pointer-events-none flex items-center justify-center bg-accent/10 border-2 border-dashed border-accent rounded-lg m-4">
|
||||
<div className="flex flex-col items-center gap-2 text-accent">
|
||||
<Music className="h-8 w-8" />
|
||||
<span className="text-sm font-medium">{t('storyContent.dropToImport')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Scroll Mask */}
|
||||
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<div className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between px-1">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{story.name}</h2>
|
||||
{story.description && (
|
||||
@@ -291,7 +388,7 @@ export function StoryContent() {
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
Generating {pendingCount} {pendingCount === 1 ? 'audio' : 'audios'}
|
||||
{t('storyContent.generatingCount', { count: pendingCount })}
|
||||
</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
@@ -301,22 +398,34 @@ export function StoryContent() {
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add
|
||||
{t('storyContent.add')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
<div className="p-2 border-b">
|
||||
<div className="p-2 border-b space-y-2">
|
||||
<Input
|
||||
placeholder="Search by name or transcript..."
|
||||
placeholder={t('storyContent.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
disabled={isImporting}
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{isImporting ? t('storyContent.importing') : t('storyContent.importAudio')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{availableGenerations.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{searchQuery ? 'No matching generations found' : 'No available generations'}
|
||||
{searchQuery
|
||||
? t('storyContent.searchNoMatches')
|
||||
: t('storyContent.searchNoAvailable')}
|
||||
</div>
|
||||
) : (
|
||||
availableGenerations.map((gen) => (
|
||||
@@ -344,7 +453,7 @@ export function StoryContent() {
|
||||
disabled={exportAudio.isPending}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export Audio
|
||||
{t('storyContent.exportAudio')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -353,13 +462,13 @@ export function StoryContent() {
|
||||
{/* Content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 min-h-0 overflow-y-auto space-y-3"
|
||||
className="flex-1 min-h-0 overflow-y-auto space-y-3 pt-16 scroll-pt-16 relative z-0"
|
||||
style={{ paddingBottom: bottomPadding > 0 ? `${bottomPadding}px` : undefined }}
|
||||
>
|
||||
{sortedItems.length === 0 ? (
|
||||
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-md text-muted-foreground">
|
||||
<p className="text-sm">No items in this story</p>
|
||||
<p className="text-xs mt-2">Generate speech using the box below to add items</p>
|
||||
<p className="text-sm">{t('storyContent.empty.title')}</p>
|
||||
<p className="text-xs mt-2">{t('storyContent.empty.hint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
@@ -388,6 +497,11 @@ export function StoryContent() {
|
||||
storyId={story.id}
|
||||
index={index}
|
||||
onRemove={() => handleRemoveItem(item.id)}
|
||||
onRegenerate={
|
||||
item.engine === 'import'
|
||||
? undefined
|
||||
: () => handleRegenerate(item.generation_id)
|
||||
}
|
||||
currentTimeMs={currentTimeMs}
|
||||
isPlaying={isPlaying && playbackStoryId === story.id}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -27,6 +29,15 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
ListPane,
|
||||
ListPaneActions,
|
||||
ListPaneHeader,
|
||||
ListPaneScroll,
|
||||
ListPaneSearch,
|
||||
ListPaneTitle,
|
||||
ListPaneTitleRow,
|
||||
} from '@/components/ListPane';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import {
|
||||
@@ -41,6 +52,7 @@ import { formatDate } from '@/lib/utils/format';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
|
||||
export function StoryList() {
|
||||
const { t } = useTranslation();
|
||||
const { data: stories, isLoading } = useStories();
|
||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
|
||||
@@ -60,6 +72,7 @@ export function StoryList() {
|
||||
const [deletingStoryId, setDeletingStoryId] = useState<string | null>(null);
|
||||
const [newStoryName, setNewStoryName] = useState('');
|
||||
const [newStoryDescription, setNewStoryDescription] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const { toast } = useToast();
|
||||
|
||||
// Auto-select the first story when the list loads with no selection
|
||||
@@ -72,8 +85,8 @@ export function StoryList() {
|
||||
const handleCreateStory = () => {
|
||||
if (!newStoryName.trim()) {
|
||||
toast({
|
||||
title: 'Name required',
|
||||
description: 'Please enter a story name',
|
||||
title: t('stories.toast.nameRequired'),
|
||||
description: t('stories.toast.nameRequiredDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -91,13 +104,13 @@ export function StoryList() {
|
||||
setNewStoryName('');
|
||||
setNewStoryDescription('');
|
||||
toast({
|
||||
title: 'Story created',
|
||||
description: `"${story.name}" has been created`,
|
||||
title: t('stories.toast.created'),
|
||||
description: t('stories.toast.createdDescription', { name: story.name }),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to create story',
|
||||
title: t('stories.toast.createFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -116,8 +129,8 @@ export function StoryList() {
|
||||
const handleUpdateStory = () => {
|
||||
if (!editingStory || !newStoryName.trim()) {
|
||||
toast({
|
||||
title: 'Name required',
|
||||
description: 'Please enter a story name',
|
||||
title: t('stories.toast.nameRequired'),
|
||||
description: t('stories.toast.nameRequiredDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -140,7 +153,7 @@ export function StoryList() {
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to update story',
|
||||
title: t('stories.toast.updateFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -168,7 +181,7 @@ export function StoryList() {
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to delete story',
|
||||
title: t('stories.toast.deleteFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -176,85 +189,113 @@ export function StoryList() {
|
||||
});
|
||||
};
|
||||
|
||||
const storyList = stories || [];
|
||||
const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return storyList;
|
||||
return storyList.filter((s) => {
|
||||
const name = (s.name || '').toLowerCase();
|
||||
const description = (s.description || '').toLowerCase();
|
||||
return name.includes(q) || description.includes(q);
|
||||
});
|
||||
}, [search, storyList]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-muted-foreground">Loading stories...</div>
|
||||
<div className="text-muted-foreground">{t('stories.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const storyList = stories || [];
|
||||
const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col relative overflow-hidden">
|
||||
{/* Scroll Mask */}
|
||||
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
<ListPane>
|
||||
<ListPaneHeader>
|
||||
<ListPaneTitleRow>
|
||||
<ListPaneTitle>{t('stories.title')}</ListPaneTitle>
|
||||
<ListPaneActions>
|
||||
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('stories.newStory')}
|
||||
</Button>
|
||||
</ListPaneActions>
|
||||
</ListPaneTitleRow>
|
||||
<ListPaneSearch
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={t('stories.searchPlaceholder')}
|
||||
/>
|
||||
</ListPaneHeader>
|
||||
|
||||
{/* Fixed Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-20">
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<h2 className="text-2xl font-bold">Stories</h2>
|
||||
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Story
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Story List */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto pt-14 relative z-0"
|
||||
<ListPaneScroll
|
||||
style={{ paddingBottom: hasTrackEditor ? `${trackEditorHeight + 140}px` : '170px' }}
|
||||
>
|
||||
{storyList.length === 0 ? (
|
||||
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
||||
<div className="mx-4 text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
||||
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">No stories yet</p>
|
||||
<p className="text-xs mt-2">Create your first story to get started</p>
|
||||
<p className="text-sm">{t('stories.empty.title')}</p>
|
||||
<p className="text-xs mt-2">{t('stories.empty.hint')}</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
<p>{t('stories.empty.noMatches', { query: search })}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{storyList.map((story) => (
|
||||
<div
|
||||
key={story.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'px-5 py-3 rounded-lg transition-colors group flex items-center cursor-pointer',
|
||||
selectedStoryId === story.id ? 'bg-muted' : 'hover:bg-muted/50',
|
||||
)}
|
||||
aria-label={`Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}`}
|
||||
aria-pressed={selectedStoryId === story.id}
|
||||
onClick={() => setSelectedStoryId(story.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setSelectedStoryId(story.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 w-full min-w-0">
|
||||
<div className="flex-1 min-w-0 text-left overflow-hidden">
|
||||
<h3 className="text-sm font-medium truncate">{story.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{story.item_count} {story.item_count === 1 ? 'item' : 'items'}
|
||||
<div className="px-4 pb-6 space-y-1">
|
||||
{filtered.map((story) => {
|
||||
const isActive = selectedStoryId === story.id;
|
||||
return (
|
||||
<div key={story.id} className="relative group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedStoryId(story.id)}
|
||||
aria-label={t('stories.row.ariaLabel', {
|
||||
name: story.name,
|
||||
count: story.item_count,
|
||||
updated: formatDate(story.updated_at),
|
||||
})}
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
'w-full text-left p-3 rounded-lg transition-colors block',
|
||||
isActive
|
||||
? 'bg-muted/70 border border-border'
|
||||
: 'border border-transparent hover:bg-muted/30',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-[11px] text-muted-foreground font-medium">
|
||||
{formatDate(story.updated_at)}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{formatDate(story.updated_at)}</span>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[13px] line-clamp-2 leading-snug mb-2">
|
||||
<span className="text-foreground font-medium">{story.name}</span>
|
||||
{story.description ? (
|
||||
<>
|
||||
<span className="mx-1.5 text-muted-foreground/50">·</span>
|
||||
<span className="text-muted-foreground">{story.description}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-muted/60 text-muted-foreground"
|
||||
>
|
||||
{t('stories.row.itemCount', { count: story.item_count })}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
className="absolute top-2 right-2 h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Actions for ${story.name}`}
|
||||
aria-label={t('stories.row.actionsLabel', { name: story.name })}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -262,39 +303,37 @@ export function StoryList() {
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEditClick(story)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
{t('common.edit')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(story.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ListPaneScroll>
|
||||
|
||||
{/* Create Story Dialog */}
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Story</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new story to organize your voice generations into conversations.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('stories.createDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('stories.createDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="story-name">Name</Label>
|
||||
<Label htmlFor="story-name">{t('stories.fields.name')}</Label>
|
||||
<Input
|
||||
id="story-name"
|
||||
placeholder="My Story"
|
||||
placeholder={t('stories.fields.namePlaceholder')}
|
||||
value={newStoryName}
|
||||
onChange={(e) => setNewStoryName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -305,10 +344,10 @@ export function StoryList() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="story-description">Description (optional)</Label>
|
||||
<Label htmlFor="story-description">{t('stories.fields.descriptionLabel')}</Label>
|
||||
<Textarea
|
||||
id="story-description"
|
||||
placeholder="A conversation between..."
|
||||
placeholder={t('stories.fields.descriptionPlaceholder')}
|
||||
value={newStoryDescription}
|
||||
onChange={(e) => setNewStoryDescription(e.target.value)}
|
||||
rows={3}
|
||||
@@ -317,28 +356,29 @@ export function StoryList() {
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleCreateStory} disabled={createStory.isPending}>
|
||||
{createStory.isPending ? 'Creating...' : 'Create'}
|
||||
{createStory.isPending
|
||||
? t('stories.createDialog.creating')
|
||||
: t('stories.createDialog.action')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Story Dialog */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Story</DialogTitle>
|
||||
<DialogDescription>Update the story name and description.</DialogDescription>
|
||||
<DialogTitle>{t('stories.editDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('stories.editDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-story-name">Name</Label>
|
||||
<Label htmlFor="edit-story-name">{t('stories.fields.name')}</Label>
|
||||
<Input
|
||||
id="edit-story-name"
|
||||
placeholder="My Story"
|
||||
placeholder={t('stories.fields.namePlaceholder')}
|
||||
value={newStoryName}
|
||||
onChange={(e) => setNewStoryName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -349,10 +389,10 @@ export function StoryList() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-story-description">Description (optional)</Label>
|
||||
<Label htmlFor="edit-story-description">{t('stories.fields.descriptionLabel')}</Label>
|
||||
<Textarea
|
||||
id="edit-story-description"
|
||||
placeholder="A conversation between..."
|
||||
placeholder={t('stories.fields.descriptionPlaceholder')}
|
||||
value={newStoryDescription}
|
||||
onChange={(e) => setNewStoryDescription(e.target.value)}
|
||||
rows={3}
|
||||
@@ -361,39 +401,35 @@ export function StoryList() {
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleUpdateStory} disabled={updateStory.isPending}>
|
||||
{updateStory.isPending ? 'Saving...' : 'Save'}
|
||||
{updateStory.isPending ? t('stories.editDialog.saving') : t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Story Confirmation Dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete the story and all its items. This action cannot be
|
||||
undone.
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogTitle>{t('stories.deleteDialog.title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t('stories.deleteDialog.description')}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteStory.isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteStory.isPending ? 'Deleting...' : 'Delete'}
|
||||
{deleteStory.isPending ? t('stories.deleteDialog.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</ListPane>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,12 @@ import {
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Scissors,
|
||||
Square,
|
||||
Trash2,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
@@ -20,6 +23,8 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { StoryItemDetail } from '@/lib/api/types';
|
||||
@@ -30,8 +35,10 @@ import {
|
||||
useSetStoryItemVersion,
|
||||
useSplitStoryItem,
|
||||
useTrimStoryItem,
|
||||
useUpdateStoryItemVolume,
|
||||
} from '@/lib/hooks/useStories';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
|
||||
// Clip waveform component with trim support
|
||||
@@ -75,8 +82,19 @@ function ClipWaveform({
|
||||
|
||||
const waveColor = getCSSVar('--accent-foreground');
|
||||
|
||||
// Hand WaveSurfer a muted <audio> element so the MediaElement backend
|
||||
// can never bleed audio. Web Audio is doing the actual playback in
|
||||
// useStoryPlayback; this clip waveform exists purely for the visual.
|
||||
// Without this, long imported clips (MP3 / M4A) end up audible from
|
||||
// wavesurfer's own element on top of the timeline, and that element
|
||||
// doesn't get paused by stopAllSources().
|
||||
const mediaElement = document.createElement('audio');
|
||||
mediaElement.muted = true;
|
||||
mediaElement.preload = 'metadata';
|
||||
|
||||
const wavesurfer = WaveSurfer.create({
|
||||
container: waveformRef.current,
|
||||
media: mediaElement,
|
||||
waveColor,
|
||||
progressColor: waveColor,
|
||||
cursorWidth: 0,
|
||||
@@ -118,6 +136,66 @@ function ClipWaveform({
|
||||
);
|
||||
}
|
||||
|
||||
// Per-clip volume popover. Local state drives the slider during a drag so
|
||||
// each pointer-move pixel doesn't fire a PATCH; commits on release.
|
||||
function ClipVolumePopover({
|
||||
storyId,
|
||||
itemId,
|
||||
volume,
|
||||
onChange,
|
||||
}: {
|
||||
storyId: string;
|
||||
itemId: string;
|
||||
volume: number;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
const [localVolume, setLocalVolume] = useState(volume);
|
||||
// Re-sync when the selected clip changes or the persisted value updates
|
||||
// out-of-band (split/duplicate carry the value forward).
|
||||
useEffect(() => {
|
||||
setLocalVolume(volume);
|
||||
}, [volume, itemId, storyId]);
|
||||
|
||||
const display = Math.round(localVolume * 100);
|
||||
const Icon = localVolume === 0 ? VolumeX : Volume2;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
title={`Volume — ${display}%`}
|
||||
aria-label="Adjust clip volume"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="center" className="w-56 p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground">Volume</span>
|
||||
<span className="text-xs tabular-nums">{display}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[localVolume * 100]}
|
||||
onValueChange={([v]) => setLocalVolume(v / 100)}
|
||||
onValueCommit={([v]) => onChange(v / 100)}
|
||||
min={0}
|
||||
max={200}
|
||||
step={1}
|
||||
aria-label="Clip volume"
|
||||
/>
|
||||
<div className="flex justify-between mt-2 text-[10px] text-muted-foreground tabular-nums">
|
||||
<span>0%</span>
|
||||
<span>100%</span>
|
||||
<span>200%</span>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
interface StoryTrackEditorProps {
|
||||
storyId: string;
|
||||
items: StoryItemDetail[];
|
||||
@@ -125,15 +203,21 @@ interface StoryTrackEditorProps {
|
||||
|
||||
const TRACK_HEIGHT = 48;
|
||||
const TIME_RULER_HEIGHT = 24; // h-6 = 1.5rem = 24px
|
||||
const MIN_PIXELS_PER_SECOND = 10;
|
||||
const MAX_PIXELS_PER_SECOND = 200;
|
||||
const DEFAULT_PIXELS_PER_SECOND = 50;
|
||||
const SCRUB_BAR_HEIGHT = 16;
|
||||
const LABEL_COL_WIDTH = 64; // w-16 = 4rem = 64px
|
||||
// Zoom is expressed to the user as how many seconds of timeline are visible
|
||||
// at once. Min scope = the most you can zoom IN; max scope = the entire
|
||||
// project. Default scope is what we land on when the editor first measures.
|
||||
const MIN_VISIBLE_SECONDS = 10;
|
||||
const DEFAULT_VISIBLE_SECONDS = 60;
|
||||
const FALLBACK_PIXELS_PER_SECOND = 50; // used until containerWidth is measured
|
||||
const DEFAULT_TRACKS = [1, 0, -1]; // Default 3 tracks
|
||||
const MIN_EDITOR_HEIGHT = 120;
|
||||
const MAX_EDITOR_HEIGHT = 500;
|
||||
|
||||
export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
const [pixelsPerSecond, setPixelsPerSecond] = useState(DEFAULT_PIXELS_PER_SECOND);
|
||||
const [pixelsPerSecond, setPixelsPerSecond] = useState(FALLBACK_PIXELS_PER_SECOND);
|
||||
const hasAppliedDefaultZoomRef = useRef(false);
|
||||
const [draggingItem, setDraggingItem] = useState<string | null>(null);
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||
@@ -149,7 +233,13 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
const duplicateItem = useDuplicateStoryItem();
|
||||
const removeItem = useRemoveStoryItem();
|
||||
const setItemVersion = useSetStoryItemVersion();
|
||||
const updateVolume = useUpdateStoryItemVolume();
|
||||
const { toast } = useToast();
|
||||
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
|
||||
// User-added empty tracks. Live in component state because a track only
|
||||
// earns its keep once a clip lands on it — no need to persist an unused
|
||||
// row across reloads.
|
||||
const [extraTracks, setExtraTracks] = useState<number[]>([]);
|
||||
|
||||
// Selection state
|
||||
const selectedClipId = useStoryStore((state) => state.selectedClipId);
|
||||
@@ -258,10 +348,32 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
stop();
|
||||
};
|
||||
|
||||
// Calculate unique tracks from items, always showing at least 3 default tracks
|
||||
// Calculate unique tracks from items, always showing at least 3 default
|
||||
// tracks. ``extraTracks`` lets the user open a fresh row without first
|
||||
// having to drag a clip there.
|
||||
const tracks = useMemo(() => {
|
||||
const trackSet = new Set([...DEFAULT_TRACKS, ...items.map((item) => item.track)]);
|
||||
const trackSet = new Set([
|
||||
...DEFAULT_TRACKS,
|
||||
...items.map((item) => item.track),
|
||||
...extraTracks,
|
||||
]);
|
||||
return Array.from(trackSet).sort((a, b) => b - a); // Higher tracks on top
|
||||
}, [items, extraTracks]);
|
||||
|
||||
const handleAddTrackAbove = useCallback(() => {
|
||||
setExtraTracks((prev) => {
|
||||
const all = new Set([...DEFAULT_TRACKS, ...items.map((i) => i.track), ...prev]);
|
||||
const next = (all.size > 0 ? Math.max(...all) : 0) + 1;
|
||||
return [...prev, next];
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
const handleAddTrackBelow = useCallback(() => {
|
||||
setExtraTracks((prev) => {
|
||||
const all = new Set([...DEFAULT_TRACKS, ...items.map((i) => i.track), ...prev]);
|
||||
const next = (all.size > 0 ? Math.min(...all) : 0) - 1;
|
||||
return [...prev, next];
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
// Track container width for full-width minimum
|
||||
@@ -282,6 +394,44 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Horizontal scrollbar state
|
||||
const [timelineScrollLeft, setTimelineScrollLeft] = useState(0);
|
||||
const [scrollbarTrackWidth, setScrollbarTrackWidth] = useState(0);
|
||||
const scrollbarTrackRef = useRef<HTMLDivElement>(null);
|
||||
const scrollbarDragRef = useRef<{
|
||||
mode: 'pan' | 'left' | 'right';
|
||||
startX: number;
|
||||
startScrollLeft: number;
|
||||
startPixelsPerSecond: number;
|
||||
} | null>(null);
|
||||
// Anchor the visible left/right edge time during a zoom drag so the edge
|
||||
// the user isn't dragging stays pinned in place across pixelsPerSecond changes.
|
||||
const zoomAnchorRef = useRef<{ type: 'left' | 'right'; timeMs: number } | null>(null);
|
||||
|
||||
// Mirror the timeline's scrollLeft into state so the scrollbar thumb tracks it
|
||||
useEffect(() => {
|
||||
const el = tracksRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => setTimelineScrollLeft(el.scrollLeft);
|
||||
el.addEventListener('scroll', onScroll);
|
||||
setTimelineScrollLeft(el.scrollLeft);
|
||||
return () => el.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
// Track scrollbar track width for thumb sizing
|
||||
useEffect(() => {
|
||||
const el = scrollbarTrackRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setScrollbarTrackWidth(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
setScrollbarTrackWidth(el.clientWidth);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Calculate effective duration (accounting for trims)
|
||||
const getEffectiveDuration = (item: StoryItemDetail) => {
|
||||
return item.duration * 1000 - (item.trim_start_ms || 0) - (item.trim_end_ms || 0);
|
||||
@@ -293,6 +443,41 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
return Math.max(...items.map((item) => item.start_time_ms + getEffectiveDuration(item)), 10000);
|
||||
}, [items, getEffectiveDuration]);
|
||||
|
||||
// Zoom bounds are framed in seconds-of-timeline-visible-at-once (the
|
||||
// "scope") rather than abstract pixels-per-second so the bar reflects
|
||||
// something meaningful: fully zoomed out shows the entire project, fully
|
||||
// zoomed in shows MIN_VISIBLE_SECONDS. Convert to pixels using the visible
|
||||
// track area (container minus the sticky label column).
|
||||
const visibleTrackWidth = Math.max(0, containerWidth - LABEL_COL_WIDTH);
|
||||
const projectSeconds = totalDurationMs / 1000;
|
||||
const { minPps, maxPps } = useMemo(() => {
|
||||
if (visibleTrackWidth <= 0 || projectSeconds <= 0) {
|
||||
return { minPps: 10, maxPps: 200 };
|
||||
}
|
||||
const min = visibleTrackWidth / projectSeconds;
|
||||
const max = visibleTrackWidth / MIN_VISIBLE_SECONDS;
|
||||
// For projects shorter than MIN_VISIBLE_SECONDS the entire bar collapses
|
||||
// to one point; clamp so the range stays non-inverted.
|
||||
return { minPps: min, maxPps: Math.max(max, min) };
|
||||
}, [visibleTrackWidth, projectSeconds]);
|
||||
|
||||
// Apply the default scope (60 s, or the whole project if shorter) once we
|
||||
// have a real measurement to convert it into pixels-per-second.
|
||||
useEffect(() => {
|
||||
if (hasAppliedDefaultZoomRef.current) return;
|
||||
if (visibleTrackWidth <= 0) return;
|
||||
const defaultScope = Math.min(DEFAULT_VISIBLE_SECONDS, Math.max(projectSeconds, MIN_VISIBLE_SECONDS));
|
||||
setPixelsPerSecond(visibleTrackWidth / defaultScope);
|
||||
hasAppliedDefaultZoomRef.current = true;
|
||||
}, [visibleTrackWidth, projectSeconds]);
|
||||
|
||||
// Re-clamp the current zoom whenever the bounds shift (project length
|
||||
// changed, window resized) so the user can't end up parked outside the
|
||||
// valid range from a previous session.
|
||||
useEffect(() => {
|
||||
setPixelsPerSecond((prev) => Math.max(minPps, Math.min(maxPps, prev)));
|
||||
}, [minPps, maxPps]);
|
||||
|
||||
// Calculate timeline width - at least full container width
|
||||
const contentWidth = (totalDurationMs / 1000) * pixelsPerSecond + 200; // Content width with padding
|
||||
const timelineWidth = Math.max(contentWidth, containerWidth);
|
||||
@@ -324,11 +509,11 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
const pixelsToMs = useCallback((px: number) => (px / pixelsPerSecond) * 1000, [pixelsPerSecond]);
|
||||
|
||||
const handleZoomIn = () => {
|
||||
setPixelsPerSecond((prev) => Math.min(prev * 1.5, MAX_PIXELS_PER_SECOND));
|
||||
setPixelsPerSecond((prev) => Math.min(prev * 1.5, maxPps));
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
setPixelsPerSecond((prev) => Math.max(prev / 1.5, MIN_PIXELS_PER_SECOND));
|
||||
setPixelsPerSecond((prev) => Math.max(prev / 1.5, minPps));
|
||||
};
|
||||
|
||||
// Resize handlers
|
||||
@@ -371,10 +556,10 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
}
|
||||
}, [isResizing, handleResizeMove, handleResizeEnd]);
|
||||
|
||||
const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const handleTimelineClick = (e: React.MouseEvent<HTMLElement>) => {
|
||||
if (!tracksRef.current || draggingItem || trimmingItem) return;
|
||||
const rect = tracksRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left + tracksRef.current.scrollLeft;
|
||||
const x = e.clientX - rect.left + tracksRef.current.scrollLeft - LABEL_COL_WIDTH;
|
||||
const timeMs = Math.max(0, pixelsToMs(x));
|
||||
seek(timeMs);
|
||||
// Deselect clip when clicking on timeline
|
||||
@@ -500,12 +685,15 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
}, [trimmingItem, trimSide, tempTrimValues, storyId, trimItem, toast]);
|
||||
|
||||
const handleSplit = useCallback(() => {
|
||||
if (!selectedClipId) return;
|
||||
if (!selectedClipId || splitItem.isPending) return;
|
||||
|
||||
const item = items.find((i) => i.id === selectedClipId);
|
||||
if (!item) return;
|
||||
|
||||
const splitTimeMs = currentTimeMs - item.start_time_ms;
|
||||
// currentTimeMs is driven by audio playback and arrives as a float;
|
||||
// the backend's StoryItemSplit.split_time_ms is `int`, so round before
|
||||
// sending or pydantic rejects the request.
|
||||
const splitTimeMs = Math.round(currentTimeMs - item.start_time_ms);
|
||||
const effectiveDuration = getEffectiveDuration(item);
|
||||
|
||||
if (splitTimeMs <= 0 || splitTimeMs >= effectiveDuration) {
|
||||
@@ -590,6 +778,20 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
);
|
||||
}, [selectedClipId, storyId, removeItem, toast, setSelectedClipId]);
|
||||
|
||||
const handleRegenerate = useCallback(async () => {
|
||||
if (!selectedItem) return;
|
||||
try {
|
||||
await apiClient.regenerateGeneration(selectedItem.generation_id);
|
||||
addPendingGeneration(selectedItem.generation_id);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to regenerate',
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [selectedItem, addPendingGeneration, toast]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -654,7 +856,13 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
y: e.clientY - rect.top,
|
||||
});
|
||||
setDragPosition({
|
||||
x: rect.left - tracksRef.current.getBoundingClientRect().left + tracksRef.current.scrollLeft,
|
||||
// Subtract label column width because clips live in a sub-container offset
|
||||
// by LABEL_COL_WIDTH, so dragPosition.x is stored in timeline-local coords.
|
||||
x:
|
||||
rect.left -
|
||||
tracksRef.current.getBoundingClientRect().left +
|
||||
tracksRef.current.scrollLeft -
|
||||
LABEL_COL_WIDTH,
|
||||
// Subtract ruler height since clips are positioned relative to tracks area, not the scrollable container
|
||||
y: rect.top - tracksRef.current.getBoundingClientRect().top - TIME_RULER_HEIGHT,
|
||||
});
|
||||
@@ -666,7 +874,12 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
if (!draggingItem || !tracksRef.current) return;
|
||||
|
||||
const rect = tracksRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left + tracksRef.current.scrollLeft - dragOffset.x;
|
||||
const x =
|
||||
e.clientX -
|
||||
rect.left +
|
||||
tracksRef.current.scrollLeft -
|
||||
dragOffset.x -
|
||||
LABEL_COL_WIDTH;
|
||||
// Subtract ruler height since clips are positioned relative to tracks area
|
||||
const y = e.clientY - rect.top - dragOffset.y - TIME_RULER_HEIGHT;
|
||||
|
||||
@@ -762,7 +975,106 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
|
||||
// Calculate tracks area height
|
||||
const tracksAreaHeight = tracks.length * TRACK_HEIGHT;
|
||||
const timelineContainerHeight = editorHeight - 40; // Subtract toolbar height
|
||||
const timelineContainerHeight = editorHeight - 40 - SCRUB_BAR_HEIGHT;
|
||||
|
||||
// Scrollbar thumb geometry
|
||||
const maxTimelineScroll = Math.max(0, timelineWidth - containerWidth);
|
||||
const visibleRatio = timelineWidth > 0 ? Math.min(1, containerWidth / timelineWidth) : 1;
|
||||
const thumbWidth = Math.max(24, visibleRatio * scrollbarTrackWidth);
|
||||
const thumbRange = Math.max(0, scrollbarTrackWidth - thumbWidth);
|
||||
const thumbLeft =
|
||||
maxTimelineScroll > 0 && thumbRange > 0
|
||||
? (timelineScrollLeft / maxTimelineScroll) * thumbRange
|
||||
: 0;
|
||||
const canScrollHorizontally = maxTimelineScroll > 0;
|
||||
|
||||
const handleScrollbarMouseDown = useCallback(
|
||||
(mode: 'pan' | 'left' | 'right') => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
scrollbarDragRef.current = {
|
||||
mode,
|
||||
startX: e.clientX,
|
||||
startScrollLeft: timelineScrollLeft,
|
||||
startPixelsPerSecond: pixelsPerSecond,
|
||||
};
|
||||
},
|
||||
[timelineScrollLeft, pixelsPerSecond],
|
||||
);
|
||||
|
||||
// After a zoom drag updates pixelsPerSecond, snap scrollLeft so the anchored
|
||||
// edge (left or right of the visible window) stays at the same time.
|
||||
useEffect(() => {
|
||||
const anchor = zoomAnchorRef.current;
|
||||
if (!anchor || !tracksRef.current) return;
|
||||
const timePx = (anchor.timeMs / 1000) * pixelsPerSecond;
|
||||
tracksRef.current.scrollLeft =
|
||||
anchor.type === 'left' ? Math.max(0, timePx) : Math.max(0, timePx - containerWidth);
|
||||
}, [pixelsPerSecond, containerWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const drag = scrollbarDragRef.current;
|
||||
if (!drag || !tracksRef.current) return;
|
||||
const deltaX = e.clientX - drag.startX;
|
||||
|
||||
if (drag.mode === 'pan') {
|
||||
if (thumbRange <= 0) return;
|
||||
const deltaScroll = (deltaX / thumbRange) * maxTimelineScroll;
|
||||
tracksRef.current.scrollLeft = Math.max(
|
||||
0,
|
||||
Math.min(maxTimelineScroll, drag.startScrollLeft + deltaScroll),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollbarTrackWidth <= 0 || containerWidth <= 0) return;
|
||||
|
||||
// Recompute the thumb width that corresponded to the drag start, then
|
||||
// apply the mouse delta to the dragged edge.
|
||||
const startTimelinePx =
|
||||
(totalDurationMs / 1000) * drag.startPixelsPerSecond + 200;
|
||||
const startThumbWidth = Math.max(
|
||||
30,
|
||||
Math.min(scrollbarTrackWidth, (containerWidth / startTimelinePx) * scrollbarTrackWidth),
|
||||
);
|
||||
const newThumbWidth = Math.max(
|
||||
30,
|
||||
Math.min(
|
||||
scrollbarTrackWidth,
|
||||
drag.mode === 'right' ? startThumbWidth + deltaX : startThumbWidth - deltaX,
|
||||
),
|
||||
);
|
||||
|
||||
const newTimelinePx = (containerWidth / newThumbWidth) * scrollbarTrackWidth;
|
||||
const rawPps = (newTimelinePx - 200) / (totalDurationMs / 1000);
|
||||
const newPps = Math.max(minPps, Math.min(maxPps, rawPps));
|
||||
|
||||
zoomAnchorRef.current =
|
||||
drag.mode === 'right'
|
||||
? {
|
||||
type: 'left',
|
||||
timeMs: (drag.startScrollLeft / drag.startPixelsPerSecond) * 1000,
|
||||
}
|
||||
: {
|
||||
type: 'right',
|
||||
timeMs:
|
||||
((drag.startScrollLeft + containerWidth) / drag.startPixelsPerSecond) * 1000,
|
||||
};
|
||||
|
||||
setPixelsPerSecond(newPps);
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
scrollbarDragRef.current = null;
|
||||
zoomAnchorRef.current = null;
|
||||
};
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
}, [maxTimelineScroll, thumbRange, scrollbarTrackWidth, containerWidth, totalDurationMs, minPps, maxPps]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
@@ -836,6 +1148,31 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
{selectedItem && (
|
||||
<ClipVolumePopover
|
||||
storyId={storyId}
|
||||
itemId={selectedItem.id}
|
||||
volume={selectedItem.volume}
|
||||
onChange={(value) =>
|
||||
updateVolume.mutate(
|
||||
{
|
||||
storyId,
|
||||
itemId: selectedItem.id,
|
||||
data: { volume: value },
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to update volume',
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -846,6 +1183,18 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{selectedItem?.engine !== 'import' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleRegenerate}
|
||||
title="Regenerate"
|
||||
aria-label="Regenerate clip"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{hasMultipleVersions && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
@@ -916,44 +1265,25 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline container with track labels sidebar */}
|
||||
<div className="flex" style={{ height: `${timelineContainerHeight}px` }}>
|
||||
{/* Track labels sidebar - fixed width */}
|
||||
<div className="w-16 shrink-0 border-r bg-muted/20 overflow-hidden">
|
||||
{/* Spacer for time ruler */}
|
||||
<div className="h-6 border-b bg-muted/30" />
|
||||
{/* Track labels */}
|
||||
<div style={{ height: `${tracksAreaHeight}px` }}>
|
||||
{tracks.map((trackNumber, index) => (
|
||||
<div
|
||||
key={trackNumber}
|
||||
className={cn(
|
||||
'border-b flex items-center justify-center',
|
||||
index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
|
||||
)}
|
||||
style={{ height: `${TRACK_HEIGHT}px` }}
|
||||
>
|
||||
<span className="text-[10px] text-muted-foreground select-none">
|
||||
{trackNumber}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable timeline area */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */}
|
||||
{/* Timeline scroll container */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */}
|
||||
<div
|
||||
ref={tracksRef}
|
||||
className="overflow-auto relative"
|
||||
style={{ height: `${timelineContainerHeight}px` }}
|
||||
onMouseMove={draggingItem ? handleDragMove : undefined}
|
||||
onMouseUp={draggingItem ? handleDragEnd : undefined}
|
||||
onMouseLeave={draggingItem ? handleDragEnd : undefined}
|
||||
>
|
||||
{/* Ruler row: corner spacer + time ruler, sticky to top */}
|
||||
<div
|
||||
ref={tracksRef}
|
||||
className="overflow-auto relative flex-1"
|
||||
onMouseMove={draggingItem ? handleDragMove : undefined}
|
||||
onMouseUp={draggingItem ? handleDragEnd : undefined}
|
||||
onMouseLeave={draggingItem ? handleDragEnd : undefined}
|
||||
className="flex sticky top-0 z-30"
|
||||
style={{ width: `${timelineWidth + LABEL_COL_WIDTH}px` }}
|
||||
>
|
||||
{/* Time ruler - clickable to seek */}
|
||||
<div className="w-16 h-6 shrink-0 border-b border-r bg-muted/30 sticky left-0 z-40" />
|
||||
<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 cursor-pointer text-left relative"
|
||||
style={{ width: `${timelineWidth}px` }}
|
||||
onClick={handleTimelineClick}
|
||||
aria-label="Seek timeline"
|
||||
@@ -971,27 +1301,72 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
</div>
|
||||
))}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tracks area */}
|
||||
<div
|
||||
className="relative"
|
||||
style={{ width: `${timelineWidth}px`, height: `${tracksAreaHeight}px` }}
|
||||
>
|
||||
{/* Track backgrounds - pointer-events-none to allow clicks to pass through */}
|
||||
{tracks.map((trackNumber, index) => (
|
||||
{/* Tracks area (rows with sticky labels + clips sub-container) */}
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
width: `${timelineWidth + LABEL_COL_WIDTH}px`,
|
||||
height: `${tracksAreaHeight}px`,
|
||||
}}
|
||||
>
|
||||
{/* Per-track rows: label and background as flex siblings guarantee alignment */}
|
||||
{tracks.map((trackNumber, index) => {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === tracks.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={trackNumber}
|
||||
className={cn(
|
||||
'absolute left-0 right-0 border-b pointer-events-none',
|
||||
index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
|
||||
)}
|
||||
className="absolute left-0 right-0 flex"
|
||||
style={{
|
||||
top: `${index * TRACK_HEIGHT}px`,
|
||||
height: `${TRACK_HEIGHT}px`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
>
|
||||
<div className="w-16 shrink-0 border-b border-r flex items-center justify-center sticky left-0 z-20 h-full bg-background">
|
||||
<div className="absolute inset-0 bg-muted/20 pointer-events-none" />
|
||||
<span className="relative text-[10px] text-muted-foreground select-none">
|
||||
{trackNumber}
|
||||
</span>
|
||||
{isFirst && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddTrackAbove}
|
||||
title="Add track above"
|
||||
aria-label="Add track above"
|
||||
className="absolute top-0 right-0 left-0 h-3 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<Plus className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
)}
|
||||
{isLast && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddTrackBelow}
|
||||
title="Add track below"
|
||||
aria-label="Add track below"
|
||||
className="absolute bottom-0 right-0 left-0 h-3 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<Plus className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'border-b flex-1 pointer-events-none',
|
||||
index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Clip/playhead/seek layer offset past the label column */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0"
|
||||
style={{ left: `${LABEL_COL_WIDTH}px`, width: `${timelineWidth}px` }}
|
||||
>
|
||||
{/* Click area for seeking - z-index lower than clips */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1052,7 +1427,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
{/* Clip label */}
|
||||
<div className="absolute top-0 left-1 right-1 z-10">
|
||||
<p className="text-[9px] font-medium text-accent-foreground truncate">
|
||||
{item.profile_name}
|
||||
{item.engine === 'import' ? item.text : item.profile_name}
|
||||
</p>
|
||||
</div>
|
||||
{/* Waveform */}
|
||||
@@ -1101,6 +1476,55 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Horizontal timeline scrollbar + zoom handles */}
|
||||
<div
|
||||
className="flex border-t bg-background/40"
|
||||
style={{ height: `${SCRUB_BAR_HEIGHT}px` }}
|
||||
>
|
||||
<div className="w-16 shrink-0 border-r" />
|
||||
<div
|
||||
ref={scrollbarTrackRef}
|
||||
className="relative flex-1 overflow-hidden select-none px-1"
|
||||
>
|
||||
<div
|
||||
className="absolute top-1 bottom-1 bg-foreground/10 hover:bg-foreground/15 transition-colors group rounded-full"
|
||||
style={{ width: `${thumbWidth}px`, left: `${thumbLeft}px` }}
|
||||
>
|
||||
{/* Left zoom handle */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: mouse-driven edge handle */}
|
||||
<div
|
||||
role="slider"
|
||||
aria-label="Zoom from left edge"
|
||||
aria-valuenow={Math.round(pixelsPerSecond)}
|
||||
aria-valuemin={Math.round(minPps)}
|
||||
aria-valuemax={Math.round(maxPps)}
|
||||
className="absolute top-0 bottom-0 left-0 w-1.5 cursor-ew-resize bg-foreground/25 hover:bg-foreground/40 transition-colors rounded-l-full"
|
||||
onMouseDown={handleScrollbarMouseDown('left')}
|
||||
/>
|
||||
{/* Pan area */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: mouse-driven drag area */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0 bottom-0 left-1.5 right-1.5',
|
||||
canScrollHorizontally ? 'cursor-grab active:cursor-grabbing' : 'cursor-default',
|
||||
)}
|
||||
onMouseDown={canScrollHorizontally ? handleScrollbarMouseDown('pan') : undefined}
|
||||
/>
|
||||
{/* Right zoom handle */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: mouse-driven edge handle */}
|
||||
<div
|
||||
role="slider"
|
||||
aria-label="Zoom from right edge"
|
||||
aria-valuenow={Math.round(pixelsPerSecond)}
|
||||
aria-valuemin={Math.round(minPps)}
|
||||
aria-valuemax={Math.round(maxPps)}
|
||||
className="absolute top-0 bottom-0 right-0 w-1.5 cursor-ew-resize bg-foreground/25 hover:bg-foreground/40 transition-colors rounded-r-full"
|
||||
onMouseDown={handleScrollbarMouseDown('right')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Mic, Pause, Play, Square } from 'lucide-react';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Visualizer } from 'react-sound-visualizer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||
@@ -14,12 +15,7 @@ const MemoizedWaveform = memo(function MemoizedWaveform({
|
||||
<div className="absolute inset-0 pointer-events-none flex items-center justify-center opacity-30">
|
||||
<Visualizer audio={audioStream} autoStart strokeColor="#b39a3d">
|
||||
{({ canvasRef }) => (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={500}
|
||||
height={150}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
<canvas ref={canvasRef} width={500} height={150} className="w-full h-full" />
|
||||
)}
|
||||
</Visualizer>
|
||||
</div>
|
||||
@@ -53,6 +49,7 @@ export function AudioSampleRecording({
|
||||
isTranscribing = false,
|
||||
showWaveform = true,
|
||||
}: AudioSampleRecordingProps) {
|
||||
const { t } = useTranslation();
|
||||
const [audioStream, setAudioStream] = useState<MediaStream | null>(null);
|
||||
|
||||
// Request microphone access when component mounts
|
||||
@@ -87,9 +84,7 @@ export function AudioSampleRecording({
|
||||
<div className="space-y-4">
|
||||
{!isRecording && !file && (
|
||||
<div className="relative flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px] overflow-hidden">
|
||||
{showWaveform && audioStream && (
|
||||
<MemoizedWaveform audioStream={audioStream} />
|
||||
)}
|
||||
{showWaveform && audioStream && <MemoizedWaveform audioStream={audioStream} />}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onStart}
|
||||
@@ -97,19 +92,17 @@ export function AudioSampleRecording({
|
||||
className="relative z-10 flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-5 w-5" />
|
||||
Start Recording
|
||||
{t('audioSample.startRecording')}
|
||||
</Button>
|
||||
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
||||
Click to start recording. Maximum duration: 30 seconds.
|
||||
{t('audioSample.recordHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<div className="relative flex flex-col items-center justify-center gap-4 p-4 border-2 border-accent rounded-lg bg-accent/5 min-h-[180px] overflow-hidden">
|
||||
{showWaveform && audioStream && (
|
||||
<MemoizedWaveform audioStream={audioStream} />
|
||||
)}
|
||||
{showWaveform && audioStream && <MemoizedWaveform audioStream={audioStream} />}
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-accent animate-pulse" />
|
||||
@@ -124,10 +117,10 @@ export function AudioSampleRecording({
|
||||
className="relative z-10 flex items-center gap-2 bg-accent text-accent-foreground hover:bg-accent/90"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop Recording
|
||||
{t('audioSample.stopRecording')}
|
||||
</Button>
|
||||
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
||||
{formatAudioDuration(30 - duration)} remaining
|
||||
{t('audioSample.remaining', { time: formatAudioDuration(30 - duration) })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -136,16 +129,18 @@ export function AudioSampleRecording({
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mic className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">Recording complete</span>
|
||||
<span className="font-medium">{t('audioSample.recordingComplete')}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t('audioSample.fileLabel', { name: file.name })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={onPlayPause}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
aria-label={isPlaying ? t('audioSample.pause') : t('audioSample.play')}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
@@ -157,7 +152,7 @@ export function AudioSampleRecording({
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -165,7 +160,7 @@ export function AudioSampleRecording({
|
||||
onClick={onCancel}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Record Again
|
||||
{t('audioSample.recordAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Mic, Monitor, Pause, Play, Square } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||
@@ -28,6 +29,7 @@ export function AudioSampleSystem({
|
||||
isPlaying,
|
||||
isTranscribing = false,
|
||||
}: AudioSampleSystemProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
@@ -36,10 +38,10 @@ export function AudioSampleSystem({
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
||||
<Button type="button" onClick={onStart} size="lg" className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5" />
|
||||
Start Capture
|
||||
{t('audioSample.startCapture')}
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Capture audio from your system. Maximum duration: 30 seconds.
|
||||
{t('audioSample.systemHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -61,10 +63,10 @@ export function AudioSampleSystem({
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop Capture
|
||||
{t('audioSample.stopCapture')}
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{formatAudioDuration(30 - duration)} remaining
|
||||
{t('audioSample.remaining', { time: formatAudioDuration(30 - duration) })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -73,16 +75,18 @@ export function AudioSampleSystem({
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">Capture complete</span>
|
||||
<span className="font-medium">{t('audioSample.captureComplete')}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t('audioSample.fileLabel', { name: file.name })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={onPlayPause}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
aria-label={isPlaying ? t('audioSample.pause') : t('audioSample.play')}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
@@ -94,7 +98,7 @@ export function AudioSampleSystem({
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -102,7 +106,7 @@ export function AudioSampleSystem({
|
||||
onClick={onCancel}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Capture Again
|
||||
{t('audioSample.captureAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Mic, Pause, Play, Upload } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||
|
||||
@@ -26,6 +27,7 @@ export function AudioSampleUpload({
|
||||
isDisabled = false,
|
||||
fieldName,
|
||||
}: AudioSampleUploadProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -90,19 +92,21 @@ export function AudioSampleUpload({
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
Choose File
|
||||
{t('audioSample.chooseFile')}
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Click to choose a file or drag and drop. Maximum duration: 30 seconds.
|
||||
{t('audioSample.uploadHint')}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">File uploaded</span>
|
||||
<span className="font-medium">{t('audioSample.fileUploaded')}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t('audioSample.fileLabel', { name: file.name })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -110,7 +114,7 @@ export function AudioSampleUpload({
|
||||
variant="outline"
|
||||
onClick={onPlayPause}
|
||||
disabled={isValidating}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
aria-label={isPlaying ? t('audioSample.pause') : t('audioSample.play')}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
@@ -122,7 +126,7 @@ export function AudioSampleUpload({
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
{isTranscribing ? 'Transcribing...' : 'Transcribe'}
|
||||
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -134,7 +138,7 @@ export function AudioSampleUpload({
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
{t('audioSample.remove')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Download, Edit, Sparkles, Trash2 } from 'lucide-react';
|
||||
import { Download, Edit, Sparkles, Trash2, Wand2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -17,11 +18,19 @@ import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
|
||||
/** Human-readable display names for preset engine badges. */
|
||||
const ENGINE_DISPLAY_NAMES: Record<string, string> = {
|
||||
kokoro: 'Kokoro',
|
||||
qwen_custom_voice: 'CustomVoice',
|
||||
};
|
||||
|
||||
interface ProfileCardProps {
|
||||
profile: VoiceProfileResponse;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const deleteProfile = useDeleteProfile();
|
||||
@@ -34,6 +43,11 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
const isSelected = selectedProfileId === profile.id;
|
||||
|
||||
const handleSelect = () => {
|
||||
if (disabled && isSelected) {
|
||||
setSelectedProfileId(null);
|
||||
setTimeout(() => setSelectedProfileId(profile.id), 0);
|
||||
return;
|
||||
}
|
||||
setSelectedProfileId(isSelected ? null : profile.id);
|
||||
};
|
||||
|
||||
@@ -66,16 +80,18 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const selectLabel = isSelected
|
||||
? `${profile.name}, ${profile.language}. Selected as voice for generation.`
|
||||
: `${profile.name}, ${profile.language}. Select as voice for generation.`;
|
||||
const selectLabel = t(
|
||||
isSelected ? 'profiles.card.selectLabelSelected' : 'profiles.card.selectLabel',
|
||||
{ name: profile.name, language: profile.language },
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
className={cn(
|
||||
'cursor-pointer hover:shadow-md transition-all flex flex-col h-[162px]',
|
||||
isSelected && 'ring-2 ring-accent shadow-md',
|
||||
'cursor-pointer transition-all flex flex-col h-[162px]',
|
||||
disabled ? 'opacity-40 hover:opacity-60' : 'hover:shadow-md',
|
||||
isSelected && !disabled && 'ring-2 border-transparent ring-accent shadow-md',
|
||||
)}
|
||||
onClick={handleSelect}
|
||||
tabIndex={0}
|
||||
@@ -91,22 +107,35 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 pt-0 flex flex-col flex-1">
|
||||
<p className="text-xs text-muted-foreground mb-1.5 line-clamp-2 leading-relaxed">
|
||||
{profile.description || 'No description'}
|
||||
{profile.description || t('profiles.card.noDescription')}
|
||||
</p>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<Badge variant="outline" className="text-xs h-5 px-1.5 text-muted-foreground">
|
||||
{profile.language}
|
||||
</Badge>
|
||||
{profile.voice_type === 'preset' && (
|
||||
<Badge variant="secondary" className="text-xs h-5 px-1.5">
|
||||
{ENGINE_DISPLAY_NAMES[profile.preset_engine ?? ''] ?? profile.preset_engine}
|
||||
</Badge>
|
||||
)}
|
||||
{profile.voice_type === 'designed' && (
|
||||
<Badge variant="secondary" className="text-xs h-5 px-1.5">
|
||||
{t('profiles.card.designed')}
|
||||
</Badge>
|
||||
)}
|
||||
{profile.effects_chain && profile.effects_chain.length > 0 && (
|
||||
<Sparkles className="h-3.5 w-3.5 text-accent fill-accent" />
|
||||
)}
|
||||
{profile.personality?.trim() && (
|
||||
<Wand2 className="h-3.5 w-3.5 text-accent" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-0.5 justify-end items-end mt-auto">
|
||||
<CircleButton
|
||||
icon={Download}
|
||||
onClick={handleExport}
|
||||
disabled={exportProfile.isPending}
|
||||
aria-label="Export profile"
|
||||
aria-label={t('profiles.card.export')}
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Edit}
|
||||
@@ -114,13 +143,13 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
e.stopPropagation();
|
||||
handleEdit();
|
||||
}}
|
||||
aria-label="Edit profile"
|
||||
aria-label={t('profiles.card.edit')}
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Trash2}
|
||||
onClick={handleDeleteClick}
|
||||
disabled={deleteProfile.isPending}
|
||||
aria-label="Delete profile"
|
||||
aria-label={t('profiles.card.delete')}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -129,21 +158,21 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Profile</DialogTitle>
|
||||
<DialogTitle>{t('profiles.deleteDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{profile.name}"? This action cannot be undone.
|
||||
{t('profiles.deleteDialog.body', { name: profile.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteProfile.isPending}
|
||||
>
|
||||
{deleteProfile.isPending ? 'Deleting...' : 'Delete'}
|
||||
{deleteProfile.isPending ? t('profiles.deleteDialog.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
||||
import { Mic, Sparkles } from 'lucide-react';
|
||||
import { Info, Mic, Sparkles } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||
@@ -6,9 +8,37 @@ import { useUIStore } from '@/stores/uiStore';
|
||||
import { ProfileCard } from './ProfileCard';
|
||||
import { ProfileForm } from './ProfileForm';
|
||||
|
||||
/** Engines that use preset (built-in) voices instead of cloned profiles. */
|
||||
const PRESET_ENGINES = new Set(['kokoro', 'qwen_custom_voice']);
|
||||
|
||||
export function ProfileList() {
|
||||
const { t } = useTranslation();
|
||||
const { data: profiles, isLoading, error } = useProfiles();
|
||||
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
const selectedEngine = useUIStore((state) => state.selectedEngine);
|
||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
// Scroll to the selected profile after engine/sort changes
|
||||
useEffect(() => {
|
||||
if (!selectedProfileId) return;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
const el = cardRefs.current.get(selectedProfileId);
|
||||
if (!el) return;
|
||||
|
||||
// Temporarily apply scroll-margin so it doesn't land flush at the top
|
||||
el.style.scrollMarginTop = '180px';
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
|
||||
timeoutId = setTimeout(() => {
|
||||
el.style.scrollMarginTop = '';
|
||||
}, 500);
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
}, [selectedProfileId, selectedEngine]);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
@@ -17,12 +47,28 @@ export function ProfileList() {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-destructive">Error loading profiles: {error.message}</div>
|
||||
<div className="text-destructive">
|
||||
{t('profiles.list.errorLoading', { message: error.message })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const allProfiles = profiles || [];
|
||||
const isPresetEngine = PRESET_ENGINES.has(selectedEngine);
|
||||
|
||||
/** Whether a profile is supported by the currently selected engine. */
|
||||
const isSupported = (p: (typeof allProfiles)[number]) =>
|
||||
isPresetEngine
|
||||
? p.voice_type === 'preset' && p.preset_engine === selectedEngine
|
||||
: p.voice_type !== 'preset';
|
||||
|
||||
// Sort so supported profiles come first
|
||||
const sortedProfiles = [...allProfiles].sort(
|
||||
(a, b) => (isSupported(a) ? 0 : 1) - (isSupported(b) ? 0 : 1),
|
||||
);
|
||||
|
||||
const hasUnsupported = sortedProfiles.some((p) => !isSupported(p));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
@@ -31,22 +77,33 @@ export function ProfileList() {
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Mic className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No voice profiles yet. Create your first profile to get started.
|
||||
</p>
|
||||
<p className="text-muted-foreground mb-4">{t('profiles.list.empty')}</p>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Create Voice
|
||||
{t('profiles.list.createVoice')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</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]">
|
||||
{allProfiles.map((profile) => (
|
||||
<div key={profile.id} className="shrink-0 w-[200px] lg:w-auto lg:shrink">
|
||||
<ProfileCard profile={profile} />
|
||||
{sortedProfiles.map((profile) => (
|
||||
<div
|
||||
key={profile.id}
|
||||
className="shrink-0 w-[200px] lg:w-auto lg:shrink"
|
||||
ref={(el) => {
|
||||
if (el) cardRefs.current.set(profile.id, el);
|
||||
else cardRefs.current.delete(profile.id);
|
||||
}}
|
||||
>
|
||||
<ProfileCard profile={profile} disabled={!isSupported(profile)} />
|
||||
</div>
|
||||
))}
|
||||
{hasUnsupported && (
|
||||
<div className="col-span-full flex items-center gap-2 text-xs text-muted-foreground py-2">
|
||||
<Info className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{t('profiles.list.unsupportedNote')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Check, Edit, Pause, Play, Plus, Trash2, Volume2, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CircleButton } from '@/components/ui/circle-button';
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ interface MiniSamplePlayerProps {
|
||||
}
|
||||
|
||||
function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
||||
const { t } = useTranslation();
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
@@ -102,7 +104,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={handlePlayPause}
|
||||
disabled={isLoading}
|
||||
aria-label={isPlaying ? 'Pause sample' : 'Play sample'}
|
||||
aria-label={isPlaying ? t('sampleList.player.pause') : t('sampleList.player.play')}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5 ml-0.5" />}
|
||||
</Button>
|
||||
@@ -114,8 +116,11 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
||||
max={100}
|
||||
step={0.1}
|
||||
className="flex-1"
|
||||
aria-label="Sample playback position"
|
||||
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
|
||||
aria-label={t('sampleList.player.position')}
|
||||
aria-valuetext={t('sampleList.player.positionValue', {
|
||||
current: formatAudioDuration(currentTime),
|
||||
total: formatAudioDuration(duration),
|
||||
})}
|
||||
/>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 min-w-[70px]">
|
||||
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
|
||||
@@ -130,8 +135,8 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={handleStop}
|
||||
title="Stop"
|
||||
aria-label="Stop playback"
|
||||
title={t('sampleList.player.stop')}
|
||||
aria-label={t('sampleList.player.stopAria')}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -145,6 +150,7 @@ interface SampleListProps {
|
||||
}
|
||||
|
||||
export function SampleList({ profileId }: SampleListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: samples, isLoading } = useProfileSamples(profileId);
|
||||
const deleteSample = useDeleteSample();
|
||||
const updateSample = useUpdateSample();
|
||||
@@ -181,8 +187,8 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
const handleSaveEdit = async (sampleId: string) => {
|
||||
if (!editedText.trim()) {
|
||||
toast({
|
||||
title: 'Invalid text',
|
||||
description: 'Reference text cannot be empty.',
|
||||
title: t('sampleList.toast.invalidText'),
|
||||
description: t('sampleList.toast.invalidTextDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -191,22 +197,23 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
try {
|
||||
await updateSample.mutateAsync({ sampleId, referenceText: editedText.trim() });
|
||||
toast({
|
||||
title: 'Sample updated',
|
||||
description: 'Reference text has been updated successfully.',
|
||||
title: t('sampleList.toast.updated'),
|
||||
description: t('sampleList.toast.updatedDescription'),
|
||||
});
|
||||
setEditingSampleId(null);
|
||||
setEditedText('');
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Update failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to update sample',
|
||||
title: t('sampleList.toast.updateFailed'),
|
||||
description:
|
||||
error instanceof Error ? error.message : t('sampleList.toast.updateFailedFallback'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading samples...</div>;
|
||||
return <div className="text-sm text-muted-foreground">{t('sampleList.loading')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -214,10 +221,8 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
{samples && samples.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
|
||||
<Volume2 className="h-8 w-8 text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No samples yet</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Add your first audio sample to get started
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t('sampleList.empty.title')}</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">{t('sampleList.empty.hint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -237,13 +242,13 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
|
||||
<Edit className="h-3 w-3" />
|
||||
<span>Editing transcription</span>
|
||||
<span>{t('sampleList.editing')}</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={editedText}
|
||||
onChange={(e) => setEditedText(e.target.value)}
|
||||
className="min-h-[100px] text-sm resize-none"
|
||||
placeholder="Enter reference text..."
|
||||
placeholder={t('sampleList.placeholder')}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
@@ -255,7 +260,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
disabled={updateSample.isPending}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -264,7 +269,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
disabled={updateSample.isPending}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
{updateSample.isPending ? 'Saving...' : 'Save'}
|
||||
{updateSample.isPending ? t('sampleList.saving') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,12 +288,12 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<CircleButton
|
||||
icon={Edit}
|
||||
title="Edit transcription"
|
||||
title={t('sampleList.editTranscription')}
|
||||
onClick={() => handleStartEdit(sample.id, sample.reference_text)}
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Trash2}
|
||||
title="Delete sample"
|
||||
title={t('sampleList.deleteSample')}
|
||||
onClick={() => handleDeleteClick(sample.id)}
|
||||
disabled={deleteSample.isPending}
|
||||
/>
|
||||
@@ -317,24 +322,18 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
onClick={() => setUploadOpen(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Sample
|
||||
{t('sampleList.addSample')}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center px-2">
|
||||
Note: A single 30-second sample is the sweet spot. Quality may decrease with multiple
|
||||
samples. In a future update samples might be interchangeable and tagged for varying styles
|
||||
of the same voice.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground text-center px-2">{t('sampleList.note')}</p>
|
||||
|
||||
<SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} />
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Sample</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this audio sample? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('sampleList.deleteDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('sampleList.deleteDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -344,14 +343,14 @@ export function SampleList({ profileId }: SampleListProps) {
|
||||
setSampleToDelete(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteSample.isPending}
|
||||
>
|
||||
{deleteSample.isPending ? 'Deleting...' : 'Delete'}
|
||||
{deleteSample.isPending ? t('sampleList.deleteDialog.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { useTranslation } from 'react-i18next';
|
||||
import * as z from 'zod';
|
||||
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -38,19 +39,26 @@ 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[]]),
|
||||
});
|
||||
function makeProfileSchema(t: (key: string) => string) {
|
||||
return z.object({
|
||||
name: z.string().min(1, t('profileForm.validation.nameRequired')).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
||||
});
|
||||
}
|
||||
|
||||
type ProfileFormValues = z.infer<typeof profileSchema>;
|
||||
type ProfileFormValues = {
|
||||
name: string;
|
||||
description?: string;
|
||||
language: LanguageCode;
|
||||
};
|
||||
|
||||
interface VoiceInspectorProps {
|
||||
profileId: string;
|
||||
}
|
||||
|
||||
export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: profile } = useProfile(profileId);
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
const isPlayerVisible = !!audioUrl;
|
||||
@@ -68,7 +76,7 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
const [effectsDirty, setEffectsDirty] = useState(false);
|
||||
|
||||
const form = useForm<ProfileFormValues>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
resolver: zodResolver(makeProfileSchema(t)),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
@@ -104,32 +112,31 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast({
|
||||
title: 'Invalid file type',
|
||||
description: 'Please select PNG, JPG, or WebP',
|
||||
title: t('profileForm.toast.invalidFile'),
|
||||
description: t('voiceInspector.toast.invalidImageFormat'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast({
|
||||
title: 'File too large',
|
||||
description: 'Image must be less than 5MB',
|
||||
title: t('profileForm.toast.fileTooLarge'),
|
||||
description: t('profileForm.toast.imageTooLargeDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Upload immediately
|
||||
uploadAvatar.mutate(
|
||||
{ profileId, file },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setAvatarPreview(URL.createObjectURL(file));
|
||||
toast({ title: 'Avatar updated' });
|
||||
toast({ title: t('voiceInspector.toast.avatarUpdated') });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: 'Avatar upload failed',
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
title: t('profileForm.toast.avatarUploadFailed'),
|
||||
description: err instanceof Error ? err.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
@@ -141,11 +148,11 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
if (profile?.avatar_path) {
|
||||
try {
|
||||
await deleteAvatar.mutateAsync(profileId);
|
||||
toast({ title: 'Avatar removed' });
|
||||
toast({ title: t('profileForm.toast.avatarRemoved') });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Failed to remove avatar',
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
title: t('profileForm.toast.avatarRemoveFailed'),
|
||||
description: err instanceof Error ? err.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -174,19 +181,25 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
setEffectsDirty(false);
|
||||
} catch (fxError) {
|
||||
toast({
|
||||
title: 'Effects update failed',
|
||||
description: fxError instanceof Error ? fxError.message : 'Failed to save effects',
|
||||
title: t('profileForm.toast.effectsUpdateFailed'),
|
||||
description:
|
||||
fxError instanceof Error
|
||||
? fxError.message
|
||||
: t('profileForm.toast.effectsUpdateFailedFallback'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
toast({ title: 'Voice updated', description: `"${data.name}" saved.` });
|
||||
toast({
|
||||
title: t('profileForm.toast.voiceUpdated'),
|
||||
description: t('voiceInspector.toast.savedDescription', { name: data.name }),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to save profile',
|
||||
title: t('common.error'),
|
||||
description: error instanceof Error ? error.message : t('profileForm.toast.saveFailed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -195,7 +208,7 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
Loading...
|
||||
{t('voiceInspector.loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -256,9 +269,9 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Voice" {...field} />
|
||||
<Input placeholder={t('profileForm.fields.namePlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -270,9 +283,13 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormLabel>{t('voiceInspector.fields.description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="Describe this voice..." rows={2} {...field} />
|
||||
<Textarea
|
||||
placeholder={t('profileForm.fields.descriptionPlaceholder')}
|
||||
rows={2}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -284,7 +301,7 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Language</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.language')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
@@ -306,9 +323,9 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
|
||||
{/* Effects */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Default Effects</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.defaultEffects')}</FormLabel>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Applied automatically to new generations with this voice.
|
||||
{t('voiceInspector.defaultEffectsHint')}
|
||||
</p>
|
||||
<EffectsChainEditor
|
||||
value={effectsChain}
|
||||
@@ -323,7 +340,9 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
{/* Save */}
|
||||
{isDirty && (
|
||||
<Button type="submit" className="w-full" disabled={updateProfile.isPending}>
|
||||
{updateProfile.isPending ? 'Saving...' : 'Save Changes'}
|
||||
{updateProfile.isPending
|
||||
? t('profileForm.actions.saving')
|
||||
: t('profileForm.actions.saveChanges')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Mic, Plus, Search, Sparkles } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
@@ -25,6 +26,7 @@ import { useUIStore } from '@/stores/uiStore';
|
||||
import { VoiceInspector } from './VoiceInspector';
|
||||
|
||||
export function VoicesTab() {
|
||||
const { t } = useTranslation();
|
||||
const { data: profiles, isLoading } = useProfiles();
|
||||
const queryClient = useQueryClient();
|
||||
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
@@ -95,7 +97,7 @@ export function VoicesTab() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-muted-foreground">Loading voices...</div>
|
||||
<div className="text-muted-foreground">{t('voicesTab.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -110,12 +112,12 @@ export function VoicesTab() {
|
||||
{/* Fixed Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-20 pl-8 pr-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<h1 className="text-2xl font-bold">Voices</h1>
|
||||
<h1 className="text-2xl font-bold">{t('voicesTab.title')}</h1>
|
||||
<div className="flex-1" />
|
||||
<div className="relative w-[240px]">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search voices..."
|
||||
placeholder={t('voicesTab.searchPlaceholder')}
|
||||
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"
|
||||
@@ -123,7 +125,7 @@ export function VoicesTab() {
|
||||
</div>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Voice
|
||||
{t('voicesTab.newVoice')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,12 +141,12 @@ export function VoicesTab() {
|
||||
<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-[30%]">{t('voicesTab.columns.name')}</TableHead>
|
||||
<TableHead className="w-[10%]">{t('voicesTab.columns.language')}</TableHead>
|
||||
<TableHead className="w-[10%]">{t('voicesTab.columns.generations')}</TableHead>
|
||||
<TableHead className="w-[8%]">{t('voicesTab.columns.samples')}</TableHead>
|
||||
<TableHead className="w-[8%]">{t('voicesTab.columns.effects')}</TableHead>
|
||||
<TableHead className="w-[24%]">{t('voicesTab.columns.channels')}</TableHead>
|
||||
<TableHead className="w-6"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -194,6 +196,7 @@ function VoiceRow({
|
||||
channels,
|
||||
onChannelChange,
|
||||
}: VoiceRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null;
|
||||
@@ -212,7 +215,7 @@ function VoiceRow({
|
||||
{avatarUrl && !avatarError ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={`${profile.name} avatar`}
|
||||
alt={t('voicesTab.avatarAlt', { name: profile.name })}
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setAvatarError(true)}
|
||||
/>
|
||||
@@ -248,11 +251,11 @@ function VoiceRow({
|
||||
<MultiSelect
|
||||
options={channels.map((ch) => ({
|
||||
value: ch.id,
|
||||
label: `${ch.name}${ch.is_default ? ' (Default)' : ''}`,
|
||||
label: ch.is_default ? t('voicesTab.channelDefaultLabel', { name: ch.name }) : ch.name,
|
||||
}))}
|
||||
value={channelIds}
|
||||
onChange={onChannelChange}
|
||||
placeholder="Select channels..."
|
||||
placeholder={t('voicesTab.selectChannels')}
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
@@ -111,4 +111,4 @@ export {
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ const badgeVariants = cva(
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
'border-border bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
|
||||
@@ -3,14 +3,18 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
const buttonVariants = cva([
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm',
|
||||
'font-medium 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',
|
||||
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-accent text-accent-foreground hover:bg-accent/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:border-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-accent underline-offset-4 hover:underline',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export function useAutoUpdater(options: boolean | UseAutoUpdaterOptions = false)
|
||||
}
|
||||
// Empty dependency array - only run once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.metadata.isTauricheckOnMountcheckForUpdates]);
|
||||
}, [checkOnMount, checkForUpdates, platform.metadata.isTauri]);
|
||||
|
||||
// Show toast when update is available
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
|
||||
export function useThemeSync() {
|
||||
const theme = useUIStore((s) => s.theme);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
return;
|
||||
}
|
||||
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const apply = () => {
|
||||
document.documentElement.classList.toggle('dark', mq.matches);
|
||||
};
|
||||
|
||||
apply();
|
||||
mq.addEventListener('change', apply);
|
||||
return () => mq.removeEventListener('change', apply);
|
||||
}, [theme]);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import i18n from 'i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import en from './locales/en/translation.json';
|
||||
import es from './locales/es/translation.json';
|
||||
import fr from './locales/fr/translation.json';
|
||||
import it from './locales/it/translation.json';
|
||||
import ja from './locales/ja/translation.json';
|
||||
import ko from './locales/ko/translation.json';
|
||||
import ptBR from './locales/pt-BR/translation.json';
|
||||
import zhCN from './locales/zh-CN/translation.json';
|
||||
import zhTW from './locales/zh-TW/translation.json';
|
||||
|
||||
export const SUPPORTED_LANGUAGES = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'es', label: 'Español' },
|
||||
{ code: 'pt-BR', label: 'Português (Brasil)' },
|
||||
{ code: 'ja', label: '日本語' },
|
||||
{ code: 'ko', label: '한국어' },
|
||||
{ code: 'zh-CN', label: '简体中文' },
|
||||
{ code: 'zh-TW', label: '繁體中文' },
|
||||
{ code: 'fr', label: 'Français' },
|
||||
{ code: 'it', label: 'Italiano' },
|
||||
] as const;
|
||||
|
||||
export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]['code'];
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
es: { translation: es },
|
||||
'pt-BR': { translation: ptBR },
|
||||
ja: { translation: ja },
|
||||
ko: { translation: ko },
|
||||
'zh-CN': { translation: zhCN },
|
||||
'zh-TW': { translation: zhTW },
|
||||
fr: { translation: fr },
|
||||
it: { translation: it },
|
||||
},
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: SUPPORTED_LANGUAGES.map((l) => l.code),
|
||||
load: 'currentOnly',
|
||||
interpolation: { escapeValue: false },
|
||||
react: { useSuspense: false },
|
||||
detection: {
|
||||
order: ['localStorage', 'navigator'],
|
||||
lookupLocalStorage: 'voicebox:lang',
|
||||
caches: ['localStorage'],
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+20
-15
@@ -44,24 +44,24 @@
|
||||
|
||||
:root {
|
||||
--background: 0 0% 95%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--foreground: 0 0% 5%;
|
||||
--card: 0 0% 97%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--card-foreground: 0 0% 5%;
|
||||
--popover: 0 0% 97%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 92%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 90%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 43 50% 50%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--popover-foreground: 0 0% 5%;
|
||||
--primary: 43 55% 58%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 0 0% 92%;
|
||||
--secondary-foreground: 0 0% 11%;
|
||||
--muted: 0 0% 90%;
|
||||
--muted-foreground: 0 0% 47%;
|
||||
--accent: 43 55% 58%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 85%;
|
||||
--input: 214.3 31.8% 88%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 85%;
|
||||
--input: 0 0% 88%;
|
||||
--ring: 0 0% 5%;
|
||||
--sidebar: 0 0% 92%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 12 76% 61%;
|
||||
@@ -157,6 +157,11 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dark .sidebar-logo {
|
||||
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));
|
||||
}
|
||||
|
||||
/* react-loaders */
|
||||
.line-scale-pulse-out-rapid > div,
|
||||
.line-scale > div {
|
||||
|
||||
+223
-1
@@ -17,7 +17,10 @@ import type {
|
||||
HistoryResponse,
|
||||
ModelDownloadRequest,
|
||||
ModelStatusListResponse,
|
||||
PresetVoice,
|
||||
PersonalityTextResponse,
|
||||
ProfileSampleResponse,
|
||||
RocmStatus,
|
||||
StoryCreate,
|
||||
StoryDetailResponse,
|
||||
StoryItemBatchUpdate,
|
||||
@@ -28,11 +31,28 @@ import type {
|
||||
StoryItemSplit,
|
||||
StoryItemTrim,
|
||||
StoryItemVersionUpdate,
|
||||
StoryItemVolumeUpdate,
|
||||
StoryResponse,
|
||||
TranscriptionResponse,
|
||||
VoiceProfileCreate,
|
||||
VoiceProfileResponse,
|
||||
WhisperModelSize,
|
||||
CaptureListResponse,
|
||||
CaptureResponse,
|
||||
CaptureCreateResponse,
|
||||
CaptureReadinessResponse,
|
||||
CaptureRefineRequest,
|
||||
CaptureRetranscribeRequest,
|
||||
CaptureSettings,
|
||||
CaptureSettingsUpdate,
|
||||
CaptureSource,
|
||||
GenerationSettings,
|
||||
GenerationSettingsUpdate,
|
||||
MCPClientBinding,
|
||||
MCPClientBindingListResponse,
|
||||
MCPClientBindingUpsert,
|
||||
CloudLoginStartResponse,
|
||||
CloudStatus,
|
||||
} from './types';
|
||||
|
||||
function formatErrorDetail(detail: unknown, fallback: string): string {
|
||||
@@ -97,6 +117,10 @@ class ApiClient {
|
||||
return this.request<VoiceProfileResponse>(`/profiles/${profileId}`);
|
||||
}
|
||||
|
||||
async listPresetVoices(engine: string): Promise<{ engine: string; voices: PresetVoice[] }> {
|
||||
return this.request<{ engine: string; voices: PresetVoice[] }>(`/profiles/presets/${engine}`);
|
||||
}
|
||||
|
||||
async updateProfile(profileId: string, data: VoiceProfileCreate): Promise<VoiceProfileResponse> {
|
||||
return this.request<VoiceProfileResponse>(`/profiles/${profileId}`, {
|
||||
method: 'PUT',
|
||||
@@ -110,6 +134,17 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Personality-driven text generation ─────────────────────────────
|
||||
// Compose produces a fresh in-character utterance the UI drops into
|
||||
// the generate textarea. Rewrite now happens server-side inside
|
||||
// `/generate` when `personality: true` is passed in the request body.
|
||||
|
||||
async composeWithPersonality(profileId: string): Promise<PersonalityTextResponse> {
|
||||
return this.request<PersonalityTextResponse>(`/profiles/${profileId}/compose`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async addProfileSample(
|
||||
profileId: string,
|
||||
file: File,
|
||||
@@ -229,12 +264,32 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async cancelGeneration(generationId: string): Promise<{ message: string }> {
|
||||
return this.request<{ message: string }>(`/generate/${generationId}/cancel`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async regenerateGeneration(generationId: string): Promise<GenerationResponse> {
|
||||
return this.request<GenerationResponse>(`/generate/${generationId}/regenerate`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async importAudio(file: File): Promise<GenerationResponse> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await fetch(`${this.getBaseUrl()}/generate/import`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => res.statusText);
|
||||
throw new Error(detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async toggleFavorite(generationId: string): Promise<{ is_favorited: boolean }> {
|
||||
return this.request<{ is_favorited: boolean }>(`/history/${generationId}/favorite`, {
|
||||
method: 'POST',
|
||||
@@ -265,6 +320,12 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async clearFailedGenerations(): Promise<{ deleted: number }> {
|
||||
return this.request<{ deleted: number }>(`/history/failed`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async exportGeneration(generationId: string): Promise<Blob> {
|
||||
const url = `${this.getBaseUrl()}/history/${generationId}/export`;
|
||||
const response = await fetch(url);
|
||||
@@ -364,6 +425,122 @@ class ApiClient {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Captures
|
||||
async listCaptures(limit = 50, offset = 0): Promise<CaptureListResponse> {
|
||||
return this.request<CaptureListResponse>(
|
||||
`/captures?limit=${limit}&offset=${offset}`,
|
||||
);
|
||||
}
|
||||
|
||||
async getCapture(captureId: string): Promise<CaptureResponse> {
|
||||
return this.request<CaptureResponse>(`/captures/${captureId}`);
|
||||
}
|
||||
|
||||
async createCapture(
|
||||
file: File,
|
||||
options?: {
|
||||
source?: CaptureSource;
|
||||
language?: LanguageCode;
|
||||
sttModel?: WhisperModelSize;
|
||||
},
|
||||
): Promise<CaptureCreateResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('source', options?.source ?? 'file');
|
||||
if (options?.language) formData.append('language', options.language);
|
||||
if (options?.sttModel) formData.append('stt_model', options.sttModel);
|
||||
|
||||
const url = `${this.getBaseUrl()}/captures`;
|
||||
const response = await fetch(url, { method: 'POST', body: formData });
|
||||
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.json();
|
||||
}
|
||||
|
||||
async deleteCapture(captureId: string): Promise<{ message: string }> {
|
||||
return this.request<{ message: string }>(`/captures/${captureId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async refineCapture(
|
||||
captureId: string,
|
||||
body: CaptureRefineRequest,
|
||||
): Promise<CaptureResponse> {
|
||||
return this.request<CaptureResponse>(`/captures/${captureId}/refine`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async retranscribeCapture(
|
||||
captureId: string,
|
||||
body: CaptureRetranscribeRequest,
|
||||
): Promise<CaptureResponse> {
|
||||
return this.request<CaptureResponse>(`/captures/${captureId}/retranscribe`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
getCaptureAudioUrl(captureId: string): string {
|
||||
return `${this.getBaseUrl()}/captures/${captureId}/audio`;
|
||||
}
|
||||
|
||||
// Settings
|
||||
async getCaptureSettings(): Promise<CaptureSettings> {
|
||||
return this.request<CaptureSettings>('/settings/captures');
|
||||
}
|
||||
|
||||
async getCaptureReadiness(): Promise<CaptureReadinessResponse> {
|
||||
return this.request<CaptureReadinessResponse>('/capture/readiness');
|
||||
}
|
||||
|
||||
async updateCaptureSettings(patch: CaptureSettingsUpdate): Promise<CaptureSettings> {
|
||||
return this.request<CaptureSettings>('/settings/captures', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
async getGenerationSettings(): Promise<GenerationSettings> {
|
||||
return this.request<GenerationSettings>('/settings/generation');
|
||||
}
|
||||
|
||||
async updateGenerationSettings(
|
||||
patch: GenerationSettingsUpdate,
|
||||
): Promise<GenerationSettings> {
|
||||
return this.request<GenerationSettings>('/settings/generation', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
// MCP bindings — per-MCP-client voice/engine/personality mapping.
|
||||
async listMCPBindings(): Promise<MCPClientBindingListResponse> {
|
||||
return this.request<MCPClientBindingListResponse>('/mcp/bindings');
|
||||
}
|
||||
|
||||
async upsertMCPBinding(
|
||||
data: MCPClientBindingUpsert,
|
||||
): Promise<MCPClientBinding> {
|
||||
return this.request<MCPClientBinding>('/mcp/bindings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteMCPBinding(clientId: string): Promise<{ deleted: string }> {
|
||||
return this.request<{ deleted: string }>(
|
||||
`/mcp/bindings/${encodeURIComponent(clientId)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
}
|
||||
|
||||
// Model Management
|
||||
async getModelStatus(): Promise<ModelStatusListResponse> {
|
||||
return this.request<ModelStatusListResponse>('/models/status');
|
||||
@@ -373,7 +550,9 @@ class ApiClient {
|
||||
return this.request<{ path: string }>('/models/cache-dir');
|
||||
}
|
||||
|
||||
async migrateModels(destination: string): Promise<{ source: string; destination: string }> {
|
||||
async migrateModels(
|
||||
destination: string,
|
||||
): Promise<{ source: string; destination: string; moved: number; errors: string[] }> {
|
||||
return this.request('/models/migrate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ destination }),
|
||||
@@ -517,6 +696,23 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
// ROCm Backend Management
|
||||
async getRocmStatus(): Promise<RocmStatus> {
|
||||
return this.request<RocmStatus>('/backend/rocm-status');
|
||||
}
|
||||
|
||||
async downloadRocmBackend(): Promise<{ message: string; progress_key: string }> {
|
||||
return this.request<{ message: string; progress_key: string }>('/backend/download-rocm', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async deleteRocmBackend(): Promise<{ message: string }> {
|
||||
return this.request<{ message: string }>('/backend/rocm', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// Stories
|
||||
async listStories(): Promise<StoryResponse[]> {
|
||||
return this.request<StoryResponse[]>('/stories');
|
||||
@@ -595,6 +791,17 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async updateStoryItemVolume(
|
||||
storyId: string,
|
||||
itemId: string,
|
||||
data: StoryItemVolumeUpdate,
|
||||
): Promise<StoryItemDetail> {
|
||||
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/volume`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async splitStoryItem(
|
||||
storyId: string,
|
||||
itemId: string,
|
||||
@@ -733,6 +940,21 @@ class ApiClient {
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
// Cloud (backup & sync) — browser-based device login. startCloudLogin opens
|
||||
// the system browser server-side; the UI then polls getCloudStatus until the
|
||||
// backend completes the exchange and the link goes live.
|
||||
async getCloudStatus(): Promise<CloudStatus> {
|
||||
return this.request<CloudStatus>('/cloud/status');
|
||||
}
|
||||
|
||||
async startCloudLogin(): Promise<CloudLoginStartResponse> {
|
||||
return this.request<CloudLoginStartResponse>('/cloud/login/start', { method: 'POST' });
|
||||
}
|
||||
|
||||
async disconnectCloud(): Promise<CloudStatus> {
|
||||
return this.request<CloudStatus>('/cloud/disconnect', { method: 'POST' });
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import type { ApiRequestOptions } from './ApiRequestOptions';
|
||||
import type { ApiResult } from './ApiResult';
|
||||
|
||||
export class ApiError extends Error {
|
||||
public readonly url: string;
|
||||
public readonly status: number;
|
||||
public readonly statusText: string;
|
||||
public readonly body: any;
|
||||
public readonly request: ApiRequestOptions;
|
||||
|
||||
constructor(request: ApiRequestOptions, response: ApiResult, message: string) {
|
||||
super(message);
|
||||
|
||||
this.name = 'ApiError';
|
||||
this.url = response.url;
|
||||
this.status = response.status;
|
||||
this.statusText = response.statusText;
|
||||
this.body = response.body;
|
||||
this.request = request;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user