mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-26 13:45:16 -07:00
Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
9a955a77d2 | ||
|
|
c18591c0c3 | ||
|
|
ea3469f2dc | ||
|
|
8b796bc6b4 | ||
|
|
707046237c | ||
|
|
83ebababe7 | ||
|
|
2e95b7c5d8 |
@@ -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.3.1
|
||||
current_version = 0.4.5
|
||||
commit = True
|
||||
tag = True
|
||||
tag_name = v{new_version}
|
||||
|
||||
@@ -38,7 +38,6 @@ biome.json
|
||||
.bumpversion.cfg
|
||||
.npmrc
|
||||
Makefile
|
||||
CHANGELOG.md
|
||||
CONTRIBUTING.md
|
||||
SECURITY.md
|
||||
LICENSE
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
frontend-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 smoke test
|
||||
run: bun run build:web
|
||||
@@ -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: |
|
||||
@@ -68,6 +90,17 @@ jobs:
|
||||
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'
|
||||
@@ -124,6 +157,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
|
||||
@@ -147,7 +195,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 }}
|
||||
@@ -159,6 +213,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__
|
||||
@@ -169,6 +226,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:
|
||||
@@ -203,6 +300,12 @@ jobs:
|
||||
- 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: Package into server core + CUDA libs archives
|
||||
|
||||
@@ -63,3 +63,8 @@ nul
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# E2E test artifacts
|
||||
backend/tests/results/
|
||||
backend/tests/fixtures/reference_voice.wav
|
||||
backend/tests/fixtures/reference_voice.txt
|
||||
|
||||
+221
-1
@@ -7,6 +7,219 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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.
|
||||
@@ -444,7 +657,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
|
||||
[Unreleased]: https://github.com/jamiepine/voicebox/compare/v0.4.5...HEAD
|
||||
[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
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ 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/
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
<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">
|
||||
@@ -30,7 +33,8 @@
|
||||
<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/>
|
||||
@@ -59,13 +63,14 @@
|
||||
|
||||
## 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 5 TTS engines, apply post-processing effects, and compose multi-voice projects with a timeline editor.
|
||||
Voicebox is a **local-first voice cloning studio** — a free and open-source alternative to ElevenLabs. Clone voices from a few seconds of audio or pick from 50+ preset voices, generate speech in 23 languages across 7 TTS engines, apply post-processing effects, and compose multi-voice projects with a timeline editor.
|
||||
|
||||
- **Complete privacy** — models and voice data stay on your machine
|
||||
- **5 TTS engines** — Qwen3-TTS, LuxTTS, Chatterbox Multilingual, Chatterbox Turbo, and HumeAI TADA
|
||||
- **7 TTS engines** — Qwen3-TTS, Qwen CustomVoice, LuxTTS, Chatterbox Multilingual, Chatterbox Turbo, HumeAI TADA, and Kokoro
|
||||
- **Cloning and preset voices** — zero-shot cloning from a reference sample, or curated preset voices via Kokoro (50 voices) and Qwen CustomVoice (9 voices)
|
||||
- **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
|
||||
@@ -87,25 +92,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
|
||||
|
||||
Five 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]`
|
||||
|
||||
@@ -231,7 +245,7 @@ Full API documentation available at `http://localhost:17493/docs`.
|
||||
| Frontend | React, TypeScript, Tailwind CSS |
|
||||
| State | Zustand, React Query |
|
||||
| Backend | FastAPI (Python) |
|
||||
| TTS Engines | Qwen3-TTS, LuxTTS, Chatterbox, Chatterbox Turbo, TADA |
|
||||
| TTS Engines | Qwen3-TTS, Qwen CustomVoice, LuxTTS, Chatterbox, Chatterbox Turbo, TADA, Kokoro |
|
||||
| Effects | Pedalboard (Spotify) |
|
||||
| Transcription | Whisper / Whisper Turbo (PyTorch or MLX) |
|
||||
| Inference | MLX (Apple Silicon) / PyTorch (CUDA/ROCm/XPU/CPU) |
|
||||
@@ -250,6 +264,8 @@ Full API documentation available at `http://localhost:17493/docs`.
|
||||
| **Plugin Architecture** | Extend with custom models and effects |
|
||||
| **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.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
+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
|
||||
|
||||
+5
-1
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "@voicebox/app",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"preview": "vite preview",
|
||||
"lint": "biome lint src",
|
||||
"lint:fix": "biome lint --write src",
|
||||
@@ -43,11 +44,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",
|
||||
|
||||
@@ -121,7 +121,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;
|
||||
}
|
||||
@@ -144,13 +143,11 @@ 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
|
||||
|
||||
@@ -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,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,6 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } 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 {
|
||||
@@ -33,6 +34,7 @@ interface AudioDevice {
|
||||
}
|
||||
|
||||
export function AudioTab() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [editingChannel, setEditingChannel] = useState<string | null>(null);
|
||||
@@ -119,14 +121,14 @@ export function AudioTab() {
|
||||
if (channelsLoading || devicesLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
<div className="text-muted-foreground">{t('audioChannels.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleChannelDelete = async (e, channelId) => {
|
||||
const handleChannelDelete = async (e: React.MouseEvent, channelId: string) => {
|
||||
e.stopPropagation();
|
||||
if (await confirm('Delete this channel?')) {
|
||||
if (await confirm(t('audioChannels.confirmDelete'))) {
|
||||
deleteChannel.mutate(channelId);
|
||||
}
|
||||
};
|
||||
@@ -140,10 +142,10 @@ export function AudioTab() {
|
||||
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>
|
||||
<h2 className="text-2xl font-bold">{t('audioChannels.title')}</h2>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Channel
|
||||
{t('audioChannels.newChannel')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -158,13 +160,10 @@ export function AudioTab() {
|
||||
{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>
|
||||
<p className="text-muted-foreground mb-4">{t('audioChannels.empty.message')}</p>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Channel
|
||||
{t('audioChannels.empty.action')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -195,7 +194,7 @@ export function AudioTab() {
|
||||
<div className="space-y-2.5 ml-10">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Output Devices
|
||||
{t('audioChannels.labels.outputDevices')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{channel.device_ids.length > 0
|
||||
@@ -224,7 +223,7 @@ export function AudioTab() {
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Assigned Voices
|
||||
{t('audioChannels.labels.assignedVoices')}
|
||||
</div>
|
||||
<ChannelVoicesList channelId={channel.id} />
|
||||
</div>
|
||||
@@ -270,13 +269,13 @@ export function AudioTab() {
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0 mb-4">
|
||||
<h3 className="text-lg font-semibold">Available Devices</h3>
|
||||
<h3 className="text-lg font-semibold">{t('audioChannels.devices.title')}</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'}
|
||||
? t('audioChannels.devices.defaultNote')
|
||||
: t('audioChannels.devices.toggleHint')
|
||||
: t('audioChannels.devices.selectHint')}
|
||||
</p>
|
||||
</div>
|
||||
{allDevices.length > 0 ? (
|
||||
@@ -344,8 +343,8 @@ export function AudioTab() {
|
||||
<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'}
|
||||
? t('audioChannels.devices.empty')
|
||||
: t('audioChannels.devices.requiresTauri')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -394,6 +393,7 @@ export function AudioTab() {
|
||||
}
|
||||
|
||||
function ChannelVoicesList({ channelId }: { channelId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: voices } = useQuery({
|
||||
queryKey: ['channel-voices', channelId],
|
||||
queryFn: () => apiClient.getChannelVoices(channelId),
|
||||
@@ -416,7 +416,7 @@ function ChannelVoicesList({ channelId }: { channelId: string }) {
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">No voices assigned</span>
|
||||
<span className="text-sm text-muted-foreground">{t('audioChannels.noVoicesAssigned')}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -430,6 +430,7 @@ interface CreateChannelDialogProps {
|
||||
}
|
||||
|
||||
function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateChannelDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState('');
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
|
||||
|
||||
@@ -445,23 +446,21 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh
|
||||
<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>
|
||||
<DialogTitle>{t('audioChannels.createDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('audioChannels.createDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="channel-name">Channel Name</Label>
|
||||
<Label htmlFor="channel-name">{t('audioChannels.fields.name')}</Label>
|
||||
<Input
|
||||
id="channel-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Virtual Cable, Broadcast"
|
||||
placeholder={t('audioChannels.fields.namePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Output Devices</Label>
|
||||
<Label>{t('audioChannels.labels.outputDevices')}</Label>
|
||||
<Select
|
||||
value={selectedDevices[0] || ''}
|
||||
onValueChange={(value) => {
|
||||
@@ -471,12 +470,12 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select device" />
|
||||
<SelectValue placeholder={t('audioChannels.selectDevice')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{devices.map((device) => (
|
||||
<SelectItem key={device.id} value={device.id}>
|
||||
{device.name} {device.is_default && '(default)'}
|
||||
{device.name} {device.is_default && `(${t('audioChannels.defaultSuffix')})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -509,10 +508,10 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!name.trim()}>
|
||||
Create
|
||||
{t('audioChannels.createDialog.action')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -545,6 +544,7 @@ function EditChannelDialog({
|
||||
onUpdate,
|
||||
onSetVoices,
|
||||
}: EditChannelDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(channel.name);
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>(channel.device_ids);
|
||||
const [selectedVoices, setSelectedVoices] = useState<string[]>(channelVoices);
|
||||
@@ -560,16 +560,16 @@ function EditChannelDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Channel</DialogTitle>
|
||||
<DialogDescription>Update channel settings and voice assignments.</DialogDescription>
|
||||
<DialogTitle>{t('audioChannels.editDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('audioChannels.editDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="edit-channel-name">Channel Name</Label>
|
||||
<Label htmlFor="edit-channel-name">{t('audioChannels.fields.name')}</Label>
|
||||
<Input id="edit-channel-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Output Devices</Label>
|
||||
<Label>{t('audioChannels.labels.outputDevices')}</Label>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(value) => {
|
||||
@@ -579,12 +579,12 @@ function EditChannelDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Add device" />
|
||||
<SelectValue placeholder={t('audioChannels.addDevice')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{devices.map((device) => (
|
||||
<SelectItem key={device.id} value={device.id}>
|
||||
{device.name} {device.is_default && '(default)'}
|
||||
{device.name} {device.is_default && `(${t('audioChannels.defaultSuffix')})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -615,7 +615,7 @@ function EditChannelDialog({
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Assigned Voices</Label>
|
||||
<Label>{t('audioChannels.labels.assignedVoices')}</Label>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(value) => {
|
||||
@@ -625,7 +625,7 @@ function EditChannelDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Add voice" />
|
||||
<SelectValue placeholder={t('audioChannels.addVoice')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profiles.map((profile) => (
|
||||
@@ -663,10 +663,10 @@ function EditChannelDialog({
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!name.trim()}>
|
||||
Save
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -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,7 +343,7 @@ 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>
|
||||
@@ -337,7 +352,7 @@ function SortableEffectItem({
|
||||
type="button"
|
||||
className="p-0.5 text-muted-foreground hover:text-destructive"
|
||||
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 && (
|
||||
@@ -261,7 +284,7 @@ export function EffectsDetail() {
|
||||
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,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2, Plus, Sparkles, Wand2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { EffectPresetResponse } from '@/lib/api/types';
|
||||
@@ -7,6 +8,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);
|
||||
@@ -44,10 +46,10 @@ export function EffectsList() {
|
||||
<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>
|
||||
<h2 className="text-lg font-semibold">{t('effects.title')}</h2>
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1.5" onClick={handleCreateNew}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New Preset
|
||||
{t('effects.newPreset')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -57,7 +59,7 @@ export function EffectsList() {
|
||||
{builtIn.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||
Built-in
|
||||
{t('effects.sections.builtin')}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{builtIn.map((preset) => (
|
||||
@@ -76,7 +78,7 @@ export function EffectsList() {
|
||||
{userPresets.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||
Custom
|
||||
{t('effects.sections.custom')}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{userPresets.map((preset) => (
|
||||
@@ -95,16 +97,14 @@ export function EffectsList() {
|
||||
{isCreatingNew && (
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||
New
|
||||
{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">Unsaved Preset</span>
|
||||
<span className="text-sm font-medium">{t('effects.unsaved.title')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Configure effects in the panel on the right.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('effects.unsaved.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -122,7 +122,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 +148,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">
|
||||
<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">
|
||||
<EffectsDetail />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMatchRoute } from '@tanstack/react-router';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Loader2, Sparkles } from 'lucide-react';
|
||||
import { Loader2, SlidersHorizontal, Sparkles } 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 {
|
||||
@@ -34,12 +35,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);
|
||||
@@ -125,22 +128,29 @@ export function FloatingGenerateBox({
|
||||
}, [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);
|
||||
}
|
||||
// Auto-switch engine if profile has a default
|
||||
if (selectedProfile?.default_engine) {
|
||||
form.setValue(
|
||||
'engine',
|
||||
selectedProfile.default_engine as
|
||||
| 'qwen'
|
||||
| 'luxtts'
|
||||
| 'chatterbox'
|
||||
| 'chatterbox_turbo'
|
||||
| 'tada'
|
||||
| 'kokoro',
|
||||
);
|
||||
// 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 (
|
||||
@@ -268,10 +278,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={{
|
||||
@@ -294,10 +306,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={{
|
||||
@@ -326,10 +340,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 ? (
|
||||
@@ -340,15 +354,86 @@ 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>
|
||||
|
||||
{/* 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 }}
|
||||
className="absolute top-0 right-[calc(100%+0.5rem)]"
|
||||
>
|
||||
<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>
|
||||
</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 }}
|
||||
@@ -365,7 +450,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) => (
|
||||
@@ -419,16 +504,16 @@ 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">
|
||||
Profile default
|
||||
{t('generation.effects.profileDefault')}
|
||||
</SelectItem>
|
||||
)}
|
||||
{effectPresets?.map((preset) => (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Loader2, Mic } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
@@ -24,7 +24,11 @@ import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/
|
||||
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
||||
import { useProfile } from '@/lib/hooks/useProfiles';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { EngineModelSelector, applyEngineSelection, getEngineDescription } from './EngineModelSelector';
|
||||
import {
|
||||
applyEngineSelection,
|
||||
EngineModelSelector,
|
||||
getEngineDescription,
|
||||
} from './EngineModelSelector';
|
||||
import { ParalinguisticInput } from './ParalinguisticInput';
|
||||
|
||||
function getEngineSelectValue(engine: string): string {
|
||||
@@ -114,7 +118,7 @@ export function GenerationForm() {
|
||||
)}
|
||||
/>
|
||||
|
||||
{(form.watch('engine') === 'qwen' || form.watch('engine') === 'qwen_custom_voice') && (
|
||||
{form.watch('engine') === 'qwen_custom_voice' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="instruct"
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
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 { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -45,6 +45,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,
|
||||
@@ -88,6 +89,7 @@ function AudioBars({ mode }: { mode: 'idle' | 'generating' | 'playing' }) {
|
||||
|
||||
// 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 +126,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 +178,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,6 +436,27 @@ 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">
|
||||
@@ -424,6 +466,23 @@ export function HistoryTable() {
|
||||
</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 hover:text-destructive"
|
||||
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 +501,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}
|
||||
@@ -590,60 +651,72 @@ export function HistoryTable() {
|
||||
<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="Cancel generation"
|
||||
disabled={isCancelling}
|
||||
onClick={() => cancelGeneration.mutate(gen.id)}
|
||||
>
|
||||
{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>
|
||||
@@ -732,10 +805,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>
|
||||
@@ -746,14 +818,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>
|
||||
@@ -762,9 +859,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>
|
||||
@@ -778,13 +875,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>
|
||||
@@ -793,21 +892,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) => (
|
||||
@@ -829,13 +927,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -119,6 +120,7 @@ function formatBytes(bytes: number): string {
|
||||
}
|
||||
|
||||
export function ModelManagement() {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const platform = usePlatform();
|
||||
@@ -270,8 +272,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',
|
||||
});
|
||||
}
|
||||
@@ -309,8 +311,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',
|
||||
});
|
||||
},
|
||||
@@ -336,8 +338,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);
|
||||
@@ -348,7 +352,7 @@ export function ModelManagement() {
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: 'Delete failed',
|
||||
title: t('models.toast.deleteFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -361,15 +365,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',
|
||||
});
|
||||
@@ -377,7 +381,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`;
|
||||
};
|
||||
@@ -410,8 +414,8 @@ export function ModelManagement() {
|
||||
|
||||
// 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 },
|
||||
];
|
||||
|
||||
// Get detail modal state for selected model
|
||||
@@ -427,16 +431,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 */}
|
||||
@@ -444,7 +446,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}
|
||||
@@ -461,12 +463,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"
|
||||
@@ -475,12 +477,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}
|
||||
@@ -490,7 +492,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
|
||||
@@ -500,13 +502,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>
|
||||
@@ -520,7 +522,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">
|
||||
@@ -565,7 +567,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>
|
||||
);
|
||||
@@ -576,12 +578,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 && (
|
||||
@@ -613,7 +615,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>
|
||||
@@ -626,7 +628,7 @@ export function ModelManagement() {
|
||||
disabled={clearAllMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
Clear All
|
||||
{t('models.problems.clearAll')}
|
||||
</Button>
|
||||
</div>
|
||||
{consoleOpen && (
|
||||
@@ -645,13 +647,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>
|
||||
))}
|
||||
@@ -692,13 +694,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>
|
||||
@@ -707,7 +709,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>
|
||||
)}
|
||||
|
||||
@@ -734,23 +736,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>
|
||||
@@ -762,8 +770,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>
|
||||
)}
|
||||
@@ -774,7 +786,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>
|
||||
)}
|
||||
|
||||
@@ -796,7 +810,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"
|
||||
@@ -825,7 +839,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>
|
||||
</>
|
||||
);
|
||||
@@ -858,7 +872,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
|
||||
@@ -875,13 +891,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>
|
||||
) : (
|
||||
@@ -891,7 +907,7 @@ export function ModelManagement() {
|
||||
className="flex-1"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
{t('models.actions.download')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -905,20 +921,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) {
|
||||
@@ -931,10 +950,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>
|
||||
@@ -948,11 +967,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"
|
||||
@@ -961,7 +977,7 @@ export function ModelManagement() {
|
||||
{pendingMigrateDir}
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
if (!pendingMigrateDir) return;
|
||||
@@ -973,11 +989,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) => {
|
||||
@@ -991,7 +1019,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 */
|
||||
@@ -999,7 +1027,7 @@ export function ModelManagement() {
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
reject(new Error('Lost connection during migration'));
|
||||
reject(new Error(t('models.toast.migrationConnectionLost')));
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1009,15 +1037,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 {
|
||||
@@ -1026,7 +1055,7 @@ export function ModelManagement() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
Move Models
|
||||
{t('models.migrateDialog.action')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -1038,11 +1067,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 && (
|
||||
@@ -1063,106 +1092,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);
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -117,15 +118,20 @@ export function AboutPage() {
|
||||
|
||||
<FadeIn delay={400}>
|
||||
<p className="text-xs text-muted-foreground/40 pt-4">
|
||||
Licensed under{' '}
|
||||
<a
|
||||
href="https://github.com/jamiepine/voicebox/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-muted-foreground/60 transition-colors"
|
||||
>
|
||||
MIT
|
||||
</a>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,19 @@ import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { LanguageSelect } from './LanguageSelect';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
|
||||
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 +37,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 +50,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 +79,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 +99,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 +110,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 +132,7 @@ export function GeneralPage() {
|
||||
/>
|
||||
{isDirty && (
|
||||
<Button type="submit" size="sm">
|
||||
Save
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
@@ -122,8 +140,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 +153,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 +172,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,16 +182,22 @@ 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 />}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
<ApiReferenceCard serverUrl={serverUrl} />
|
||||
@@ -192,11 +216,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 +234,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 +245,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 +255,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 +301,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 +317,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 +351,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 +369,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 +414,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,5 +1,6 @@
|
||||
import { FolderOpen } 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';
|
||||
@@ -8,6 +9,7 @@ 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);
|
||||
@@ -48,15 +50,15 @@ export function GenerationPage() {
|
||||
return (
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
<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>
|
||||
}
|
||||
>
|
||||
@@ -67,16 +69,18 @@ export function GenerationPage() {
|
||||
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>
|
||||
}
|
||||
>
|
||||
@@ -87,13 +91,13 @@ export function GenerationPage() {
|
||||
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
|
||||
@@ -105,8 +109,8 @@ export function GenerationPage() {
|
||||
/>
|
||||
|
||||
<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
|
||||
@@ -118,8 +122,8 @@ export function GenerationPage() {
|
||||
/>
|
||||
|
||||
<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,7 +132,7 @@ export function GenerationPage() {
|
||||
disabled={opening || !generationsPath}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5 mr-1.5" />
|
||||
Open
|
||||
{t('settings.generation.folder.open')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -40,9 +41,9 @@ 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!
|
||||
@@ -64,7 +65,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 +79,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 +96,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 +107,7 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
|
||||
}
|
||||
|
||||
export function GpuPage() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const queryClient = useQueryClient();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
@@ -111,6 +117,12 @@ export function GpuPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [downloadProgress, setDownloadProgress] = useState<CudaDownloadProgress | null>(null);
|
||||
const healthPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// Hold the latest `t` in a ref so the CUDA progress SSE effect below doesn't
|
||||
// tear down and reconnect the EventSource every time the language changes.
|
||||
const tRef = useRef(t);
|
||||
useEffect(() => {
|
||||
tRef.current = t;
|
||||
}, [t]);
|
||||
|
||||
const {
|
||||
data: cudaStatus,
|
||||
@@ -153,7 +165,7 @@ export function GpuPage() {
|
||||
refetchCudaStatus();
|
||||
} else if (data.status === 'error') {
|
||||
eventSource.close();
|
||||
setError(data.error || 'Download failed');
|
||||
setError(data.error || tRef.current('settings.gpu.errors.downloadFailed'));
|
||||
setDownloadProgress(null);
|
||||
refetchCudaStatus();
|
||||
}
|
||||
@@ -218,7 +230,7 @@ export function GpuPage() {
|
||||
await apiClient.downloadCudaBackend();
|
||||
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 {
|
||||
@@ -230,9 +242,9 @@ export function GpuPage() {
|
||||
const handleRestart = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await restartServerWithPolling('Restart failed');
|
||||
await restartServerWithPolling(t('settings.gpu.errors.restartFailed'));
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Restart failed');
|
||||
setError(e instanceof Error ? e.message : t('settings.gpu.errors.restartFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -241,9 +253,9 @@ export function GpuPage() {
|
||||
setRestartPhase('stopping');
|
||||
try {
|
||||
await apiClient.deleteCudaBackend();
|
||||
await restartServerWithPolling('Failed to switch to CPU');
|
||||
await restartServerWithPolling(t('settings.gpu.errors.switchCpu'));
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to switch to CPU');
|
||||
setError(e instanceof Error ? e.message : t('settings.gpu.errors.switchCpu'));
|
||||
refetchCudaStatus();
|
||||
}
|
||||
};
|
||||
@@ -254,7 +266,7 @@ export function GpuPage() {
|
||||
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'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -278,21 +290,21 @@ 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."
|
||||
title={t('settings.gpu.cuda.title')}
|
||||
description={t('settings.gpu.cuda.description')}
|
||||
>
|
||||
{/* Download progress */}
|
||||
{cudaDownloading && downloadProgress && (
|
||||
<SettingRow title="Downloading CUDA backend...">
|
||||
<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 ? 'Updating...' : 'Downloading...')}
|
||||
(cudaAvailable
|
||||
? t('settings.gpu.cuda.updating')
|
||||
: t('settings.gpu.cuda.downloadingShort'))}
|
||||
</span>
|
||||
<span>
|
||||
{downloadProgress.total > 0
|
||||
@@ -304,23 +316,21 @@ export function GpuPage() {
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{/* Restart in progress */}
|
||||
{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" />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
@@ -328,17 +338,16 @@ export function GpuPage() {
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{restartPhase === 'idle' && !cudaDownloading && (
|
||||
<>
|
||||
{!cudaAvailable && !isCurrentlyCuda && (
|
||||
<SettingRow
|
||||
title="Download CUDA backend"
|
||||
description="~2.4 GB download. Requires an NVIDIA GPU with CUDA support."
|
||||
title={t('settings.gpu.download.title')}
|
||||
description={t('settings.gpu.download.description')}
|
||||
action={
|
||||
<Button onClick={handleDownload} size="sm">
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
Download
|
||||
{t('settings.gpu.download.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -346,12 +355,12 @@ export function GpuPage() {
|
||||
|
||||
{cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && (
|
||||
<SettingRow
|
||||
title="Switch to CUDA backend"
|
||||
description="CUDA backend is downloaded and ready. Restart to enable."
|
||||
title={t('settings.gpu.switchToCuda.title')}
|
||||
description={t('settings.gpu.switchToCuda.description')}
|
||||
action={
|
||||
<Button onClick={handleRestart} size="sm">
|
||||
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
Restart
|
||||
{t('settings.gpu.switchToCuda.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -359,12 +368,12 @@ export function GpuPage() {
|
||||
|
||||
{isCurrentlyCuda && platform.metadata.isTauri && (
|
||||
<SettingRow
|
||||
title="Switch to CPU backend"
|
||||
description="Disable GPU acceleration. You can re-download CUDA later."
|
||||
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" />
|
||||
Switch
|
||||
{t('settings.gpu.switchToCpu.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -372,8 +381,8 @@ export function GpuPage() {
|
||||
|
||||
{cudaAvailable && !isCurrentlyCuda && (
|
||||
<SettingRow
|
||||
title="Remove CUDA backend"
|
||||
description="Delete the downloaded CUDA binary to free disk space."
|
||||
title={t('settings.gpu.remove.title')}
|
||||
description={t('settings.gpu.remove.description')}
|
||||
action={
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
@@ -382,7 +391,7 @@ export function GpuPage() {
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
Remove
|
||||
{t('settings.gpu.remove.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -392,14 +401,7 @@ export function GpuPage() {
|
||||
</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} />)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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;
|
||||
path:
|
||||
| '/settings'
|
||||
| '/settings/generation'
|
||||
@@ -17,15 +18,16 @@ 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.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 +54,7 @@ export function SettingsLayout() {
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{t(tab.labelKey)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link, useMatchRoute } from '@tanstack/react-router';
|
||||
import { AudioLines, Box, Mic, Settings, Speaker, 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';
|
||||
@@ -13,16 +14,17 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
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' },
|
||||
{ id: 'main', path: '/', icon: Volume2, labelKey: 'nav.generate' },
|
||||
{ id: 'stories', path: '/stories', icon: AudioLines, labelKey: 'nav.stories' },
|
||||
{ id: 'voices', path: '/voices', icon: Mic, labelKey: 'nav.voices' },
|
||||
{ id: 'effects', path: '/effects', icon: Wand2, labelKey: 'nav.effects' },
|
||||
{ id: 'audio', path: '/audio', icon: Speaker, labelKey: 'nav.audio' },
|
||||
{ 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();
|
||||
@@ -72,8 +74,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={t(tab.labelKey)}
|
||||
aria-label={t(tab.labelKey)}
|
||||
>
|
||||
{isActive && (
|
||||
<div
|
||||
@@ -102,7 +104,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>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical, Mic, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -34,6 +35,7 @@ export function StoryChatItem({
|
||||
dragHandleProps,
|
||||
isDragging,
|
||||
}: StoryChatItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const seek = useStoryStore((state) => state.seek);
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
@@ -87,7 +89,7 @@ export function StoryChatItem({
|
||||
alt={`${item.profile_name} avatar`}
|
||||
className={cn(
|
||||
'h-full w-full object-cover transition-all duration-200',
|
||||
!isCurrentlyPlaying && 'grayscale'
|
||||
!isCurrentlyPlaying && 'grayscale',
|
||||
)}
|
||||
onError={() => setAvatarError(true)}
|
||||
/>
|
||||
@@ -118,18 +120,26 @@ export function StoryChatItem({
|
||||
<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">
|
||||
<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 +149,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 +163,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Link } from '@tanstack/react-router';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Download, Plus } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Loader from 'react-loaders';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -36,6 +37,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();
|
||||
@@ -147,7 +149,7 @@ export function StoryContent() {
|
||||
{
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to remove item',
|
||||
title: t('storyContent.toast.removeFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -179,7 +181,7 @@ export function StoryContent() {
|
||||
{
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to reorder items',
|
||||
title: t('storyContent.toast.reorderFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -199,7 +201,7 @@ export function StoryContent() {
|
||||
{
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to export audio',
|
||||
title: t('storyContent.toast.exportFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -223,7 +225,7 @@ export function StoryContent() {
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to add generation',
|
||||
title: t('storyContent.toast.addFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -236,8 +238,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 +248,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,8 +257,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">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>
|
||||
);
|
||||
@@ -291,7 +293,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,13 +303,13 @@ 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">
|
||||
<Input
|
||||
placeholder="Search by name or transcript..."
|
||||
placeholder={t('storyContent.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
@@ -316,7 +318,9 @@ export function StoryContent() {
|
||||
<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 +348,7 @@ export function StoryContent() {
|
||||
disabled={exportAudio.isPending}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export Audio
|
||||
{t('storyContent.exportAudio')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -358,8 +362,8 @@ export function StoryContent() {
|
||||
>
|
||||
{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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -41,6 +42,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);
|
||||
@@ -72,8 +74,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 +93,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 +118,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 +142,7 @@ export function StoryList() {
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to update story',
|
||||
title: t('stories.toast.updateFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -168,7 +170,7 @@ export function StoryList() {
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Failed to delete story',
|
||||
title: t('stories.toast.deleteFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -179,7 +181,7 @@ export function 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>
|
||||
);
|
||||
}
|
||||
@@ -195,10 +197,10 @@ export function StoryList() {
|
||||
{/* 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>
|
||||
<h2 className="text-2xl font-bold">{t('stories.title')}</h2>
|
||||
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Story
|
||||
{t('stories.newStory')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -211,8 +213,8 @@ export function StoryList() {
|
||||
{storyList.length === 0 ? (
|
||||
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
||||
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">No stories yet</p>
|
||||
<p className="text-xs mt-2">Create your first story to get started</p>
|
||||
<p className="text-sm">{t('stories.empty.title')}</p>
|
||||
<p className="text-xs mt-2">{t('stories.empty.hint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
@@ -225,7 +227,11 @@ export function StoryList() {
|
||||
'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-label={t('stories.row.ariaLabel', {
|
||||
name: story.name,
|
||||
count: story.item_count,
|
||||
updated: formatDate(story.updated_at),
|
||||
})}
|
||||
aria-pressed={selectedStoryId === story.id}
|
||||
onClick={() => setSelectedStoryId(story.id)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -240,9 +246,7 @@ export function StoryList() {
|
||||
<div className="flex-1 min-w-0 text-left overflow-hidden">
|
||||
<h3 className="text-sm font-medium truncate">{story.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{story.item_count} {story.item_count === 1 ? 'item' : 'items'}
|
||||
</span>
|
||||
<span>{t('stories.row.itemCount', { count: story.item_count })}</span>
|
||||
<span>·</span>
|
||||
<span>{formatDate(story.updated_at)}</span>
|
||||
</div>
|
||||
@@ -254,7 +258,7 @@ export function StoryList() {
|
||||
size="icon"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Actions for ${story.name}`}
|
||||
aria-label={t('stories.row.actionsLabel', { name: story.name })}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -262,14 +266,14 @@ 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>
|
||||
@@ -284,17 +288,15 @@ export function StoryList() {
|
||||
<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 +307,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 +319,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 +352,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,34 +364,30 @@ 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>
|
||||
|
||||
@@ -371,7 +371,7 @@ 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;
|
||||
@@ -500,7 +500,7 @@ 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;
|
||||
|
||||
@@ -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 { 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';
|
||||
@@ -25,9 +26,11 @@ const ENGINE_DISPLAY_NAMES: Record<string, string> = {
|
||||
|
||||
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();
|
||||
@@ -40,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);
|
||||
};
|
||||
|
||||
@@ -72,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 ring-accent shadow-md',
|
||||
)}
|
||||
onClick={handleSelect}
|
||||
tabIndex={0}
|
||||
@@ -97,7 +107,7 @@ 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">
|
||||
@@ -110,7 +120,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
)}
|
||||
{profile.voice_type === 'designed' && (
|
||||
<Badge variant="secondary" className="text-xs h-5 px-1.5">
|
||||
designed
|
||||
{t('profiles.card.designed')}
|
||||
</Badge>
|
||||
)}
|
||||
{profile.effects_chain && profile.effects_chain.length > 0 && (
|
||||
@@ -122,7 +132,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
icon={Download}
|
||||
onClick={handleExport}
|
||||
disabled={exportProfile.isPending}
|
||||
aria-label="Export profile"
|
||||
aria-label={t('profiles.card.export')}
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Edit}
|
||||
@@ -130,13 +140,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>
|
||||
@@ -145,21 +155,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>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { Edit2, Mic, Monitor, Music, Upload, 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 { Badge } from '@/components/ui/badge';
|
||||
@@ -71,30 +72,38 @@ const DEFAULT_ENGINE_OPTIONS = [
|
||||
{ value: 'kokoro', label: 'Kokoro 82M' },
|
||||
] as const;
|
||||
|
||||
const baseProfileSchema = 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[]]),
|
||||
sampleFile: z.instanceof(File).optional(),
|
||||
referenceText: z.string().max(1000).optional(),
|
||||
avatarFile: z.instanceof(File).optional(),
|
||||
});
|
||||
function makeProfileSchema(t: (key: string) => string) {
|
||||
const baseProfileSchema = 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[]]),
|
||||
sampleFile: z.instanceof(File).optional(),
|
||||
referenceText: z.string().max(1000).optional(),
|
||||
avatarFile: z.instanceof(File).optional(),
|
||||
});
|
||||
|
||||
const profileSchema = baseProfileSchema.refine(
|
||||
(data) => {
|
||||
// If sample file is provided, reference text is required
|
||||
if (data.sampleFile && (!data.referenceText || data.referenceText.trim().length === 0)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Reference text is required when adding a sample',
|
||||
path: ['referenceText'],
|
||||
},
|
||||
);
|
||||
return baseProfileSchema.refine(
|
||||
(data) => {
|
||||
if (data.sampleFile && (!data.referenceText || data.referenceText.trim().length === 0)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t('profileForm.validation.referenceRequired'),
|
||||
path: ['referenceText'],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
type ProfileFormValues = z.infer<typeof profileSchema>;
|
||||
type ProfileFormValues = {
|
||||
name: string;
|
||||
description?: string;
|
||||
language: LanguageCode;
|
||||
sampleFile?: File;
|
||||
referenceText?: string;
|
||||
avatarFile?: File;
|
||||
};
|
||||
|
||||
// Helper to convert File to base64
|
||||
async function fileToBase64(file: File): Promise<string> {
|
||||
@@ -119,6 +128,7 @@ function base64ToFile(base64: string, fileName: string, fileType: string): File
|
||||
}
|
||||
|
||||
export function ProfileForm() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const open = useUIStore((state) => state.profileDialogOpen);
|
||||
const setOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
@@ -151,7 +161,7 @@ export function ProfileForm() {
|
||||
const [defaultEngine, setDefaultEngine] = useState<string>('');
|
||||
|
||||
const form = useForm<ProfileFormValues>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
resolver: zodResolver(makeProfileSchema(t)),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
@@ -175,7 +185,10 @@ export function ProfileForm() {
|
||||
if (duration > MAX_AUDIO_DURATION_SECONDS) {
|
||||
form.setError('sampleFile', {
|
||||
type: 'manual',
|
||||
message: `Audio is too long (${formatAudioDuration(duration)}). Maximum duration is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
|
||||
message: t('profileForm.validation.audioTooLong', {
|
||||
duration: formatAudioDuration(duration),
|
||||
max: formatAudioDuration(MAX_AUDIO_DURATION_SECONDS),
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
form.clearErrors('sampleFile');
|
||||
@@ -184,14 +197,13 @@ export function ProfileForm() {
|
||||
.catch((error) => {
|
||||
console.error('Failed to get audio duration:', error);
|
||||
setAudioDuration(null);
|
||||
// For recordings, we auto-stop at max duration, so we can skip validation errors
|
||||
const isRecordedFile =
|
||||
selectedFile.name.startsWith('recording-') ||
|
||||
selectedFile.name.startsWith('system-audio-');
|
||||
if (!isRecordedFile) {
|
||||
form.setError('sampleFile', {
|
||||
type: 'manual',
|
||||
message: 'Failed to validate audio file. Please try a different file.',
|
||||
message: t('profileForm.validation.audioFailed'),
|
||||
});
|
||||
} else {
|
||||
// Clear any existing errors for recorded files
|
||||
@@ -205,7 +217,7 @@ export function ProfileForm() {
|
||||
setAudioDuration(null);
|
||||
form.clearErrors('sampleFile');
|
||||
}
|
||||
}, [selectedFile, form]);
|
||||
}, [selectedFile, form, t]);
|
||||
|
||||
const {
|
||||
isRecording,
|
||||
@@ -226,8 +238,8 @@ export function ProfileForm() {
|
||||
}
|
||||
form.setValue('sampleFile', file, { shouldValidate: true });
|
||||
toast({
|
||||
title: 'Recording complete',
|
||||
description: 'Audio has been recorded successfully.',
|
||||
title: t('profileForm.toast.recordingComplete'),
|
||||
description: t('profileForm.toast.recordingCompleteDescription'),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -252,8 +264,8 @@ export function ProfileForm() {
|
||||
}
|
||||
form.setValue('sampleFile', file, { shouldValidate: true });
|
||||
toast({
|
||||
title: 'System audio captured',
|
||||
description: 'Audio has been captured successfully.',
|
||||
title: t('profileForm.toast.systemAudioCaptured'),
|
||||
description: t('profileForm.toast.systemAudioCapturedDescription'),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -282,23 +294,22 @@ export function ProfileForm() {
|
||||
useEffect(() => {
|
||||
if (recordingError) {
|
||||
toast({
|
||||
title: 'Recording error',
|
||||
title: t('profileForm.toast.recordingError'),
|
||||
description: recordingError,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [recordingError, toast]);
|
||||
}, [recordingError, toast, t]);
|
||||
|
||||
// Show system audio recording errors
|
||||
useEffect(() => {
|
||||
if (systemRecordingError) {
|
||||
toast({
|
||||
title: 'System audio capture error',
|
||||
title: t('profileForm.toast.systemAudioError'),
|
||||
description: systemRecordingError,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [systemRecordingError, toast]);
|
||||
}, [systemRecordingError, toast, t]);
|
||||
|
||||
// Handle avatar preview
|
||||
useEffect(() => {
|
||||
@@ -388,8 +399,8 @@ export function ProfileForm() {
|
||||
const file = form.getValues('sampleFile');
|
||||
if (!file) {
|
||||
toast({
|
||||
title: 'No file selected',
|
||||
description: 'Please select an audio file first.',
|
||||
title: t('profileForm.toast.noFile'),
|
||||
description: t('profileForm.toast.noFileDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -402,8 +413,9 @@ export function ProfileForm() {
|
||||
form.setValue('referenceText', result.text, { shouldValidate: true });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Transcription failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to transcribe audio',
|
||||
title: t('profileForm.toast.transcribeFailed'),
|
||||
description:
|
||||
error instanceof Error ? error.message : t('profileForm.toast.transcribeFailedFallback'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -429,16 +441,16 @@ export function ProfileForm() {
|
||||
if (file) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast({
|
||||
title: 'Invalid file type',
|
||||
description: 'Please select an image file (PNG, JPG, or WebP)',
|
||||
title: t('profileForm.toast.invalidFile'),
|
||||
description: t('profileForm.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;
|
||||
@@ -452,13 +464,13 @@ export function ProfileForm() {
|
||||
try {
|
||||
await deleteAvatar.mutateAsync(editingProfileId);
|
||||
toast({
|
||||
title: 'Avatar removed',
|
||||
description: 'Avatar image has been removed successfully.',
|
||||
title: t('profileForm.toast.avatarRemoved'),
|
||||
description: t('profileForm.toast.avatarRemovedDescription'),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to remove avatar',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
title: t('profileForm.toast.avatarRemoveFailed'),
|
||||
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -493,9 +505,11 @@ export function ProfileForm() {
|
||||
});
|
||||
} catch (avatarError) {
|
||||
toast({
|
||||
title: 'Avatar upload failed',
|
||||
title: t('profileForm.toast.avatarUploadFailed'),
|
||||
description:
|
||||
avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
|
||||
avatarError instanceof Error
|
||||
? avatarError.message
|
||||
: t('profileForm.toast.avatarUploadFailedFallback'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -510,9 +524,11 @@ export function ProfileForm() {
|
||||
);
|
||||
} catch (fxError) {
|
||||
toast({
|
||||
title: 'Effects update failed',
|
||||
title: t('profileForm.toast.effectsUpdateFailed'),
|
||||
description:
|
||||
fxError instanceof Error ? fxError.message : 'Failed to save effects chain',
|
||||
fxError instanceof Error
|
||||
? fxError.message
|
||||
: t('profileForm.toast.effectsUpdateFailedFallback'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -520,15 +536,15 @@ export function ProfileForm() {
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Voice updated',
|
||||
description: `"${data.name}" has been updated successfully.`,
|
||||
title: t('profileForm.toast.voiceUpdated'),
|
||||
description: t('profileForm.toast.voiceUpdatedDescription', { name: data.name }),
|
||||
});
|
||||
} else if (voiceSource === 'builtin') {
|
||||
// Creating preset profile from built-in voice
|
||||
if (!selectedPresetVoiceId) {
|
||||
toast({
|
||||
title: 'No voice selected',
|
||||
description: 'Please select a built-in voice.',
|
||||
title: t('profileForm.toast.noVoiceSelected'),
|
||||
description: t('profileForm.toast.noVoiceSelectedDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -553,17 +569,19 @@ export function ProfileForm() {
|
||||
});
|
||||
} catch (avatarError) {
|
||||
toast({
|
||||
title: 'Avatar upload failed',
|
||||
title: t('profileForm.toast.avatarUploadFailed'),
|
||||
description:
|
||||
avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
|
||||
avatarError instanceof Error
|
||||
? avatarError.message
|
||||
: t('profileForm.toast.avatarUploadFailedFallback'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Profile created',
|
||||
description: `"${data.name}" has been created with a built-in voice.`,
|
||||
title: t('profileForm.toast.profileCreated'),
|
||||
description: t('profileForm.toast.profileCreatedBuiltin', { name: data.name }),
|
||||
});
|
||||
} else {
|
||||
// Creating cloned profile: require sample file and reference text
|
||||
@@ -573,11 +591,11 @@ export function ProfileForm() {
|
||||
if (!sampleFile) {
|
||||
form.setError('sampleFile', {
|
||||
type: 'manual',
|
||||
message: 'Audio sample is required',
|
||||
message: t('profileForm.validation.sampleRequired'),
|
||||
});
|
||||
toast({
|
||||
title: 'Audio sample required',
|
||||
description: 'Please provide an audio sample to create the voice profile.',
|
||||
title: t('profileForm.toast.sampleRequired'),
|
||||
description: t('profileForm.toast.sampleRequiredDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -586,42 +604,48 @@ export function ProfileForm() {
|
||||
if (!referenceText || referenceText.trim().length === 0) {
|
||||
form.setError('referenceText', {
|
||||
type: 'manual',
|
||||
message: 'Reference text is required',
|
||||
message: t('profileForm.validation.referenceTextRequired'),
|
||||
});
|
||||
toast({
|
||||
title: 'Reference text required',
|
||||
description: 'Please provide the reference text for the audio sample.',
|
||||
title: t('profileForm.toast.referenceTextRequired'),
|
||||
description: t('profileForm.toast.referenceTextRequiredDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate audio duration before creating profile
|
||||
try {
|
||||
const duration = await getAudioDuration(sampleFile);
|
||||
if (duration > MAX_AUDIO_DURATION_SECONDS) {
|
||||
form.setError('sampleFile', {
|
||||
type: 'manual',
|
||||
message: `Audio is too long (${formatAudioDuration(duration)}). Maximum duration is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
|
||||
message: t('profileForm.validation.audioTooLong', {
|
||||
duration: formatAudioDuration(duration),
|
||||
max: formatAudioDuration(MAX_AUDIO_DURATION_SECONDS),
|
||||
}),
|
||||
});
|
||||
toast({
|
||||
title: 'Invalid audio file',
|
||||
description: `Audio duration is ${formatAudioDuration(duration)}, but maximum is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
|
||||
title: t('profileForm.toast.invalidAudio'),
|
||||
description: t('profileForm.toast.invalidAudioDescription', {
|
||||
duration: formatAudioDuration(duration),
|
||||
max: formatAudioDuration(MAX_AUDIO_DURATION_SECONDS),
|
||||
}),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return; // Prevent form submission
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
form.setError('sampleFile', {
|
||||
type: 'manual',
|
||||
message: 'Failed to validate audio file. Please try a different file.',
|
||||
message: t('profileForm.validation.audioFailed'),
|
||||
});
|
||||
toast({
|
||||
title: 'Validation error',
|
||||
description: error instanceof Error ? error.message : 'Failed to validate audio file',
|
||||
title: t('profileForm.toast.validationError'),
|
||||
description:
|
||||
error instanceof Error ? error.message : t('profileForm.validation.audioFailed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return; // Prevent form submission
|
||||
return;
|
||||
}
|
||||
|
||||
// Creating: create profile, then add sample
|
||||
@@ -670,8 +694,8 @@ export function ProfileForm() {
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Profile created',
|
||||
description: `"${data.name}" has been created with a sample.`,
|
||||
title: t('profileForm.toast.profileCreated'),
|
||||
description: t('profileForm.toast.profileCreatedSample', { name: data.name }),
|
||||
});
|
||||
} catch (sampleError) {
|
||||
let rollbackSucceeded = false;
|
||||
@@ -680,23 +704,26 @@ export function ProfileForm() {
|
||||
rollbackSucceeded = true;
|
||||
} catch (rollbackError) {
|
||||
toast({
|
||||
title: 'Rollback failed',
|
||||
title: t('profileForm.toast.rollbackFailed'),
|
||||
description:
|
||||
rollbackError instanceof Error
|
||||
? rollbackError.message
|
||||
: 'Created profile could not be removed after sample upload failure.',
|
||||
: t('profileForm.toast.rollbackFailedDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
const rollbackSuffix = rollbackSucceeded
|
||||
? ` ${t('profileForm.toast.profileRolledBack')}`
|
||||
: '';
|
||||
toast({
|
||||
title: 'Failed to add sample',
|
||||
title: t('profileForm.toast.sampleFailed'),
|
||||
description:
|
||||
sampleError instanceof Error
|
||||
? `${sampleError.message}${rollbackSucceeded ? ' The profile was rolled back.' : ''}`
|
||||
? `${sampleError.message}${rollbackSuffix}`
|
||||
: rollbackSucceeded
|
||||
? 'Failed to add sample. The profile was rolled back.'
|
||||
: 'Failed to add sample.',
|
||||
? t('profileForm.toast.sampleFailedRolledBack')
|
||||
: t('profileForm.toast.sampleFailedDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -710,8 +737,8 @@ export function ProfileForm() {
|
||||
setOpen(false);
|
||||
} 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',
|
||||
});
|
||||
}
|
||||
@@ -768,16 +795,18 @@ export function ProfileForm() {
|
||||
<div className="max-w-5xl h-[85vh] mx-auto my-auto w-full flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl">
|
||||
{editingProfileId ? 'Edit Voice' : 'Create Voice'}
|
||||
{editingProfileId ? t('profileForm.editTitle') : t('profileForm.createTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingProfileId
|
||||
? 'Update your voice profile details and manage samples.'
|
||||
: 'Create a new voice profile from an audio sample or a built-in voice.'}
|
||||
? t('profileForm.editDescription')
|
||||
: t('profileForm.createDescription')}
|
||||
</DialogDescription>
|
||||
{isCreating && profileFormDraft && (
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<span className="text-xs text-muted-foreground">Draft restored</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('profileForm.draftRestored')}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -796,7 +825,7 @@ export function ProfileForm() {
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
Discard
|
||||
{t('profileForm.discard')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -822,7 +851,7 @@ export function ProfileForm() {
|
||||
}`}
|
||||
>
|
||||
<Mic className="h-3.5 w-3.5" />
|
||||
Clone from audio
|
||||
{t('profileForm.source.clone')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -834,20 +863,17 @@ export function ProfileForm() {
|
||||
}`}
|
||||
>
|
||||
<Music className="h-3.5 w-3.5" />
|
||||
Built-in voice
|
||||
{t('profileForm.source.builtin')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{voiceSource === 'builtin' ? (
|
||||
<div className="space-y-4">
|
||||
<FormDescription>
|
||||
Choose a pre-built voice. These don't require an audio sample.
|
||||
</FormDescription>
|
||||
<FormDescription>{t('profileForm.builtin.hint')}</FormDescription>
|
||||
|
||||
{/* Engine selector */}
|
||||
<FormItem>
|
||||
<FormLabel>Engine</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.engine')}</FormLabel>
|
||||
<Select
|
||||
value={selectedPresetEngine}
|
||||
onValueChange={setSelectedPresetEngine}
|
||||
@@ -866,7 +892,7 @@ export function ProfileForm() {
|
||||
|
||||
{/* Voice picker */}
|
||||
<FormItem>
|
||||
<FormLabel>Voice</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.voice')}</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-1.5 max-h-[340px] overflow-y-auto pr-1">
|
||||
{presetVoices.map((voice: PresetVoice) => (
|
||||
<button
|
||||
@@ -921,16 +947,16 @@ export function ProfileForm() {
|
||||
>
|
||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 shrink-0" />
|
||||
Upload
|
||||
{t('profileForm.sampleTabs.upload')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||
<Mic className="h-4 w-4 shrink-0" />
|
||||
Record
|
||||
{t('profileForm.sampleTabs.record')}
|
||||
</TabsTrigger>
|
||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 shrink-0" />
|
||||
System Audio
|
||||
{t('profileForm.sampleTabs.system')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
@@ -1008,10 +1034,10 @@ export function ProfileForm() {
|
||||
name="referenceText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Reference Text</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.referenceText')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter the exact text spoken in the audio..."
|
||||
placeholder={t('profileForm.fields.referenceTextPlaceholder')}
|
||||
className="min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
@@ -1031,7 +1057,7 @@ export function ProfileForm() {
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="rounded-lg border border-border p-4 space-y-3">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
Built-in Voice
|
||||
{t('profileForm.builtin.badge')}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-lg font-semibold">
|
||||
@@ -1060,8 +1086,7 @@ export function ProfileForm() {
|
||||
})()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This profile uses a built-in voice. The voice cannot be changed after
|
||||
creation.
|
||||
{t('profileForm.builtin.note')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1087,7 +1112,7 @@ export function ProfileForm() {
|
||||
{avatarPreview ? (
|
||||
<img
|
||||
src={avatarPreview}
|
||||
alt="Avatar preview"
|
||||
alt={t('profileForm.avatar.alt')}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
@@ -1131,9 +1156,9 @@ export function ProfileForm() {
|
||||
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>
|
||||
@@ -1145,9 +1170,12 @@ export function ProfileForm() {
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description (Optional)</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.descriptionLabel')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="Describe this voice..." {...field} />
|
||||
<Textarea
|
||||
placeholder={t('profileForm.fields.descriptionPlaceholder')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -1159,7 +1187,7 @@ export function ProfileForm() {
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Language</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.language')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
@@ -1180,7 +1208,7 @@ export function ProfileForm() {
|
||||
/>
|
||||
|
||||
<FormItem>
|
||||
<FormLabel>Default Engine</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.defaultEngine')}</FormLabel>
|
||||
<Select
|
||||
value={defaultEngine || '_none'}
|
||||
onValueChange={(v) => {
|
||||
@@ -1192,11 +1220,13 @@ export function ProfileForm() {
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No preference" />
|
||||
<SelectValue placeholder={t('profileForm.fields.noPreference')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none">No preference</SelectItem>
|
||||
<SelectItem value="_none">
|
||||
{t('profileForm.fields.noPreference')}
|
||||
</SelectItem>
|
||||
{availableDefaultEngines.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
@@ -1205,15 +1235,15 @@ export function ProfileForm() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Auto-selects this engine when the profile is chosen.
|
||||
{t('profileForm.fields.defaultEngineHint')}
|
||||
</p>
|
||||
</FormItem>
|
||||
|
||||
{editingProfileId && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Default Effects</FormLabel>
|
||||
<FormLabel>{t('profileForm.fields.defaultEffects')}</FormLabel>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Effects applied automatically to all new generations with this voice.
|
||||
{t('profileForm.fields.defaultEffectsHint')}
|
||||
</p>
|
||||
<EffectsChainEditor
|
||||
value={profileEffectsChain}
|
||||
@@ -1230,7 +1260,7 @@ export function ProfileForm() {
|
||||
|
||||
<div className="flex gap-2 justify-end mt-6 pt-4 border-t">
|
||||
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -1239,10 +1269,10 @@ export function ProfileForm() {
|
||||
}
|
||||
>
|
||||
{createProfile.isPending || updateProfile.isPending || addSample.isPending
|
||||
? 'Saving...'
|
||||
? t('profileForm.actions.saving')
|
||||
: editingProfileId
|
||||
? 'Save Changes'
|
||||
: 'Create Profile'}
|
||||
? t('profileForm.actions.saveChanges')
|
||||
: t('profileForm.actions.createProfile')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Mic, Music, 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';
|
||||
@@ -9,16 +11,34 @@ import { ProfileForm } from './ProfileForm';
|
||||
/** Engines that use preset (built-in) voices instead of cloned profiles. */
|
||||
const PRESET_ENGINES = new Set(['kokoro', 'qwen_custom_voice']);
|
||||
|
||||
/** Human-readable engine names for empty state messages. */
|
||||
const ENGINE_NAMES: Record<string, string> = {
|
||||
kokoro: 'Kokoro',
|
||||
qwen_custom_voice: 'Qwen CustomVoice',
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -27,7 +47,9 @@ 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>
|
||||
);
|
||||
}
|
||||
@@ -35,10 +57,18 @@ export function ProfileList() {
|
||||
const allProfiles = profiles || [];
|
||||
const isPresetEngine = PRESET_ENGINES.has(selectedEngine);
|
||||
|
||||
// Filter profiles based on selected engine
|
||||
const filteredProfiles = isPresetEngine
|
||||
? allProfiles.filter((p) => p.voice_type === 'preset' && p.preset_engine === selectedEngine)
|
||||
: allProfiles.filter((p) => p.voice_type !== 'preset');
|
||||
/** 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">
|
||||
@@ -47,38 +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
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : filteredProfiles.length === 0 && isPresetEngine ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Music className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground mb-2">
|
||||
No {ENGINE_NAMES[selectedEngine] ?? selectedEngine} voices created yet.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create a profile to choose a specific voice before generating.
|
||||
</p>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Create {ENGINE_NAMES[selectedEngine] ?? selectedEngine} 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]">
|
||||
{filteredProfiles.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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,7 +5,15 @@ import type { UpdateStatus } from '@/platform/types';
|
||||
// Re-export UpdateStatus for backwards compatibility
|
||||
export type { UpdateStatus };
|
||||
|
||||
export function useAutoUpdater(checkOnMount = false) {
|
||||
interface UseAutoUpdaterOptions {
|
||||
checkOnMount?: boolean;
|
||||
showToast?: boolean;
|
||||
}
|
||||
|
||||
export function useAutoUpdater(options: boolean | UseAutoUpdaterOptions = false) {
|
||||
const { checkOnMount } =
|
||||
typeof options === 'boolean' ? { checkOnMount: options } : { checkOnMount: options.checkOnMount ?? false };
|
||||
|
||||
const platform = usePlatform();
|
||||
const [status, setStatus] = useState<UpdateStatus>(platform.updater.getStatus());
|
||||
const hasCheckedRef = useRef(false);
|
||||
@@ -38,10 +46,11 @@ export function useAutoUpdater(checkOnMount = false) {
|
||||
useEffect(() => {
|
||||
if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) {
|
||||
hasCheckedRef.current = true;
|
||||
checkForUpdates();
|
||||
checkForUpdates().catch((error) => {
|
||||
console.error('Auto update check failed:', error);
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.metadata.isTauricheckOnMountcheckForUpdates]);
|
||||
}, [checkOnMount, checkForUpdates, platform.metadata.isTauri]);
|
||||
|
||||
return {
|
||||
status,
|
||||
|
||||
@@ -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,40 @@
|
||||
import i18n from 'i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import en from './locales/en/translation.json';
|
||||
import ja from './locales/ja/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: 'ja', label: '日本語' },
|
||||
{ code: 'zh-CN', label: '简体中文' },
|
||||
{ code: 'zh-TW', label: '繁體中文' },
|
||||
] as const;
|
||||
|
||||
export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]['code'];
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
ja: { translation: ja },
|
||||
'zh-CN': { translation: zhCN },
|
||||
'zh-TW': { translation: zhTW },
|
||||
},
|
||||
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;
|
||||
@@ -0,0 +1,834 @@
|
||||
{
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"loading": "Loading…",
|
||||
"error": "Error",
|
||||
"unknown": "Unknown",
|
||||
"unknownError": "Unknown error"
|
||||
},
|
||||
"nav": {
|
||||
"generate": "Generate",
|
||||
"stories": "Stories",
|
||||
"voices": "Voices",
|
||||
"effects": "Effects",
|
||||
"audio": "Audio",
|
||||
"models": "Models",
|
||||
"settings": "Settings",
|
||||
"updateBadge": "Update"
|
||||
},
|
||||
"voicesTab": {
|
||||
"title": "Voices",
|
||||
"loading": "Loading voices…",
|
||||
"searchPlaceholder": "Search voices…",
|
||||
"newVoice": "New Voice",
|
||||
"avatarAlt": "{{name}} avatar",
|
||||
"selectChannels": "Select channels…",
|
||||
"channelDefaultLabel": "{{name}} (Default)",
|
||||
"columns": {
|
||||
"name": "Name",
|
||||
"language": "Language",
|
||||
"generations": "Generations",
|
||||
"samples": "Samples",
|
||||
"effects": "Effects",
|
||||
"channels": "Channels"
|
||||
}
|
||||
},
|
||||
"voiceInspector": {
|
||||
"loading": "Loading…",
|
||||
"defaultEffectsHint": "Applied automatically to new generations with this voice.",
|
||||
"fields": {
|
||||
"description": "Description"
|
||||
},
|
||||
"toast": {
|
||||
"invalidImageFormat": "Please select PNG, JPG, or WebP",
|
||||
"avatarUpdated": "Avatar updated",
|
||||
"savedDescription": "\"{{name}}\" saved."
|
||||
}
|
||||
},
|
||||
"audioChannels": {
|
||||
"title": "Audio Channels",
|
||||
"newChannel": "New Channel",
|
||||
"loading": "Loading…",
|
||||
"confirmDelete": "Delete this channel?",
|
||||
"noVoicesAssigned": "No voices assigned",
|
||||
"selectDevice": "Select device",
|
||||
"addDevice": "Add device",
|
||||
"addVoice": "Add voice",
|
||||
"defaultSuffix": "default",
|
||||
"empty": {
|
||||
"message": "No audio channels yet. Create your first channel to route voices to specific devices.",
|
||||
"action": "Create Channel"
|
||||
},
|
||||
"labels": {
|
||||
"outputDevices": "Output Devices",
|
||||
"assignedVoices": "Assigned Voices"
|
||||
},
|
||||
"devices": {
|
||||
"title": "Available Devices",
|
||||
"defaultNote": "Default channel uses system default device",
|
||||
"toggleHint": "Click devices to add or remove them from the selected channel",
|
||||
"selectHint": "Select a channel to assign devices",
|
||||
"empty": "No audio devices found",
|
||||
"requiresTauri": "Audio device selection requires Tauri"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Channel Name",
|
||||
"namePlaceholder": "e.g., Virtual Cable, Broadcast"
|
||||
},
|
||||
"createDialog": {
|
||||
"title": "Create Audio Channel",
|
||||
"description": "Create a new audio channel (bus) to route voices to specific output devices.",
|
||||
"action": "Create"
|
||||
},
|
||||
"editDialog": {
|
||||
"title": "Edit Channel",
|
||||
"description": "Update channel settings and voice assignments."
|
||||
}
|
||||
},
|
||||
"profileForm": {
|
||||
"createTitle": "Create Voice",
|
||||
"editTitle": "Edit Voice",
|
||||
"createDescription": "Create a new voice profile from an audio sample or a built-in voice.",
|
||||
"editDescription": "Update your voice profile details and manage samples.",
|
||||
"draftRestored": "Draft restored",
|
||||
"discard": "Discard",
|
||||
"source": {
|
||||
"clone": "Clone from audio",
|
||||
"builtin": "Built-in voice"
|
||||
},
|
||||
"builtin": {
|
||||
"hint": "Choose a pre-built voice. These don't require an audio sample.",
|
||||
"badge": "Built-in Voice",
|
||||
"note": "This profile uses a built-in voice. The voice cannot be changed after creation."
|
||||
},
|
||||
"sampleTabs": {
|
||||
"upload": "Upload",
|
||||
"record": "Record",
|
||||
"system": "System Audio"
|
||||
},
|
||||
"fields": {
|
||||
"engine": "Engine",
|
||||
"voice": "Voice",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "My Voice",
|
||||
"descriptionLabel": "Description (Optional)",
|
||||
"descriptionPlaceholder": "Describe this voice…",
|
||||
"language": "Language",
|
||||
"referenceText": "Reference Text",
|
||||
"referenceTextPlaceholder": "Enter the exact text spoken in the audio…",
|
||||
"defaultEngine": "Default Engine",
|
||||
"noPreference": "No preference",
|
||||
"defaultEngineHint": "Auto-selects this engine when the profile is chosen.",
|
||||
"defaultEffects": "Default Effects",
|
||||
"defaultEffectsHint": "Effects applied automatically to all new generations with this voice."
|
||||
},
|
||||
"avatar": {
|
||||
"alt": "Avatar preview"
|
||||
},
|
||||
"actions": {
|
||||
"saving": "Saving…",
|
||||
"saveChanges": "Save Changes",
|
||||
"createProfile": "Create Profile"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Name is required",
|
||||
"referenceRequired": "Reference text is required when adding a sample",
|
||||
"sampleRequired": "Audio sample is required",
|
||||
"referenceTextRequired": "Reference text is required",
|
||||
"audioTooLong": "Audio is too long ({{duration}}). Maximum duration is {{max}}.",
|
||||
"audioFailed": "Failed to validate audio file. Please try a different file."
|
||||
},
|
||||
"toast": {
|
||||
"recordingComplete": "Recording complete",
|
||||
"recordingCompleteDescription": "Audio has been recorded successfully.",
|
||||
"recordingError": "Recording error",
|
||||
"systemAudioCaptured": "System audio captured",
|
||||
"systemAudioCapturedDescription": "Audio has been captured successfully.",
|
||||
"systemAudioError": "System audio capture error",
|
||||
"transcribeFailed": "Transcription failed",
|
||||
"transcribeFailedFallback": "Failed to transcribe audio",
|
||||
"noFile": "No file selected",
|
||||
"noFileDescription": "Please select an audio file first.",
|
||||
"invalidFile": "Invalid file type",
|
||||
"invalidImageFormat": "Please select an image file (PNG, JPG, or WebP)",
|
||||
"fileTooLarge": "File too large",
|
||||
"imageTooLargeDescription": "Image must be less than 5MB",
|
||||
"avatarRemoved": "Avatar removed",
|
||||
"avatarRemovedDescription": "Avatar image has been removed successfully.",
|
||||
"avatarRemoveFailed": "Failed to remove avatar",
|
||||
"avatarUploadFailed": "Avatar upload failed",
|
||||
"avatarUploadFailedFallback": "Failed to upload avatar",
|
||||
"effectsUpdateFailed": "Effects update failed",
|
||||
"effectsUpdateFailedFallback": "Failed to save effects chain",
|
||||
"voiceUpdated": "Voice updated",
|
||||
"voiceUpdatedDescription": "\"{{name}}\" has been updated successfully.",
|
||||
"noVoiceSelected": "No voice selected",
|
||||
"noVoiceSelectedDescription": "Please select a built-in voice.",
|
||||
"profileCreated": "Profile created",
|
||||
"profileCreatedBuiltin": "\"{{name}}\" has been created with a built-in voice.",
|
||||
"profileCreatedSample": "\"{{name}}\" has been created with a sample.",
|
||||
"sampleRequired": "Audio sample required",
|
||||
"sampleRequiredDescription": "Please provide an audio sample to create the voice profile.",
|
||||
"referenceTextRequired": "Reference text required",
|
||||
"referenceTextRequiredDescription": "Please provide the reference text for the audio sample.",
|
||||
"invalidAudio": "Invalid audio file",
|
||||
"invalidAudioDescription": "Audio duration is {{duration}}, but maximum is {{max}}.",
|
||||
"validationError": "Validation error",
|
||||
"rollbackFailed": "Rollback failed",
|
||||
"rollbackFailedDescription": "Created profile could not be removed after sample upload failure.",
|
||||
"profileRolledBack": "The profile was rolled back.",
|
||||
"sampleFailed": "Failed to add sample",
|
||||
"sampleFailedDescription": "Failed to add sample.",
|
||||
"sampleFailedRolledBack": "Failed to add sample. The profile was rolled back.",
|
||||
"saveFailed": "Failed to save profile"
|
||||
}
|
||||
},
|
||||
"audioSample": {
|
||||
"chooseFile": "Choose File",
|
||||
"uploadHint": "Click to choose a file or drag and drop. Maximum duration: 30 seconds.",
|
||||
"fileUploaded": "File uploaded",
|
||||
"fileLabel": "File: {{name}}",
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"transcribe": "Transcribe",
|
||||
"transcribing": "Transcribing…",
|
||||
"remove": "Remove",
|
||||
"startRecording": "Start Recording",
|
||||
"recordHint": "Click to start recording. Maximum duration: 30 seconds.",
|
||||
"stopRecording": "Stop Recording",
|
||||
"remaining": "{{time}} remaining",
|
||||
"recordingComplete": "Recording complete",
|
||||
"recordAgain": "Record Again",
|
||||
"startCapture": "Start Capture",
|
||||
"systemHint": "Capture audio from your system. Maximum duration: 30 seconds.",
|
||||
"stopCapture": "Stop Capture",
|
||||
"captureComplete": "Capture complete",
|
||||
"captureAgain": "Capture Again"
|
||||
},
|
||||
"sampleList": {
|
||||
"loading": "Loading samples…",
|
||||
"empty": {
|
||||
"title": "No samples yet",
|
||||
"hint": "Add your first audio sample to get started"
|
||||
},
|
||||
"editing": "Editing transcription",
|
||||
"placeholder": "Enter reference text…",
|
||||
"saving": "Saving…",
|
||||
"editTranscription": "Edit transcription",
|
||||
"deleteSample": "Delete sample",
|
||||
"addSample": "Add Sample",
|
||||
"note": "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.",
|
||||
"deleteDialog": {
|
||||
"title": "Delete Sample",
|
||||
"description": "Are you sure you want to delete this audio sample? This action cannot be undone.",
|
||||
"deleting": "Deleting…"
|
||||
},
|
||||
"player": {
|
||||
"play": "Play sample",
|
||||
"pause": "Pause sample",
|
||||
"stop": "Stop",
|
||||
"stopAria": "Stop playback",
|
||||
"position": "Sample playback position",
|
||||
"positionValue": "{{current}} of {{total}}"
|
||||
},
|
||||
"toast": {
|
||||
"invalidText": "Invalid text",
|
||||
"invalidTextDescription": "Reference text cannot be empty.",
|
||||
"updated": "Sample updated",
|
||||
"updatedDescription": "Reference text has been updated successfully.",
|
||||
"updateFailed": "Update failed",
|
||||
"updateFailedFallback": "Failed to update sample"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"card": {
|
||||
"noDescription": "No description",
|
||||
"designed": "designed",
|
||||
"export": "Export profile",
|
||||
"edit": "Edit profile",
|
||||
"delete": "Delete profile",
|
||||
"selectLabel": "{{name}}, {{language}}. Select as voice for generation.",
|
||||
"selectLabelSelected": "{{name}}, {{language}}. Selected as voice for generation."
|
||||
},
|
||||
"list": {
|
||||
"errorLoading": "Error loading profiles: {{message}}",
|
||||
"empty": "No voice profiles yet. Create your first profile to get started.",
|
||||
"createVoice": "Create Voice",
|
||||
"unsupportedNote": "Only supported voice profiles can be selected for the current model."
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Delete Profile",
|
||||
"body": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"deleting": "Deleting…"
|
||||
}
|
||||
},
|
||||
"effects": {
|
||||
"title": "Effects",
|
||||
"newPreset": "New Preset",
|
||||
"noDescription": "No description",
|
||||
"placeholder": "Select a preset or create a new one",
|
||||
"effectCount_one": "{{count}} effect",
|
||||
"effectCount_other": "{{count}} effects",
|
||||
"sections": {
|
||||
"builtin": "Built-in",
|
||||
"custom": "Custom",
|
||||
"new": "New"
|
||||
},
|
||||
"badge": {
|
||||
"builtin": "built-in"
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "Unsaved Preset",
|
||||
"hint": "Configure effects in the panel on the right."
|
||||
},
|
||||
"detail": {
|
||||
"newTitle": "New Preset",
|
||||
"editTitle": "Edit Preset",
|
||||
"savePreset": "Save Preset",
|
||||
"saveAsCustom": "Save as Custom",
|
||||
"saving": "Saving…",
|
||||
"deleting": "Deleting…"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"namePlaceholder": "My preset…",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Describe what this preset does…"
|
||||
},
|
||||
"preview": {
|
||||
"label": "Preview",
|
||||
"button": "Preview",
|
||||
"processing": "Processing…",
|
||||
"hint": "Preview applies effects to the clean version without saving."
|
||||
},
|
||||
"saveAs": {
|
||||
"title": "Save as Custom Preset",
|
||||
"description": "Create a new custom preset based on the current effects chain.",
|
||||
"suggestedName": "{{name}} (Copy)"
|
||||
},
|
||||
"toast": {
|
||||
"saved": "Preset saved",
|
||||
"createdDescription": "\"{{name}}\" has been created.",
|
||||
"updated": "Preset updated",
|
||||
"deleted": "Preset deleted",
|
||||
"saveFailed": "Failed to save",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"previewFailed": "Preview failed",
|
||||
"nameRequired": "Name required"
|
||||
},
|
||||
"chain": {
|
||||
"loadPreset": "Load preset…",
|
||||
"addEffect": "Add effect…",
|
||||
"clear": "Clear",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"remove": "Remove"
|
||||
},
|
||||
"types": {
|
||||
"chorus": {
|
||||
"label": "Chorus / Flanger",
|
||||
"params": {
|
||||
"rate_hz": "LFO speed (Hz)",
|
||||
"depth": "Modulation depth",
|
||||
"feedback": "Feedback amount",
|
||||
"centre_delay_ms": "Centre delay (ms)",
|
||||
"mix": "Wet/dry mix"
|
||||
}
|
||||
},
|
||||
"reverb": {
|
||||
"label": "Reverb",
|
||||
"params": {
|
||||
"room_size": "Room size",
|
||||
"damping": "High frequency damping",
|
||||
"wet_level": "Wet level",
|
||||
"dry_level": "Dry level",
|
||||
"width": "Stereo width"
|
||||
}
|
||||
},
|
||||
"delay": {
|
||||
"label": "Delay",
|
||||
"params": {
|
||||
"delay_seconds": "Delay time (seconds)",
|
||||
"feedback": "Feedback amount",
|
||||
"mix": "Wet/dry mix"
|
||||
}
|
||||
},
|
||||
"compressor": {
|
||||
"label": "Compressor",
|
||||
"params": {
|
||||
"threshold_db": "Threshold (dB)",
|
||||
"ratio": "Compression ratio",
|
||||
"attack_ms": "Attack time (ms)",
|
||||
"release_ms": "Release time (ms)"
|
||||
}
|
||||
},
|
||||
"gain": {
|
||||
"label": "Gain",
|
||||
"params": {
|
||||
"gain_db": "Gain (dB)"
|
||||
}
|
||||
},
|
||||
"highpass": {
|
||||
"label": "High-Pass Filter",
|
||||
"params": {
|
||||
"cutoff_frequency_hz": "Cutoff frequency (Hz)"
|
||||
}
|
||||
},
|
||||
"lowpass": {
|
||||
"label": "Low-Pass Filter",
|
||||
"params": {
|
||||
"cutoff_frequency_hz": "Cutoff frequency (Hz)"
|
||||
}
|
||||
},
|
||||
"pitch_shift": {
|
||||
"label": "Pitch Shift",
|
||||
"params": {
|
||||
"semitones": "Semitones to shift"
|
||||
}
|
||||
}
|
||||
},
|
||||
"builtinPresets": {
|
||||
"Robotic": {
|
||||
"name": "Robotic",
|
||||
"description": "Metallic robotic voice (flanger with slow LFO and high feedback)"
|
||||
},
|
||||
"Radio": {
|
||||
"name": "Radio",
|
||||
"description": "Thin AM-radio voice with band-pass filtering and light compression"
|
||||
},
|
||||
"Echo Chamber": {
|
||||
"name": "Echo Chamber",
|
||||
"description": "Spacious reverb with trailing echo"
|
||||
},
|
||||
"Deep Voice": {
|
||||
"name": "Deep Voice",
|
||||
"description": "Lower pitch with added warmth"
|
||||
}
|
||||
}
|
||||
},
|
||||
"stories": {
|
||||
"title": "Stories",
|
||||
"newStory": "New Story",
|
||||
"loading": "Loading stories…",
|
||||
"empty": {
|
||||
"title": "No stories yet",
|
||||
"hint": "Create your first story to get started"
|
||||
},
|
||||
"row": {
|
||||
"itemCount_one": "{{count}} item",
|
||||
"itemCount_other": "{{count}} items",
|
||||
"ariaLabel": "Story {{name}}, {{count}} items, {{updated}}",
|
||||
"actionsLabel": "Actions for {{name}}"
|
||||
},
|
||||
"createDialog": {
|
||||
"title": "Create New Story",
|
||||
"description": "Create a new story to organize your voice generations into conversations.",
|
||||
"action": "Create",
|
||||
"creating": "Creating…"
|
||||
},
|
||||
"editDialog": {
|
||||
"title": "Edit Story",
|
||||
"description": "Update the story name and description.",
|
||||
"saving": "Saving…"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Are you sure?",
|
||||
"description": "This will permanently delete the story and all its items. This action cannot be undone.",
|
||||
"deleting": "Deleting…"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"namePlaceholder": "My Story",
|
||||
"descriptionLabel": "Description (optional)",
|
||||
"descriptionPlaceholder": "A conversation between…"
|
||||
},
|
||||
"toast": {
|
||||
"nameRequired": "Name required",
|
||||
"nameRequiredDescription": "Please enter a story name",
|
||||
"created": "Story created",
|
||||
"createdDescription": "\"{{name}}\" has been created",
|
||||
"createFailed": "Failed to create story",
|
||||
"updateFailed": "Failed to update story",
|
||||
"deleteFailed": "Failed to delete story"
|
||||
}
|
||||
},
|
||||
"storyContent": {
|
||||
"selectStory": {
|
||||
"title": "Select a story",
|
||||
"hint": "Choose a story from the list to view its content"
|
||||
},
|
||||
"loading": "Loading story…",
|
||||
"notFound": {
|
||||
"title": "Story not found",
|
||||
"hint": "The selected story could not be loaded"
|
||||
},
|
||||
"generatingCount_one": "Generating {{count}} audio",
|
||||
"generatingCount_other": "Generating {{count}} audios",
|
||||
"add": "Add",
|
||||
"searchPlaceholder": "Search by name or transcript…",
|
||||
"searchNoMatches": "No matching generations found",
|
||||
"searchNoAvailable": "No available generations",
|
||||
"exportAudio": "Export Audio",
|
||||
"empty": {
|
||||
"title": "No items in this story",
|
||||
"hint": "Generate speech using the box below to add items"
|
||||
},
|
||||
"itemActions": {
|
||||
"playFromHere": "Play from here",
|
||||
"removeFromStory": "Remove from Story"
|
||||
},
|
||||
"toast": {
|
||||
"removeFailed": "Failed to remove item",
|
||||
"reorderFailed": "Failed to reorder items",
|
||||
"exportFailed": "Failed to export audio",
|
||||
"addFailed": "Failed to add generation"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"actions": {
|
||||
"menu": "Actions",
|
||||
"play": "Play",
|
||||
"exportAudio": "Export Audio",
|
||||
"exportPackage": "Export Package",
|
||||
"applyEffects": "Apply Effects",
|
||||
"regenerate": "Regenerate"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Delete Generation",
|
||||
"body": "Are you sure you want to delete this generation from \"{{name}}\"? This action cannot be undone.",
|
||||
"deleting": "Deleting…"
|
||||
},
|
||||
"clearFailedDialog": {
|
||||
"title": "Clear failed generations",
|
||||
"body_one": "This will permanently delete {{count}} failed generation from your history. This cannot be undone.",
|
||||
"body_other": "This will permanently delete {{count}} failed generations from your history. This cannot be undone.",
|
||||
"clearing": "Clearing…",
|
||||
"clearAll": "Clear all"
|
||||
},
|
||||
"importDialog": {
|
||||
"title": "Import Generation",
|
||||
"body": "Import the generation from \"{{name}}\". This will add it to your history.",
|
||||
"importing": "Importing…",
|
||||
"action": "Import"
|
||||
},
|
||||
"effectsDialog": {
|
||||
"title": "Apply Effects",
|
||||
"body": "Configure post-processing effects to apply to this generation. A new version will be created.",
|
||||
"sourceLabel": "Source",
|
||||
"sourcePlaceholder": "Select source version",
|
||||
"apply": "Apply",
|
||||
"applying": "Applying…"
|
||||
}
|
||||
},
|
||||
"generation": {
|
||||
"placeholder": {
|
||||
"storyWithEffects": "Generate speech for \"{{name}}\"… (type / for effects)",
|
||||
"story": "Generate speech for \"{{name}}\"…",
|
||||
"profile": "Generate speech using {{name}}…",
|
||||
"effectsHint": "Type / for effects like [laugh], [sigh]…",
|
||||
"selectVoice": "Select a voice profile above…"
|
||||
},
|
||||
"button": {
|
||||
"generate": "Generate speech",
|
||||
"generating": "Generating…",
|
||||
"selectFirst": "Select a voice profile first"
|
||||
},
|
||||
"instruct": {
|
||||
"show": "Show delivery instructions",
|
||||
"hide": "Hide delivery instructions",
|
||||
"tooltip": "Delivery instructions (tone, emotion, pace)",
|
||||
"placeholder": "Delivery instructions — e.g. Speak slowly with warmth, Authoritative and clear…"
|
||||
},
|
||||
"voiceSelector": {
|
||||
"placeholder": "Select a voice…"
|
||||
},
|
||||
"effects": {
|
||||
"none": "No effects",
|
||||
"profileDefault": "Profile default"
|
||||
}
|
||||
},
|
||||
"main": {
|
||||
"importVoice": "Import Voice",
|
||||
"createVoice": "Create Voice",
|
||||
"import": {
|
||||
"invalidTitle": "Invalid file type",
|
||||
"invalidDescription": "Please select a valid .voicebox.zip file",
|
||||
"successTitle": "Profile imported",
|
||||
"successDescription": "Voice profile imported successfully",
|
||||
"failedTitle": "Failed to import profile",
|
||||
"dialogTitle": "Import Profile",
|
||||
"dialogDescription": "Import the profile from \"{{name}}\". This will create a new profile with all samples.",
|
||||
"importing": "Importing…",
|
||||
"action": "Import"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"tabs": {
|
||||
"general": "General",
|
||||
"generation": "Generation",
|
||||
"gpu": "GPU",
|
||||
"logs": "Logs",
|
||||
"changelog": "Changelog",
|
||||
"about": "About"
|
||||
},
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"description": "Choose the display language for Voicebox."
|
||||
},
|
||||
"general": {
|
||||
"docs": { "title": "Read the Docs" },
|
||||
"discord": { "title": "Join the Discord", "subtitle": "Get help & share voices" },
|
||||
"serverUrl": {
|
||||
"title": "Server URL",
|
||||
"description": "The address of your voicebox backend server.",
|
||||
"invalidUrl": "Please enter a valid URL",
|
||||
"updatedTitle": "Server URL updated",
|
||||
"updatedDescription": "Connected to {{url}}"
|
||||
},
|
||||
"keepServerRunning": {
|
||||
"title": "Keep server running when app closes",
|
||||
"description": "The server will continue running in the background after closing the app.",
|
||||
"failedTitle": "Failed to update setting",
|
||||
"failedDescription": "Could not sync setting to backend.",
|
||||
"updatedTitle": "Setting updated",
|
||||
"runningDescription": "Server will continue running when app closes",
|
||||
"stoppedDescription": "Server will stop when app closes"
|
||||
},
|
||||
"networkAccess": {
|
||||
"title": "Allow network access",
|
||||
"description": "Makes the server accessible from other devices on your network. Restart the app after changing.",
|
||||
"updatedTitle": "Setting updated",
|
||||
"enabled": "Network access enabled. Restart the app to apply.",
|
||||
"disabled": "Network access disabled. Restart the app to apply."
|
||||
},
|
||||
"connection": {
|
||||
"connecting": "Connecting",
|
||||
"offline": "Offline",
|
||||
"online": "Online"
|
||||
},
|
||||
"updates": {
|
||||
"title": "App Updates",
|
||||
"devSuffix": " (dev)",
|
||||
"devMode": {
|
||||
"title": "Development mode",
|
||||
"description": "Auto-updates are disabled in development mode."
|
||||
},
|
||||
"check": {
|
||||
"title": "Check for updates",
|
||||
"available": "Version {{version}} available",
|
||||
"checking": "Checking…",
|
||||
"upToDate": "You're up to date",
|
||||
"button": "Check"
|
||||
},
|
||||
"error": "Update error",
|
||||
"download": {
|
||||
"title": "Update to {{version}}",
|
||||
"description": "Download and install the latest version.",
|
||||
"button": "Download"
|
||||
},
|
||||
"downloading": "Downloading update…",
|
||||
"ready": {
|
||||
"title": "Update ready to install",
|
||||
"description": "Version {{version}} has been downloaded. Restart to complete.",
|
||||
"button": "Restart Now"
|
||||
}
|
||||
},
|
||||
"api": {
|
||||
"title": "API Access",
|
||||
"description": "Integrate Voicebox into your workflow via the REST API at <code>{{url}}</code>",
|
||||
"viewReference": "View the full API reference",
|
||||
"endpoints": {
|
||||
"generate": "Generate speech",
|
||||
"health": "Server status",
|
||||
"profiles": "List voices",
|
||||
"history": "Past generations"
|
||||
}
|
||||
}
|
||||
},
|
||||
"generation": {
|
||||
"title": "Generation",
|
||||
"description": "Controls for long text generation. These settings apply to all engines.",
|
||||
"chunkLimit": {
|
||||
"title": "Auto-chunking limit",
|
||||
"description": "Long text is split into chunks at sentence boundaries. Lower values can improve quality for long outputs.",
|
||||
"value": "{{chars}} chars"
|
||||
},
|
||||
"crossfade": {
|
||||
"title": "Chunk crossfade",
|
||||
"description": "Blends audio between chunks to smooth transitions. Set to 0 for a hard cut.",
|
||||
"cut": "Cut",
|
||||
"ms": "{{ms}}ms"
|
||||
},
|
||||
"normalize": {
|
||||
"title": "Normalize audio",
|
||||
"description": "Adjusts output volume to a consistent level across generations."
|
||||
},
|
||||
"autoplay": {
|
||||
"title": "Autoplay on generate",
|
||||
"description": "Automatically play audio when a generation completes."
|
||||
},
|
||||
"folder": {
|
||||
"title": "Generations folder",
|
||||
"description": "Where generated audio files are stored on disk.",
|
||||
"open": "Open"
|
||||
}
|
||||
},
|
||||
"gpu": {
|
||||
"cpuOnly": "CPU Only",
|
||||
"vramUsed": "{{mb}} MB VRAM",
|
||||
"noAcceleration": "No GPU acceleration detected",
|
||||
"active": "Active",
|
||||
"cuda": {
|
||||
"title": "CUDA Backend",
|
||||
"description": "NVIDIA GPU acceleration via a downloadable CUDA backend.",
|
||||
"downloading": "Downloading CUDA backend…",
|
||||
"downloadingShort": "Downloading…",
|
||||
"updating": "Updating…"
|
||||
},
|
||||
"restart": {
|
||||
"ready": "Server restarted successfully",
|
||||
"waiting": "Restarting server…",
|
||||
"stopping": "Stopping server…"
|
||||
},
|
||||
"download": {
|
||||
"title": "Download CUDA backend",
|
||||
"description": "~2.4 GB download. Requires an NVIDIA GPU with CUDA support.",
|
||||
"button": "Download"
|
||||
},
|
||||
"switchToCuda": {
|
||||
"title": "Switch to CUDA backend",
|
||||
"description": "CUDA backend is downloaded and ready. Restart to enable.",
|
||||
"button": "Restart"
|
||||
},
|
||||
"switchToCpu": {
|
||||
"title": "Switch to CPU backend",
|
||||
"description": "Disable GPU acceleration. You can re-download CUDA later.",
|
||||
"button": "Switch"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Remove CUDA backend",
|
||||
"description": "Delete the downloaded CUDA binary to free disk space.",
|
||||
"button": "Remove"
|
||||
},
|
||||
"errors": {
|
||||
"downloadFailed": "Download failed",
|
||||
"downloadStart": "Failed to start download",
|
||||
"restartFailed": "Restart failed",
|
||||
"switchCpu": "Failed to switch to CPU",
|
||||
"deleteCuda": "Failed to delete CUDA backend"
|
||||
},
|
||||
"footer": "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."
|
||||
},
|
||||
"logs": {
|
||||
"title": "Server Logs",
|
||||
"lineCount_one": "{{count}} line",
|
||||
"lineCount_other": "{{count}} lines",
|
||||
"scrollToBottom": "Scroll to bottom",
|
||||
"clear": "Clear",
|
||||
"empty": "No log output yet.",
|
||||
"devHint": "Server logs are only captured when the app manages the server process (production builds)."
|
||||
},
|
||||
"changelog": {
|
||||
"devBadge": "dev",
|
||||
"showLess": "Show less",
|
||||
"showMore": "Show more"
|
||||
},
|
||||
"about": {
|
||||
"tagline": "The open-source voice synthesis studio. Clone voices, generate speech, apply effects, and build voice-powered apps — all running locally on your machine.",
|
||||
"createdBy": "Created by",
|
||||
"buyCoffee": "Buy me a coffee",
|
||||
"license": "Licensed under <link>MIT</link>"
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"title": "Models",
|
||||
"subtitle": "Download and manage AI models for voice generation and transcription",
|
||||
"defaultName": "Model",
|
||||
"unknownSize": "Unknown size",
|
||||
"sections": {
|
||||
"voiceGeneration": "Voice Generation",
|
||||
"transcription": "Transcription"
|
||||
},
|
||||
"status": {
|
||||
"loaded": "Loaded"
|
||||
},
|
||||
"storage": {
|
||||
"location": "Storage location",
|
||||
"open": "Open",
|
||||
"change": "Change",
|
||||
"migrating": "Migrating…",
|
||||
"reset": "Reset",
|
||||
"pickerTitle": "Choose model storage folder"
|
||||
},
|
||||
"progress": {
|
||||
"connecting": "Connecting…",
|
||||
"connectingHf": "Connecting to HuggingFace…"
|
||||
},
|
||||
"problems": {
|
||||
"title": "Problems",
|
||||
"clearAll": "Clear All",
|
||||
"noDetails": "No error details available. Try downloading again.",
|
||||
"startedAt": "started at {{time}}"
|
||||
},
|
||||
"detail": {
|
||||
"loadingInfo": "Loading model info…",
|
||||
"byAuthor": "by {{author}}",
|
||||
"downloads": "Downloads",
|
||||
"likes": "Likes",
|
||||
"license": "License",
|
||||
"languagesCount": "{{count}} languages supported",
|
||||
"languagesList": "Languages: {{list}}",
|
||||
"onDisk": "{{size}} on disk"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Download",
|
||||
"retry": "Retry Download",
|
||||
"unload": "Unload",
|
||||
"unloading": "Unloading…",
|
||||
"unloadFirst": "Unload model before deleting",
|
||||
"deleteModel": "Delete Model"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Delete Model",
|
||||
"body": "Are you sure you want to delete <strong>{{name}}</strong>?",
|
||||
"sizeNote": "This will free up {{size}} of disk space. The model will need to be re-downloaded if you want to use it again.",
|
||||
"deleting": "Deleting…"
|
||||
},
|
||||
"migrateDialog": {
|
||||
"title": "Move models to new location?",
|
||||
"description": "The server will shut down while models are being moved to the new folder. It will restart automatically once the migration is complete.",
|
||||
"action": "Move Models",
|
||||
"preparing": "Preparing…",
|
||||
"restartingServer": "Restarting server…"
|
||||
},
|
||||
"migrate": {
|
||||
"title": "Moving models",
|
||||
"offline": "The server is offline while models are being moved."
|
||||
},
|
||||
"toast": {
|
||||
"downloadFailed": "Download failed",
|
||||
"cancelFailed": "Cancel failed",
|
||||
"cancelFailedDescription": "Could not cancel the download task.",
|
||||
"deleted": "Model deleted",
|
||||
"deletedDescription": "{{name}} has been deleted successfully.",
|
||||
"deleteFailed": "Delete failed",
|
||||
"unloaded": "Model unloaded",
|
||||
"unloadedDescription": "{{name}} has been unloaded from memory.",
|
||||
"unloadFailed": "Unload failed",
|
||||
"openFolderFailed": "Failed to open model folder",
|
||||
"pickerFailed": "Failed to open folder picker",
|
||||
"resetToDefault": "Reset to default location. Restarting server…",
|
||||
"noModelsToMigrate": "No models to migrate",
|
||||
"noModelsToMigrateDescription": "Download at least one model before changing the storage location.",
|
||||
"migrated": "Models moved successfully",
|
||||
"migrationFailed": "Migration failed",
|
||||
"migrationFailedGeneric": "Failed to migrate models",
|
||||
"migrationConnectionLost": "Lost connection during migration"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,834 @@
|
||||
{
|
||||
"common": {
|
||||
"cancel": "キャンセル",
|
||||
"save": "保存",
|
||||
"delete": "削除",
|
||||
"edit": "編集",
|
||||
"close": "閉じる",
|
||||
"confirm": "確認",
|
||||
"loading": "読み込み中…",
|
||||
"error": "エラー",
|
||||
"unknown": "不明",
|
||||
"unknownError": "不明なエラー"
|
||||
},
|
||||
"nav": {
|
||||
"generate": "生成",
|
||||
"stories": "ストーリー",
|
||||
"voices": "ボイス",
|
||||
"effects": "エフェクト",
|
||||
"audio": "オーディオ",
|
||||
"models": "モデル",
|
||||
"settings": "設定",
|
||||
"updateBadge": "更新"
|
||||
},
|
||||
"voicesTab": {
|
||||
"title": "ボイス",
|
||||
"loading": "ボイスを読み込み中…",
|
||||
"searchPlaceholder": "ボイスを検索…",
|
||||
"newVoice": "新しいボイス",
|
||||
"avatarAlt": "{{name}} のアバター",
|
||||
"selectChannels": "チャンネルを選択…",
|
||||
"channelDefaultLabel": "{{name}}(デフォルト)",
|
||||
"columns": {
|
||||
"name": "名前",
|
||||
"language": "言語",
|
||||
"generations": "生成",
|
||||
"samples": "サンプル",
|
||||
"effects": "エフェクト",
|
||||
"channels": "チャンネル"
|
||||
}
|
||||
},
|
||||
"voiceInspector": {
|
||||
"loading": "読み込み中…",
|
||||
"defaultEffectsHint": "このボイスで新しく生成する際に自動的に適用されます。",
|
||||
"fields": {
|
||||
"description": "説明"
|
||||
},
|
||||
"toast": {
|
||||
"invalidImageFormat": "PNG、JPG、または WebP を選択してください",
|
||||
"avatarUpdated": "アバターを更新しました",
|
||||
"savedDescription": "「{{name}}」を保存しました。"
|
||||
}
|
||||
},
|
||||
"audioChannels": {
|
||||
"title": "オーディオチャンネル",
|
||||
"newChannel": "新しいチャンネル",
|
||||
"loading": "読み込み中…",
|
||||
"confirmDelete": "このチャンネルを削除しますか?",
|
||||
"noVoicesAssigned": "割り当てられたボイスはありません",
|
||||
"selectDevice": "デバイスを選択",
|
||||
"addDevice": "デバイスを追加",
|
||||
"addVoice": "ボイスを追加",
|
||||
"defaultSuffix": "デフォルト",
|
||||
"empty": {
|
||||
"message": "オーディオチャンネルがまだありません。最初のチャンネルを作成して、ボイスを特定のデバイスにルーティングしましょう。",
|
||||
"action": "チャンネルを作成"
|
||||
},
|
||||
"labels": {
|
||||
"outputDevices": "出力デバイス",
|
||||
"assignedVoices": "割り当てられたボイス"
|
||||
},
|
||||
"devices": {
|
||||
"title": "利用可能なデバイス",
|
||||
"defaultNote": "デフォルトチャンネルはシステムのデフォルトデバイスを使用します",
|
||||
"toggleHint": "デバイスをクリックして、選択中のチャンネルに追加または削除します",
|
||||
"selectHint": "デバイスを割り当てるチャンネルを選択してください",
|
||||
"empty": "オーディオデバイスが見つかりません",
|
||||
"requiresTauri": "オーディオデバイスの選択には Tauri が必要です"
|
||||
},
|
||||
"fields": {
|
||||
"name": "チャンネル名",
|
||||
"namePlaceholder": "例:仮想ケーブル、放送"
|
||||
},
|
||||
"createDialog": {
|
||||
"title": "オーディオチャンネルを作成",
|
||||
"description": "新しいオーディオチャンネル(バス)を作成して、ボイスを特定の出力デバイスにルーティングします。",
|
||||
"action": "作成"
|
||||
},
|
||||
"editDialog": {
|
||||
"title": "チャンネルを編集",
|
||||
"description": "チャンネルの設定とボイスの割り当てを更新します。"
|
||||
}
|
||||
},
|
||||
"profileForm": {
|
||||
"createTitle": "ボイスを作成",
|
||||
"editTitle": "ボイスを編集",
|
||||
"createDescription": "オーディオサンプルまたはビルトインボイスから新しいボイスプロファイルを作成します。",
|
||||
"editDescription": "ボイスプロファイルの詳細を更新し、サンプルを管理します。",
|
||||
"draftRestored": "下書きを復元しました",
|
||||
"discard": "破棄",
|
||||
"source": {
|
||||
"clone": "オーディオから複製",
|
||||
"builtin": "ビルトインボイス"
|
||||
},
|
||||
"builtin": {
|
||||
"hint": "あらかじめ用意されたボイスを選択してください。オーディオサンプルは不要です。",
|
||||
"badge": "ビルトインボイス",
|
||||
"note": "このプロファイルはビルトインボイスを使用しています。作成後はボイスを変更できません。"
|
||||
},
|
||||
"sampleTabs": {
|
||||
"upload": "アップロード",
|
||||
"record": "録音",
|
||||
"system": "システムオーディオ"
|
||||
},
|
||||
"fields": {
|
||||
"engine": "エンジン",
|
||||
"voice": "ボイス",
|
||||
"name": "名前",
|
||||
"namePlaceholder": "マイボイス",
|
||||
"descriptionLabel": "説明(任意)",
|
||||
"descriptionPlaceholder": "このボイスを説明してください…",
|
||||
"language": "言語",
|
||||
"referenceText": "リファレンステキスト",
|
||||
"referenceTextPlaceholder": "オーディオで話されている正確なテキストを入力してください…",
|
||||
"defaultEngine": "デフォルトエンジン",
|
||||
"noPreference": "指定なし",
|
||||
"defaultEngineHint": "このプロファイルが選ばれたとき、このエンジンを自動で選択します。",
|
||||
"defaultEffects": "デフォルトエフェクト",
|
||||
"defaultEffectsHint": "このボイスで新しく生成するすべてのものに自動適用されるエフェクトです。"
|
||||
},
|
||||
"avatar": {
|
||||
"alt": "アバタープレビュー"
|
||||
},
|
||||
"actions": {
|
||||
"saving": "保存中…",
|
||||
"saveChanges": "変更を保存",
|
||||
"createProfile": "プロファイルを作成"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "名前は必須です",
|
||||
"referenceRequired": "サンプルを追加する際はリファレンステキストが必須です",
|
||||
"sampleRequired": "オーディオサンプルが必要です",
|
||||
"referenceTextRequired": "リファレンステキストは必須です",
|
||||
"audioTooLong": "オーディオが長すぎます({{duration}})。最大時間は {{max}} です。",
|
||||
"audioFailed": "オーディオファイルを検証できませんでした。別のファイルをお試しください。"
|
||||
},
|
||||
"toast": {
|
||||
"recordingComplete": "録音完了",
|
||||
"recordingCompleteDescription": "オーディオを正常に録音しました。",
|
||||
"recordingError": "録音エラー",
|
||||
"systemAudioCaptured": "システムオーディオをキャプチャしました",
|
||||
"systemAudioCapturedDescription": "オーディオを正常にキャプチャしました。",
|
||||
"systemAudioError": "システムオーディオのキャプチャエラー",
|
||||
"transcribeFailed": "文字起こしに失敗しました",
|
||||
"transcribeFailedFallback": "オーディオの文字起こしに失敗しました",
|
||||
"noFile": "ファイルが選択されていません",
|
||||
"noFileDescription": "まずオーディオファイルを選択してください。",
|
||||
"invalidFile": "無効なファイル形式",
|
||||
"invalidImageFormat": "画像ファイル(PNG、JPG、または WebP)を選択してください",
|
||||
"fileTooLarge": "ファイルが大きすぎます",
|
||||
"imageTooLargeDescription": "画像は 5MB 未満である必要があります",
|
||||
"avatarRemoved": "アバターを削除しました",
|
||||
"avatarRemovedDescription": "アバター画像を正常に削除しました。",
|
||||
"avatarRemoveFailed": "アバターの削除に失敗しました",
|
||||
"avatarUploadFailed": "アバターのアップロードに失敗しました",
|
||||
"avatarUploadFailedFallback": "アバターのアップロードに失敗しました",
|
||||
"effectsUpdateFailed": "エフェクトの更新に失敗しました",
|
||||
"effectsUpdateFailedFallback": "エフェクトチェーンの保存に失敗しました",
|
||||
"voiceUpdated": "ボイスを更新しました",
|
||||
"voiceUpdatedDescription": "「{{name}}」を正常に更新しました。",
|
||||
"noVoiceSelected": "ボイスが選択されていません",
|
||||
"noVoiceSelectedDescription": "ビルトインボイスを選択してください。",
|
||||
"profileCreated": "プロファイルを作成しました",
|
||||
"profileCreatedBuiltin": "「{{name}}」をビルトインボイスで作成しました。",
|
||||
"profileCreatedSample": "「{{name}}」をサンプルで作成しました。",
|
||||
"sampleRequired": "オーディオサンプルが必要です",
|
||||
"sampleRequiredDescription": "ボイスプロファイルを作成するには、オーディオサンプルを用意してください。",
|
||||
"referenceTextRequired": "リファレンステキストが必要です",
|
||||
"referenceTextRequiredDescription": "オーディオサンプルのリファレンステキストを入力してください。",
|
||||
"invalidAudio": "無効なオーディオファイル",
|
||||
"invalidAudioDescription": "オーディオの長さは {{duration}} ですが、最大は {{max}} です。",
|
||||
"validationError": "検証エラー",
|
||||
"rollbackFailed": "ロールバックに失敗しました",
|
||||
"rollbackFailedDescription": "サンプルのアップロード失敗後、作成されたプロファイルを削除できませんでした。",
|
||||
"profileRolledBack": "プロファイルはロールバックされました。",
|
||||
"sampleFailed": "サンプルの追加に失敗しました",
|
||||
"sampleFailedDescription": "サンプルの追加に失敗しました。",
|
||||
"sampleFailedRolledBack": "サンプルの追加に失敗しました。プロファイルはロールバックされました。",
|
||||
"saveFailed": "プロファイルの保存に失敗しました"
|
||||
}
|
||||
},
|
||||
"audioSample": {
|
||||
"chooseFile": "ファイルを選択",
|
||||
"uploadHint": "クリックしてファイルを選択するか、ドラッグ&ドロップしてください。最大時間:30 秒。",
|
||||
"fileUploaded": "ファイルをアップロードしました",
|
||||
"fileLabel": "ファイル:{{name}}",
|
||||
"play": "再生",
|
||||
"pause": "一時停止",
|
||||
"transcribe": "文字起こし",
|
||||
"transcribing": "文字起こし中…",
|
||||
"remove": "削除",
|
||||
"startRecording": "録音開始",
|
||||
"recordHint": "クリックして録音を開始します。最大時間:30 秒。",
|
||||
"stopRecording": "録音停止",
|
||||
"remaining": "残り {{time}}",
|
||||
"recordingComplete": "録音完了",
|
||||
"recordAgain": "もう一度録音",
|
||||
"startCapture": "キャプチャ開始",
|
||||
"systemHint": "システムからオーディオをキャプチャします。最大時間:30 秒。",
|
||||
"stopCapture": "キャプチャ停止",
|
||||
"captureComplete": "キャプチャ完了",
|
||||
"captureAgain": "もう一度キャプチャ"
|
||||
},
|
||||
"sampleList": {
|
||||
"loading": "サンプルを読み込み中…",
|
||||
"empty": {
|
||||
"title": "サンプルがまだありません",
|
||||
"hint": "最初のオーディオサンプルを追加して始めましょう"
|
||||
},
|
||||
"editing": "文字起こしを編集中",
|
||||
"placeholder": "リファレンステキストを入力…",
|
||||
"saving": "保存中…",
|
||||
"editTranscription": "文字起こしを編集",
|
||||
"deleteSample": "サンプルを削除",
|
||||
"addSample": "サンプルを追加",
|
||||
"note": "メモ:30 秒のサンプル 1 本が最適です。サンプルを複数追加すると品質が低下することがあります。今後のアップデートで、同じボイスの異なるスタイル向けにサンプルを切り替え可能にし、タグ付けできるようにするかもしれません。",
|
||||
"deleteDialog": {
|
||||
"title": "サンプルを削除",
|
||||
"description": "このオーディオサンプルを本当に削除しますか? この操作は元に戻せません。",
|
||||
"deleting": "削除中…"
|
||||
},
|
||||
"player": {
|
||||
"play": "サンプルを再生",
|
||||
"pause": "サンプルを一時停止",
|
||||
"stop": "停止",
|
||||
"stopAria": "再生を停止",
|
||||
"position": "サンプルの再生位置",
|
||||
"positionValue": "{{current}} / {{total}}"
|
||||
},
|
||||
"toast": {
|
||||
"invalidText": "無効なテキスト",
|
||||
"invalidTextDescription": "リファレンステキストは空にできません。",
|
||||
"updated": "サンプルを更新しました",
|
||||
"updatedDescription": "リファレンステキストを正常に更新しました。",
|
||||
"updateFailed": "更新に失敗しました",
|
||||
"updateFailedFallback": "サンプルの更新に失敗しました"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"card": {
|
||||
"noDescription": "説明なし",
|
||||
"designed": "designed",
|
||||
"export": "プロファイルをエクスポート",
|
||||
"edit": "プロファイルを編集",
|
||||
"delete": "プロファイルを削除",
|
||||
"selectLabel": "{{name}}、{{language}}。生成のボイスとして選択。",
|
||||
"selectLabelSelected": "{{name}}、{{language}}。生成のボイスとして選択済み。"
|
||||
},
|
||||
"list": {
|
||||
"errorLoading": "プロファイルの読み込みエラー:{{message}}",
|
||||
"empty": "ボイスプロファイルがまだありません。最初のプロファイルを作成して始めましょう。",
|
||||
"createVoice": "ボイスを作成",
|
||||
"unsupportedNote": "現在のモデルでは、対応しているボイスプロファイルのみ選択できます。"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "プロファイルを削除",
|
||||
"body": "「{{name}}」を本当に削除しますか? この操作は元に戻せません。",
|
||||
"deleting": "削除中…"
|
||||
}
|
||||
},
|
||||
"effects": {
|
||||
"title": "エフェクト",
|
||||
"newPreset": "新しいプリセット",
|
||||
"noDescription": "説明なし",
|
||||
"placeholder": "プリセットを選択するか、新しく作成します",
|
||||
"effectCount_one": "エフェクト {{count}} 件",
|
||||
"effectCount_other": "エフェクト {{count}} 件",
|
||||
"sections": {
|
||||
"builtin": "ビルトイン",
|
||||
"custom": "カスタム",
|
||||
"new": "新規"
|
||||
},
|
||||
"badge": {
|
||||
"builtin": "ビルトイン"
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "未保存のプリセット",
|
||||
"hint": "右側のパネルでエフェクトを設定します。"
|
||||
},
|
||||
"detail": {
|
||||
"newTitle": "新しいプリセット",
|
||||
"editTitle": "プリセットを編集",
|
||||
"savePreset": "プリセットを保存",
|
||||
"saveAsCustom": "カスタムとして保存",
|
||||
"saving": "保存中…",
|
||||
"deleting": "削除中…"
|
||||
},
|
||||
"fields": {
|
||||
"name": "名前",
|
||||
"namePlaceholder": "マイプリセット…",
|
||||
"description": "説明",
|
||||
"descriptionPlaceholder": "このプリセットの内容を説明…"
|
||||
},
|
||||
"preview": {
|
||||
"label": "プレビュー",
|
||||
"button": "プレビュー",
|
||||
"processing": "処理中…",
|
||||
"hint": "プレビューでは保存せずにクリーン版へエフェクトを適用します。"
|
||||
},
|
||||
"saveAs": {
|
||||
"title": "カスタムプリセットとして保存",
|
||||
"description": "現在のエフェクトチェーンをもとに新しいカスタムプリセットを作成します。",
|
||||
"suggestedName": "{{name}}(コピー)"
|
||||
},
|
||||
"toast": {
|
||||
"saved": "プリセットを保存しました",
|
||||
"createdDescription": "「{{name}}」を作成しました。",
|
||||
"updated": "プリセットを更新しました",
|
||||
"deleted": "プリセットを削除しました",
|
||||
"saveFailed": "保存に失敗しました",
|
||||
"deleteFailed": "削除に失敗しました",
|
||||
"previewFailed": "プレビューに失敗しました",
|
||||
"nameRequired": "名前が必要です"
|
||||
},
|
||||
"chain": {
|
||||
"loadPreset": "プリセットを読み込む…",
|
||||
"addEffect": "エフェクトを追加…",
|
||||
"clear": "クリア",
|
||||
"enable": "有効化",
|
||||
"disable": "無効化",
|
||||
"remove": "削除"
|
||||
},
|
||||
"types": {
|
||||
"chorus": {
|
||||
"label": "コーラス / フランジャー",
|
||||
"params": {
|
||||
"rate_hz": "LFO 速度(Hz)",
|
||||
"depth": "モジュレーション深度",
|
||||
"feedback": "フィードバック量",
|
||||
"centre_delay_ms": "センターディレイ(ms)",
|
||||
"mix": "ウェット/ドライミックス"
|
||||
}
|
||||
},
|
||||
"reverb": {
|
||||
"label": "リバーブ",
|
||||
"params": {
|
||||
"room_size": "ルームサイズ",
|
||||
"damping": "高域ダンピング",
|
||||
"wet_level": "ウェットレベル",
|
||||
"dry_level": "ドライレベル",
|
||||
"width": "ステレオ幅"
|
||||
}
|
||||
},
|
||||
"delay": {
|
||||
"label": "ディレイ",
|
||||
"params": {
|
||||
"delay_seconds": "ディレイタイム(秒)",
|
||||
"feedback": "フィードバック量",
|
||||
"mix": "ウェット/ドライミックス"
|
||||
}
|
||||
},
|
||||
"compressor": {
|
||||
"label": "コンプレッサー",
|
||||
"params": {
|
||||
"threshold_db": "スレッショルド(dB)",
|
||||
"ratio": "コンプレッションレシオ",
|
||||
"attack_ms": "アタックタイム(ms)",
|
||||
"release_ms": "リリースタイム(ms)"
|
||||
}
|
||||
},
|
||||
"gain": {
|
||||
"label": "ゲイン",
|
||||
"params": {
|
||||
"gain_db": "ゲイン(dB)"
|
||||
}
|
||||
},
|
||||
"highpass": {
|
||||
"label": "ハイパスフィルター",
|
||||
"params": {
|
||||
"cutoff_frequency_hz": "カットオフ周波数(Hz)"
|
||||
}
|
||||
},
|
||||
"lowpass": {
|
||||
"label": "ローパスフィルター",
|
||||
"params": {
|
||||
"cutoff_frequency_hz": "カットオフ周波数(Hz)"
|
||||
}
|
||||
},
|
||||
"pitch_shift": {
|
||||
"label": "ピッチシフト",
|
||||
"params": {
|
||||
"semitones": "シフトする半音数"
|
||||
}
|
||||
}
|
||||
},
|
||||
"builtinPresets": {
|
||||
"Robotic": {
|
||||
"name": "ロボット",
|
||||
"description": "メタリックなロボット音声(遅い LFO と高フィードバックのフランジャー)"
|
||||
},
|
||||
"Radio": {
|
||||
"name": "ラジオ",
|
||||
"description": "バンドパスフィルタリングと軽いコンプレッションによる AM ラジオ風の細い音声"
|
||||
},
|
||||
"Echo Chamber": {
|
||||
"name": "エコーチェンバー",
|
||||
"description": "広がりのあるリバーブと尾を引くエコー"
|
||||
},
|
||||
"Deep Voice": {
|
||||
"name": "ディープボイス",
|
||||
"description": "低いピッチに暖かみを加えた音声"
|
||||
}
|
||||
}
|
||||
},
|
||||
"stories": {
|
||||
"title": "ストーリー",
|
||||
"newStory": "新しいストーリー",
|
||||
"loading": "ストーリーを読み込み中…",
|
||||
"empty": {
|
||||
"title": "ストーリーがまだありません",
|
||||
"hint": "最初のストーリーを作成して始めましょう"
|
||||
},
|
||||
"row": {
|
||||
"itemCount_one": "{{count}} 項目",
|
||||
"itemCount_other": "{{count}} 項目",
|
||||
"ariaLabel": "ストーリー {{name}}、{{count}} 項目、{{updated}}",
|
||||
"actionsLabel": "{{name}} の操作"
|
||||
},
|
||||
"createDialog": {
|
||||
"title": "新しいストーリーを作成",
|
||||
"description": "新しいストーリーを作成して、ボイス生成を会話としてまとめます。",
|
||||
"action": "作成",
|
||||
"creating": "作成中…"
|
||||
},
|
||||
"editDialog": {
|
||||
"title": "ストーリーを編集",
|
||||
"description": "ストーリーの名前と説明を更新します。",
|
||||
"saving": "保存中…"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "本当に削除しますか?",
|
||||
"description": "このストーリーとすべての項目が完全に削除されます。この操作は元に戻せません。",
|
||||
"deleting": "削除中…"
|
||||
},
|
||||
"fields": {
|
||||
"name": "名前",
|
||||
"namePlaceholder": "マイストーリー",
|
||||
"descriptionLabel": "説明(任意)",
|
||||
"descriptionPlaceholder": "例:○○と△△の会話…"
|
||||
},
|
||||
"toast": {
|
||||
"nameRequired": "名前が必要です",
|
||||
"nameRequiredDescription": "ストーリー名を入力してください",
|
||||
"created": "ストーリーを作成しました",
|
||||
"createdDescription": "「{{name}}」を作成しました",
|
||||
"createFailed": "ストーリーの作成に失敗しました",
|
||||
"updateFailed": "ストーリーの更新に失敗しました",
|
||||
"deleteFailed": "ストーリーの削除に失敗しました"
|
||||
}
|
||||
},
|
||||
"storyContent": {
|
||||
"selectStory": {
|
||||
"title": "ストーリーを選択",
|
||||
"hint": "リストからストーリーを選んで内容を表示します"
|
||||
},
|
||||
"loading": "ストーリーを読み込み中…",
|
||||
"notFound": {
|
||||
"title": "ストーリーが見つかりません",
|
||||
"hint": "選択したストーリーを読み込めませんでした"
|
||||
},
|
||||
"generatingCount_one": "オーディオ {{count}} 件を生成中",
|
||||
"generatingCount_other": "オーディオ {{count}} 件を生成中",
|
||||
"add": "追加",
|
||||
"searchPlaceholder": "名前または文字起こしで検索…",
|
||||
"searchNoMatches": "一致する生成が見つかりません",
|
||||
"searchNoAvailable": "利用可能な生成がありません",
|
||||
"exportAudio": "オーディオをエクスポート",
|
||||
"empty": {
|
||||
"title": "このストーリーには項目がありません",
|
||||
"hint": "下のボックスで音声を生成して項目を追加します"
|
||||
},
|
||||
"itemActions": {
|
||||
"playFromHere": "ここから再生",
|
||||
"removeFromStory": "ストーリーから削除"
|
||||
},
|
||||
"toast": {
|
||||
"removeFailed": "項目の削除に失敗しました",
|
||||
"reorderFailed": "項目の並び替えに失敗しました",
|
||||
"exportFailed": "オーディオのエクスポートに失敗しました",
|
||||
"addFailed": "生成の追加に失敗しました"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"actions": {
|
||||
"menu": "操作",
|
||||
"play": "再生",
|
||||
"exportAudio": "オーディオをエクスポート",
|
||||
"exportPackage": "パッケージをエクスポート",
|
||||
"applyEffects": "エフェクトを適用",
|
||||
"regenerate": "再生成"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "生成を削除",
|
||||
"body": "「{{name}}」のこの生成を本当に削除しますか? この操作は元に戻せません。",
|
||||
"deleting": "削除中…"
|
||||
},
|
||||
"clearFailedDialog": {
|
||||
"title": "失敗した生成をクリア",
|
||||
"body_one": "失敗した生成 {{count}} 件を履歴から完全に削除します。この操作は元に戻せません。",
|
||||
"body_other": "失敗した生成 {{count}} 件を履歴から完全に削除します。この操作は元に戻せません。",
|
||||
"clearing": "クリア中…",
|
||||
"clearAll": "すべてクリア"
|
||||
},
|
||||
"importDialog": {
|
||||
"title": "生成をインポート",
|
||||
"body": "「{{name}}」から生成をインポートします。履歴に追加されます。",
|
||||
"importing": "インポート中…",
|
||||
"action": "インポート"
|
||||
},
|
||||
"effectsDialog": {
|
||||
"title": "エフェクトを適用",
|
||||
"body": "この生成に適用するポストプロセッシングのエフェクトを設定します。新しいバージョンが作成されます。",
|
||||
"sourceLabel": "ソース",
|
||||
"sourcePlaceholder": "ソースバージョンを選択",
|
||||
"apply": "適用",
|
||||
"applying": "適用中…"
|
||||
}
|
||||
},
|
||||
"generation": {
|
||||
"placeholder": {
|
||||
"storyWithEffects": "「{{name}}」用の音声を生成…(エフェクトは / を入力)",
|
||||
"story": "「{{name}}」用の音声を生成…",
|
||||
"profile": "{{name}} を使って音声を生成…",
|
||||
"effectsHint": "/ を入力して [laugh]、[sigh] などのエフェクトを使う",
|
||||
"selectVoice": "上でボイスプロファイルを選択してください…"
|
||||
},
|
||||
"button": {
|
||||
"generate": "音声を生成",
|
||||
"generating": "生成中…",
|
||||
"selectFirst": "まずボイスプロファイルを選択してください"
|
||||
},
|
||||
"instruct": {
|
||||
"show": "デリバリー指示を表示",
|
||||
"hide": "デリバリー指示を非表示",
|
||||
"tooltip": "デリバリー指示(トーン、感情、ペース)",
|
||||
"placeholder": "デリバリー指示 — 例:暖かくゆっくり話す、はっきりと威厳をもって…"
|
||||
},
|
||||
"voiceSelector": {
|
||||
"placeholder": "ボイスを選択…"
|
||||
},
|
||||
"effects": {
|
||||
"none": "エフェクトなし",
|
||||
"profileDefault": "プロファイルのデフォルト"
|
||||
}
|
||||
},
|
||||
"main": {
|
||||
"importVoice": "ボイスをインポート",
|
||||
"createVoice": "ボイスを作成",
|
||||
"import": {
|
||||
"invalidTitle": "無効なファイル形式",
|
||||
"invalidDescription": "有効な .voicebox.zip ファイルを選択してください",
|
||||
"successTitle": "プロファイルをインポートしました",
|
||||
"successDescription": "ボイスプロファイルを正常にインポートしました",
|
||||
"failedTitle": "プロファイルのインポートに失敗しました",
|
||||
"dialogTitle": "プロファイルをインポート",
|
||||
"dialogDescription": "「{{name}}」からプロファイルをインポートします。すべてのサンプルを含む新しいプロファイルが作成されます。",
|
||||
"importing": "インポート中…",
|
||||
"action": "インポート"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"tabs": {
|
||||
"general": "一般",
|
||||
"generation": "生成",
|
||||
"gpu": "GPU",
|
||||
"logs": "ログ",
|
||||
"changelog": "変更履歴",
|
||||
"about": "このアプリについて"
|
||||
},
|
||||
"language": {
|
||||
"label": "言語",
|
||||
"description": "Voicebox の表示言語を選択します。"
|
||||
},
|
||||
"general": {
|
||||
"docs": { "title": "ドキュメントを読む" },
|
||||
"discord": { "title": "Discord に参加", "subtitle": "ヘルプやボイスの共有" },
|
||||
"serverUrl": {
|
||||
"title": "サーバー URL",
|
||||
"description": "Voicebox バックエンドサーバーのアドレス。",
|
||||
"invalidUrl": "有効な URL を入力してください",
|
||||
"updatedTitle": "サーバー URL を更新しました",
|
||||
"updatedDescription": "{{url}} に接続しました"
|
||||
},
|
||||
"keepServerRunning": {
|
||||
"title": "アプリ終了後もサーバーを起動したままにする",
|
||||
"description": "アプリを閉じた後もサーバーがバックグラウンドで動作し続けます。",
|
||||
"failedTitle": "設定の更新に失敗しました",
|
||||
"failedDescription": "バックエンドに設定を同期できませんでした。",
|
||||
"updatedTitle": "設定を更新しました",
|
||||
"runningDescription": "アプリ終了後もサーバーは動作し続けます",
|
||||
"stoppedDescription": "アプリ終了時にサーバーは停止します"
|
||||
},
|
||||
"networkAccess": {
|
||||
"title": "ネットワークアクセスを許可",
|
||||
"description": "ネットワーク上の他のデバイスからサーバーにアクセスできるようにします。変更後はアプリを再起動してください。",
|
||||
"updatedTitle": "設定を更新しました",
|
||||
"enabled": "ネットワークアクセスが有効になりました。適用するにはアプリを再起動してください。",
|
||||
"disabled": "ネットワークアクセスが無効になりました。適用するにはアプリを再起動してください。"
|
||||
},
|
||||
"connection": {
|
||||
"connecting": "接続中",
|
||||
"offline": "オフライン",
|
||||
"online": "オンライン"
|
||||
},
|
||||
"updates": {
|
||||
"title": "アプリの更新",
|
||||
"devSuffix": " (開発版)",
|
||||
"devMode": {
|
||||
"title": "開発モード",
|
||||
"description": "開発モードでは自動更新が無効になっています。"
|
||||
},
|
||||
"check": {
|
||||
"title": "更新を確認",
|
||||
"available": "バージョン {{version}} が利用可能",
|
||||
"checking": "確認中…",
|
||||
"upToDate": "最新の状態です",
|
||||
"button": "確認"
|
||||
},
|
||||
"error": "更新エラー",
|
||||
"download": {
|
||||
"title": "バージョン {{version}} に更新",
|
||||
"description": "最新バージョンをダウンロードしてインストールします。",
|
||||
"button": "ダウンロード"
|
||||
},
|
||||
"downloading": "更新をダウンロード中…",
|
||||
"ready": {
|
||||
"title": "更新をインストールする準備ができました",
|
||||
"description": "バージョン {{version}} をダウンロードしました。再起動して完了します。",
|
||||
"button": "今すぐ再起動"
|
||||
}
|
||||
},
|
||||
"api": {
|
||||
"title": "API アクセス",
|
||||
"description": "<code>{{url}}</code> の REST API を通じて Voicebox をワークフローに統合できます",
|
||||
"viewReference": "API リファレンス全文を表示",
|
||||
"endpoints": {
|
||||
"generate": "音声を生成",
|
||||
"health": "サーバーステータス",
|
||||
"profiles": "ボイス一覧",
|
||||
"history": "過去の生成"
|
||||
}
|
||||
}
|
||||
},
|
||||
"generation": {
|
||||
"title": "生成",
|
||||
"description": "長文生成の制御。これらの設定はすべてのエンジンに適用されます。",
|
||||
"chunkLimit": {
|
||||
"title": "自動チャンク分割の上限",
|
||||
"description": "長文は文境界でチャンクに分割されます。値を小さくすると長い出力の品質が向上することがあります。",
|
||||
"value": "{{chars}} 文字"
|
||||
},
|
||||
"crossfade": {
|
||||
"title": "チャンク間のクロスフェード",
|
||||
"description": "チャンク間のオーディオをブレンドして遷移を滑らかにします。0 にするとハードカットになります。",
|
||||
"cut": "カット",
|
||||
"ms": "{{ms}}ms"
|
||||
},
|
||||
"normalize": {
|
||||
"title": "オーディオを正規化",
|
||||
"description": "生成間で一貫した音量になるよう出力を調整します。"
|
||||
},
|
||||
"autoplay": {
|
||||
"title": "生成時に自動再生",
|
||||
"description": "生成が完了したら自動的にオーディオを再生します。"
|
||||
},
|
||||
"folder": {
|
||||
"title": "生成物の保存先フォルダ",
|
||||
"description": "生成されたオーディオファイルをディスク上に保存する場所。",
|
||||
"open": "開く"
|
||||
}
|
||||
},
|
||||
"gpu": {
|
||||
"cpuOnly": "CPU のみ",
|
||||
"vramUsed": "VRAM 使用量 {{mb}} MB",
|
||||
"noAcceleration": "GPU アクセラレーションは検出されていません",
|
||||
"active": "有効",
|
||||
"cuda": {
|
||||
"title": "CUDA バックエンド",
|
||||
"description": "ダウンロード可能な CUDA バックエンドによる NVIDIA GPU アクセラレーション。",
|
||||
"downloading": "CUDA バックエンドをダウンロード中…",
|
||||
"downloadingShort": "ダウンロード中…",
|
||||
"updating": "更新中…"
|
||||
},
|
||||
"restart": {
|
||||
"ready": "サーバーを正常に再起動しました",
|
||||
"waiting": "サーバーを再起動中…",
|
||||
"stopping": "サーバーを停止中…"
|
||||
},
|
||||
"download": {
|
||||
"title": "CUDA バックエンドをダウンロード",
|
||||
"description": "約 2.4 GB のダウンロード。CUDA 対応の NVIDIA GPU が必要です。",
|
||||
"button": "ダウンロード"
|
||||
},
|
||||
"switchToCuda": {
|
||||
"title": "CUDA バックエンドに切り替え",
|
||||
"description": "CUDA バックエンドはダウンロード済みです。再起動して有効にします。",
|
||||
"button": "再起動"
|
||||
},
|
||||
"switchToCpu": {
|
||||
"title": "CPU バックエンドに切り替え",
|
||||
"description": "GPU アクセラレーションを無効にします。CUDA は後で再ダウンロードできます。",
|
||||
"button": "切り替え"
|
||||
},
|
||||
"remove": {
|
||||
"title": "CUDA バックエンドを削除",
|
||||
"description": "ダウンロードした CUDA バイナリを削除してディスク容量を空けます。",
|
||||
"button": "削除"
|
||||
},
|
||||
"errors": {
|
||||
"downloadFailed": "ダウンロードに失敗しました",
|
||||
"downloadStart": "ダウンロードを開始できませんでした",
|
||||
"restartFailed": "再起動に失敗しました",
|
||||
"switchCpu": "CPU への切り替えに失敗しました",
|
||||
"deleteCuda": "CUDA バックエンドの削除に失敗しました"
|
||||
},
|
||||
"footer": "Voicebox はシステムで利用可能な最適な GPU を自動で検出し使用します。Apple Silicon Mac では、MLX バックエンドが Metal Performance Shaders(MPS)を介して Neural Engine と GPU 上でネイティブに動作し、追加のセットアップは不要です。NVIDIA GPU 搭載の Windows および Linux では、オプションの CUDA バックエンドをダウンロードしてハードウェアアクセラレーションによる推論が可能です。AMD ROCm、Intel XPU、DirectML も PyTorch を通じて利用可能な環境でサポートされます。GPU が検出されない場合、Voicebox は CPU にフォールバックし、すべてのエンジンはそのまま動作しますが速度は低下します。"
|
||||
},
|
||||
"logs": {
|
||||
"title": "サーバーログ",
|
||||
"lineCount_one": "{{count}} 行",
|
||||
"lineCount_other": "{{count}} 行",
|
||||
"scrollToBottom": "一番下までスクロール",
|
||||
"clear": "クリア",
|
||||
"empty": "まだログ出力はありません。",
|
||||
"devHint": "サーバーログはアプリがサーバープロセスを管理している場合(本番ビルド)にのみ記録されます。"
|
||||
},
|
||||
"changelog": {
|
||||
"devBadge": "開発版",
|
||||
"showLess": "折りたたむ",
|
||||
"showMore": "もっと見る"
|
||||
},
|
||||
"about": {
|
||||
"tagline": "オープンソースの音声合成スタジオ。ボイスのクローン、音声生成、エフェクトの適用、音声対応アプリの構築まで、すべてローカル環境で実行できます。",
|
||||
"createdBy": "作者",
|
||||
"buyCoffee": "コーヒーをおごる",
|
||||
"license": "<link>MIT</link> ライセンス"
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"title": "モデル",
|
||||
"subtitle": "音声生成および文字起こし用の AI モデルをダウンロードして管理します",
|
||||
"defaultName": "モデル",
|
||||
"unknownSize": "サイズ不明",
|
||||
"sections": {
|
||||
"voiceGeneration": "音声生成",
|
||||
"transcription": "文字起こし"
|
||||
},
|
||||
"status": {
|
||||
"loaded": "読み込み済み"
|
||||
},
|
||||
"storage": {
|
||||
"location": "保存場所",
|
||||
"open": "開く",
|
||||
"change": "変更",
|
||||
"migrating": "移行中…",
|
||||
"reset": "リセット",
|
||||
"pickerTitle": "モデル保存フォルダを選択"
|
||||
},
|
||||
"progress": {
|
||||
"connecting": "接続中…",
|
||||
"connectingHf": "HuggingFace に接続中…"
|
||||
},
|
||||
"problems": {
|
||||
"title": "問題",
|
||||
"clearAll": "すべてクリア",
|
||||
"noDetails": "エラーの詳細はありません。もう一度ダウンロードしてください。",
|
||||
"startedAt": "{{time}} に開始"
|
||||
},
|
||||
"detail": {
|
||||
"loadingInfo": "モデル情報を読み込み中…",
|
||||
"byAuthor": "{{author}} 作",
|
||||
"downloads": "ダウンロード数",
|
||||
"likes": "いいね",
|
||||
"license": "ライセンス",
|
||||
"languagesCount": "{{count}} 言語に対応",
|
||||
"languagesList": "対応言語:{{list}}",
|
||||
"onDisk": "ディスク使用量 {{size}}"
|
||||
},
|
||||
"actions": {
|
||||
"download": "ダウンロード",
|
||||
"retry": "ダウンロードを再試行",
|
||||
"unload": "アンロード",
|
||||
"unloading": "アンロード中…",
|
||||
"unloadFirst": "削除する前にモデルをアンロードしてください",
|
||||
"deleteModel": "モデルを削除"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "モデルを削除",
|
||||
"body": "<strong>{{name}}</strong> を本当に削除しますか?",
|
||||
"sizeNote": "これにより {{size}} のディスク容量が解放されます。再度使用する場合は再ダウンロードが必要です。",
|
||||
"deleting": "削除中…"
|
||||
},
|
||||
"migrateDialog": {
|
||||
"title": "モデルを新しい場所に移動しますか?",
|
||||
"description": "モデルを新しいフォルダに移動する間、サーバーは停止します。移行が完了すると自動的に再起動します。",
|
||||
"action": "モデルを移動",
|
||||
"preparing": "準備中…",
|
||||
"restartingServer": "サーバーを再起動中…"
|
||||
},
|
||||
"migrate": {
|
||||
"title": "モデルを移動中",
|
||||
"offline": "モデルの移動中はサーバーがオフラインになります。"
|
||||
},
|
||||
"toast": {
|
||||
"downloadFailed": "ダウンロードに失敗しました",
|
||||
"cancelFailed": "キャンセルに失敗しました",
|
||||
"cancelFailedDescription": "ダウンロードタスクをキャンセルできませんでした。",
|
||||
"deleted": "モデルを削除しました",
|
||||
"deletedDescription": "{{name}} を正常に削除しました。",
|
||||
"deleteFailed": "削除に失敗しました",
|
||||
"unloaded": "モデルをアンロードしました",
|
||||
"unloadedDescription": "{{name}} をメモリからアンロードしました。",
|
||||
"unloadFailed": "アンロードに失敗しました",
|
||||
"openFolderFailed": "モデルフォルダを開けませんでした",
|
||||
"pickerFailed": "フォルダ選択ダイアログを開けませんでした",
|
||||
"resetToDefault": "デフォルトの場所にリセットしました。サーバーを再起動中…",
|
||||
"noModelsToMigrate": "移行するモデルがありません",
|
||||
"noModelsToMigrateDescription": "保存場所を変更する前に、少なくとも 1 つのモデルをダウンロードしてください。",
|
||||
"migrated": "モデルを正常に移動しました",
|
||||
"migrationFailed": "移行に失敗しました",
|
||||
"migrationFailedGeneric": "モデルの移行に失敗しました",
|
||||
"migrationConnectionLost": "移行中に接続が切断されました"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,834 @@
|
||||
{
|
||||
"common": {
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"close": "关闭",
|
||||
"confirm": "确认",
|
||||
"loading": "加载中…",
|
||||
"error": "错误",
|
||||
"unknown": "未知",
|
||||
"unknownError": "未知错误"
|
||||
},
|
||||
"nav": {
|
||||
"generate": "生成",
|
||||
"stories": "故事",
|
||||
"voices": "声音",
|
||||
"effects": "效果",
|
||||
"audio": "音频",
|
||||
"models": "模型",
|
||||
"settings": "设置",
|
||||
"updateBadge": "更新"
|
||||
},
|
||||
"voicesTab": {
|
||||
"title": "声音",
|
||||
"loading": "加载声音中…",
|
||||
"searchPlaceholder": "搜索声音……",
|
||||
"newVoice": "新建声音",
|
||||
"avatarAlt": "{{name}} 的头像",
|
||||
"selectChannels": "选择通道……",
|
||||
"channelDefaultLabel": "{{name}}(默认)",
|
||||
"columns": {
|
||||
"name": "名称",
|
||||
"language": "语言",
|
||||
"generations": "生成次数",
|
||||
"samples": "样本",
|
||||
"effects": "效果",
|
||||
"channels": "通道"
|
||||
}
|
||||
},
|
||||
"voiceInspector": {
|
||||
"loading": "加载中…",
|
||||
"defaultEffectsHint": "自动应用于使用此声音的新生成。",
|
||||
"fields": {
|
||||
"description": "描述"
|
||||
},
|
||||
"toast": {
|
||||
"invalidImageFormat": "请选择 PNG、JPG 或 WebP 格式",
|
||||
"avatarUpdated": "头像已更新",
|
||||
"savedDescription": "\"{{name}}\" 已保存。"
|
||||
}
|
||||
},
|
||||
"audioChannels": {
|
||||
"title": "音频通道",
|
||||
"newChannel": "新建通道",
|
||||
"loading": "加载中…",
|
||||
"confirmDelete": "删除此通道?",
|
||||
"noVoicesAssigned": "未分配声音",
|
||||
"selectDevice": "选择设备",
|
||||
"addDevice": "添加设备",
|
||||
"addVoice": "添加声音",
|
||||
"defaultSuffix": "默认",
|
||||
"empty": {
|
||||
"message": "暂无音频通道。创建您的第一个通道,将声音路由到特定设备。",
|
||||
"action": "创建通道"
|
||||
},
|
||||
"labels": {
|
||||
"outputDevices": "输出设备",
|
||||
"assignedVoices": "已分配声音"
|
||||
},
|
||||
"devices": {
|
||||
"title": "可用设备",
|
||||
"defaultNote": "默认通道使用系统默认设备",
|
||||
"toggleHint": "点击设备以将其添加到或从选定通道中移除",
|
||||
"selectHint": "选择通道以分配设备",
|
||||
"empty": "未找到音频设备",
|
||||
"requiresTauri": "音频设备选择需要 Tauri"
|
||||
},
|
||||
"fields": {
|
||||
"name": "通道名称",
|
||||
"namePlaceholder": "例如:虚拟线缆、广播"
|
||||
},
|
||||
"createDialog": {
|
||||
"title": "创建音频通道",
|
||||
"description": "创建新的音频通道(总线),将声音路由到特定的输出设备。",
|
||||
"action": "创建"
|
||||
},
|
||||
"editDialog": {
|
||||
"title": "编辑通道",
|
||||
"description": "更新通道设置和声音分配。"
|
||||
}
|
||||
},
|
||||
"profileForm": {
|
||||
"createTitle": "创建声音",
|
||||
"editTitle": "编辑声音",
|
||||
"createDescription": "从音频样本或内置声音创建新的声音档案。",
|
||||
"editDescription": "更新您的声音档案详情并管理样本。",
|
||||
"draftRestored": "已恢复草稿",
|
||||
"discard": "丢弃",
|
||||
"source": {
|
||||
"clone": "从音频克隆",
|
||||
"builtin": "内置声音"
|
||||
},
|
||||
"builtin": {
|
||||
"hint": "选择一个预建的声音。这些不需要音频样本。",
|
||||
"badge": "内置声音",
|
||||
"note": "此档案使用内置声音。创建后声音无法更改。"
|
||||
},
|
||||
"sampleTabs": {
|
||||
"upload": "上传",
|
||||
"record": "录制",
|
||||
"system": "系统音频"
|
||||
},
|
||||
"fields": {
|
||||
"engine": "引擎",
|
||||
"voice": "声音",
|
||||
"name": "名称",
|
||||
"namePlaceholder": "我的声音",
|
||||
"descriptionLabel": "描述(可选)",
|
||||
"descriptionPlaceholder": "描述此声音……",
|
||||
"language": "语言",
|
||||
"referenceText": "参考文本",
|
||||
"referenceTextPlaceholder": "输入音频中所说的准确文字……",
|
||||
"defaultEngine": "默认引擎",
|
||||
"noPreference": "无偏好",
|
||||
"defaultEngineHint": "选择该档案时自动使用此引擎。",
|
||||
"defaultEffects": "默认效果",
|
||||
"defaultEffectsHint": "自动应用于使用此声音的所有新生成的效果。"
|
||||
},
|
||||
"avatar": {
|
||||
"alt": "头像预览"
|
||||
},
|
||||
"actions": {
|
||||
"saving": "保存中…",
|
||||
"saveChanges": "保存更改",
|
||||
"createProfile": "创建档案"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "请输入名称",
|
||||
"referenceRequired": "添加样本时需要参考文本",
|
||||
"sampleRequired": "需要音频样本",
|
||||
"referenceTextRequired": "需要参考文本",
|
||||
"audioTooLong": "音频过长({{duration}})。最大时长为 {{max}}。",
|
||||
"audioFailed": "音频文件验证失败。请尝试其他文件。"
|
||||
},
|
||||
"toast": {
|
||||
"recordingComplete": "录制完成",
|
||||
"recordingCompleteDescription": "音频已成功录制。",
|
||||
"recordingError": "录制错误",
|
||||
"systemAudioCaptured": "系统音频已捕获",
|
||||
"systemAudioCapturedDescription": "音频已成功捕获。",
|
||||
"systemAudioError": "系统音频捕获错误",
|
||||
"transcribeFailed": "转录失败",
|
||||
"transcribeFailedFallback": "无法转录音频",
|
||||
"noFile": "未选择文件",
|
||||
"noFileDescription": "请先选择一个音频文件。",
|
||||
"invalidFile": "文件类型无效",
|
||||
"invalidImageFormat": "请选择图片文件(PNG、JPG 或 WebP)",
|
||||
"fileTooLarge": "文件过大",
|
||||
"imageTooLargeDescription": "图片必须小于 5MB",
|
||||
"avatarRemoved": "头像已移除",
|
||||
"avatarRemovedDescription": "头像图片已成功移除。",
|
||||
"avatarRemoveFailed": "移除头像失败",
|
||||
"avatarUploadFailed": "头像上传失败",
|
||||
"avatarUploadFailedFallback": "无法上传头像",
|
||||
"effectsUpdateFailed": "效果更新失败",
|
||||
"effectsUpdateFailedFallback": "无法保存效果链",
|
||||
"voiceUpdated": "声音已更新",
|
||||
"voiceUpdatedDescription": "\"{{name}}\" 已成功更新。",
|
||||
"noVoiceSelected": "未选择声音",
|
||||
"noVoiceSelectedDescription": "请选择内置声音。",
|
||||
"profileCreated": "档案已创建",
|
||||
"profileCreatedBuiltin": "\"{{name}}\" 已使用内置声音创建。",
|
||||
"profileCreatedSample": "\"{{name}}\" 已使用样本创建。",
|
||||
"sampleRequired": "需要音频样本",
|
||||
"sampleRequiredDescription": "请提供音频样本以创建声音档案。",
|
||||
"referenceTextRequired": "需要参考文本",
|
||||
"referenceTextRequiredDescription": "请提供音频样本的参考文本。",
|
||||
"invalidAudio": "音频文件无效",
|
||||
"invalidAudioDescription": "音频时长为 {{duration}},但最大为 {{max}}。",
|
||||
"validationError": "验证错误",
|
||||
"rollbackFailed": "回滚失败",
|
||||
"rollbackFailedDescription": "样本上传失败后无法移除已创建的档案。",
|
||||
"profileRolledBack": "档案已回滚。",
|
||||
"sampleFailed": "添加样本失败",
|
||||
"sampleFailedDescription": "添加样本失败。",
|
||||
"sampleFailedRolledBack": "添加样本失败。档案已回滚。",
|
||||
"saveFailed": "保存档案失败"
|
||||
}
|
||||
},
|
||||
"audioSample": {
|
||||
"chooseFile": "选择文件",
|
||||
"uploadHint": "点击选择文件或拖放。最大时长:30 秒。",
|
||||
"fileUploaded": "文件已上传",
|
||||
"fileLabel": "文件:{{name}}",
|
||||
"play": "播放",
|
||||
"pause": "暂停",
|
||||
"transcribe": "转录",
|
||||
"transcribing": "转录中…",
|
||||
"remove": "移除",
|
||||
"startRecording": "开始录制",
|
||||
"recordHint": "点击开始录制。最大时长:30 秒。",
|
||||
"stopRecording": "停止录制",
|
||||
"remaining": "剩余 {{time}}",
|
||||
"recordingComplete": "录制完成",
|
||||
"recordAgain": "重新录制",
|
||||
"startCapture": "开始捕获",
|
||||
"systemHint": "从您的系统捕获音频。最大时长:30 秒。",
|
||||
"stopCapture": "停止捕获",
|
||||
"captureComplete": "捕获完成",
|
||||
"captureAgain": "重新捕获"
|
||||
},
|
||||
"sampleList": {
|
||||
"loading": "加载样本中…",
|
||||
"empty": {
|
||||
"title": "暂无样本",
|
||||
"hint": "添加第一个音频样本以开始"
|
||||
},
|
||||
"editing": "正在编辑转录",
|
||||
"placeholder": "输入参考文本……",
|
||||
"saving": "保存中…",
|
||||
"editTranscription": "编辑转录",
|
||||
"deleteSample": "删除样本",
|
||||
"addSample": "添加样本",
|
||||
"note": "注意:单个 30 秒的样本效果最佳。多个样本可能会降低质量。未来版本中样本可能会变得可互换,并为同一声音的不同风格打标签。",
|
||||
"deleteDialog": {
|
||||
"title": "删除样本",
|
||||
"description": "确定要删除此音频样本吗?此操作不可撤销。",
|
||||
"deleting": "删除中…"
|
||||
},
|
||||
"player": {
|
||||
"play": "播放样本",
|
||||
"pause": "暂停样本",
|
||||
"stop": "停止",
|
||||
"stopAria": "停止播放",
|
||||
"position": "样本播放位置",
|
||||
"positionValue": "{{current}} / {{total}}"
|
||||
},
|
||||
"toast": {
|
||||
"invalidText": "文本无效",
|
||||
"invalidTextDescription": "参考文本不能为空。",
|
||||
"updated": "样本已更新",
|
||||
"updatedDescription": "参考文本已成功更新。",
|
||||
"updateFailed": "更新失败",
|
||||
"updateFailedFallback": "更新样本失败"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"card": {
|
||||
"noDescription": "无描述",
|
||||
"designed": "设计",
|
||||
"export": "导出声音档案",
|
||||
"edit": "编辑声音档案",
|
||||
"delete": "删除声音档案",
|
||||
"selectLabel": "{{name}},{{language}}。选择用于生成的声音。",
|
||||
"selectLabelSelected": "{{name}},{{language}}。已选为用于生成的声音。"
|
||||
},
|
||||
"list": {
|
||||
"errorLoading": "加载声音档案时出错:{{message}}",
|
||||
"empty": "还没有声音档案。创建您的第一个档案以开始使用。",
|
||||
"createVoice": "创建声音",
|
||||
"unsupportedNote": "当前模型仅可选择支持的声音档案。"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "删除声音档案",
|
||||
"body": "确定要删除 \"{{name}}\" 吗?此操作不可撤销。",
|
||||
"deleting": "删除中…"
|
||||
}
|
||||
},
|
||||
"effects": {
|
||||
"title": "效果",
|
||||
"newPreset": "新建预设",
|
||||
"noDescription": "无描述",
|
||||
"placeholder": "选择一个预设或创建新的",
|
||||
"effectCount_one": "{{count}} 个效果",
|
||||
"effectCount_other": "{{count}} 个效果",
|
||||
"sections": {
|
||||
"builtin": "内置",
|
||||
"custom": "自定义",
|
||||
"new": "新建"
|
||||
},
|
||||
"badge": {
|
||||
"builtin": "内置"
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "未保存的预设",
|
||||
"hint": "在右侧面板配置效果。"
|
||||
},
|
||||
"detail": {
|
||||
"newTitle": "新建预设",
|
||||
"editTitle": "编辑预设",
|
||||
"savePreset": "保存预设",
|
||||
"saveAsCustom": "另存为自定义",
|
||||
"saving": "保存中…",
|
||||
"deleting": "删除中…"
|
||||
},
|
||||
"fields": {
|
||||
"name": "名称",
|
||||
"namePlaceholder": "我的预设……",
|
||||
"description": "描述",
|
||||
"descriptionPlaceholder": "描述此预设的作用……"
|
||||
},
|
||||
"preview": {
|
||||
"label": "预览",
|
||||
"button": "预览",
|
||||
"processing": "处理中…",
|
||||
"hint": "预览仅将效果应用于干净版本,不会保存。"
|
||||
},
|
||||
"saveAs": {
|
||||
"title": "另存为自定义预设",
|
||||
"description": "基于当前效果链创建一个新的自定义预设。",
|
||||
"suggestedName": "{{name}}(副本)"
|
||||
},
|
||||
"toast": {
|
||||
"saved": "预设已保存",
|
||||
"createdDescription": "\"{{name}}\" 已创建。",
|
||||
"updated": "预设已更新",
|
||||
"deleted": "预设已删除",
|
||||
"saveFailed": "保存失败",
|
||||
"deleteFailed": "删除失败",
|
||||
"previewFailed": "预览失败",
|
||||
"nameRequired": "请输入名称"
|
||||
},
|
||||
"chain": {
|
||||
"loadPreset": "加载预设……",
|
||||
"addEffect": "添加效果……",
|
||||
"clear": "清空",
|
||||
"enable": "启用",
|
||||
"disable": "禁用",
|
||||
"remove": "移除"
|
||||
},
|
||||
"types": {
|
||||
"chorus": {
|
||||
"label": "合唱 / 镶边",
|
||||
"params": {
|
||||
"rate_hz": "LFO 速度(Hz)",
|
||||
"depth": "调制深度",
|
||||
"feedback": "反馈量",
|
||||
"centre_delay_ms": "中心延迟(毫秒)",
|
||||
"mix": "干湿混合"
|
||||
}
|
||||
},
|
||||
"reverb": {
|
||||
"label": "混响",
|
||||
"params": {
|
||||
"room_size": "房间大小",
|
||||
"damping": "高频阻尼",
|
||||
"wet_level": "湿声电平",
|
||||
"dry_level": "干声电平",
|
||||
"width": "立体声宽度"
|
||||
}
|
||||
},
|
||||
"delay": {
|
||||
"label": "延迟",
|
||||
"params": {
|
||||
"delay_seconds": "延迟时间(秒)",
|
||||
"feedback": "反馈量",
|
||||
"mix": "干湿混合"
|
||||
}
|
||||
},
|
||||
"compressor": {
|
||||
"label": "压缩器",
|
||||
"params": {
|
||||
"threshold_db": "阈值(dB)",
|
||||
"ratio": "压缩比",
|
||||
"attack_ms": "起音时间(毫秒)",
|
||||
"release_ms": "释放时间(毫秒)"
|
||||
}
|
||||
},
|
||||
"gain": {
|
||||
"label": "增益",
|
||||
"params": {
|
||||
"gain_db": "增益(dB)"
|
||||
}
|
||||
},
|
||||
"highpass": {
|
||||
"label": "高通滤波器",
|
||||
"params": {
|
||||
"cutoff_frequency_hz": "截止频率(Hz)"
|
||||
}
|
||||
},
|
||||
"lowpass": {
|
||||
"label": "低通滤波器",
|
||||
"params": {
|
||||
"cutoff_frequency_hz": "截止频率(Hz)"
|
||||
}
|
||||
},
|
||||
"pitch_shift": {
|
||||
"label": "音高变换",
|
||||
"params": {
|
||||
"semitones": "半音移动"
|
||||
}
|
||||
}
|
||||
},
|
||||
"builtinPresets": {
|
||||
"Robotic": {
|
||||
"name": "机器人",
|
||||
"description": "金属机器人嗓音(慢速 LFO 加高反馈的镶边效果)"
|
||||
},
|
||||
"Radio": {
|
||||
"name": "收音机",
|
||||
"description": "带通滤波加轻度压缩的 AM 收音机薄嗓音"
|
||||
},
|
||||
"Echo Chamber": {
|
||||
"name": "回声室",
|
||||
"description": "宽广的混响加尾随回声"
|
||||
},
|
||||
"Deep Voice": {
|
||||
"name": "低沉嗓音",
|
||||
"description": "降低音高并增添温暖"
|
||||
}
|
||||
}
|
||||
},
|
||||
"stories": {
|
||||
"title": "故事",
|
||||
"newStory": "新建故事",
|
||||
"loading": "加载故事中…",
|
||||
"empty": {
|
||||
"title": "暂无故事",
|
||||
"hint": "创建您的第一个故事以开始"
|
||||
},
|
||||
"row": {
|
||||
"itemCount_one": "{{count}} 项",
|
||||
"itemCount_other": "{{count}} 项",
|
||||
"ariaLabel": "故事 {{name}},{{count}} 项,{{updated}}",
|
||||
"actionsLabel": "{{name}} 的操作"
|
||||
},
|
||||
"createDialog": {
|
||||
"title": "新建故事",
|
||||
"description": "创建新故事以将您的语音生成整理成对话。",
|
||||
"action": "创建",
|
||||
"creating": "创建中…"
|
||||
},
|
||||
"editDialog": {
|
||||
"title": "编辑故事",
|
||||
"description": "更新故事名称和描述。",
|
||||
"saving": "保存中…"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "确定吗?",
|
||||
"description": "这将永久删除该故事及其所有项目。此操作不可撤销。",
|
||||
"deleting": "删除中…"
|
||||
},
|
||||
"fields": {
|
||||
"name": "名称",
|
||||
"namePlaceholder": "我的故事",
|
||||
"descriptionLabel": "描述(可选)",
|
||||
"descriptionPlaceholder": "一段对话……"
|
||||
},
|
||||
"toast": {
|
||||
"nameRequired": "请输入名称",
|
||||
"nameRequiredDescription": "请输入故事名称",
|
||||
"created": "故事已创建",
|
||||
"createdDescription": "\"{{name}}\" 已创建",
|
||||
"createFailed": "创建故事失败",
|
||||
"updateFailed": "更新故事失败",
|
||||
"deleteFailed": "删除故事失败"
|
||||
}
|
||||
},
|
||||
"storyContent": {
|
||||
"selectStory": {
|
||||
"title": "选择一个故事",
|
||||
"hint": "从列表中选择一个故事以查看其内容"
|
||||
},
|
||||
"loading": "加载故事中…",
|
||||
"notFound": {
|
||||
"title": "未找到故事",
|
||||
"hint": "无法加载所选故事"
|
||||
},
|
||||
"generatingCount_one": "生成 {{count}} 个音频中",
|
||||
"generatingCount_other": "生成 {{count}} 个音频中",
|
||||
"add": "添加",
|
||||
"searchPlaceholder": "按名称或文字内容搜索……",
|
||||
"searchNoMatches": "未找到匹配的生成",
|
||||
"searchNoAvailable": "暂无可用的生成",
|
||||
"exportAudio": "导出音频",
|
||||
"empty": {
|
||||
"title": "此故事暂无项目",
|
||||
"hint": "使用下方输入框生成语音以添加项目"
|
||||
},
|
||||
"itemActions": {
|
||||
"playFromHere": "从此处播放",
|
||||
"removeFromStory": "从故事中移除"
|
||||
},
|
||||
"toast": {
|
||||
"removeFailed": "移除项目失败",
|
||||
"reorderFailed": "重新排序项目失败",
|
||||
"exportFailed": "导出音频失败",
|
||||
"addFailed": "添加生成失败"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"actions": {
|
||||
"menu": "操作",
|
||||
"play": "播放",
|
||||
"exportAudio": "导出音频",
|
||||
"exportPackage": "导出包",
|
||||
"applyEffects": "应用效果",
|
||||
"regenerate": "重新生成"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "删除生成",
|
||||
"body": "确定要删除来自 \"{{name}}\" 的这次生成吗?此操作不可撤销。",
|
||||
"deleting": "删除中…"
|
||||
},
|
||||
"clearFailedDialog": {
|
||||
"title": "清除失败的生成",
|
||||
"body_one": "这将从历史记录中永久删除 {{count}} 条失败的生成。此操作不可撤销。",
|
||||
"body_other": "这将从历史记录中永久删除 {{count}} 条失败的生成。此操作不可撤销。",
|
||||
"clearing": "清除中…",
|
||||
"clearAll": "全部清除"
|
||||
},
|
||||
"importDialog": {
|
||||
"title": "导入生成",
|
||||
"body": "从 \"{{name}}\" 导入生成。这将添加到您的历史记录中。",
|
||||
"importing": "导入中…",
|
||||
"action": "导入"
|
||||
},
|
||||
"effectsDialog": {
|
||||
"title": "应用效果",
|
||||
"body": "配置应用于此次生成的后处理效果。将会创建一个新版本。",
|
||||
"sourceLabel": "来源",
|
||||
"sourcePlaceholder": "选择来源版本",
|
||||
"apply": "应用",
|
||||
"applying": "应用中…"
|
||||
}
|
||||
},
|
||||
"generation": {
|
||||
"placeholder": {
|
||||
"storyWithEffects": "为 \"{{name}}\" 生成语音… (输入 / 使用效果)",
|
||||
"story": "为 \"{{name}}\" 生成语音…",
|
||||
"profile": "使用 {{name}} 生成语音…",
|
||||
"effectsHint": "输入 / 使用效果,如 [笑声]、[叹息]…",
|
||||
"selectVoice": "请在上方选择一个声音档案…"
|
||||
},
|
||||
"button": {
|
||||
"generate": "生成语音",
|
||||
"generating": "生成中…",
|
||||
"selectFirst": "请先选择声音档案"
|
||||
},
|
||||
"instruct": {
|
||||
"show": "显示传达说明",
|
||||
"hide": "隐藏传达说明",
|
||||
"tooltip": "传达说明 (语气、情感、节奏)",
|
||||
"placeholder": "传达说明——例如:温柔缓慢地说、威严清晰…"
|
||||
},
|
||||
"voiceSelector": {
|
||||
"placeholder": "选择声音…"
|
||||
},
|
||||
"effects": {
|
||||
"none": "无效果",
|
||||
"profileDefault": "档案默认"
|
||||
}
|
||||
},
|
||||
"main": {
|
||||
"importVoice": "导入声音",
|
||||
"createVoice": "创建声音",
|
||||
"import": {
|
||||
"invalidTitle": "文件类型无效",
|
||||
"invalidDescription": "请选择有效的 .voicebox.zip 文件",
|
||||
"successTitle": "声音已导入",
|
||||
"successDescription": "成功导入声音档案",
|
||||
"failedTitle": "导入声音档案失败",
|
||||
"dialogTitle": "导入声音档案",
|
||||
"dialogDescription": "从 \"{{name}}\" 导入声音档案。这将创建一个新的声音档案,包含所有样本。",
|
||||
"importing": "导入中…",
|
||||
"action": "导入"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"tabs": {
|
||||
"general": "常规",
|
||||
"generation": "生成",
|
||||
"gpu": "GPU",
|
||||
"logs": "日志",
|
||||
"changelog": "更新日志",
|
||||
"about": "关于"
|
||||
},
|
||||
"language": {
|
||||
"label": "语言",
|
||||
"description": "选择 Voicebox 的显示语言。"
|
||||
},
|
||||
"general": {
|
||||
"docs": { "title": "阅读文档" },
|
||||
"discord": { "title": "加入 Discord", "subtitle": "获取帮助 & 分享声音" },
|
||||
"serverUrl": {
|
||||
"title": "服务器 URL",
|
||||
"description": "Voicebox 后端服务器的地址。",
|
||||
"invalidUrl": "请输入有效的 URL",
|
||||
"updatedTitle": "服务器 URL 已更新",
|
||||
"updatedDescription": "已连接到 {{url}}"
|
||||
},
|
||||
"keepServerRunning": {
|
||||
"title": "关闭应用时保持服务器运行",
|
||||
"description": "关闭应用后,服务器将继续在后台运行。",
|
||||
"failedTitle": "更新设置失败",
|
||||
"failedDescription": "无法将设置同步到后端。",
|
||||
"updatedTitle": "设置已更新",
|
||||
"runningDescription": "关闭应用时服务器将继续运行",
|
||||
"stoppedDescription": "关闭应用时服务器将停止"
|
||||
},
|
||||
"networkAccess": {
|
||||
"title": "允许网络访问",
|
||||
"description": "使网络上的其他设备可以访问服务器。更改后请重启应用。",
|
||||
"updatedTitle": "设置已更新",
|
||||
"enabled": "已启用网络访问。重启应用以应用更改。",
|
||||
"disabled": "已禁用网络访问。重启应用以应用更改。"
|
||||
},
|
||||
"connection": {
|
||||
"connecting": "连接中",
|
||||
"offline": "离线",
|
||||
"online": "在线"
|
||||
},
|
||||
"updates": {
|
||||
"title": "应用更新",
|
||||
"devSuffix": " (开发版)",
|
||||
"devMode": {
|
||||
"title": "开发模式",
|
||||
"description": "开发模式下已禁用自动更新。"
|
||||
},
|
||||
"check": {
|
||||
"title": "检查更新",
|
||||
"available": "版本 {{version}} 可用",
|
||||
"checking": "检查中…",
|
||||
"upToDate": "已是最新版本",
|
||||
"button": "检查"
|
||||
},
|
||||
"error": "更新错误",
|
||||
"download": {
|
||||
"title": "更新到 {{version}}",
|
||||
"description": "下载并安装最新版本。",
|
||||
"button": "下载"
|
||||
},
|
||||
"downloading": "下载更新中…",
|
||||
"ready": {
|
||||
"title": "更新已准备就绪",
|
||||
"description": "版本 {{version}} 已下载。重启以完成。",
|
||||
"button": "立即重启"
|
||||
}
|
||||
},
|
||||
"api": {
|
||||
"title": "API 访问",
|
||||
"description": "通过 <code>{{url}}</code> 的 REST API 将 Voicebox 集成到您的工作流程中",
|
||||
"viewReference": "查看完整的 API 参考",
|
||||
"endpoints": {
|
||||
"generate": "生成语音",
|
||||
"health": "服务器状态",
|
||||
"profiles": "声音列表",
|
||||
"history": "历史生成"
|
||||
}
|
||||
}
|
||||
},
|
||||
"generation": {
|
||||
"title": "生成",
|
||||
"description": "长文本生成的控件。这些设置适用于所有引擎。",
|
||||
"chunkLimit": {
|
||||
"title": "自动分块上限",
|
||||
"description": "长文本在句子边界处分块。较低的值可以提高长输出的质量。",
|
||||
"value": "{{chars}} 字符"
|
||||
},
|
||||
"crossfade": {
|
||||
"title": "块间淡入淡出",
|
||||
"description": "在块之间混合音频以平滑过渡。设为 0 表示硬切换。",
|
||||
"cut": "切换",
|
||||
"ms": "{{ms}}毫秒"
|
||||
},
|
||||
"normalize": {
|
||||
"title": "音频归一化",
|
||||
"description": "将输出音量调整到所有生成结果一致的水平。"
|
||||
},
|
||||
"autoplay": {
|
||||
"title": "生成后自动播放",
|
||||
"description": "生成完成后自动播放音频。"
|
||||
},
|
||||
"folder": {
|
||||
"title": "生成文件夹",
|
||||
"description": "生成的音频文件在磁盘上的存储位置。",
|
||||
"open": "打开"
|
||||
}
|
||||
},
|
||||
"gpu": {
|
||||
"cpuOnly": "仅 CPU",
|
||||
"vramUsed": "{{mb}} MB 显存",
|
||||
"noAcceleration": "未检测到 GPU 加速",
|
||||
"active": "活动",
|
||||
"cuda": {
|
||||
"title": "CUDA 后端",
|
||||
"description": "通过可下载的 CUDA 后端实现 NVIDIA GPU 加速。",
|
||||
"downloading": "下载 CUDA 后端中…",
|
||||
"downloadingShort": "下载中…",
|
||||
"updating": "更新中…"
|
||||
},
|
||||
"restart": {
|
||||
"ready": "服务器重启成功",
|
||||
"waiting": "重启服务器中…",
|
||||
"stopping": "停止服务器中…"
|
||||
},
|
||||
"download": {
|
||||
"title": "下载 CUDA 后端",
|
||||
"description": "约 2.4 GB 下载。需要支持 CUDA 的 NVIDIA GPU。",
|
||||
"button": "下载"
|
||||
},
|
||||
"switchToCuda": {
|
||||
"title": "切换到 CUDA 后端",
|
||||
"description": "CUDA 后端已下载完成。重启以启用。",
|
||||
"button": "重启"
|
||||
},
|
||||
"switchToCpu": {
|
||||
"title": "切换到 CPU 后端",
|
||||
"description": "禁用 GPU 加速。您之后可以重新下载 CUDA。",
|
||||
"button": "切换"
|
||||
},
|
||||
"remove": {
|
||||
"title": "移除 CUDA 后端",
|
||||
"description": "删除已下载的 CUDA 二进制文件以释放磁盘空间。",
|
||||
"button": "移除"
|
||||
},
|
||||
"errors": {
|
||||
"downloadFailed": "下载失败",
|
||||
"downloadStart": "启动下载失败",
|
||||
"restartFailed": "重启失败",
|
||||
"switchCpu": "切换到 CPU 失败",
|
||||
"deleteCuda": "删除 CUDA 后端失败"
|
||||
},
|
||||
"footer": "Voicebox 会自动检测并使用系统上可用的最佳 GPU。在 Apple Silicon Mac 上,MLX 后端通过 Metal Performance Shaders (MPS) 在神经引擎和 GPU 上原生运行,无需额外设置。在配备 NVIDIA GPU 的 Windows 和 Linux 上,您可以下载可选的 CUDA 后端以获得硬件加速推理。AMD ROCm、Intel XPU 和 DirectML 也通过 PyTorch 获得支持。未检测到 GPU 时,Voicebox 会退回到 CPU——所有引擎仍可工作,只是速度较慢。"
|
||||
},
|
||||
"logs": {
|
||||
"title": "服务器日志",
|
||||
"lineCount_one": "{{count}} 行",
|
||||
"lineCount_other": "{{count}} 行",
|
||||
"scrollToBottom": "滚动到底部",
|
||||
"clear": "清除",
|
||||
"empty": "暂无日志输出。",
|
||||
"devHint": "仅当应用管理服务器进程(生产构建)时才会捕获服务器日志。"
|
||||
},
|
||||
"changelog": {
|
||||
"devBadge": "开发版",
|
||||
"showLess": "收起",
|
||||
"showMore": "展开"
|
||||
},
|
||||
"about": {
|
||||
"tagline": "开源语音合成工作室。克隆声音、生成语音、应用效果、构建语音驱动的应用——全部在您的本地机器上运行。",
|
||||
"createdBy": "创建者",
|
||||
"buyCoffee": "请我喝杯咖啡",
|
||||
"license": "采用 <link>MIT</link> 协议"
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"title": "模型",
|
||||
"subtitle": "下载和管理用于语音生成和转录的 AI 模型",
|
||||
"defaultName": "模型",
|
||||
"unknownSize": "未知大小",
|
||||
"sections": {
|
||||
"voiceGeneration": "语音生成",
|
||||
"transcription": "语音转录"
|
||||
},
|
||||
"status": {
|
||||
"loaded": "已加载"
|
||||
},
|
||||
"storage": {
|
||||
"location": "存储位置",
|
||||
"open": "打开",
|
||||
"change": "更改",
|
||||
"migrating": "迁移中…",
|
||||
"reset": "重置",
|
||||
"pickerTitle": "选择模型存储文件夹"
|
||||
},
|
||||
"progress": {
|
||||
"connecting": "连接中…",
|
||||
"connectingHf": "连接到 HuggingFace 中…"
|
||||
},
|
||||
"problems": {
|
||||
"title": "问题",
|
||||
"clearAll": "全部清除",
|
||||
"noDetails": "没有可用的错误详情。请重试下载。",
|
||||
"startedAt": "开始于 {{time}}"
|
||||
},
|
||||
"detail": {
|
||||
"loadingInfo": "加载模型信息中…",
|
||||
"byAuthor": "由 {{author}}",
|
||||
"downloads": "下载量",
|
||||
"likes": "点赞数",
|
||||
"license": "许可",
|
||||
"languagesCount": "支持 {{count}} 种语言",
|
||||
"languagesList": "语言:{{list}}",
|
||||
"onDisk": "磁盘占用 {{size}}"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下载",
|
||||
"retry": "重试下载",
|
||||
"unload": "卸载",
|
||||
"unloading": "卸载中…",
|
||||
"unloadFirst": "删除前请先卸载模型",
|
||||
"deleteModel": "删除模型"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "删除模型",
|
||||
"body": "确定要删除 <strong>{{name}}</strong> 吗?",
|
||||
"sizeNote": "这将释放 {{size}} 磁盘空间。如果您想再次使用该模型,需要重新下载。",
|
||||
"deleting": "删除中…"
|
||||
},
|
||||
"migrateDialog": {
|
||||
"title": "移动模型到新位置?",
|
||||
"description": "在模型迁移到新文件夹期间,服务器将关闭。迁移完成后会自动重启。",
|
||||
"action": "移动模型",
|
||||
"preparing": "准备中…",
|
||||
"restartingServer": "重启服务器中…"
|
||||
},
|
||||
"migrate": {
|
||||
"title": "移动模型中",
|
||||
"offline": "模型迁移期间服务器处于离线状态。"
|
||||
},
|
||||
"toast": {
|
||||
"downloadFailed": "下载失败",
|
||||
"cancelFailed": "取消失败",
|
||||
"cancelFailedDescription": "无法取消下载任务。",
|
||||
"deleted": "模型已删除",
|
||||
"deletedDescription": "{{name}} 已成功删除。",
|
||||
"deleteFailed": "删除失败",
|
||||
"unloaded": "模型已卸载",
|
||||
"unloadedDescription": "{{name}} 已从内存中卸载。",
|
||||
"unloadFailed": "卸载失败",
|
||||
"openFolderFailed": "打开模型文件夹失败",
|
||||
"pickerFailed": "打开文件夹选择器失败",
|
||||
"resetToDefault": "已重置到默认位置。重启服务器中…",
|
||||
"noModelsToMigrate": "没有可迁移的模型",
|
||||
"noModelsToMigrateDescription": "更改存储位置前请先下载至少一个模型。",
|
||||
"migrated": "模型已成功移动",
|
||||
"migrationFailed": "迁移失败",
|
||||
"migrationFailedGeneric": "迁移模型失败",
|
||||
"migrationConnectionLost": "迁移期间丢失连接"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,834 @@
|
||||
{
|
||||
"common": {
|
||||
"cancel": "取消",
|
||||
"save": "儲存",
|
||||
"delete": "刪除",
|
||||
"edit": "編輯",
|
||||
"close": "關閉",
|
||||
"confirm": "確認",
|
||||
"loading": "載入中…",
|
||||
"error": "錯誤",
|
||||
"unknown": "未知",
|
||||
"unknownError": "未知錯誤"
|
||||
},
|
||||
"nav": {
|
||||
"generate": "生成",
|
||||
"stories": "故事",
|
||||
"voices": "聲音",
|
||||
"effects": "效果",
|
||||
"audio": "音訊",
|
||||
"models": "模型",
|
||||
"settings": "設定",
|
||||
"updateBadge": "更新"
|
||||
},
|
||||
"voicesTab": {
|
||||
"title": "聲音",
|
||||
"loading": "載入聲音中…",
|
||||
"searchPlaceholder": "搜尋聲音……",
|
||||
"newVoice": "新增聲音",
|
||||
"avatarAlt": "{{name}} 的頭像",
|
||||
"selectChannels": "選擇通道……",
|
||||
"channelDefaultLabel": "{{name}}(預設)",
|
||||
"columns": {
|
||||
"name": "名稱",
|
||||
"language": "語言",
|
||||
"generations": "生成次數",
|
||||
"samples": "樣本",
|
||||
"effects": "效果",
|
||||
"channels": "通道"
|
||||
}
|
||||
},
|
||||
"voiceInspector": {
|
||||
"loading": "載入中…",
|
||||
"defaultEffectsHint": "自動套用於使用此聲音的新生成。",
|
||||
"fields": {
|
||||
"description": "描述"
|
||||
},
|
||||
"toast": {
|
||||
"invalidImageFormat": "請選擇 PNG、JPG 或 WebP 格式",
|
||||
"avatarUpdated": "頭像已更新",
|
||||
"savedDescription": "\"{{name}}\" 已儲存。"
|
||||
}
|
||||
},
|
||||
"audioChannels": {
|
||||
"title": "音訊通道",
|
||||
"newChannel": "新增通道",
|
||||
"loading": "載入中…",
|
||||
"confirmDelete": "刪除此通道?",
|
||||
"noVoicesAssigned": "未指派聲音",
|
||||
"selectDevice": "選擇裝置",
|
||||
"addDevice": "新增裝置",
|
||||
"addVoice": "新增聲音",
|
||||
"defaultSuffix": "預設",
|
||||
"empty": {
|
||||
"message": "尚無音訊通道。建立您的第一個通道,將聲音路由到特定裝置。",
|
||||
"action": "建立通道"
|
||||
},
|
||||
"labels": {
|
||||
"outputDevices": "輸出裝置",
|
||||
"assignedVoices": "已指派聲音"
|
||||
},
|
||||
"devices": {
|
||||
"title": "可用裝置",
|
||||
"defaultNote": "預設通道使用系統預設裝置",
|
||||
"toggleHint": "點選裝置以將其加入或從所選通道中移除",
|
||||
"selectHint": "選擇通道以指派裝置",
|
||||
"empty": "找不到音訊裝置",
|
||||
"requiresTauri": "音訊裝置選擇需要 Tauri"
|
||||
},
|
||||
"fields": {
|
||||
"name": "通道名稱",
|
||||
"namePlaceholder": "例如:虛擬纜線、廣播"
|
||||
},
|
||||
"createDialog": {
|
||||
"title": "建立音訊通道",
|
||||
"description": "建立新的音訊通道(匯流排),將聲音路由到特定的輸出裝置。",
|
||||
"action": "建立"
|
||||
},
|
||||
"editDialog": {
|
||||
"title": "編輯通道",
|
||||
"description": "更新通道設定與聲音指派。"
|
||||
}
|
||||
},
|
||||
"profileForm": {
|
||||
"createTitle": "建立聲音",
|
||||
"editTitle": "編輯聲音",
|
||||
"createDescription": "從音訊樣本或內建聲音建立新的聲音檔案。",
|
||||
"editDescription": "更新您的聲音檔案細節並管理樣本。",
|
||||
"draftRestored": "已還原草稿",
|
||||
"discard": "捨棄",
|
||||
"source": {
|
||||
"clone": "從音訊複製",
|
||||
"builtin": "內建聲音"
|
||||
},
|
||||
"builtin": {
|
||||
"hint": "選擇預建的聲音。這些不需要音訊樣本。",
|
||||
"badge": "內建聲音",
|
||||
"note": "此檔案使用內建聲音。建立後聲音無法變更。"
|
||||
},
|
||||
"sampleTabs": {
|
||||
"upload": "上傳",
|
||||
"record": "錄製",
|
||||
"system": "系統音訊"
|
||||
},
|
||||
"fields": {
|
||||
"engine": "引擎",
|
||||
"voice": "聲音",
|
||||
"name": "名稱",
|
||||
"namePlaceholder": "我的聲音",
|
||||
"descriptionLabel": "描述(選填)",
|
||||
"descriptionPlaceholder": "描述此聲音……",
|
||||
"language": "語言",
|
||||
"referenceText": "參考文字",
|
||||
"referenceTextPlaceholder": "輸入音訊中所說的確切文字……",
|
||||
"defaultEngine": "預設引擎",
|
||||
"noPreference": "無偏好",
|
||||
"defaultEngineHint": "選擇此檔案時自動使用此引擎。",
|
||||
"defaultEffects": "預設效果",
|
||||
"defaultEffectsHint": "自動套用於使用此聲音所有新生成的效果。"
|
||||
},
|
||||
"avatar": {
|
||||
"alt": "頭像預覽"
|
||||
},
|
||||
"actions": {
|
||||
"saving": "儲存中…",
|
||||
"saveChanges": "儲存變更",
|
||||
"createProfile": "建立檔案"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "請輸入名稱",
|
||||
"referenceRequired": "新增樣本時需要參考文字",
|
||||
"sampleRequired": "需要音訊樣本",
|
||||
"referenceTextRequired": "需要參考文字",
|
||||
"audioTooLong": "音訊過長({{duration}})。最大時長為 {{max}}。",
|
||||
"audioFailed": "音訊檔案驗證失敗。請嘗試其他檔案。"
|
||||
},
|
||||
"toast": {
|
||||
"recordingComplete": "錄製完成",
|
||||
"recordingCompleteDescription": "音訊已成功錄製。",
|
||||
"recordingError": "錄製錯誤",
|
||||
"systemAudioCaptured": "已擷取系統音訊",
|
||||
"systemAudioCapturedDescription": "音訊已成功擷取。",
|
||||
"systemAudioError": "系統音訊擷取錯誤",
|
||||
"transcribeFailed": "轉錄失敗",
|
||||
"transcribeFailedFallback": "無法轉錄音訊",
|
||||
"noFile": "未選擇檔案",
|
||||
"noFileDescription": "請先選擇音訊檔案。",
|
||||
"invalidFile": "檔案類型無效",
|
||||
"invalidImageFormat": "請選擇圖片檔案(PNG、JPG 或 WebP)",
|
||||
"fileTooLarge": "檔案過大",
|
||||
"imageTooLargeDescription": "圖片必須小於 5MB",
|
||||
"avatarRemoved": "頭像已移除",
|
||||
"avatarRemovedDescription": "頭像圖片已成功移除。",
|
||||
"avatarRemoveFailed": "移除頭像失敗",
|
||||
"avatarUploadFailed": "頭像上傳失敗",
|
||||
"avatarUploadFailedFallback": "無法上傳頭像",
|
||||
"effectsUpdateFailed": "效果更新失敗",
|
||||
"effectsUpdateFailedFallback": "無法儲存效果鏈",
|
||||
"voiceUpdated": "聲音已更新",
|
||||
"voiceUpdatedDescription": "\"{{name}}\" 已成功更新。",
|
||||
"noVoiceSelected": "未選擇聲音",
|
||||
"noVoiceSelectedDescription": "請選擇內建聲音。",
|
||||
"profileCreated": "已建立檔案",
|
||||
"profileCreatedBuiltin": "\"{{name}}\" 已使用內建聲音建立。",
|
||||
"profileCreatedSample": "\"{{name}}\" 已使用樣本建立。",
|
||||
"sampleRequired": "需要音訊樣本",
|
||||
"sampleRequiredDescription": "請提供音訊樣本以建立聲音檔案。",
|
||||
"referenceTextRequired": "需要參考文字",
|
||||
"referenceTextRequiredDescription": "請提供音訊樣本的參考文字。",
|
||||
"invalidAudio": "音訊檔案無效",
|
||||
"invalidAudioDescription": "音訊時長為 {{duration}},但最大為 {{max}}。",
|
||||
"validationError": "驗證錯誤",
|
||||
"rollbackFailed": "復原失敗",
|
||||
"rollbackFailedDescription": "樣本上傳失敗後無法移除已建立的檔案。",
|
||||
"profileRolledBack": "檔案已復原。",
|
||||
"sampleFailed": "新增樣本失敗",
|
||||
"sampleFailedDescription": "新增樣本失敗。",
|
||||
"sampleFailedRolledBack": "新增樣本失敗。檔案已復原。",
|
||||
"saveFailed": "儲存檔案失敗"
|
||||
}
|
||||
},
|
||||
"audioSample": {
|
||||
"chooseFile": "選擇檔案",
|
||||
"uploadHint": "點選以選擇檔案或拖放。最大時長:30 秒。",
|
||||
"fileUploaded": "檔案已上傳",
|
||||
"fileLabel": "檔案:{{name}}",
|
||||
"play": "播放",
|
||||
"pause": "暫停",
|
||||
"transcribe": "轉錄",
|
||||
"transcribing": "轉錄中…",
|
||||
"remove": "移除",
|
||||
"startRecording": "開始錄製",
|
||||
"recordHint": "點選以開始錄製。最大時長:30 秒。",
|
||||
"stopRecording": "停止錄製",
|
||||
"remaining": "剩餘 {{time}}",
|
||||
"recordingComplete": "錄製完成",
|
||||
"recordAgain": "重新錄製",
|
||||
"startCapture": "開始擷取",
|
||||
"systemHint": "從您的系統擷取音訊。最大時長:30 秒。",
|
||||
"stopCapture": "停止擷取",
|
||||
"captureComplete": "擷取完成",
|
||||
"captureAgain": "重新擷取"
|
||||
},
|
||||
"sampleList": {
|
||||
"loading": "載入樣本中…",
|
||||
"empty": {
|
||||
"title": "尚無樣本",
|
||||
"hint": "新增第一個音訊樣本以開始"
|
||||
},
|
||||
"editing": "正在編輯轉錄",
|
||||
"placeholder": "輸入參考文字……",
|
||||
"saving": "儲存中…",
|
||||
"editTranscription": "編輯轉錄",
|
||||
"deleteSample": "刪除樣本",
|
||||
"addSample": "新增樣本",
|
||||
"note": "注意:單一 30 秒的樣本效果最佳。多個樣本可能會降低品質。未來版本中樣本可能可互換,並為同一聲音的不同風格加上標籤。",
|
||||
"deleteDialog": {
|
||||
"title": "刪除樣本",
|
||||
"description": "確定要刪除此音訊樣本嗎?此操作無法復原。",
|
||||
"deleting": "刪除中…"
|
||||
},
|
||||
"player": {
|
||||
"play": "播放樣本",
|
||||
"pause": "暫停樣本",
|
||||
"stop": "停止",
|
||||
"stopAria": "停止播放",
|
||||
"position": "樣本播放位置",
|
||||
"positionValue": "{{current}} / {{total}}"
|
||||
},
|
||||
"toast": {
|
||||
"invalidText": "文字無效",
|
||||
"invalidTextDescription": "參考文字不能為空。",
|
||||
"updated": "樣本已更新",
|
||||
"updatedDescription": "參考文字已成功更新。",
|
||||
"updateFailed": "更新失敗",
|
||||
"updateFailedFallback": "更新樣本失敗"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"card": {
|
||||
"noDescription": "無描述",
|
||||
"designed": "設計",
|
||||
"export": "匯出聲音檔案",
|
||||
"edit": "編輯聲音檔案",
|
||||
"delete": "刪除聲音檔案",
|
||||
"selectLabel": "{{name}},{{language}}。選擇用於生成的聲音。",
|
||||
"selectLabelSelected": "{{name}},{{language}}。已選為用於生成的聲音。"
|
||||
},
|
||||
"list": {
|
||||
"errorLoading": "載入聲音檔案時出錯:{{message}}",
|
||||
"empty": "尚無聲音檔案。建立您的第一個檔案以開始使用。",
|
||||
"createVoice": "建立聲音",
|
||||
"unsupportedNote": "目前模型僅可選擇支援的聲音檔案。"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "刪除聲音檔案",
|
||||
"body": "確定要刪除 \"{{name}}\" 嗎?此操作無法復原。",
|
||||
"deleting": "刪除中…"
|
||||
}
|
||||
},
|
||||
"effects": {
|
||||
"title": "效果",
|
||||
"newPreset": "新增預設集",
|
||||
"noDescription": "無描述",
|
||||
"placeholder": "選擇預設集或建立新的",
|
||||
"effectCount_one": "{{count}} 個效果",
|
||||
"effectCount_other": "{{count}} 個效果",
|
||||
"sections": {
|
||||
"builtin": "內建",
|
||||
"custom": "自訂",
|
||||
"new": "新增"
|
||||
},
|
||||
"badge": {
|
||||
"builtin": "內建"
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "未儲存的預設集",
|
||||
"hint": "在右側面板設定效果。"
|
||||
},
|
||||
"detail": {
|
||||
"newTitle": "新增預設集",
|
||||
"editTitle": "編輯預設集",
|
||||
"savePreset": "儲存預設集",
|
||||
"saveAsCustom": "另存為自訂",
|
||||
"saving": "儲存中…",
|
||||
"deleting": "刪除中…"
|
||||
},
|
||||
"fields": {
|
||||
"name": "名稱",
|
||||
"namePlaceholder": "我的預設集……",
|
||||
"description": "描述",
|
||||
"descriptionPlaceholder": "描述此預設集的作用……"
|
||||
},
|
||||
"preview": {
|
||||
"label": "預覽",
|
||||
"button": "預覽",
|
||||
"processing": "處理中…",
|
||||
"hint": "預覽僅將效果套用於乾淨版本,不會儲存。"
|
||||
},
|
||||
"saveAs": {
|
||||
"title": "另存為自訂預設集",
|
||||
"description": "基於目前的效果鏈建立新的自訂預設集。",
|
||||
"suggestedName": "{{name}}(副本)"
|
||||
},
|
||||
"toast": {
|
||||
"saved": "預設集已儲存",
|
||||
"createdDescription": "\"{{name}}\" 已建立。",
|
||||
"updated": "預設集已更新",
|
||||
"deleted": "預設集已刪除",
|
||||
"saveFailed": "儲存失敗",
|
||||
"deleteFailed": "刪除失敗",
|
||||
"previewFailed": "預覽失敗",
|
||||
"nameRequired": "請輸入名稱"
|
||||
},
|
||||
"chain": {
|
||||
"loadPreset": "載入預設集……",
|
||||
"addEffect": "新增效果……",
|
||||
"clear": "清除",
|
||||
"enable": "啟用",
|
||||
"disable": "停用",
|
||||
"remove": "移除"
|
||||
},
|
||||
"types": {
|
||||
"chorus": {
|
||||
"label": "合聲 / 鑲邊",
|
||||
"params": {
|
||||
"rate_hz": "LFO 速度(Hz)",
|
||||
"depth": "調變深度",
|
||||
"feedback": "回饋量",
|
||||
"centre_delay_ms": "中心延遲(毫秒)",
|
||||
"mix": "乾溼混合"
|
||||
}
|
||||
},
|
||||
"reverb": {
|
||||
"label": "殘響",
|
||||
"params": {
|
||||
"room_size": "空間大小",
|
||||
"damping": "高頻阻尼",
|
||||
"wet_level": "溼聲電平",
|
||||
"dry_level": "乾聲電平",
|
||||
"width": "立體聲寬度"
|
||||
}
|
||||
},
|
||||
"delay": {
|
||||
"label": "延遲",
|
||||
"params": {
|
||||
"delay_seconds": "延遲時間(秒)",
|
||||
"feedback": "回饋量",
|
||||
"mix": "乾溼混合"
|
||||
}
|
||||
},
|
||||
"compressor": {
|
||||
"label": "壓縮器",
|
||||
"params": {
|
||||
"threshold_db": "閾值(dB)",
|
||||
"ratio": "壓縮比",
|
||||
"attack_ms": "起音時間(毫秒)",
|
||||
"release_ms": "釋放時間(毫秒)"
|
||||
}
|
||||
},
|
||||
"gain": {
|
||||
"label": "增益",
|
||||
"params": {
|
||||
"gain_db": "增益(dB)"
|
||||
}
|
||||
},
|
||||
"highpass": {
|
||||
"label": "高通濾波器",
|
||||
"params": {
|
||||
"cutoff_frequency_hz": "截止頻率(Hz)"
|
||||
}
|
||||
},
|
||||
"lowpass": {
|
||||
"label": "低通濾波器",
|
||||
"params": {
|
||||
"cutoff_frequency_hz": "截止頻率(Hz)"
|
||||
}
|
||||
},
|
||||
"pitch_shift": {
|
||||
"label": "音高變換",
|
||||
"params": {
|
||||
"semitones": "半音移動"
|
||||
}
|
||||
}
|
||||
},
|
||||
"builtinPresets": {
|
||||
"Robotic": {
|
||||
"name": "機器人",
|
||||
"description": "金屬機器人嗓音(慢速 LFO 加高回饋的鑲邊效果)"
|
||||
},
|
||||
"Radio": {
|
||||
"name": "收音機",
|
||||
"description": "帶通濾波加輕度壓縮的 AM 收音機薄嗓音"
|
||||
},
|
||||
"Echo Chamber": {
|
||||
"name": "回音室",
|
||||
"description": "寬廣的殘響加尾隨回音"
|
||||
},
|
||||
"Deep Voice": {
|
||||
"name": "低沉嗓音",
|
||||
"description": "降低音高並增添溫暖"
|
||||
}
|
||||
}
|
||||
},
|
||||
"stories": {
|
||||
"title": "故事",
|
||||
"newStory": "新增故事",
|
||||
"loading": "載入故事中…",
|
||||
"empty": {
|
||||
"title": "尚無故事",
|
||||
"hint": "建立您的第一個故事以開始"
|
||||
},
|
||||
"row": {
|
||||
"itemCount_one": "{{count}} 項",
|
||||
"itemCount_other": "{{count}} 項",
|
||||
"ariaLabel": "故事 {{name}},{{count}} 項,{{updated}}",
|
||||
"actionsLabel": "{{name}} 的操作"
|
||||
},
|
||||
"createDialog": {
|
||||
"title": "新增故事",
|
||||
"description": "建立新故事以將您的語音生成整理成對話。",
|
||||
"action": "建立",
|
||||
"creating": "建立中…"
|
||||
},
|
||||
"editDialog": {
|
||||
"title": "編輯故事",
|
||||
"description": "更新故事名稱與描述。",
|
||||
"saving": "儲存中…"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "確定嗎?",
|
||||
"description": "這將永久刪除該故事及其所有項目。此操作無法復原。",
|
||||
"deleting": "刪除中…"
|
||||
},
|
||||
"fields": {
|
||||
"name": "名稱",
|
||||
"namePlaceholder": "我的故事",
|
||||
"descriptionLabel": "描述(選填)",
|
||||
"descriptionPlaceholder": "一段對話……"
|
||||
},
|
||||
"toast": {
|
||||
"nameRequired": "請輸入名稱",
|
||||
"nameRequiredDescription": "請輸入故事名稱",
|
||||
"created": "已建立故事",
|
||||
"createdDescription": "\"{{name}}\" 已建立",
|
||||
"createFailed": "建立故事失敗",
|
||||
"updateFailed": "更新故事失敗",
|
||||
"deleteFailed": "刪除故事失敗"
|
||||
}
|
||||
},
|
||||
"storyContent": {
|
||||
"selectStory": {
|
||||
"title": "選擇一個故事",
|
||||
"hint": "從清單中選擇故事以檢視其內容"
|
||||
},
|
||||
"loading": "載入故事中…",
|
||||
"notFound": {
|
||||
"title": "找不到故事",
|
||||
"hint": "無法載入所選故事"
|
||||
},
|
||||
"generatingCount_one": "生成 {{count}} 個音訊中",
|
||||
"generatingCount_other": "生成 {{count}} 個音訊中",
|
||||
"add": "新增",
|
||||
"searchPlaceholder": "依名稱或文字內容搜尋……",
|
||||
"searchNoMatches": "找不到相符的生成",
|
||||
"searchNoAvailable": "尚無可用的生成",
|
||||
"exportAudio": "匯出音訊",
|
||||
"empty": {
|
||||
"title": "此故事尚無項目",
|
||||
"hint": "使用下方輸入框生成語音以新增項目"
|
||||
},
|
||||
"itemActions": {
|
||||
"playFromHere": "從此處播放",
|
||||
"removeFromStory": "從故事中移除"
|
||||
},
|
||||
"toast": {
|
||||
"removeFailed": "移除項目失敗",
|
||||
"reorderFailed": "重新排序項目失敗",
|
||||
"exportFailed": "匯出音訊失敗",
|
||||
"addFailed": "新增生成失敗"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"actions": {
|
||||
"menu": "操作",
|
||||
"play": "播放",
|
||||
"exportAudio": "匯出音訊",
|
||||
"exportPackage": "匯出套件",
|
||||
"applyEffects": "套用效果",
|
||||
"regenerate": "重新生成"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "刪除生成",
|
||||
"body": "確定要刪除來自 \"{{name}}\" 的這次生成嗎?此操作無法復原。",
|
||||
"deleting": "刪除中…"
|
||||
},
|
||||
"clearFailedDialog": {
|
||||
"title": "清除失敗的生成",
|
||||
"body_one": "這將從歷史記錄中永久刪除 {{count}} 筆失敗的生成。此操作無法復原。",
|
||||
"body_other": "這將從歷史記錄中永久刪除 {{count}} 筆失敗的生成。此操作無法復原。",
|
||||
"clearing": "清除中…",
|
||||
"clearAll": "全部清除"
|
||||
},
|
||||
"importDialog": {
|
||||
"title": "匯入生成",
|
||||
"body": "從 \"{{name}}\" 匯入生成。這會將其加入您的歷史記錄。",
|
||||
"importing": "匯入中…",
|
||||
"action": "匯入"
|
||||
},
|
||||
"effectsDialog": {
|
||||
"title": "套用效果",
|
||||
"body": "設定要套用於此生成的後製效果。將會建立一個新版本。",
|
||||
"sourceLabel": "來源",
|
||||
"sourcePlaceholder": "選擇來源版本",
|
||||
"apply": "套用",
|
||||
"applying": "套用中…"
|
||||
}
|
||||
},
|
||||
"generation": {
|
||||
"placeholder": {
|
||||
"storyWithEffects": "為 \"{{name}}\" 生成語音… (輸入 / 使用效果)",
|
||||
"story": "為 \"{{name}}\" 生成語音…",
|
||||
"profile": "使用 {{name}} 生成語音…",
|
||||
"effectsHint": "輸入 / 使用效果,如 [笑聲]、[嘆息]…",
|
||||
"selectVoice": "請在上方選擇一個聲音檔案…"
|
||||
},
|
||||
"button": {
|
||||
"generate": "生成語音",
|
||||
"generating": "生成中…",
|
||||
"selectFirst": "請先選擇聲音檔案"
|
||||
},
|
||||
"instruct": {
|
||||
"show": "顯示傳達指示",
|
||||
"hide": "隱藏傳達指示",
|
||||
"tooltip": "傳達指示 (語氣、情感、節奏)",
|
||||
"placeholder": "傳達指示——例如:溫柔緩慢地說、威嚴清晰…"
|
||||
},
|
||||
"voiceSelector": {
|
||||
"placeholder": "選擇聲音…"
|
||||
},
|
||||
"effects": {
|
||||
"none": "無效果",
|
||||
"profileDefault": "檔案預設"
|
||||
}
|
||||
},
|
||||
"main": {
|
||||
"importVoice": "匯入聲音",
|
||||
"createVoice": "建立聲音",
|
||||
"import": {
|
||||
"invalidTitle": "檔案類型無效",
|
||||
"invalidDescription": "請選擇有效的 .voicebox.zip 檔案",
|
||||
"successTitle": "聲音已匯入",
|
||||
"successDescription": "成功匯入聲音檔案",
|
||||
"failedTitle": "匯入聲音檔案失敗",
|
||||
"dialogTitle": "匯入聲音檔案",
|
||||
"dialogDescription": "從 \"{{name}}\" 匯入聲音檔案。這將建立包含所有樣本的新聲音檔案。",
|
||||
"importing": "匯入中…",
|
||||
"action": "匯入"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"tabs": {
|
||||
"general": "一般",
|
||||
"generation": "生成",
|
||||
"gpu": "GPU",
|
||||
"logs": "日誌",
|
||||
"changelog": "更新日誌",
|
||||
"about": "關於"
|
||||
},
|
||||
"language": {
|
||||
"label": "語言",
|
||||
"description": "選擇 Voicebox 的顯示語言。"
|
||||
},
|
||||
"general": {
|
||||
"docs": { "title": "閱讀文件" },
|
||||
"discord": { "title": "加入 Discord", "subtitle": "取得協助與分享聲音" },
|
||||
"serverUrl": {
|
||||
"title": "伺服器 URL",
|
||||
"description": "Voicebox 後端伺服器的位址。",
|
||||
"invalidUrl": "請輸入有效的 URL",
|
||||
"updatedTitle": "伺服器 URL 已更新",
|
||||
"updatedDescription": "已連線至 {{url}}"
|
||||
},
|
||||
"keepServerRunning": {
|
||||
"title": "關閉應用程式時保持伺服器執行",
|
||||
"description": "關閉應用程式後,伺服器將繼續在背景執行。",
|
||||
"failedTitle": "更新設定失敗",
|
||||
"failedDescription": "無法將設定同步到後端。",
|
||||
"updatedTitle": "設定已更新",
|
||||
"runningDescription": "關閉應用程式時伺服器將繼續執行",
|
||||
"stoppedDescription": "關閉應用程式時伺服器將停止"
|
||||
},
|
||||
"networkAccess": {
|
||||
"title": "允許網路存取",
|
||||
"description": "讓網路上的其他裝置可存取伺服器。變更後請重新啟動應用程式。",
|
||||
"updatedTitle": "設定已更新",
|
||||
"enabled": "已啟用網路存取。重新啟動應用程式以套用。",
|
||||
"disabled": "已停用網路存取。重新啟動應用程式以套用。"
|
||||
},
|
||||
"connection": {
|
||||
"connecting": "連線中",
|
||||
"offline": "離線",
|
||||
"online": "線上"
|
||||
},
|
||||
"updates": {
|
||||
"title": "應用程式更新",
|
||||
"devSuffix": " (開發版)",
|
||||
"devMode": {
|
||||
"title": "開發模式",
|
||||
"description": "開發模式下已停用自動更新。"
|
||||
},
|
||||
"check": {
|
||||
"title": "檢查更新",
|
||||
"available": "版本 {{version}} 可用",
|
||||
"checking": "檢查中…",
|
||||
"upToDate": "已是最新版本",
|
||||
"button": "檢查"
|
||||
},
|
||||
"error": "更新錯誤",
|
||||
"download": {
|
||||
"title": "更新到 {{version}}",
|
||||
"description": "下載並安裝最新版本。",
|
||||
"button": "下載"
|
||||
},
|
||||
"downloading": "下載更新中…",
|
||||
"ready": {
|
||||
"title": "更新已準備就緒",
|
||||
"description": "版本 {{version}} 已下載。重新啟動以完成。",
|
||||
"button": "立即重新啟動"
|
||||
}
|
||||
},
|
||||
"api": {
|
||||
"title": "API 存取",
|
||||
"description": "透過 <code>{{url}}</code> 的 REST API 將 Voicebox 整合到您的工作流程中",
|
||||
"viewReference": "檢視完整的 API 參考",
|
||||
"endpoints": {
|
||||
"generate": "生成語音",
|
||||
"health": "伺服器狀態",
|
||||
"profiles": "聲音清單",
|
||||
"history": "歷史生成"
|
||||
}
|
||||
}
|
||||
},
|
||||
"generation": {
|
||||
"title": "生成",
|
||||
"description": "長文字生成的控制項。這些設定適用於所有引擎。",
|
||||
"chunkLimit": {
|
||||
"title": "自動分塊上限",
|
||||
"description": "長文字會在句子邊界處分塊。較低的值可以提升長輸出的品質。",
|
||||
"value": "{{chars}} 字元"
|
||||
},
|
||||
"crossfade": {
|
||||
"title": "區塊間淡入淡出",
|
||||
"description": "在區塊之間混合音訊以平滑過渡。設為 0 表示硬切換。",
|
||||
"cut": "切換",
|
||||
"ms": "{{ms}} 毫秒"
|
||||
},
|
||||
"normalize": {
|
||||
"title": "音訊標準化",
|
||||
"description": "將輸出音量調整到所有生成結果一致的水準。"
|
||||
},
|
||||
"autoplay": {
|
||||
"title": "生成後自動播放",
|
||||
"description": "生成完成後自動播放音訊。"
|
||||
},
|
||||
"folder": {
|
||||
"title": "生成資料夾",
|
||||
"description": "生成的音訊檔案在磁碟上的儲存位置。",
|
||||
"open": "開啟"
|
||||
}
|
||||
},
|
||||
"gpu": {
|
||||
"cpuOnly": "僅 CPU",
|
||||
"vramUsed": "{{mb}} MB 顯示記憶體",
|
||||
"noAcceleration": "未偵測到 GPU 加速",
|
||||
"active": "啟用中",
|
||||
"cuda": {
|
||||
"title": "CUDA 後端",
|
||||
"description": "透過可下載的 CUDA 後端實現 NVIDIA GPU 加速。",
|
||||
"downloading": "下載 CUDA 後端中…",
|
||||
"downloadingShort": "下載中…",
|
||||
"updating": "更新中…"
|
||||
},
|
||||
"restart": {
|
||||
"ready": "伺服器重新啟動成功",
|
||||
"waiting": "重新啟動伺服器中…",
|
||||
"stopping": "停止伺服器中…"
|
||||
},
|
||||
"download": {
|
||||
"title": "下載 CUDA 後端",
|
||||
"description": "約 2.4 GB 下載。需要支援 CUDA 的 NVIDIA GPU。",
|
||||
"button": "下載"
|
||||
},
|
||||
"switchToCuda": {
|
||||
"title": "切換到 CUDA 後端",
|
||||
"description": "CUDA 後端已下載完成。重新啟動以啟用。",
|
||||
"button": "重新啟動"
|
||||
},
|
||||
"switchToCpu": {
|
||||
"title": "切換到 CPU 後端",
|
||||
"description": "停用 GPU 加速。稍後可以重新下載 CUDA。",
|
||||
"button": "切換"
|
||||
},
|
||||
"remove": {
|
||||
"title": "移除 CUDA 後端",
|
||||
"description": "刪除已下載的 CUDA 二進位檔以釋放磁碟空間。",
|
||||
"button": "移除"
|
||||
},
|
||||
"errors": {
|
||||
"downloadFailed": "下載失敗",
|
||||
"downloadStart": "啟動下載失敗",
|
||||
"restartFailed": "重新啟動失敗",
|
||||
"switchCpu": "切換到 CPU 失敗",
|
||||
"deleteCuda": "刪除 CUDA 後端失敗"
|
||||
},
|
||||
"footer": "Voicebox 會自動偵測並使用系統上可用的最佳 GPU。在 Apple Silicon Mac 上,MLX 後端透過 Metal Performance Shaders (MPS) 在神經引擎與 GPU 上原生執行,無需額外設定。在配備 NVIDIA GPU 的 Windows 與 Linux 上,可以下載選用的 CUDA 後端以取得硬體加速推論。AMD ROCm、Intel XPU 與 DirectML 也透過 PyTorch 獲得支援。未偵測到 GPU 時,Voicebox 會退回到 CPU——所有引擎仍可運作,只是速度較慢。"
|
||||
},
|
||||
"logs": {
|
||||
"title": "伺服器日誌",
|
||||
"lineCount_one": "{{count}} 行",
|
||||
"lineCount_other": "{{count}} 行",
|
||||
"scrollToBottom": "捲動到底部",
|
||||
"clear": "清除",
|
||||
"empty": "尚無日誌輸出。",
|
||||
"devHint": "僅當應用程式管理伺服器程序(正式版建置)時才會擷取伺服器日誌。"
|
||||
},
|
||||
"changelog": {
|
||||
"devBadge": "開發版",
|
||||
"showLess": "收合",
|
||||
"showMore": "展開"
|
||||
},
|
||||
"about": {
|
||||
"tagline": "開源語音合成工作室。複製聲音、生成語音、套用效果、打造語音驅動的應用程式——全部在您的本機執行。",
|
||||
"createdBy": "作者",
|
||||
"buyCoffee": "請我喝杯咖啡",
|
||||
"license": "採用 <link>MIT</link> 授權"
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"title": "模型",
|
||||
"subtitle": "下載與管理用於語音生成和轉錄的 AI 模型",
|
||||
"defaultName": "模型",
|
||||
"unknownSize": "未知大小",
|
||||
"sections": {
|
||||
"voiceGeneration": "語音生成",
|
||||
"transcription": "語音轉錄"
|
||||
},
|
||||
"status": {
|
||||
"loaded": "已載入"
|
||||
},
|
||||
"storage": {
|
||||
"location": "儲存位置",
|
||||
"open": "開啟",
|
||||
"change": "變更",
|
||||
"migrating": "遷移中…",
|
||||
"reset": "重設",
|
||||
"pickerTitle": "選擇模型儲存資料夾"
|
||||
},
|
||||
"progress": {
|
||||
"connecting": "連線中…",
|
||||
"connectingHf": "連線至 HuggingFace 中…"
|
||||
},
|
||||
"problems": {
|
||||
"title": "問題",
|
||||
"clearAll": "全部清除",
|
||||
"noDetails": "沒有可用的錯誤詳細資訊。請重試下載。",
|
||||
"startedAt": "開始於 {{time}}"
|
||||
},
|
||||
"detail": {
|
||||
"loadingInfo": "載入模型資訊中…",
|
||||
"byAuthor": "作者 {{author}}",
|
||||
"downloads": "下載次數",
|
||||
"likes": "喜愛數",
|
||||
"license": "授權",
|
||||
"languagesCount": "支援 {{count}} 種語言",
|
||||
"languagesList": "語言:{{list}}",
|
||||
"onDisk": "磁碟佔用 {{size}}"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下載",
|
||||
"retry": "重試下載",
|
||||
"unload": "卸載",
|
||||
"unloading": "卸載中…",
|
||||
"unloadFirst": "刪除前請先卸載模型",
|
||||
"deleteModel": "刪除模型"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "刪除模型",
|
||||
"body": "確定要刪除 <strong>{{name}}</strong> 嗎?",
|
||||
"sizeNote": "這將釋放 {{size}} 磁碟空間。若要再次使用該模型,必須重新下載。",
|
||||
"deleting": "刪除中…"
|
||||
},
|
||||
"migrateDialog": {
|
||||
"title": "將模型移動到新位置?",
|
||||
"description": "在模型遷移到新資料夾期間,伺服器將會關閉。遷移完成後會自動重新啟動。",
|
||||
"action": "移動模型",
|
||||
"preparing": "準備中…",
|
||||
"restartingServer": "重新啟動伺服器中…"
|
||||
},
|
||||
"migrate": {
|
||||
"title": "移動模型中",
|
||||
"offline": "模型遷移期間伺服器處於離線狀態。"
|
||||
},
|
||||
"toast": {
|
||||
"downloadFailed": "下載失敗",
|
||||
"cancelFailed": "取消失敗",
|
||||
"cancelFailedDescription": "無法取消下載任務。",
|
||||
"deleted": "模型已刪除",
|
||||
"deletedDescription": "{{name}} 已成功刪除。",
|
||||
"deleteFailed": "刪除失敗",
|
||||
"unloaded": "模型已卸載",
|
||||
"unloadedDescription": "{{name}} 已從記憶體中卸載。",
|
||||
"unloadFailed": "卸載失敗",
|
||||
"openFolderFailed": "開啟模型資料夾失敗",
|
||||
"pickerFailed": "開啟資料夾選擇器失敗",
|
||||
"resetToDefault": "已重設至預設位置。重新啟動伺服器中…",
|
||||
"noModelsToMigrate": "沒有可遷移的模型",
|
||||
"noModelsToMigrateDescription": "變更儲存位置前請先下載至少一個模型。",
|
||||
"migrated": "模型已成功移動",
|
||||
"migrationFailed": "遷移失敗",
|
||||
"migrationFailedGeneric": "遷移模型失敗",
|
||||
"migrationConnectionLost": "遷移期間連線中斷"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,6 +234,12 @@ 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',
|
||||
@@ -270,6 +276,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);
|
||||
@@ -378,7 +390,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 }),
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useGeneration } from '@/lib/hooks/useGeneration';
|
||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
|
||||
const generationSchema = z.object({
|
||||
text: z.string().min(1, '').max(50000),
|
||||
@@ -45,6 +46,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
||||
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
|
||||
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
|
||||
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
|
||||
const selectedEngine = useUIStore((state) => state.selectedEngine);
|
||||
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
|
||||
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
||||
|
||||
@@ -62,7 +64,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
||||
seed: undefined,
|
||||
modelSize: '1.7B',
|
||||
instruct: '',
|
||||
engine: 'qwen',
|
||||
engine: (selectedEngine as GenerationFormValues['engine']) || 'qwen',
|
||||
...options.defaultValues,
|
||||
},
|
||||
});
|
||||
@@ -134,7 +136,9 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
|
||||
|
||||
const hasModelSizes =
|
||||
engine === 'qwen' || engine === 'qwen_custom_voice' || engine === 'tada';
|
||||
const supportsInstruct = engine === 'qwen' || engine === 'qwen_custom_voice';
|
||||
// Only Qwen CustomVoice actually honors the instruct kwarg at model level.
|
||||
// Base Qwen3-TTS accepts the kwarg but ignores it.
|
||||
const supportsInstruct = engine === 'qwen_custom_voice';
|
||||
const effectsChain = options.getEffectsChain?.();
|
||||
// This now returns immediately with status="generating"
|
||||
const result = await generation.mutateAsync({
|
||||
|
||||
@@ -29,6 +29,17 @@ export function useDeleteGeneration() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useClearFailedGenerations() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => apiClient.clearFailedGenerations(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useExportGeneration() {
|
||||
const platform = usePlatform();
|
||||
|
||||
|
||||
@@ -131,7 +131,8 @@ export function useModelDownloadToast({
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
duration: progress.status === 'complete' || progress.status === 'error' ? 5000 : Infinity,
|
||||
duration:
|
||||
progress.status === 'complete' || progress.status === 'error' ? 5000 : Infinity,
|
||||
});
|
||||
|
||||
// Close connection and dismiss toast on completion or error
|
||||
|
||||
@@ -26,8 +26,24 @@ export function useSystemAudioCapture({
|
||||
|
||||
// Check if system audio capture is supported
|
||||
useEffect(() => {
|
||||
const supported = platform.audio.isSystemAudioSupported();
|
||||
setIsSupported(supported);
|
||||
let isActive = true;
|
||||
|
||||
void platform.audio
|
||||
.isSystemAudioSupported()
|
||||
.then((supported) => {
|
||||
if (isActive) {
|
||||
setIsSupported(supported);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (isActive) {
|
||||
setIsSupported(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [platform]);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { formatDistance } from 'date-fns';
|
||||
import { ja, zhCN, zhTW } from 'date-fns/locale';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
@@ -6,15 +8,25 @@ export function formatDuration(seconds: number): string {
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getDateLocale() {
|
||||
switch (i18n.language) {
|
||||
case 'ja':
|
||||
return ja;
|
||||
case 'zh-CN':
|
||||
return zhCN;
|
||||
case 'zh-TW':
|
||||
return zhTW;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDate(date: string | Date): string {
|
||||
// Parse the date string - if it doesn't have timezone info, treat it as UTC
|
||||
let dateObj: Date;
|
||||
if (typeof date === 'string') {
|
||||
// If the string doesn't end with Z or have timezone offset, assume it's UTC
|
||||
const dateStr = date.trim();
|
||||
if (!dateStr.includes('Z') && !dateStr.match(/[+-]\d{2}:\d{2}$/)) {
|
||||
// No timezone info, treat as UTC
|
||||
dateObj = new Date(dateStr + 'Z');
|
||||
dateObj = new Date(`${dateStr}Z`);
|
||||
} else {
|
||||
dateObj = new Date(dateStr);
|
||||
}
|
||||
@@ -22,7 +34,10 @@ export function formatDate(date: string | Date): string {
|
||||
dateObj = date;
|
||||
}
|
||||
|
||||
return formatDistance(dateObj, new Date(), { addSuffix: true }).replace(/^about /i, '');
|
||||
return formatDistance(dateObj, new Date(), {
|
||||
addSuffix: true,
|
||||
locale: getDateLocale(),
|
||||
}).replace(/^about /i, '');
|
||||
}
|
||||
|
||||
const ENGINE_DISPLAY_NAMES: Record<string, string> = {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './i18n';
|
||||
import './index.css';
|
||||
import { queryClient } from './lib/queryClient';
|
||||
|
||||
|
||||
@@ -9,11 +9,7 @@ export interface PlatformProviderProps {
|
||||
}
|
||||
|
||||
export function PlatformProvider({ platform, children }: PlatformProviderProps) {
|
||||
return (
|
||||
<PlatformContext.Provider value={platform}>
|
||||
{children}
|
||||
</PlatformContext.Provider>
|
||||
);
|
||||
return <PlatformContext.Provider value={platform}>{children}</PlatformContext.Provider>;
|
||||
}
|
||||
|
||||
export function usePlatform(): Platform {
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface AudioDevice {
|
||||
}
|
||||
|
||||
export interface PlatformAudio {
|
||||
isSystemAudioSupported(): boolean;
|
||||
isSystemAudioSupported(): Promise<boolean>;
|
||||
startSystemAudioCapture(maxDurationSecs: number): Promise<void>;
|
||||
stopSystemAudioCapture(): Promise<Blob>;
|
||||
listOutputDevices(): Promise<AudioDevice[]>;
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
# Backend package
|
||||
|
||||
__version__ = "0.3.1"
|
||||
__version__ = "0.4.5"
|
||||
|
||||
+31
-3
@@ -135,7 +135,7 @@ def _mount_frontend(application: FastAPI) -> None:
|
||||
async def serve_spa(full_path: str):
|
||||
file_path = (frontend_dir / full_path).resolve()
|
||||
# Guard against path traversal — only serve files inside frontend_dir
|
||||
if full_path and file_path.is_file() and str(file_path).startswith(str(frontend_dir)):
|
||||
if full_path and file_path.is_file() and file_path.is_relative_to(frontend_dir):
|
||||
return FileResponse(file_path)
|
||||
return FileResponse(frontend_dir / "index.html", media_type="text/html")
|
||||
|
||||
@@ -146,15 +146,36 @@ def _get_gpu_status() -> str:
|
||||
"""Return a human-readable string describing GPU availability."""
|
||||
backend_type = get_backend_type()
|
||||
if torch.cuda.is_available():
|
||||
from .backends.base import check_cuda_compatibility
|
||||
|
||||
device_name = torch.cuda.get_device_name(0)
|
||||
compatible, _warning = check_cuda_compatibility()
|
||||
is_rocm = hasattr(torch.version, "hip") and torch.version.hip is not None
|
||||
if is_rocm:
|
||||
return f"ROCm ({device_name})"
|
||||
return f"CUDA ({device_name})"
|
||||
label = f"ROCm ({device_name})"
|
||||
else:
|
||||
label = f"CUDA ({device_name})"
|
||||
if not compatible:
|
||||
label += " [UNSUPPORTED - see logs]"
|
||||
return label
|
||||
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
return "MPS (Apple Silicon)"
|
||||
elif backend_type == "mlx":
|
||||
return "Metal (Apple Silicon via MLX)"
|
||||
|
||||
# Intel XPU (Arc / Data Center) via IPEX
|
||||
try:
|
||||
import intel_extension_for_pytorch # noqa: F401
|
||||
|
||||
if hasattr(torch, "xpu") and torch.xpu.is_available():
|
||||
try:
|
||||
xpu_name = torch.xpu.get_device_name(0)
|
||||
except Exception:
|
||||
xpu_name = "Intel GPU"
|
||||
return f"XPU ({xpu_name})"
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return "None (CPU only)"
|
||||
|
||||
|
||||
@@ -216,6 +237,13 @@ def _register_lifecycle(application: FastAPI) -> None:
|
||||
logger.info("Backend: %s", backend_type.upper())
|
||||
logger.info("GPU: %s", _get_gpu_status())
|
||||
|
||||
# Warn if GPU architecture is not supported by this PyTorch build
|
||||
from .backends.base import check_cuda_compatibility
|
||||
|
||||
_compatible, _cuda_warning = check_cuda_compatibility()
|
||||
if not _compatible:
|
||||
logger.warning("GPU COMPATIBILITY: %s", _cuda_warning)
|
||||
|
||||
from .services.cuda import check_and_update_cuda_binary
|
||||
|
||||
create_background_task(check_and_update_cuda_binary())
|
||||
|
||||
@@ -5,6 +5,13 @@ Provides a unified interface for MLX and PyTorch backends,
|
||||
and a model config registry that eliminates per-engine dispatch maps.
|
||||
"""
|
||||
|
||||
# Install HF compatibility patches before any backend imports transformers /
|
||||
# huggingface_hub. The module runs ``patch_transformers_mistral_regex`` at
|
||||
# import time, which wraps transformers' tokenizer load against the
|
||||
# unconditional HuggingFace metadata call that otherwise raises on
|
||||
# HF_HUB_OFFLINE=1 and on network failures.
|
||||
from ..utils import hf_offline_patch # noqa: F401
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol, Optional, Tuple, List
|
||||
@@ -177,7 +184,7 @@ def _get_qwen_model_configs() -> list[ModelConfig]:
|
||||
backend_type = get_backend_type()
|
||||
if backend_type == "mlx":
|
||||
repo_1_7b = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16"
|
||||
repo_0_6b = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16" # 0.6B not available in MLX, falls back
|
||||
repo_0_6b = "mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16"
|
||||
else:
|
||||
repo_1_7b = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
||||
repo_0_6b = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
|
||||
|
||||
@@ -126,6 +126,75 @@ def get_torch_device(
|
||||
return "cpu"
|
||||
|
||||
|
||||
def check_cuda_compatibility() -> tuple[bool, str | None]:
|
||||
"""Check if the installed PyTorch supports the current GPU's compute capability.
|
||||
|
||||
Returns:
|
||||
(compatible, warning_message) — compatible is True if OK or no CUDA GPU,
|
||||
warning_message is a human-readable string if there's a problem.
|
||||
"""
|
||||
import torch
|
||||
|
||||
if not torch.cuda.is_available():
|
||||
return True, None
|
||||
|
||||
major, minor = torch.cuda.get_device_capability(0)
|
||||
capability = f"{major}.{minor}"
|
||||
device_name = torch.cuda.get_device_name(0)
|
||||
sm_tag = f"sm_{major}{minor}"
|
||||
|
||||
# torch.cuda._get_arch_list() returns the SM architectures this build
|
||||
# was compiled for (e.g. ["sm_50", "sm_60", ..., "sm_90"]).
|
||||
try:
|
||||
arch_list = torch.cuda._get_arch_list()
|
||||
if arch_list:
|
||||
# Check for both sm_XX and compute_XX (JIT-compiled) entries
|
||||
compute_tag = f"compute_{major}{minor}"
|
||||
if sm_tag not in arch_list and compute_tag not in arch_list:
|
||||
return False, (
|
||||
f"{device_name} (compute capability {capability} / {sm_tag}) "
|
||||
f"is not supported by this PyTorch build. "
|
||||
f"Supported architectures: {', '.join(arch_list)}. "
|
||||
f"Install PyTorch nightly (cu128) for newer GPU support: "
|
||||
f"pip install torch --index-url https://download.pytorch.org/whl/nightly/cu128"
|
||||
)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def empty_device_cache(device: str) -> None:
|
||||
"""
|
||||
Free cached memory on the given device (CUDA or XPU).
|
||||
|
||||
Backends should call this after unloading models so VRAM is returned
|
||||
to the OS.
|
||||
"""
|
||||
import torch
|
||||
|
||||
if device == "cuda" and torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
elif device == "xpu" and hasattr(torch, "xpu"):
|
||||
torch.xpu.empty_cache()
|
||||
|
||||
|
||||
def manual_seed(seed: int, device: str) -> None:
|
||||
"""
|
||||
Set the random seed on both CPU and the active accelerator.
|
||||
|
||||
Covers CUDA and Intel XPU so that generation is reproducible
|
||||
regardless of which GPU backend is in use.
|
||||
"""
|
||||
import torch
|
||||
|
||||
torch.manual_seed(seed)
|
||||
if device == "cuda" and torch.cuda.is_available():
|
||||
torch.cuda.manual_seed(seed)
|
||||
elif device == "xpu" and hasattr(torch, "xpu"):
|
||||
torch.xpu.manual_seed(seed)
|
||||
|
||||
|
||||
async def combine_voice_prompts(
|
||||
audio_paths: List[str],
|
||||
reference_texts: List[str],
|
||||
|
||||
@@ -18,6 +18,8 @@ from . import TTSBackend
|
||||
from .base import (
|
||||
is_model_cached,
|
||||
get_torch_device,
|
||||
empty_device_cache,
|
||||
manual_seed,
|
||||
combine_voice_prompts as _combine_voice_prompts,
|
||||
model_load_progress,
|
||||
patch_chatterbox_f32,
|
||||
@@ -48,7 +50,7 @@ class ChatterboxTTSBackend:
|
||||
self._model_load_lock = asyncio.Lock()
|
||||
|
||||
def _get_device(self) -> str:
|
||||
return get_torch_device(force_cpu_on_mac=True)
|
||||
return get_torch_device(force_cpu_on_mac=True, allow_xpu=True)
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
return self.model is not None
|
||||
@@ -117,10 +119,7 @@ class ChatterboxTTSBackend:
|
||||
del self.model
|
||||
self.model = None
|
||||
self._device = None
|
||||
if device == "cuda":
|
||||
import torch
|
||||
|
||||
torch.cuda.empty_cache()
|
||||
empty_device_cache(device)
|
||||
logger.info("Chatterbox unloaded")
|
||||
|
||||
async def create_voice_prompt(
|
||||
@@ -200,7 +199,7 @@ class ChatterboxTTSBackend:
|
||||
import torch
|
||||
|
||||
if seed is not None:
|
||||
torch.manual_seed(seed)
|
||||
manual_seed(seed, self._device)
|
||||
|
||||
logger.info(f"[Chatterbox] Generating: lang={language}")
|
||||
|
||||
@@ -220,10 +219,7 @@ class ChatterboxTTSBackend:
|
||||
else:
|
||||
audio = np.asarray(wav, dtype=np.float32)
|
||||
|
||||
sample_rate = (
|
||||
getattr(self.model, "sr", None)
|
||||
or getattr(self.model, "sample_rate", 24000)
|
||||
)
|
||||
sample_rate = getattr(self.model, "sr", None) or getattr(self.model, "sample_rate", 24000)
|
||||
|
||||
return audio, sample_rate
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ from . import TTSBackend
|
||||
from .base import (
|
||||
is_model_cached,
|
||||
get_torch_device,
|
||||
empty_device_cache,
|
||||
manual_seed,
|
||||
combine_voice_prompts as _combine_voice_prompts,
|
||||
model_load_progress,
|
||||
patch_chatterbox_f32,
|
||||
@@ -48,7 +50,7 @@ class ChatterboxTurboTTSBackend:
|
||||
self._model_load_lock = asyncio.Lock()
|
||||
|
||||
def _get_device(self) -> str:
|
||||
return get_torch_device(force_cpu_on_mac=True)
|
||||
return get_torch_device(force_cpu_on_mac=True, allow_xpu=True)
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
return self.model is not None
|
||||
@@ -116,10 +118,7 @@ class ChatterboxTurboTTSBackend:
|
||||
del self.model
|
||||
self.model = None
|
||||
self._device = None
|
||||
if device == "cuda":
|
||||
import torch
|
||||
|
||||
torch.cuda.empty_cache()
|
||||
empty_device_cache(device)
|
||||
logger.info("Chatterbox Turbo unloaded")
|
||||
|
||||
async def create_voice_prompt(
|
||||
@@ -181,7 +180,7 @@ class ChatterboxTurboTTSBackend:
|
||||
import torch
|
||||
|
||||
if seed is not None:
|
||||
torch.manual_seed(seed)
|
||||
manual_seed(seed, self._device)
|
||||
|
||||
logger.info("[Chatterbox Turbo] Generating (English)")
|
||||
|
||||
@@ -200,10 +199,7 @@ class ChatterboxTurboTTSBackend:
|
||||
else:
|
||||
audio = np.asarray(wav, dtype=np.float32)
|
||||
|
||||
sample_rate = (
|
||||
getattr(self.model, "sr", None)
|
||||
or getattr(self.model, "sample_rate", 24000)
|
||||
)
|
||||
sample_rate = getattr(self.model, "sr", None) or getattr(self.model, "sample_rate", 24000)
|
||||
|
||||
return audio, sample_rate
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ from . import TTSBackend
|
||||
from .base import (
|
||||
is_model_cached,
|
||||
get_torch_device,
|
||||
empty_device_cache,
|
||||
manual_seed,
|
||||
combine_voice_prompts as _combine_voice_prompts,
|
||||
model_load_progress,
|
||||
)
|
||||
@@ -66,7 +68,7 @@ class HumeTadaBackend:
|
||||
def _get_device(self) -> str:
|
||||
# Force CPU on macOS — MPS has issues with flow matching
|
||||
# and large vocab lm_head (>65536 output channels)
|
||||
return get_torch_device(force_cpu_on_mac=True)
|
||||
return get_torch_device(force_cpu_on_mac=True, allow_xpu=True)
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
return self.model is not None
|
||||
@@ -105,6 +107,7 @@ class HumeTadaBackend:
|
||||
# package. The real package pulls in onnx/tensorboard/matplotlib via
|
||||
# descript-audiotools, so we use a lightweight shim instead.
|
||||
from ..utils.dac_shim import install_dac_shim
|
||||
|
||||
install_dac_shim()
|
||||
|
||||
import torch
|
||||
@@ -142,9 +145,12 @@ class HumeTadaBackend:
|
||||
allow_patterns=["tokenizer*", "special_tokens*"],
|
||||
)
|
||||
|
||||
# Determine dtype — use bf16 on CUDA for ~50% memory savings
|
||||
# Determine dtype — use bf16 on CUDA/XPU for ~50% memory savings
|
||||
if device == "cuda" and torch.cuda.is_bf16_supported():
|
||||
model_dtype = torch.bfloat16
|
||||
elif device == "xpu":
|
||||
# Intel Arc (Alchemist+) supports bf16 natively
|
||||
model_dtype = torch.bfloat16
|
||||
else:
|
||||
model_dtype = torch.float32
|
||||
|
||||
@@ -153,14 +159,14 @@ class HumeTadaBackend:
|
||||
# This avoids monkey-patching AutoTokenizer.from_pretrained
|
||||
# which corrupts the classmethod descriptor for other engines.
|
||||
from tada.modules.aligner import AlignerConfig
|
||||
|
||||
AlignerConfig.tokenizer_name = tokenizer_path
|
||||
|
||||
# Load encoder (only needed for voice prompt encoding)
|
||||
from tada.modules.encoder import Encoder
|
||||
|
||||
logger.info("Loading TADA encoder...")
|
||||
self.encoder = Encoder.from_pretrained(
|
||||
TADA_CODEC_REPO, subfolder="encoder"
|
||||
).to(device)
|
||||
self.encoder = Encoder.from_pretrained(TADA_CODEC_REPO, subfolder="encoder").to(device)
|
||||
self.encoder.eval()
|
||||
|
||||
# Load the causal LM (includes decoder for wav generation).
|
||||
@@ -169,12 +175,11 @@ class HumeTadaBackend:
|
||||
# which hits the gated repo. Pre-load the config from HF,
|
||||
# inject the local tokenizer path, then pass it in.
|
||||
from tada.modules.tada import TadaForCausalLM, TadaConfig
|
||||
|
||||
logger.info(f"Loading TADA {model_size} model...")
|
||||
config = TadaConfig.from_pretrained(repo)
|
||||
config.tokenizer_name = tokenizer_path
|
||||
self.model = TadaForCausalLM.from_pretrained(
|
||||
repo, config=config, torch_dtype=model_dtype
|
||||
).to(device)
|
||||
self.model = TadaForCausalLM.from_pretrained(repo, config=config, torch_dtype=model_dtype).to(device)
|
||||
self.model.eval()
|
||||
|
||||
logger.info(f"HumeAI TADA {model_size} loaded successfully on {device}")
|
||||
@@ -188,11 +193,11 @@ class HumeTadaBackend:
|
||||
del self.encoder
|
||||
self.encoder = None
|
||||
|
||||
device = self._device
|
||||
self._device = None
|
||||
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
if device:
|
||||
empty_device_cache(device)
|
||||
|
||||
logger.info("HumeAI TADA unloaded")
|
||||
|
||||
@@ -213,9 +218,7 @@ class HumeTadaBackend:
|
||||
"""
|
||||
await self.load_model(self.model_size)
|
||||
|
||||
cache_key = (
|
||||
"tada_" + get_cache_key(audio_path, reference_text)
|
||||
) if use_cache else None
|
||||
cache_key = ("tada_" + get_cache_key(audio_path, reference_text)) if use_cache else None
|
||||
|
||||
if cache_key:
|
||||
cached = get_cached_voice_prompt(cache_key)
|
||||
@@ -239,9 +242,7 @@ class HumeTadaBackend:
|
||||
|
||||
# Encode with forced alignment
|
||||
text_arg = [reference_text] if reference_text else None
|
||||
prompt = self.encoder(
|
||||
audio, text=text_arg, sample_rate=sr
|
||||
)
|
||||
prompt = self.encoder(audio, text=text_arg, sample_rate=sr)
|
||||
|
||||
# Serialize EncoderOutput to a dict of CPU tensors for caching
|
||||
prompt_dict = {}
|
||||
@@ -299,9 +300,7 @@ class HumeTadaBackend:
|
||||
from tada.modules.encoder import EncoderOutput
|
||||
|
||||
if seed is not None:
|
||||
torch.manual_seed(seed)
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.manual_seed(seed)
|
||||
manual_seed(seed, self._device)
|
||||
|
||||
device = self._device
|
||||
|
||||
|
||||
@@ -12,7 +12,14 @@ from typing import Optional, Tuple
|
||||
import numpy as np
|
||||
|
||||
from . import TTSBackend
|
||||
from .base import is_model_cached, get_torch_device, combine_voice_prompts as _combine_voice_prompts, model_load_progress
|
||||
from .base import (
|
||||
is_model_cached,
|
||||
get_torch_device,
|
||||
empty_device_cache,
|
||||
manual_seed,
|
||||
combine_voice_prompts as _combine_voice_prompts,
|
||||
model_load_progress,
|
||||
)
|
||||
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -30,7 +37,7 @@ class LuxTTSBackend:
|
||||
self._device = None
|
||||
|
||||
def _get_device(self) -> str:
|
||||
return get_torch_device(allow_mps=True)
|
||||
return get_torch_device(allow_mps=True, allow_xpu=True)
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
return self.model is not None
|
||||
@@ -69,9 +76,12 @@ class LuxTTSBackend:
|
||||
|
||||
if device == "cpu":
|
||||
import os
|
||||
|
||||
threads = os.cpu_count() or 4
|
||||
self.model = LuxTTS(
|
||||
model_path=LUXTTS_HF_REPO, device="cpu", threads=min(threads, 8),
|
||||
model_path=LUXTTS_HF_REPO,
|
||||
device="cpu",
|
||||
threads=min(threads, 8),
|
||||
)
|
||||
else:
|
||||
self.model = LuxTTS(model_path=LUXTTS_HF_REPO, device=device)
|
||||
@@ -81,12 +91,12 @@ class LuxTTSBackend:
|
||||
def unload_model(self) -> None:
|
||||
"""Unload model to free memory."""
|
||||
if self.model is not None:
|
||||
device = self.device
|
||||
del self.model
|
||||
self.model = None
|
||||
self._device = None
|
||||
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
empty_device_cache(device)
|
||||
|
||||
logger.info("LuxTTS unloaded")
|
||||
|
||||
@@ -154,12 +164,8 @@ class LuxTTSBackend:
|
||||
await self.load_model()
|
||||
|
||||
def _generate_sync():
|
||||
import torch
|
||||
|
||||
if seed is not None:
|
||||
torch.manual_seed(seed)
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.manual_seed(seed)
|
||||
manual_seed(seed, self.device)
|
||||
|
||||
wav = self.model.generate_speech(
|
||||
text=text,
|
||||
|
||||
@@ -6,7 +6,6 @@ from typing import Optional, List, Tuple
|
||||
import asyncio
|
||||
import logging
|
||||
import numpy as np
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -45,11 +44,9 @@ class MLXTTSBackend:
|
||||
Returns:
|
||||
HuggingFace Hub model ID for MLX
|
||||
"""
|
||||
# MLX model mapping
|
||||
mlx_model_map = {
|
||||
"1.7B": "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16",
|
||||
# 0.6B not yet converted to MLX format
|
||||
"0.6B": "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16", # Fallback to 1.7B
|
||||
"0.6B": "mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16",
|
||||
}
|
||||
|
||||
if model_size not in mlx_model_map:
|
||||
@@ -96,32 +93,12 @@ class MLXTTSBackend:
|
||||
model_name = f"qwen-tts-{model_size}"
|
||||
is_cached = self._is_model_cached(model_size)
|
||||
|
||||
# Force offline mode when cached to avoid network requests
|
||||
original_hf_hub_offline = os.environ.get("HF_HUB_OFFLINE")
|
||||
if is_cached:
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
logger.info("[PATCH] Model %s is cached, forcing HF_HUB_OFFLINE=1 to avoid network requests", model_size)
|
||||
with model_load_progress(model_name, is_cached):
|
||||
from mlx_audio.tts import load
|
||||
|
||||
try:
|
||||
with model_load_progress(model_name, is_cached):
|
||||
from mlx_audio.tts import load
|
||||
logger.info("Loading MLX TTS model %s...", model_size)
|
||||
|
||||
logger.info("Loading MLX TTS model %s...", model_size)
|
||||
|
||||
try:
|
||||
self.model = load(model_path)
|
||||
except Exception as load_error:
|
||||
if is_cached and "offline" in str(load_error).lower():
|
||||
logger.warning("[PATCH] Offline load failed, trying with network: %s", load_error)
|
||||
os.environ.pop("HF_HUB_OFFLINE", None)
|
||||
self.model = load(model_path)
|
||||
else:
|
||||
raise
|
||||
finally:
|
||||
if original_hf_hub_offline is not None:
|
||||
os.environ["HF_HUB_OFFLINE"] = original_hf_hub_offline
|
||||
else:
|
||||
os.environ.pop("HF_HUB_OFFLINE", None)
|
||||
self.model = load(model_path)
|
||||
|
||||
self._current_model_size = model_size
|
||||
self.model_size = model_size
|
||||
@@ -239,10 +216,12 @@ class MLXTTSBackend:
|
||||
logger.warning("Regenerating without voice prompt.")
|
||||
ref_audio = None
|
||||
|
||||
# Check if model supports voice cloning via generate method
|
||||
# MLX API may support ref_audio parameter directly
|
||||
# Inference runs with the process's default HF_HUB_OFFLINE
|
||||
# state. Forcing offline here (previously used to avoid lazy
|
||||
# mlx_audio lookups hanging when the network drops mid-inference,
|
||||
# issue #462) regressed online users because libraries make
|
||||
# legitimate metadata calls during generation.
|
||||
try:
|
||||
# Try with voice cloning parameters if supported
|
||||
if ref_audio:
|
||||
# Check if generate accepts ref_audio parameter
|
||||
import inspect
|
||||
@@ -329,6 +308,7 @@ class MLXSTTBackend:
|
||||
|
||||
model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
|
||||
logger.info("Loading MLX Whisper model %s...", model_size)
|
||||
|
||||
self.model = load(model_name)
|
||||
|
||||
self.model_size = model_size
|
||||
@@ -368,6 +348,9 @@ class MLXSTTBackend:
|
||||
if language:
|
||||
decode_options["language"] = language
|
||||
|
||||
# Inference runs with the process's default HF_HUB_OFFLINE
|
||||
# state — see the comment in MLXTTSBackend.generate for the
|
||||
# regression this revert fixes (issue #462).
|
||||
result = self.model.generate(str(audio_path), **decode_options)
|
||||
|
||||
# Extract text from result
|
||||
|
||||
@@ -14,6 +14,8 @@ from . import TTSBackend, STTBackend, LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS
|
||||
from .base import (
|
||||
is_model_cached,
|
||||
get_torch_device,
|
||||
empty_device_cache,
|
||||
manual_seed,
|
||||
combine_voice_prompts as _combine_voice_prompts,
|
||||
model_load_progress,
|
||||
)
|
||||
@@ -96,15 +98,24 @@ class PyTorchTTSBackend:
|
||||
model_path = self._get_model_path(model_size)
|
||||
logger.info("Loading TTS model %s on %s...", model_size, self.device)
|
||||
|
||||
# Route both HF Hub and Transformers through a single cache root.
|
||||
# On Windows local setups, model assets can otherwise split between
|
||||
# .hf-cache/hub and .hf-cache/transformers, causing speech_tokenizer
|
||||
# and preprocessor_config.json to fail to resolve during load.
|
||||
from huggingface_hub import constants as hf_constants
|
||||
tts_cache_dir = hf_constants.HF_HUB_CACHE
|
||||
|
||||
if self.device == "cpu":
|
||||
self.model = Qwen3TTSModel.from_pretrained(
|
||||
model_path,
|
||||
cache_dir=tts_cache_dir,
|
||||
torch_dtype=torch.float32,
|
||||
low_cpu_mem_usage=False,
|
||||
)
|
||||
else:
|
||||
self.model = Qwen3TTSModel.from_pretrained(
|
||||
model_path,
|
||||
cache_dir=tts_cache_dir,
|
||||
device_map=self.device,
|
||||
torch_dtype=torch.bfloat16,
|
||||
)
|
||||
@@ -120,8 +131,7 @@ class PyTorchTTSBackend:
|
||||
self.model = None
|
||||
self._current_model_size = None
|
||||
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
empty_device_cache(self.device)
|
||||
|
||||
logger.info("TTS model unloaded")
|
||||
|
||||
@@ -162,6 +172,10 @@ class PyTorchTTSBackend:
|
||||
|
||||
def _create_prompt_sync():
|
||||
"""Run synchronous voice prompt creation in thread pool."""
|
||||
# Inference runs with the process's default HF_HUB_OFFLINE
|
||||
# state. Forcing offline here (issue #462) regressed online
|
||||
# users whose libraries issue legitimate metadata lookups
|
||||
# during voice-prompt creation.
|
||||
return self.model.create_voice_clone_prompt(
|
||||
ref_audio=str(audio_path),
|
||||
ref_text=reference_text,
|
||||
@@ -213,11 +227,10 @@ class PyTorchTTSBackend:
|
||||
"""Run synchronous generation in thread pool."""
|
||||
# Set seed if provided
|
||||
if seed is not None:
|
||||
torch.manual_seed(seed)
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.manual_seed(seed)
|
||||
manual_seed(seed, self.device)
|
||||
|
||||
# Generate audio - this is the blocking operation
|
||||
# See _create_prompt_sync comment — inference runs with the
|
||||
# process's default HF_HUB_OFFLINE state (issue #462).
|
||||
wavs, sample_rate = self.model.generate_voice_clone(
|
||||
text=text,
|
||||
voice_clone_prompt=voice_prompt,
|
||||
@@ -297,8 +310,7 @@ class PyTorchSTTBackend:
|
||||
self.model = None
|
||||
self.processor = None
|
||||
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
empty_device_cache(self.device)
|
||||
|
||||
logger.info("Whisper model unloaded")
|
||||
|
||||
@@ -324,8 +336,12 @@ class PyTorchSTTBackend:
|
||||
def _transcribe_sync():
|
||||
"""Run synchronous transcription in thread pool."""
|
||||
# Load audio
|
||||
audio, sr = load_audio(audio_path, sample_rate=16000)
|
||||
audio, _sr = load_audio(audio_path, sample_rate=16000)
|
||||
|
||||
# Inference runs with the process's default HF_HUB_OFFLINE
|
||||
# state — forcing offline here (issue #462) broke online users
|
||||
# whose `get_decoder_prompt_ids` / tokenizer calls issue
|
||||
# legitimate metadata lookups.
|
||||
# Process audio
|
||||
inputs = self.processor(
|
||||
audio,
|
||||
|
||||
@@ -203,6 +203,10 @@ class QwenCustomVoiceBackend:
|
||||
if instruct:
|
||||
kwargs["instruct"] = instruct
|
||||
|
||||
# Inference runs with the process's default HF_HUB_OFFLINE
|
||||
# state. Forcing offline here (issue #462) regressed online
|
||||
# users whose libraries issue legitimate metadata lookups
|
||||
# during generation.
|
||||
wavs, sample_rate = self.model.generate_custom_voice(**kwargs)
|
||||
return wavs[0], sample_rate
|
||||
|
||||
|
||||
+38
-11
@@ -52,6 +52,29 @@ def build_server(cuda=False):
|
||||
if platform.system() == "Windows":
|
||||
args.append("--noconsole")
|
||||
|
||||
# numpy 2.x / torch ABI mismatch fix: install memmove fallback for
|
||||
# torch.from_numpy() before the app starts. Runtime hooks run after
|
||||
# FrozenImporter is registered so frozen torch/numpy are importable.
|
||||
# Paths are passed relative to backend_dir because os.chdir(backend_dir)
|
||||
# runs before PyInstaller. Absolute paths would get baked into the
|
||||
# generated .spec, breaking reproducible builds on other machines / CI.
|
||||
args.extend(
|
||||
[
|
||||
"--runtime-hook",
|
||||
"pyi_rth_numpy_compat.py",
|
||||
# Stub torch.compiler.disable before transformers imports
|
||||
# flex_attention, which otherwise triggers torch._dynamo →
|
||||
# torch._numpy._ufuncs and crashes at module load under
|
||||
# PyInstaller. See pyi_rth_torch_compiler_disable.py.
|
||||
"--runtime-hook",
|
||||
"pyi_rth_torch_compiler_disable.py",
|
||||
# Per-module collection overrides (e.g. forcing scipy.stats._distn_infrastructure
|
||||
# to bundle .py source alongside .pyc so the runtime hook can source-patch it).
|
||||
"--additional-hooks-dir",
|
||||
"pyi_hooks",
|
||||
]
|
||||
)
|
||||
|
||||
# Add local qwen_tts path if specified (for editable installs)
|
||||
qwen_tts_path = os.getenv("QWEN_TTS_PATH")
|
||||
if qwen_tts_path and Path(qwen_tts_path).exists():
|
||||
@@ -115,6 +138,11 @@ def build_server(cuda=False):
|
||||
"backend.backends.chatterbox_backend",
|
||||
"--hidden-import",
|
||||
"backend.backends.chatterbox_turbo_backend",
|
||||
# chatterbox multilingual uses spacy_pkuseg for Chinese word
|
||||
# segmentation, which ships pickled dict files (dicts/default.pkl)
|
||||
# and native .so extensions that --hidden-import alone won't bundle.
|
||||
"--collect-all",
|
||||
"spacy_pkuseg",
|
||||
"--hidden-import",
|
||||
"backend.backends.luxtts_backend",
|
||||
"--hidden-import",
|
||||
@@ -231,20 +259,13 @@ def build_server(cuda=False):
|
||||
"--collect-submodules",
|
||||
"tada",
|
||||
# Kokoro 82M — lightweight TTS engine using misaki G2P
|
||||
# collect-all is required because transformers introspects .py source
|
||||
# files at runtime (e.g. _can_set_attn_implementation opens the class
|
||||
# file); hidden-import alone only bundles bytecode.
|
||||
"--hidden-import",
|
||||
"backend.backends.kokoro_backend",
|
||||
"--hidden-import",
|
||||
"--collect-all",
|
||||
"kokoro",
|
||||
"--hidden-import",
|
||||
"kokoro.pipeline",
|
||||
"--hidden-import",
|
||||
"kokoro.model",
|
||||
"--hidden-import",
|
||||
"kokoro.istftnet",
|
||||
"--hidden-import",
|
||||
"kokoro.modules",
|
||||
"--hidden-import",
|
||||
"kokoro.custom_stft",
|
||||
# misaki ships G2P data files (dictionaries, phoneme tables)
|
||||
# that must be bundled for espeak/en/ja/zh G2P to work
|
||||
"--collect-all",
|
||||
@@ -266,6 +287,12 @@ def build_server(cuda=False):
|
||||
"en_core_web_sm",
|
||||
"--hidden-import",
|
||||
"en_core_web_sm",
|
||||
# unidic-lite ships the MeCab dictionary used by fugashi (pulled in
|
||||
# by misaki[ja]). The dict lives in unidic_lite/dicdir/ and is
|
||||
# discovered via the package's DICDIR constant, so the data files
|
||||
# must be collected or Japanese Kokoro voices crash at runtime.
|
||||
"--collect-all",
|
||||
"unidic_lite",
|
||||
"--hidden-import",
|
||||
"loguru",
|
||||
]
|
||||
|
||||
@@ -89,6 +89,14 @@ def resolve_storage_path(path: str | Path | None) -> Path | None:
|
||||
|
||||
return stored_path
|
||||
|
||||
# 0.3.0 records sometimes stored relative paths with the data-dir name
|
||||
# baked in (e.g. "data/profiles/..."). Joining those directly with
|
||||
# _data_dir produces a spurious "<data_dir>/data/profiles/..." nest.
|
||||
if stored_path.parts and stored_path.parts[0] == "data":
|
||||
stored_path = (
|
||||
Path(*stored_path.parts[1:]) if len(stored_path.parts) > 1 else Path()
|
||||
)
|
||||
|
||||
return (_data_dir / stored_path).resolve()
|
||||
|
||||
|
||||
|
||||
@@ -182,6 +182,7 @@ class HealthResponse(BaseModel):
|
||||
vram_used_mb: Optional[float] = None
|
||||
backend_type: Optional[str] = None # Backend type (mlx or pytorch)
|
||||
backend_variant: Optional[str] = None # Binary variant (cpu or cuda)
|
||||
gpu_compatibility_warning: Optional[str] = None # Warning if GPU arch unsupported
|
||||
|
||||
|
||||
class DirectoryCheck(BaseModel):
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Force scipy.stats._distn_infrastructure to be bundled with its .py source file
|
||||
alongside the .pyc bytecode.
|
||||
|
||||
The runtime hook in backend/pyi_rth_torch_compiler_disable.py patches this
|
||||
module's source at load time (the module has a `del obj` at line 369 that
|
||||
raises NameError under PyInstaller's frozen importer). That patch reads the
|
||||
source via loader.get_source(), which only works if the .py file was
|
||||
actually collected into the bundle.
|
||||
"""
|
||||
|
||||
module_collection_mode = "pyz+py"
|
||||
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Force transformers.masking_utils to be bundled with its .py source alongside
|
||||
the .pyc bytecode so the runtime hook in
|
||||
backend/pyi_rth_torch_compiler_disable.py can source-patch it.
|
||||
|
||||
The patch forces the torch<2.6 code path, bypassing `with TransformGetItemToIndex()`
|
||||
which our torch._dynamo no-op stub can't implement for real — the real context
|
||||
manager uses dynamo graph transforms to avoid `.item()` calls inside vmap.
|
||||
"""
|
||||
|
||||
module_collection_mode = "pyz+py"
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
PyInstaller runtime hook: numpy 2.x / torch ABI mismatch fix.
|
||||
|
||||
Problem
|
||||
-------
|
||||
torch is compiled against numpy 1.x headers. numpy 2.x changed the version
|
||||
number returned by PyArray_GetNDArrayCVersion() (0x01000009 → 0x02000000),
|
||||
so torch's is_numpy_available() returns False and every torch.from_numpy()
|
||||
call raises:
|
||||
|
||||
RuntimeError: Numpy is not available
|
||||
|
||||
This surfaces as:
|
||||
|
||||
ValueError: Unable to create tensor, you should probably activate
|
||||
padding with 'padding=True'
|
||||
|
||||
during TTS generation (EncodecFeatureExtractor → BatchFeature.convert_to_tensors).
|
||||
|
||||
Fix
|
||||
---
|
||||
Runtime hooks execute after PyInstaller's FrozenImporter is registered, so
|
||||
frozen torch/numpy are importable here. We start a background thread that
|
||||
waits for torch to finish loading then wraps torch.from_numpy with a ctypes
|
||||
memmove fallback that bypasses the C-level numpy ABI check entirely.
|
||||
|
||||
This approach works with any numpy version and is safer than binary-patching
|
||||
libtorch_python.dylib (which risks PyArray_Descr struct layout mismatches).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
|
||||
|
||||
def _patch_torch_from_numpy():
|
||||
import time
|
||||
|
||||
for _ in range(7200): # poll up to 360 s at 50 ms intervals
|
||||
time.sleep(0.05)
|
||||
torch = sys.modules.get("torch")
|
||||
if torch is None or not hasattr(torch, "from_numpy"):
|
||||
continue
|
||||
if getattr(torch, "_vb_from_numpy_patched", False):
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
import numpy as np
|
||||
|
||||
_orig = torch.from_numpy
|
||||
|
||||
# Explicit numpy → torch dtype map. Silent fallback to float32 on
|
||||
# unknown dtypes would reinterpret the memcpy'd bytes as fp32 and
|
||||
# silently corrupt data (e.g. fp16 tensors from some TTS engines),
|
||||
# so we raise instead.
|
||||
dtype_map = {
|
||||
"float16": _t.float16,
|
||||
"float32": _t.float32,
|
||||
"float64": _t.float64,
|
||||
"int8": _t.int8,
|
||||
"int16": _t.int16,
|
||||
"int32": _t.int32,
|
||||
"int64": _t.int64,
|
||||
"uint8": _t.uint8,
|
||||
"bool": _t.bool,
|
||||
"complex64": _t.complex64,
|
||||
"complex128": _t.complex128,
|
||||
}
|
||||
|
||||
def _safe_from_numpy(
|
||||
arr, _orig=_orig, _c=ctypes, _np=np, _t=torch, _map=dtype_map
|
||||
):
|
||||
try:
|
||||
return _orig(arr)
|
||||
except RuntimeError:
|
||||
a = _np.ascontiguousarray(arr)
|
||||
key = str(a.dtype)
|
||||
if key not in _map:
|
||||
raise TypeError(
|
||||
f"pyi_rth_numpy_compat: unsupported numpy dtype "
|
||||
f"{key!r} in torch.from_numpy fallback; add an "
|
||||
f"explicit mapping rather than silently copying "
|
||||
f"bytes into the wrong dtype."
|
||||
)
|
||||
out = _t.empty(list(a.shape), dtype=_map[key])
|
||||
_c.memmove(out.data_ptr(), a.ctypes.data, a.nbytes)
|
||||
return out
|
||||
|
||||
torch.from_numpy = _safe_from_numpy
|
||||
torch._vb_from_numpy_patched = True
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
|
||||
threading.Thread(target=_patch_torch_from_numpy, daemon=True).start()
|
||||
@@ -0,0 +1,540 @@
|
||||
"""
|
||||
PyInstaller runtime hook: stub torch._dynamo to a no-op module.
|
||||
|
||||
Problem
|
||||
-------
|
||||
transformers triggers torch._dynamo import at module-load time (not just
|
||||
when torch.compile is called) via class-body decorators:
|
||||
|
||||
transformers/modeling_utils.py:1984
|
||||
@torch._dynamo.allow_in_graph
|
||||
class PreTrainedModel(...)
|
||||
|
||||
transformers/integrations/flex_attention.py:61
|
||||
@torch.compiler.disable(recursive=False)
|
||||
class WrappedFlexAttention...
|
||||
|
||||
The attribute access triggers torch.__getattr__ -> importlib.import_module
|
||||
-> torch._dynamo -> torch._dynamo.utils imports torch._numpy ->
|
||||
torch._numpy._ndarray imports torch._numpy._ufuncs, which crashes under
|
||||
PyInstaller with:
|
||||
|
||||
File "torch/_numpy/_ufuncs.py", line 235, in <module>
|
||||
vars()[name] = deco_binary_ufunc(ufunc)
|
||||
NameError: name 'name' is not defined
|
||||
|
||||
(The module-level `for name in _binary: vars()[name] = ...` pattern works
|
||||
in a regular venv but fails in the PyInstaller bundle. Root cause is in
|
||||
PyInstaller's importer / bytecode pipeline and not easily fixed upstream.)
|
||||
|
||||
Surfaces as Kokoro failing to load when `from transformers import AlbertModel`
|
||||
trips the decorator chain.
|
||||
|
||||
Fix
|
||||
---
|
||||
voicebox never uses torch.compile / torch._dynamo for inference, so we
|
||||
replace torch._dynamo with a no-op stub module before transformers is
|
||||
imported. Any attribute access on the stub returns a pass-through callable,
|
||||
so `@torch._dynamo.allow_in_graph`, `torch._dynamo.is_compiling()`,
|
||||
`torch._dynamo.mark_static_address(...)`, etc. all work.
|
||||
|
||||
This hook is pure sys.modules manipulation — we deliberately do NOT import
|
||||
torch here. Runtime hooks run before the app starts and before
|
||||
pyi_rth_numpy_compat has had a chance to patch torch.from_numpy (it runs
|
||||
in a background thread, waiting for torch to appear in sys.modules).
|
||||
Eager-importing torch at hook time would trip the numpy ABI issue and
|
||||
kill the server process at startup.
|
||||
|
||||
torch.compiler.disable does not need a separate stub: its implementation
|
||||
is effectively `import torch._dynamo; return torch._dynamo.disable(...)`,
|
||||
and since our stub is in sys.modules, that call resolves to our no-op
|
||||
_NoopDecorator pass-through.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
|
||||
|
||||
# Diagnostics — log hook activity to a file alongside the bundle so we can
|
||||
# see what's happening when the server is run as a sidecar (no stdout for
|
||||
# runtime hook prints). Safe no-op if the file can't be written.
|
||||
_DIAG_PATH = os.path.join(tempfile.gettempdir(), "voicebox_rt_hook.log")
|
||||
|
||||
|
||||
def _diag(msg: str) -> None:
|
||||
try:
|
||||
with open(_DIAG_PATH, "a", encoding="utf-8") as f:
|
||||
f.write(msg + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_HOOK_VERSION = "v6-masking-utils-finder"
|
||||
_diag(f"=== runtime hook load @ pid={os.getpid()} version={_HOOK_VERSION} ===")
|
||||
|
||||
|
||||
class _NoopDecorator:
|
||||
"""Multi-role no-op: decorator, falsey predicate, and context manager.
|
||||
|
||||
Returned from calls like `torch._dynamo.disable()` (decorator),
|
||||
`torch._dynamo.is_compiling()` (predicate used in `if not ...`), and
|
||||
`with torch._dynamo._trace_wrapped_higher_order_op.TransformGetItemToIndex():`
|
||||
(context manager used to scope an fx graph transformation).
|
||||
|
||||
By implementing __call__, __bool__, __enter__, __exit__, and __iter__ we
|
||||
cover every use pattern we've seen transformers/torch use on a stubbed
|
||||
object. Anything we haven't covered will raise a clearer error than a
|
||||
silent wrong-result.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def __call__(self, fn=None, *args, **kwargs):
|
||||
return fn
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return False
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
return False # don't suppress exceptions
|
||||
|
||||
def __iter__(self):
|
||||
return iter(())
|
||||
|
||||
|
||||
_noop_decorator_singleton = _NoopDecorator()
|
||||
|
||||
|
||||
def _noop_callable(*args, **kwargs):
|
||||
# Direct-decorator use: @torch._dynamo.foo (no parens) — fn is positional
|
||||
if len(args) == 1 and callable(args[0]) and not kwargs:
|
||||
return args[0]
|
||||
# Side-effect call with non-callable arg(s), e.g. mark_static_address(tensor)
|
||||
return _noop_decorator_singleton
|
||||
|
||||
|
||||
class _NoopDynamoModule(types.ModuleType):
|
||||
"""Permissive stub: every attribute is a pass-through callable.
|
||||
|
||||
Covers attributes transformers hits at import time (allow_in_graph) and
|
||||
runtime (is_compiling, mark_static_address, reset, disable, ...).
|
||||
|
||||
Dunder attributes (__file__, __spec__, __loader__, ...) raise
|
||||
AttributeError so probes like inspect.getmodule() — which does
|
||||
`hasattr(m, '__file__')` then `os.path.normpath(m.__file__)` — see the
|
||||
module as having no source file and fall through to its normal
|
||||
handling, instead of receiving a function and blowing up.
|
||||
"""
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
if name.startswith("__") and name.endswith("__"):
|
||||
raise AttributeError(name)
|
||||
return _noop_callable
|
||||
|
||||
|
||||
class _DynamoLoader:
|
||||
"""Loader used by _DynamoMetaPathFinder to materialise stub submodules."""
|
||||
|
||||
def create_module(self, spec):
|
||||
return _NoopDynamoModule(spec.name)
|
||||
|
||||
def exec_module(self, module):
|
||||
# Mark every stub submodule as a package so deeper submodule imports
|
||||
# (`from torch._dynamo.X.Y import Z`) keep working.
|
||||
module.__path__ = []
|
||||
|
||||
|
||||
class _DynamoMetaPathFinder:
|
||||
"""Resolve any `torch._dynamo.X[.Y...]` import to a no-op stub module.
|
||||
|
||||
Without this, `from torch._dynamo._trace_wrapped_higher_order_op import X`
|
||||
fails even with torch._dynamo pre-populated in sys.modules — Python's
|
||||
import machinery checks the parent's __path__ and then looks up the
|
||||
child, and we need to provide both.
|
||||
"""
|
||||
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
if fullname == "torch._dynamo":
|
||||
return None # handled by the pre-populated sys.modules entry
|
||||
if not fullname.startswith("torch._dynamo."):
|
||||
return None
|
||||
from importlib.machinery import ModuleSpec
|
||||
|
||||
return ModuleSpec(fullname, _DynamoLoader(), is_package=True)
|
||||
|
||||
|
||||
class _TransformersStubFinder:
|
||||
"""Replace specific transformers submodules with no-op stubs.
|
||||
|
||||
Two modules are targeted:
|
||||
|
||||
1. transformers.utils.auto_docstring
|
||||
The real @auto_docstring decorator loads
|
||||
transformers.models.auto.modeling_auto just to build example docstrings,
|
||||
which drags in GenerationMixin -> candidate_generator -> sklearn.metrics
|
||||
-> scipy.stats._distn_infrastructure and trips (2) below. Docstrings
|
||||
aren't functional for inference, so a pass-through decorator is safe.
|
||||
|
||||
2. transformers.generation.candidate_generator
|
||||
Imported at module scope by transformers.generation.utils. It does
|
||||
`from sklearn.metrics import roc_curve` at module load, which triggers:
|
||||
|
||||
File "scipy/stats/_distn_infrastructure.py", line 369, in <module>
|
||||
NameError: name 'obj' is not defined
|
||||
|
||||
This is a PyInstaller-specific module-load bug (same class as the
|
||||
torch._numpy._ufuncs crash) where a module-level `for obj in [s for s
|
||||
in dir() if ...]` loop evaluates to empty in the bundle, leaving `obj`
|
||||
unbound before `del obj`.
|
||||
|
||||
The exports (AssistedCandidateGenerator, EarlyExitCandidateGenerator,
|
||||
etc.) are speculative-decoding helpers voicebox's TTS engines do not
|
||||
use; a no-op stub module satisfies the imports.
|
||||
"""
|
||||
|
||||
_STUBBED_MODULES = frozenset(
|
||||
{
|
||||
"transformers.utils.auto_docstring",
|
||||
"transformers.generation.candidate_generator",
|
||||
}
|
||||
)
|
||||
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
if fullname not in self._STUBBED_MODULES:
|
||||
return None
|
||||
from importlib.machinery import ModuleSpec
|
||||
|
||||
return ModuleSpec(fullname, _NoopStubLoader(), is_package=False)
|
||||
|
||||
|
||||
class _NoopStubLoader:
|
||||
def create_module(self, spec):
|
||||
return _NoopDynamoModule(spec.name)
|
||||
|
||||
def exec_module(self, module):
|
||||
# _NoopDynamoModule.__getattr__ already answers every non-dunder
|
||||
# attribute with a pass-through callable, which satisfies
|
||||
# `from stubbed_module import X` for any X.
|
||||
pass
|
||||
|
||||
|
||||
def _patch_scipy_distn_source(source: str) -> str:
|
||||
"""Replace the unsafe `del obj` with a no-op that survives when obj is unbound.
|
||||
|
||||
Returns the input unchanged if the target line isn't found (e.g. scipy
|
||||
version has changed).
|
||||
"""
|
||||
target = "\ndel obj\n"
|
||||
replacement = "\nglobals().pop('obj', None)\n"
|
||||
if target in source:
|
||||
return source.replace(target, replacement, 1)
|
||||
return source
|
||||
|
||||
|
||||
def _patch_masking_utils_source(source: str) -> str:
|
||||
"""Force torch<2.6 code path in transformers.masking_utils.
|
||||
|
||||
The torch>=2.6 path uses `with TransformGetItemToIndex():` to allow
|
||||
`.item()` calls inside vmap. That context manager is implemented via
|
||||
torch._dynamo graph transforms, which our stub doesn't reproduce — it's
|
||||
a no-op. The inner `_vmap_for_bhqkv` then crashes with:
|
||||
|
||||
RuntimeError: vmap: It looks like you're calling .item() on a Tensor.
|
||||
|
||||
Forcing the torch<2.6 flag off selects sdpa_mask_older_torch which uses
|
||||
a different vmap pattern that does not hit .item() and does not need
|
||||
TransformGetItemToIndex.
|
||||
"""
|
||||
target = 'is_torch_greater_or_equal("2.6", accept_dev=True)'
|
||||
# Find the specific line that assigns _is_torch_greater_or_equal_than_2_6
|
||||
if "_is_torch_greater_or_equal_than_2_6 = " + target in source:
|
||||
return source.replace(
|
||||
"_is_torch_greater_or_equal_than_2_6 = " + target,
|
||||
"_is_torch_greater_or_equal_than_2_6 = False",
|
||||
1,
|
||||
)
|
||||
return source
|
||||
|
||||
|
||||
class _SourcePatchingFinder:
|
||||
"""Generic delegate-and-wrap meta-path finder that patches a module's
|
||||
source before exec'ing.
|
||||
|
||||
Subclasses declare `target` (module fullname) and `patch` (str->str).
|
||||
Requires the target module's .py source to be bundled (use a PyInstaller
|
||||
hook setting module_collection_mode = "pyz+py").
|
||||
"""
|
||||
|
||||
target: str
|
||||
patch_fn: callable = None
|
||||
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
if fullname != self.target:
|
||||
return None
|
||||
for finder in sys.meta_path:
|
||||
if finder is self:
|
||||
continue
|
||||
find = getattr(finder, "find_spec", None)
|
||||
if find is None:
|
||||
continue
|
||||
try:
|
||||
real_spec = find(fullname, path, target)
|
||||
except Exception:
|
||||
continue
|
||||
if real_spec is None or real_spec.loader is None:
|
||||
continue
|
||||
real_spec.loader = _SourcePatchLoader(real_spec.loader, self.patch_fn)
|
||||
return real_spec
|
||||
return None
|
||||
|
||||
|
||||
class _SourcePatchLoader:
|
||||
"""Delegate loader that reads source via get_source, applies a patch, and
|
||||
compile/exec's the patched text into module.__dict__.
|
||||
"""
|
||||
|
||||
def __init__(self, inner, patch_fn):
|
||||
self._inner = inner
|
||||
self._patch_fn = patch_fn
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._inner, name)
|
||||
|
||||
def create_module(self, spec):
|
||||
return self._inner.create_module(spec)
|
||||
|
||||
def exec_module(self, module):
|
||||
source = None
|
||||
try:
|
||||
source = self._inner.get_source(module.__name__)
|
||||
except Exception as e:
|
||||
_diag(f"[source-patch] get_source({module.__name__}) failed: {e!r}")
|
||||
|
||||
if not source:
|
||||
_diag(
|
||||
f"[source-patch] no source for {module.__name__}; "
|
||||
"falling back to inner exec_module (patch NOT applied)"
|
||||
)
|
||||
self._inner.exec_module(module)
|
||||
return
|
||||
|
||||
patched = self._patch_fn(source)
|
||||
_diag(
|
||||
f"[source-patch] {module.__name__}: "
|
||||
f"patched={patched is not source}, len={len(patched)}"
|
||||
)
|
||||
spec = module.__spec__
|
||||
if spec is not None and spec.submodule_search_locations is not None:
|
||||
module.__path__ = spec.submodule_search_locations
|
||||
filename = getattr(self._inner, "path", module.__name__)
|
||||
exec(compile(patched, filename, "exec"), module.__dict__)
|
||||
_diag(f"[source-patch] {module.__name__} OK")
|
||||
|
||||
|
||||
class _MaskingUtilsFinder(_SourcePatchingFinder):
|
||||
target = "transformers.masking_utils"
|
||||
patch_fn = staticmethod(_patch_masking_utils_source)
|
||||
|
||||
|
||||
class _ScipyDistnPatchingFinder:
|
||||
"""Delegate-and-wrap finder for scipy.stats._distn_infrastructure.
|
||||
|
||||
That module ends with:
|
||||
|
||||
for obj in [s for s in dir() if s.startswith('_doc_')]:
|
||||
exec('del ' + obj)
|
||||
del obj
|
||||
|
||||
In the PyInstaller bundle the list comprehension evaluates to empty
|
||||
(module-level dir() under the frozen importer returns a different scope
|
||||
than CPython's normal module-exec path — same class of bug as the
|
||||
torch._numpy._ufuncs crash). The for loop body doesn't run, `obj` is
|
||||
never bound, and the trailing `del obj` raises NameError at module load.
|
||||
|
||||
This kills every downstream module: librosa (needed by nearly every TTS
|
||||
engine for mel filters) -> scipy.signal -> scipy.stats -> here.
|
||||
|
||||
Workaround: delegate to the real loader, but pre-bind `obj = None` in the
|
||||
module namespace before its bytecode runs. If the for loop executes, each
|
||||
iteration overwrites the sentinel via STORE_NAME (normal behaviour). If it
|
||||
doesn't, `del obj` removes the sentinel and module load succeeds. The
|
||||
`_doc_*` cleanup this line was meant to do is purely cosmetic — those vars
|
||||
stay in the module namespace but nothing references them after this point.
|
||||
"""
|
||||
|
||||
_TARGET = "scipy.stats._distn_infrastructure"
|
||||
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
if fullname != self._TARGET:
|
||||
return None
|
||||
_diag(f"[scipy-finder] match: {fullname}, path={path!r}")
|
||||
# Delegate to the other finders to locate the real spec
|
||||
for finder in sys.meta_path:
|
||||
if finder is self:
|
||||
continue
|
||||
find = getattr(finder, "find_spec", None)
|
||||
if find is None:
|
||||
continue
|
||||
try:
|
||||
real_spec = find(fullname, path, target)
|
||||
except Exception as e:
|
||||
_diag(f"[scipy-finder] inner finder {type(finder).__name__} raised: {e}")
|
||||
continue
|
||||
if real_spec is None:
|
||||
continue
|
||||
if real_spec.loader is None:
|
||||
_diag(f"[scipy-finder] {type(finder).__name__} returned spec with loader=None")
|
||||
continue
|
||||
_diag(
|
||||
f"[scipy-finder] wrapped loader from "
|
||||
f"{type(finder).__name__} -> {type(real_spec.loader).__name__}"
|
||||
)
|
||||
real_spec.loader = _ScipyDistnPrebindLoader(real_spec.loader)
|
||||
return real_spec
|
||||
_diag("[scipy-finder] NO inner finder returned a spec")
|
||||
return None
|
||||
|
||||
|
||||
class _ScipyDistnPrebindLoader:
|
||||
"""Thin wrapper that pre-binds `obj = None` before delegating to the
|
||||
real PyInstaller loader.
|
||||
|
||||
Every other attribute/method delegates to the inner loader — PyiFrozenLoader
|
||||
is a rich FileLoader/ExecutionLoader with get_code/get_source/get_filename/
|
||||
is_package/get_resource_reader/etc., any of which Python's import machinery
|
||||
or 3rd-party code may call on spec.loader. Forwarding via __getattr__
|
||||
avoids breaking any of those paths (and preserves @_check_name contracts
|
||||
because the decorated methods run on the inner instance where self.name
|
||||
matches spec.name).
|
||||
"""
|
||||
|
||||
def __init__(self, inner):
|
||||
self._inner = inner
|
||||
|
||||
def __getattr__(self, name):
|
||||
# __getattr__ fires only for attrs not already on self, so delegate
|
||||
# everything that isn't create_module/exec_module (or __getattr__/init).
|
||||
return getattr(self._inner, name)
|
||||
|
||||
def create_module(self, spec):
|
||||
return self._inner.create_module(spec)
|
||||
|
||||
def exec_module(self, module):
|
||||
# Compile scipy's module source with the problematic line patched.
|
||||
#
|
||||
# The real module ends with:
|
||||
# for obj in [s for s in dir() if s.startswith('_doc_')]:
|
||||
# exec('del ' + obj)
|
||||
# del obj
|
||||
#
|
||||
# Under PyInstaller's frozen importer, `del obj` raises NameError
|
||||
# even when we pre-populate module.__dict__['obj'] — the pre-compiled
|
||||
# .pyc bytecode interacts with the frame setup differently than a
|
||||
# fresh compile() from source. Easiest robust fix: read the source
|
||||
# and replace `del obj` with a safe variant before compiling.
|
||||
#
|
||||
# Requires the .py source to be bundled alongside the .pyc — see
|
||||
# backend/pyi_hooks/hook-scipy.stats._distn_infrastructure.py.
|
||||
source = None
|
||||
try:
|
||||
source = self._inner.get_source(module.__name__)
|
||||
except Exception as e:
|
||||
_diag(f"[scipy-loader] get_source failed: {e!r}")
|
||||
|
||||
if source:
|
||||
patched = _patch_scipy_distn_source(source)
|
||||
_diag(
|
||||
f"[scipy-loader] source-patch path: patched={patched is not source}, "
|
||||
f"len={len(patched)}"
|
||||
)
|
||||
spec = module.__spec__
|
||||
if spec is not None and spec.submodule_search_locations is not None:
|
||||
module.__path__ = spec.submodule_search_locations
|
||||
filename = getattr(self._inner, "path", module.__name__)
|
||||
bytecode = compile(patched, filename, "exec")
|
||||
try:
|
||||
exec(bytecode, module.__dict__)
|
||||
except Exception as e:
|
||||
_diag(f"[scipy-loader] patched exec raised {type(e).__name__}: {e!r}")
|
||||
raise
|
||||
_diag(f"[scipy-loader] exec_module {module.__name__} OK (source-patched)")
|
||||
return
|
||||
|
||||
# No source available — fall back to the pre-bind approach. This is
|
||||
# best-effort; if the frozen .pyc really does see a different `obj`
|
||||
# slot, this will still crash, but we've done all we can without
|
||||
# source.
|
||||
_diag("[scipy-loader] no source available; falling back to pre-bind")
|
||||
module.__dict__["obj"] = None
|
||||
self._inner.exec_module(module)
|
||||
|
||||
|
||||
def _install_dynamo_stub() -> None:
|
||||
stub = _NoopDynamoModule("torch._dynamo")
|
||||
# Mark as a package so `from torch._dynamo.X import Y` imports work
|
||||
# (Python's import machinery checks parent.__path__ before looking up
|
||||
# the child).
|
||||
stub.__path__ = []
|
||||
# torch._dynamo.config is accessed as a nested attribute namespace
|
||||
# (e.g. `torch._dynamo.config.capture_scalar_outputs = True`), so use
|
||||
# a permissive module so any attr read returns a no-op and sets succeed.
|
||||
stub.config = _NoopDynamoModule("torch._dynamo.config")
|
||||
stub.config.__path__ = []
|
||||
sys.modules["torch._dynamo"] = stub
|
||||
sys.modules["torch._dynamo.config"] = stub.config
|
||||
|
||||
# Finders:
|
||||
# - torch._dynamo.* submodules -> no-op stubs
|
||||
# - transformers.utils.auto_docstring and
|
||||
# transformers.generation.candidate_generator -> no-op stubs (both
|
||||
# paths reach sklearn -> scipy.stats which trips a separate crash)
|
||||
# - scipy.stats._distn_infrastructure -> real load with `obj` pre-bound,
|
||||
# so librosa -> scipy.signal -> scipy.stats loads cleanly
|
||||
for _FinderCls in (
|
||||
_DynamoMetaPathFinder,
|
||||
_TransformersStubFinder,
|
||||
_ScipyDistnPatchingFinder,
|
||||
_MaskingUtilsFinder,
|
||||
):
|
||||
try:
|
||||
sys.meta_path.insert(0, _FinderCls())
|
||||
_diag(f"installed finder: {_FinderCls.__name__}")
|
||||
except Exception as e:
|
||||
_diag(f"FAILED to install {_FinderCls.__name__}: {e!r}")
|
||||
_diag(
|
||||
"final sys.meta_path head: "
|
||||
+ ", ".join(type(f).__name__ for f in sys.meta_path[:6])
|
||||
)
|
||||
|
||||
# If torch is already imported, also set the attribute on the package so
|
||||
# `torch._dynamo` resolves to our stub without triggering torch.__getattr__
|
||||
# (which would lazy-import the real module and crash).
|
||||
torch_mod = sys.modules.get("torch")
|
||||
if torch_mod is not None:
|
||||
torch_mod._dynamo = stub
|
||||
|
||||
|
||||
try:
|
||||
_install_dynamo_stub()
|
||||
except Exception as _e:
|
||||
# Best effort. If this fails the original NameError will surface when
|
||||
# transformers imports — no worse than not patching at all.
|
||||
_diag(f"_install_dynamo_stub FAILED: {_e!r}")
|
||||
|
||||
# NOTE: we deliberately do NOT import torch or torch.compiler here.
|
||||
# Runtime hooks run before the app starts and before pyi_rth_numpy_compat
|
||||
# has had a chance to patch torch.from_numpy (it runs in a background
|
||||
# thread, waiting for torch to appear in sys.modules). Importing torch
|
||||
# eagerly at hook time would trip the numpy ABI issue and kill the
|
||||
# server process at startup.
|
||||
#
|
||||
# torch.compiler.disable does not need an explicit stub: its
|
||||
# implementation is effectively `import torch._dynamo; return
|
||||
# torch._dynamo.disable(fn, recursive, reason=reason)`, and since our
|
||||
# stub is installed in sys.modules, that call resolves to our no-op
|
||||
# _NoopDecorator pass-through.
|
||||
@@ -2,4 +2,21 @@
|
||||
# These should only be installed on aarch64-apple-darwin platforms
|
||||
|
||||
mlx>=0.30.0
|
||||
mlx-audio>=0.3.1
|
||||
|
||||
# miniaudio is a runtime dep of mlx-audio's STT path (mlx_audio.stt).
|
||||
# mlx-audio itself is installed --no-deps (see comment below), so we
|
||||
# must list miniaudio explicitly here or transcription fails on fresh
|
||||
# M1 installs with `ModuleNotFoundError: miniaudio` (issue #505).
|
||||
miniaudio>=1.59
|
||||
|
||||
# NOTE: mlx-audio is intentionally not listed here. From 0.3.1 onward it
|
||||
# declares `transformers==5.0.0rc3` / `>=5.0.0`, which conflicts with the
|
||||
# `transformers<=4.57.6` cap in requirements.txt and breaks CI's clean
|
||||
# resolver. The mlx-audio API surface we use (mlx_audio.tts.load,
|
||||
# mlx_audio.stt.load) works fine on transformers 4.57.x in practice.
|
||||
#
|
||||
# Install it via `pip install --no-deps mlx-audio==0.4.1` after this file
|
||||
# (see .github/workflows/release.yml). Most other mlx-audio runtime deps
|
||||
# (huggingface_hub, librosa, mlx-lm, numba, numpy, protobuf, pyloudnorm,
|
||||
# sounddevice, tqdm) are already in requirements.txt or pulled in by
|
||||
# other engines.
|
||||
|
||||
@@ -8,7 +8,7 @@ sqlalchemy>=2.0.0
|
||||
alembic>=1.13.0
|
||||
|
||||
# ML models
|
||||
torch>=2.7.0
|
||||
torch>=2.2.0
|
||||
transformers>=4.36.0,<=4.57.6
|
||||
accelerate>=0.26.0
|
||||
huggingface_hub>=0.20.0
|
||||
@@ -46,11 +46,16 @@ misaki[en,ja,zh]>=0.9.4
|
||||
# spacy model for misaki English G2P — must be pre-installed or misaki
|
||||
# tries spacy.cli.download() at runtime which crashes frozen builds
|
||||
en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl
|
||||
# fugashi (pulled in by misaki[ja]) needs a MeCab dictionary on disk.
|
||||
# unidic-lite ships one inside the wheel (~50MB); the full `unidic` package
|
||||
# requires `python -m unidic download` (~526MB) which breaks frozen builds
|
||||
# for the same reason en_core_web_sm does.
|
||||
unidic-lite>=1.0.8
|
||||
|
||||
# Audio processing
|
||||
librosa>=0.10.0
|
||||
soundfile>=0.12.0
|
||||
numpy>=1.24.0
|
||||
numpy>=1.24.0,<2.0
|
||||
numba>=0.60.0,<0.61.0
|
||||
pedalboard>=0.9.0
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from .. import models
|
||||
from ..services import history, profiles, tts
|
||||
from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db
|
||||
from ..services.generation import run_generation
|
||||
from ..services.task_queue import enqueue_generation
|
||||
from ..services.task_queue import cancel_generation as cancel_generation_job, enqueue_generation
|
||||
from ..utils.tasks import get_task_manager
|
||||
|
||||
router = APIRouter()
|
||||
@@ -82,6 +82,7 @@ async def generate_speech(
|
||||
pass
|
||||
|
||||
enqueue_generation(
|
||||
generation_id,
|
||||
run_generation(
|
||||
generation_id=generation_id,
|
||||
profile_id=data.profile_id,
|
||||
@@ -127,6 +128,7 @@ async def retry_generation(generation_id: str, db: Session = Depends(get_db)):
|
||||
)
|
||||
|
||||
enqueue_generation(
|
||||
generation_id,
|
||||
run_generation(
|
||||
generation_id=generation_id,
|
||||
profile_id=gen.profile_id,
|
||||
@@ -170,6 +172,7 @@ async def regenerate_generation(generation_id: str, db: Session = Depends(get_db
|
||||
version_id = str(uuid.uuid4())
|
||||
|
||||
enqueue_generation(
|
||||
generation_id,
|
||||
run_generation(
|
||||
generation_id=generation_id,
|
||||
profile_id=gen.profile_id,
|
||||
@@ -187,6 +190,34 @@ async def regenerate_generation(generation_id: str, db: Session = Depends(get_db
|
||||
return models.GenerationResponse.model_validate(gen)
|
||||
|
||||
|
||||
@router.post("/generate/{generation_id}/cancel")
|
||||
async def cancel_generation(generation_id: str, db: Session = Depends(get_db)):
|
||||
"""Cancel a queued or running generation."""
|
||||
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
|
||||
if not gen:
|
||||
raise HTTPException(status_code=404, detail="Generation not found")
|
||||
|
||||
if (gen.status or "completed") not in ("loading_model", "generating"):
|
||||
raise HTTPException(status_code=400, detail="Only active generations can be cancelled")
|
||||
|
||||
cancellation_state = cancel_generation_job(generation_id)
|
||||
if cancellation_state is None:
|
||||
raise HTTPException(status_code=409, detail="Generation is no longer cancellable")
|
||||
|
||||
if cancellation_state == "queued":
|
||||
task_manager = get_task_manager()
|
||||
task_manager.complete_generation(generation_id)
|
||||
await history.update_generation_status(
|
||||
generation_id=generation_id,
|
||||
status="failed",
|
||||
db=db,
|
||||
error="Generation cancelled",
|
||||
)
|
||||
return {"message": "Queued generation cancelled"}
|
||||
|
||||
return {"message": "Generation cancellation requested"}
|
||||
|
||||
|
||||
@router.get("/generate/{generation_id}/status")
|
||||
async def get_generation_status(generation_id: str, db: Session = Depends(get_db)):
|
||||
"""SSE endpoint that streams generation status updates."""
|
||||
|
||||
@@ -93,6 +93,12 @@ async def health():
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
gpu_compat_warning = None
|
||||
if has_cuda:
|
||||
from ..backends.base import check_cuda_compatibility
|
||||
|
||||
_compatible, gpu_compat_warning = check_cuda_compatibility()
|
||||
|
||||
gpu_available = has_cuda or has_mps or has_xpu or has_directml or backend_type == "mlx"
|
||||
|
||||
gpu_type = None
|
||||
@@ -110,6 +116,11 @@ async def health():
|
||||
vram_used = None
|
||||
if has_cuda:
|
||||
vram_used = torch.cuda.memory_allocated() / 1024 / 1024
|
||||
elif has_xpu:
|
||||
try:
|
||||
vram_used = torch.xpu.memory_allocated() / 1024 / 1024
|
||||
except Exception:
|
||||
pass # memory_allocated() may not be available on all IPEX versions
|
||||
|
||||
model_loaded = False
|
||||
model_size = None
|
||||
@@ -162,7 +173,11 @@ async def health():
|
||||
gpu_type=gpu_type,
|
||||
vram_used_mb=vram_used,
|
||||
backend_type=backend_type,
|
||||
backend_variant=os.environ.get("VOICEBOX_BACKEND_VARIANT", "cuda" if torch.cuda.is_available() else "cpu"),
|
||||
backend_variant=os.environ.get(
|
||||
"VOICEBOX_BACKEND_VARIANT",
|
||||
"cuda" if torch.cuda.is_available() else ("xpu" if has_xpu else "cpu"),
|
||||
),
|
||||
gpu_compatibility_warning=gpu_compat_warning,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -62,6 +62,13 @@ async def import_generation(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/history/failed")
|
||||
async def clear_failed_generations(db: Session = Depends(get_db)):
|
||||
"""Delete every generation with status='failed'. Used by the UI's 'Clear failed' button (#410)."""
|
||||
count = await history.delete_failed_generations(db)
|
||||
return {"deleted": count}
|
||||
|
||||
|
||||
@router.get("/history/{generation_id}", response_model=models.HistoryResponse)
|
||||
async def get_generation(
|
||||
generation_id: str,
|
||||
@@ -89,6 +96,11 @@ async def get_generation(
|
||||
duration=gen.duration,
|
||||
seed=gen.seed,
|
||||
instruct=gen.instruct,
|
||||
engine=gen.engine or "qwen",
|
||||
model_size=gen.model_size,
|
||||
status=gen.status or "completed",
|
||||
error=gen.error,
|
||||
is_favorited=bool(gen.is_favorited),
|
||||
created_at=gen.created_at,
|
||||
)
|
||||
|
||||
|
||||
@@ -135,14 +135,15 @@ async def migrate_models(request: models.ModelMigrateRequest):
|
||||
if destination.resolve().is_relative_to(source.resolve()):
|
||||
raise HTTPException(status_code=400, detail="Destination cannot be inside the current cache directory")
|
||||
|
||||
progress_manager = get_progress_manager()
|
||||
model_dirs = [d for d in source.iterdir() if d.name.startswith("models--") and d.is_dir()]
|
||||
if not model_dirs:
|
||||
progress_manager.update_progress("migration", 1, 1, status="complete")
|
||||
progress_manager.mark_complete("migration")
|
||||
return {"moved": 0, "errors": [], "source": str(source), "destination": str(destination)}
|
||||
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
progress_manager = get_progress_manager()
|
||||
|
||||
same_fs = False
|
||||
try:
|
||||
same_fs = source.stat().st_dev == destination.stat().st_dev
|
||||
|
||||
@@ -4,6 +4,7 @@ import io
|
||||
import json as _json
|
||||
import logging
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
|
||||
@@ -105,6 +105,11 @@ def _start_parent_watchdog(parent_pid, data_dir=None):
|
||||
This is the clean shutdown mechanism: instead of the Tauri app trying to
|
||||
forcefully kill the server (which spawns console windows on Windows),
|
||||
the server monitors its parent and shuts itself down gracefully.
|
||||
|
||||
The Tauri app writes a .keep-running sentinel file to data_dir before
|
||||
exiting when "remain running after close" is enabled. This is a reliable
|
||||
fallback for the HTTP /watchdog/disable request, which can race with
|
||||
process exit on Windows.
|
||||
"""
|
||||
import os
|
||||
import signal
|
||||
@@ -164,6 +169,19 @@ def _start_parent_watchdog(parent_pid, data_dir=None):
|
||||
if not alive:
|
||||
watchdog_logger.warning(f"Parent PID {parent_pid} not found on first check — disabling watchdog")
|
||||
return
|
||||
# Clear any stale .keep-running sentinel from a previous session. The
|
||||
# sentinel is only removed by the watchdog when it's consumed during a
|
||||
# grace period; if the HTTP /watchdog/disable path wins the race on a
|
||||
# "keep running" exit, the sentinel is left on disk. Wipe it here so a
|
||||
# future session can't inherit that stale signal.
|
||||
if data_dir:
|
||||
stale = os.path.join(data_dir, ".keep-running")
|
||||
if os.path.exists(stale):
|
||||
try:
|
||||
os.remove(stale)
|
||||
watchdog_logger.info("Removed stale .keep-running sentinel from previous session")
|
||||
except OSError as e:
|
||||
watchdog_logger.warning(f"Failed to remove stale sentinel: {e}")
|
||||
while True:
|
||||
if _watchdog_disabled:
|
||||
watchdog_logger.info("Watchdog disabled (keep server running), stopping monitor")
|
||||
@@ -178,6 +196,18 @@ def _start_parent_watchdog(parent_pid, data_dir=None):
|
||||
if _watchdog_disabled:
|
||||
watchdog_logger.info("Watchdog was disabled during grace period, keeping server alive")
|
||||
return
|
||||
# Check for sentinel file written by Tauri before exit.
|
||||
# This catches the case where the HTTP disable request
|
||||
# didn't arrive before the parent process died (common
|
||||
# on Windows where process teardown is fast).
|
||||
sentinel = os.path.join(data_dir, ".keep-running") if data_dir else None
|
||||
if sentinel and os.path.exists(sentinel):
|
||||
watchdog_logger.info("Found .keep-running sentinel file, keeping server alive")
|
||||
try:
|
||||
os.remove(sentinel)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
watchdog_logger.info("Watchdog still enabled after grace period, shutting down server...")
|
||||
if sys.platform == "win32":
|
||||
# sys.exit triggers SystemExit, allowing uvicorn to run
|
||||
|
||||
@@ -11,6 +11,7 @@ Both archives are extracted into {data_dir}/backends/cuda/ which forms the
|
||||
complete PyInstaller --onedir directory structure that torch expects.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
@@ -34,6 +35,12 @@ PROGRESS_KEY = "cuda-backend"
|
||||
# CUDA toolkit version or torch's CUDA dependency changes (e.g. cu126 -> cu128).
|
||||
CUDA_LIBS_VERSION = "cu128-v1"
|
||||
|
||||
# Prevents concurrent download_cuda_binary() calls from racing on the same
|
||||
# temp file. The auto-update background task and the manual HTTP endpoint
|
||||
# can both invoke download_cuda_binary(); without this lock the progress-
|
||||
# manager status check is a TOCTOU race.
|
||||
_download_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def get_backends_dir() -> Path:
|
||||
"""Directory where downloaded backend binaries are stored."""
|
||||
@@ -241,6 +248,15 @@ async def download_cuda_binary(version: Optional[str] = None):
|
||||
Args:
|
||||
version: Version tag (e.g. "v0.3.0"). Defaults to current app version.
|
||||
"""
|
||||
if _download_lock.locked():
|
||||
logger.info("CUDA download already in progress, skipping duplicate request")
|
||||
return
|
||||
async with _download_lock:
|
||||
await _download_cuda_binary_locked(version)
|
||||
|
||||
|
||||
async def _download_cuda_binary_locked(version: Optional[str] = None):
|
||||
"""Inner implementation of download_cuda_binary, called under _download_lock."""
|
||||
import httpx
|
||||
|
||||
if version is None:
|
||||
|
||||
@@ -11,6 +11,8 @@ from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from ..utils.effects import validate_effects_chain
|
||||
|
||||
from ..database import EffectPreset as DBEffectPreset
|
||||
from ..models import EffectPresetResponse, EffectPresetCreate, EffectPresetUpdate, EffectConfig
|
||||
|
||||
@@ -52,7 +54,6 @@ def get_preset_by_name(name: str, db: Session) -> Optional[EffectPresetResponse]
|
||||
|
||||
def create_preset(data: EffectPresetCreate, db: Session) -> EffectPresetResponse:
|
||||
"""Create a new user effect preset."""
|
||||
from .utils.effects import validate_effects_chain
|
||||
|
||||
chain_dicts = [e.model_dump() for e in data.effects_chain]
|
||||
error = validate_effects_chain(chain_dicts)
|
||||
@@ -94,7 +95,6 @@ def update_preset(preset_id: str, data: EffectPresetUpdate, db: Session) -> Opti
|
||||
if data.description is not None:
|
||||
preset.description = data.description
|
||||
if data.effects_chain is not None:
|
||||
from .utils.effects import validate_effects_chain
|
||||
|
||||
chain_dicts = [e.model_dump() for e in data.effects_chain]
|
||||
error = validate_effects_chain(chain_dicts)
|
||||
|
||||
@@ -16,6 +16,7 @@ Mode differences:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
from typing import Literal, Optional
|
||||
|
||||
@@ -126,6 +127,13 @@ async def run_generation(
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await history.update_generation_status(
|
||||
generation_id=generation_id,
|
||||
status="failed",
|
||||
db=bg_db,
|
||||
error="Generation cancelled",
|
||||
)
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
await history.update_generation_status(
|
||||
|
||||
@@ -264,6 +264,43 @@ async def delete_generation(
|
||||
return True
|
||||
|
||||
|
||||
async def delete_failed_generations(db: Session) -> int:
|
||||
"""
|
||||
Delete every generation whose status is 'failed'.
|
||||
|
||||
Used by the "Clear failed" action in the UI so users can tidy up
|
||||
history after the model wasn't loaded, the app was closed mid-run,
|
||||
or a generation otherwise errored out (see issue #410).
|
||||
|
||||
Returns:
|
||||
Number of generations deleted.
|
||||
"""
|
||||
from . import versions as versions_mod
|
||||
|
||||
failed = db.query(DBGeneration).filter(DBGeneration.status == "failed").all()
|
||||
count = 0
|
||||
for generation in failed:
|
||||
# Clean up version files/rows first.
|
||||
versions_mod.delete_versions_for_generation(generation.id, db)
|
||||
|
||||
# Remove the main audio file if it somehow made it to disk.
|
||||
if generation.audio_path:
|
||||
audio_path = config.resolve_storage_path(generation.audio_path)
|
||||
if audio_path is not None and audio_path.exists():
|
||||
try:
|
||||
audio_path.unlink()
|
||||
except OSError:
|
||||
# Best-effort cleanup — don't abort the whole sweep
|
||||
# if a single file can't be removed.
|
||||
pass
|
||||
|
||||
db.delete(generation)
|
||||
count += 1
|
||||
|
||||
db.commit()
|
||||
return count
|
||||
|
||||
|
||||
async def delete_generations_by_profile(
|
||||
profile_id: str,
|
||||
db: Session,
|
||||
@@ -282,6 +319,10 @@ async def delete_generations_by_profile(
|
||||
|
||||
count = 0
|
||||
for generation in generations:
|
||||
# Delete associated version files and rows first
|
||||
from . import versions as versions_mod
|
||||
versions_mod.delete_versions_for_generation(generation.id, db)
|
||||
|
||||
# Delete audio file
|
||||
audio_path = config.resolve_storage_path(generation.audio_path)
|
||||
if audio_path is not None and audio_path.exists():
|
||||
|
||||
@@ -484,13 +484,15 @@ async def split_story_item(
|
||||
Returns:
|
||||
List of two updated item details (original and new) or None if not found/invalid
|
||||
"""
|
||||
# Get the item
|
||||
# Get the item with a row lock to prevent concurrent splits on the
|
||||
# same clip (e.g. from rapid double-clicks racing each other).
|
||||
item = (
|
||||
db.query(DBStoryItem)
|
||||
.filter_by(
|
||||
id=item_id,
|
||||
story_id=story_id,
|
||||
)
|
||||
.with_for_update()
|
||||
.first()
|
||||
)
|
||||
if not item:
|
||||
|
||||
@@ -5,12 +5,27 @@ to avoid GPU contention.
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from typing import Coroutine, Literal
|
||||
|
||||
# Keep references to fire-and-forget background tasks to prevent GC
|
||||
_background_tasks: set = set()
|
||||
|
||||
|
||||
@dataclass
|
||||
class GenerationJob:
|
||||
"""Queued generation work plus the generation ID it belongs to."""
|
||||
|
||||
generation_id: str
|
||||
coro: Coroutine
|
||||
|
||||
|
||||
# Generation queue — serializes TTS inference to avoid GPU contention
|
||||
_generation_queue: asyncio.Queue = None # type: ignore # initialized at startup
|
||||
_generation_worker_task: asyncio.Task | None = None
|
||||
_queued_generation_ids: set[str] = set()
|
||||
_running_generation_tasks: dict[str, asyncio.Task] = {}
|
||||
_cancelled_generation_ids: set[str] = set()
|
||||
|
||||
|
||||
def create_background_task(coro) -> asyncio.Task:
|
||||
@@ -24,25 +39,70 @@ def create_background_task(coro) -> asyncio.Task:
|
||||
async def _generation_worker():
|
||||
"""Worker that processes generation tasks one at a time."""
|
||||
while True:
|
||||
coro = await _generation_queue.get()
|
||||
job = await _generation_queue.get()
|
||||
try:
|
||||
await coro
|
||||
if job.generation_id in _cancelled_generation_ids:
|
||||
_cancelled_generation_ids.discard(job.generation_id)
|
||||
job.coro.close()
|
||||
continue
|
||||
|
||||
task = asyncio.create_task(job.coro)
|
||||
_running_generation_tasks[job.generation_id] = task
|
||||
_queued_generation_ids.discard(job.generation_id)
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
if not task.cancelled():
|
||||
raise
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
_running_generation_tasks.pop(job.generation_id, None)
|
||||
_queued_generation_ids.discard(job.generation_id)
|
||||
_generation_queue.task_done()
|
||||
|
||||
|
||||
def enqueue_generation(coro):
|
||||
def enqueue_generation(generation_id: str, coro):
|
||||
"""Add a generation coroutine to the serial queue."""
|
||||
_generation_queue.put_nowait(coro)
|
||||
if _generation_queue is None:
|
||||
raise RuntimeError("Generation queue has not been initialized")
|
||||
|
||||
_queued_generation_ids.add(generation_id)
|
||||
_generation_queue.put_nowait(GenerationJob(generation_id=generation_id, coro=coro))
|
||||
|
||||
|
||||
def init_queue():
|
||||
def cancel_generation(generation_id: str) -> Literal["queued", "running"] | None:
|
||||
"""Cancel a queued or running generation if it is still active."""
|
||||
running_task = _running_generation_tasks.get(generation_id)
|
||||
if running_task is not None:
|
||||
running_task.cancel()
|
||||
return "running"
|
||||
|
||||
if generation_id in _queued_generation_ids:
|
||||
_queued_generation_ids.discard(generation_id)
|
||||
_cancelled_generation_ids.add(generation_id)
|
||||
return "queued"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def init_queue(force: bool = False):
|
||||
"""Initialize the generation queue and start the worker.
|
||||
|
||||
Must be called once during application startup (inside a running event loop).
|
||||
"""
|
||||
global _generation_queue
|
||||
global _generation_queue, _generation_worker_task
|
||||
global _queued_generation_ids, _running_generation_tasks, _cancelled_generation_ids
|
||||
|
||||
if _generation_worker_task is not None and not _generation_worker_task.done():
|
||||
if not force:
|
||||
return
|
||||
_generation_worker_task.cancel()
|
||||
for task in list(_running_generation_tasks.values()):
|
||||
task.cancel()
|
||||
|
||||
_generation_queue = asyncio.Queue()
|
||||
create_background_task(_generation_worker())
|
||||
_queued_generation_ids = set()
|
||||
_running_generation_tasks = {}
|
||||
_cancelled_generation_ids = set()
|
||||
_generation_worker_task = create_background_task(_generation_worker())
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
# End-to-End Model Generation Test — Design
|
||||
|
||||
## Goal
|
||||
|
||||
A single script, runnable on macOS and Windows, that exercises every TTS model against the **frozen PyInstaller binary** (not the dev server), captures per-model pass/fail and error messages, and exits non-zero if any model fails. Generation is strictly sequential — one model loaded at a time.
|
||||
|
||||
## Test matrix (10 runs)
|
||||
|
||||
Derived from `backend/backends/__init__.py:185-316`. Each row maps to one `POST /generate` call.
|
||||
|
||||
| # | engine | model_size | profile kind | notes |
|
||||
|---|-----------------------|------------|--------------|-------|
|
||||
| 1 | `qwen` | `1.7B` | cloned | reference audio required |
|
||||
| 2 | `qwen` | `0.6B` | cloned | |
|
||||
| 3 | `qwen_custom_voice` | `1.7B` | preset | `preset_voice_id="Ryan"` |
|
||||
| 4 | `qwen_custom_voice` | `0.6B` | preset | `preset_voice_id="Ryan"` |
|
||||
| 5 | `luxtts` | — | cloned | English only |
|
||||
| 6 | `chatterbox` | — | cloned | |
|
||||
| 7 | `chatterbox_turbo` | — | cloned | English only |
|
||||
| 8 | `tada` | `1B` | cloned | tada-1b, English only |
|
||||
| 9 | `tada` | `3B` | cloned | tada-3b-ml, multilingual |
|
||||
| 10| `kokoro` | — | preset | `preset_voice_id="af_heart"` |
|
||||
|
||||
Cloned engines (1, 2, 5, 6, 7, 8, 9) share **one** profile created once with the reference WAV. Preset profiles are created separately, one for kokoro and one for qwen_custom_voice.
|
||||
|
||||
Language for every run: `en` (covers every engine's supported set).
|
||||
|
||||
## End-to-end flow
|
||||
|
||||
```
|
||||
1. Resolve paths → find binary, build if missing
|
||||
2. Launch binary → spawn with --port --data-dir --parent-pid
|
||||
3. Wait for /health → poll until status=="healthy" or 120s timeout
|
||||
4. Create profiles → 1 cloned + 2 preset, via /profiles (+ /samples)
|
||||
5. For each (engine, model_size) in matrix:
|
||||
a. Check cache → GET /models/status → cached? short timeout : long
|
||||
b. POST /generate → get generation_id
|
||||
c. Stream /status → consume SSE until completed/failed/timeout
|
||||
d. Record result → {engine, model_size, status, duration, error, elapsed}
|
||||
6. Write results → JSON + Markdown table to ./results/
|
||||
7. Shutdown binary → SIGTERM, fall back to kill, verify port freed
|
||||
8. Exit code → 0 if all passed, 1 otherwise
|
||||
```
|
||||
|
||||
## Binary resolution
|
||||
|
||||
Search order — **first hit wins**:
|
||||
|
||||
| Platform | Path | Build type |
|
||||
|----------|------|------------|
|
||||
| macOS | `backend/dist/voicebox-server-cuda/voicebox-server-cuda` | onedir (CUDA, rarely on Mac) |
|
||||
| macOS | `backend/dist/voicebox-server` | onefile (CPU) |
|
||||
| Windows | `backend\dist\voicebox-server-cuda\voicebox-server-cuda.exe` | onedir (CUDA) |
|
||||
| Windows | `backend\dist\voicebox-server.exe` | onefile (CPU) |
|
||||
|
||||
If none exist, run `python backend/build_binary.py` and wait for it to finish (can take 5-20 min). Fail with a clear error if the build itself fails. `--skip-build` flag forces "error out if no binary" instead of building.
|
||||
|
||||
## Spawn command
|
||||
|
||||
Mirrors Tauri's launch in `tauri/src-tauri/src/main.rs:369-388`:
|
||||
|
||||
```
|
||||
<binary> --host 127.0.0.1 --port <free-port> --data-dir <tempdir> --parent-pid <test-pid>
|
||||
```
|
||||
|
||||
- **Port**: bind to `0` first in Python to grab a free port, then pass that number.
|
||||
- **Data dir**: `tempfile.mkdtemp(prefix="voicebox-e2e-")`. Deleted after the run unless `--keep-data-dir`. Profiles and generated WAVs land here.
|
||||
- **Parent PID**: current Python PID — ensures the backend dies if the test crashes (watchdog in `server.py:102-224`).
|
||||
- **stdout/stderr**: tee to both a log file in `./results/server-<timestamp>.log` and a rolling in-memory buffer. On model failure, last 100 lines of the buffer are attached to that model's error record.
|
||||
|
||||
## Profile setup
|
||||
|
||||
One cloned profile shared across all cloning engines:
|
||||
|
||||
```http
|
||||
POST /profiles
|
||||
{
|
||||
"name": "e2e-cloned",
|
||||
"voice_type": "cloned",
|
||||
"language": "en"
|
||||
}
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```http
|
||||
POST /profiles/{id}/samples (multipart)
|
||||
file: <reference WAV>
|
||||
reference_text: <exact transcription>
|
||||
```
|
||||
|
||||
Two preset profiles:
|
||||
|
||||
```http
|
||||
POST /profiles
|
||||
{ "name": "e2e-kokoro", "voice_type": "preset", "language": "en",
|
||||
"preset_engine": "kokoro", "preset_voice_id": "af_heart" }
|
||||
|
||||
POST /profiles
|
||||
{ "name": "e2e-qwen-cv", "voice_type": "preset", "language": "en",
|
||||
"preset_engine": "qwen_custom_voice", "preset_voice_id": "Ryan" }
|
||||
```
|
||||
|
||||
## Generation request (per matrix row)
|
||||
|
||||
```http
|
||||
POST /generate
|
||||
{
|
||||
"profile_id": "<appropriate profile>",
|
||||
"text": "The quick brown fox jumps over the lazy dog.",
|
||||
"language": "en",
|
||||
"engine": "<engine>",
|
||||
"model_size": "<size or omitted>",
|
||||
"seed": 42,
|
||||
"normalize": true
|
||||
}
|
||||
```
|
||||
|
||||
Response `id` feeds into the SSE status loop (`GET /generate/{id}/status`, `routes/generations.py:190-227`). Loop reads lines until a payload with `status in ("completed", "failed")` arrives, then breaks.
|
||||
|
||||
## Timeout strategy (split)
|
||||
|
||||
Check `GET /models/status` for the target model **before** generation:
|
||||
|
||||
| Cached? | Per-model timeout | Rationale |
|
||||
|---------|-------------------|-----------|
|
||||
| Yes | **3 minutes** | Inference only; generous for CPU builds |
|
||||
| No | **20 minutes** | First-run HF download up to 8 GB (tada-3b-ml) |
|
||||
|
||||
On timeout: cancel the SSE stream, mark the row `timeout`, and continue to the next row. Don't abort the whole run on one timeout.
|
||||
|
||||
## Result format
|
||||
|
||||
`./results/e2e-<platform>-<arch>-<timestamp>.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"platform": "darwin-arm64",
|
||||
"binary": "/abs/path/voicebox-server",
|
||||
"binary_size_mb": 612,
|
||||
"started_at": "2026-04-16T12:34:56Z",
|
||||
"finished_at": "...",
|
||||
"results": [
|
||||
{
|
||||
"engine": "qwen",
|
||||
"model_size": "1.7B",
|
||||
"status": "passed|failed|timeout",
|
||||
"generation_id": "...",
|
||||
"was_cached": true,
|
||||
"elapsed_seconds": 12.4,
|
||||
"audio_duration": 3.1,
|
||||
"audio_path": "/tmp/.../gen.wav",
|
||||
"error": null,
|
||||
"server_log_tail": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Companion `./results/e2e-<...>.md`:
|
||||
|
||||
```
|
||||
# Voicebox E2E — darwin-arm64 — 2026-04-16 12:34
|
||||
|
||||
| Engine | Size | Status | Elapsed | Error |
|
||||
|---------------------|------|--------|---------|-------|
|
||||
| qwen | 1.7B | PASS | 12.4s | |
|
||||
| qwen | 0.6B | FAIL | 4.1s | CUDA OOM: ... |
|
||||
...
|
||||
```
|
||||
|
||||
## CLI flags
|
||||
|
||||
```
|
||||
python -m backend.tests.test_all_models_e2e [flags]
|
||||
|
||||
--binary PATH Use this binary instead of auto-detecting
|
||||
--skip-build Error if no binary found (no auto-build)
|
||||
--reference-wav PATH Reference audio (default: backend/tests/fixtures/reference_voice.wav)
|
||||
--reference-text STR Transcription (default: read from fixtures/reference_voice.txt)
|
||||
--only ENGINE[,...] Run only these engines (e.g. kokoro,qwen)
|
||||
--skip ENGINE[,...] Skip these engines
|
||||
--keep-data-dir Don't delete tempdir after run
|
||||
--timeout-cached SEC Override 180
|
||||
--timeout-download SEC Override 1200
|
||||
--port N Override auto-picked port
|
||||
--output-dir PATH Default: backend/tests/results/
|
||||
```
|
||||
|
||||
## File layout
|
||||
|
||||
```
|
||||
backend/tests/
|
||||
├── E2E_MODEL_TEST_DESIGN.md (this file)
|
||||
├── test_all_models_e2e.py (main script, ~400-500 LoC)
|
||||
├── fixtures/
|
||||
│ ├── reference_voice.wav (user-provided, ~5-15s clean speech)
|
||||
│ └── reference_voice.txt (exact transcription)
|
||||
└── results/ (gitignored)
|
||||
├── e2e-darwin-arm64-<ts>.json
|
||||
├── e2e-darwin-arm64-<ts>.md
|
||||
└── server-<ts>.log
|
||||
```
|
||||
|
||||
The script uses only stdlib + `httpx` (or `requests`) + `sseclient-py` — all already in `backend/requirements.txt`. No pytest to keep it invocable as a single command on fresh checkouts.
|
||||
|
||||
## Safety & cleanup
|
||||
|
||||
- Always kill the spawned binary in a `try/finally`. On Windows, `taskkill /F /T` the whole tree (Tauri does the same).
|
||||
- Verify the port is free on shutdown (Tauri port-reuse check in `main.rs:114-186` could otherwise pick up a ghost).
|
||||
- Don't touch the user's HF cache by default — let the server use `HF_HUB_CACHE` / `VOICEBOX_MODELS_DIR`. Passing `--isolated-cache` would point both env vars at the tempdir for a true cold-start run (opt-in only; would re-download every time).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Not validating audio quality (no WER, no waveform comparison). Pass = "endpoint returned `completed` and produced a non-empty WAV".
|
||||
- Not testing STT (Whisper), effects chains, channels, or streaming endpoints.
|
||||
- Not running on CI today — human-invoked on dev machines. CI integration is a follow-up once the script is stable.
|
||||
- No model unload between runs — models stay loaded; server manages its own eviction.
|
||||
- No version-drift check on the binary.
|
||||
- No `instruct` parameter exercised on qwen_custom_voice runs.
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
# E2E Test Fixtures
|
||||
|
||||
Place two files here before running `test_all_models_e2e.py`:
|
||||
|
||||
- `reference_voice.wav` — a clean speech sample, mono, 16–24 kHz, ~5–15 seconds.
|
||||
- `reference_voice.txt` — the **exact** transcription of the WAV (single line, no trailing newline required).
|
||||
|
||||
These are used to create a cloned voice profile for every cloning-capable engine (qwen, luxtts, chatterbox, chatterbox_turbo, tada). Keep them out of version control if they contain personal audio — this directory is not gitignored by default, so add them to `.gitignore` locally if needed.
|
||||
|
||||
You can point the test at different files with:
|
||||
|
||||
```
|
||||
python backend/tests/test_all_models_e2e.py \
|
||||
--reference-wav /path/to/your.wav \
|
||||
--reference-text "exact transcription here"
|
||||
```
|
||||
@@ -0,0 +1,630 @@
|
||||
"""
|
||||
End-to-end model generation test.
|
||||
|
||||
Exercises every TTS model against the frozen PyInstaller binary, captures
|
||||
per-model pass/fail, and writes a JSON + Markdown report.
|
||||
|
||||
Usage:
|
||||
python backend/tests/test_all_models_e2e.py [flags]
|
||||
|
||||
See E2E_MODEL_TEST_DESIGN.md for the full design.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
BACKEND_DIR = REPO_ROOT / "backend"
|
||||
DIST_DIR = BACKEND_DIR / "dist"
|
||||
FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures"
|
||||
RESULTS_DIR = Path(__file__).resolve().parent / "results"
|
||||
|
||||
|
||||
# ── Test matrix ──────────────────────────────────────────────────────
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MatrixRow:
|
||||
label: str # human-readable (appears in report)
|
||||
engine: str # /generate engine
|
||||
model_size: Optional[str] # /generate model_size (None = omit)
|
||||
profile_kind: str # "cloned" | "preset_kokoro" | "preset_qwen_cv"
|
||||
model_name: str # /models/status key for cache lookup
|
||||
|
||||
|
||||
MATRIX: list[MatrixRow] = [
|
||||
MatrixRow("qwen 1.7B", "qwen", "1.7B", "cloned", "qwen-tts-1.7B"),
|
||||
MatrixRow("qwen 0.6B", "qwen", "0.6B", "cloned", "qwen-tts-0.6B"),
|
||||
MatrixRow("qwen_custom_voice 1.7B", "qwen_custom_voice", "1.7B", "preset_qwen_cv", "qwen-custom-voice-1.7B"),
|
||||
MatrixRow("qwen_custom_voice 0.6B", "qwen_custom_voice", "0.6B", "preset_qwen_cv", "qwen-custom-voice-0.6B"),
|
||||
MatrixRow("luxtts", "luxtts", None, "cloned", "luxtts"),
|
||||
MatrixRow("chatterbox", "chatterbox", None, "cloned", "chatterbox-tts"),
|
||||
MatrixRow("chatterbox_turbo", "chatterbox_turbo", None, "cloned", "chatterbox-turbo"),
|
||||
MatrixRow("tada 1B", "tada", "1B", "cloned", "tada-1b"),
|
||||
MatrixRow("tada 3B", "tada", "3B", "cloned", "tada-3b-ml"),
|
||||
MatrixRow("kokoro", "kokoro", None, "preset_kokoro", "kokoro"),
|
||||
]
|
||||
|
||||
TEXT = "The quick brown fox jumps over the lazy dog."
|
||||
DEFAULT_TIMEOUT_CACHED = 180
|
||||
DEFAULT_TIMEOUT_DOWNLOAD = 1200
|
||||
HEALTH_TIMEOUT = 120
|
||||
|
||||
|
||||
# ── Result record ────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class ModelResult:
|
||||
label: str
|
||||
engine: str
|
||||
model_size: Optional[str]
|
||||
status: str # "passed" | "failed" | "timeout"
|
||||
was_cached: Optional[bool] = None
|
||||
generation_id: Optional[str] = None
|
||||
elapsed_seconds: float = 0.0
|
||||
audio_duration: Optional[float] = None
|
||||
audio_path: Optional[str] = None
|
||||
audio_bytes: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
http_status: Optional[int] = None
|
||||
server_log_tail: Optional[list[str]] = None
|
||||
|
||||
|
||||
# ── Binary resolution ────────────────────────────────────────────────
|
||||
|
||||
def find_binary() -> Optional[Path]:
|
||||
"""Return the first existing binary in priority order, or None."""
|
||||
is_win = platform.system() == "Windows"
|
||||
exe = ".exe" if is_win else ""
|
||||
candidates = [
|
||||
DIST_DIR / "voicebox-server-cuda" / f"voicebox-server-cuda{exe}",
|
||||
DIST_DIR / f"voicebox-server{exe}",
|
||||
]
|
||||
for c in candidates:
|
||||
if c.exists() and c.is_file():
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def build_binary() -> Path:
|
||||
"""Invoke build_binary.py and return the resulting binary path."""
|
||||
print("[build] No frozen binary found — invoking build_binary.py (this may take 5-20 minutes)...", flush=True)
|
||||
script = BACKEND_DIR / "build_binary.py"
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(script)],
|
||||
cwd=str(BACKEND_DIR),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"build_binary.py exited with code {result.returncode}")
|
||||
found = find_binary()
|
||||
if found is None:
|
||||
raise RuntimeError("build_binary.py finished but no binary was found in backend/dist/")
|
||||
return found
|
||||
|
||||
|
||||
# ── Server spawn + log capture ───────────────────────────────────────
|
||||
|
||||
class ServerProcess:
|
||||
def __init__(self, binary: Path, port: int, data_dir: Path, log_path: Path):
|
||||
self.binary = binary
|
||||
self.port = port
|
||||
self.data_dir = data_dir
|
||||
self.log_path = log_path
|
||||
self.proc: Optional[subprocess.Popen] = None
|
||||
self._log_buffer: deque[str] = deque(maxlen=500)
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
|
||||
def start(self) -> None:
|
||||
args = [
|
||||
str(self.binary),
|
||||
"--host", "127.0.0.1",
|
||||
"--port", str(self.port),
|
||||
"--data-dir", str(self.data_dir),
|
||||
"--parent-pid", str(os.getpid()),
|
||||
]
|
||||
print(f"[spawn] {' '.join(args)}", flush=True)
|
||||
self._log_fh = open(self.log_path, "w", encoding="utf-8", errors="replace")
|
||||
# Combine stderr into stdout so we get a single ordered stream.
|
||||
self.proc = subprocess.Popen(
|
||||
args,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
bufsize=1,
|
||||
text=True,
|
||||
errors="replace",
|
||||
)
|
||||
self._reader_thread = threading.Thread(target=self._pump_logs, daemon=True)
|
||||
self._reader_thread.start()
|
||||
|
||||
def _pump_logs(self) -> None:
|
||||
assert self.proc is not None and self.proc.stdout is not None
|
||||
for line in self.proc.stdout:
|
||||
self._log_buffer.append(line.rstrip("\n"))
|
||||
self._log_fh.write(line)
|
||||
self._log_fh.flush()
|
||||
|
||||
def log_tail(self, n: int = 100) -> list[str]:
|
||||
tail = list(self._log_buffer)[-n:]
|
||||
return tail
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
return self.proc is not None and self.proc.poll() is None
|
||||
|
||||
def stop(self) -> None:
|
||||
if self.proc is None:
|
||||
return
|
||||
if self.proc.poll() is not None:
|
||||
return
|
||||
try:
|
||||
if platform.system() == "Windows":
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/T", "/PID", str(self.proc.pid)],
|
||||
capture_output=True,
|
||||
)
|
||||
else:
|
||||
self.proc.send_signal(signal.SIGTERM)
|
||||
except Exception as e:
|
||||
print(f"[shutdown] signal failed: {e}", flush=True)
|
||||
try:
|
||||
self.proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
print("[shutdown] server didn't exit cleanly, killing", flush=True)
|
||||
self.proc.kill()
|
||||
try:
|
||||
self.proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
if self._reader_thread is not None:
|
||||
self._reader_thread.join(timeout=2)
|
||||
try:
|
||||
self._log_fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def pick_free_port() -> int:
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
return port
|
||||
|
||||
|
||||
# ── HTTP helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def wait_for_health(base_url: str, server: ServerProcess, timeout: int) -> None:
|
||||
deadline = time.time() + timeout
|
||||
with httpx.Client(timeout=5.0) as client:
|
||||
while time.time() < deadline:
|
||||
if not server.is_alive():
|
||||
raise RuntimeError("Server process exited before becoming healthy")
|
||||
try:
|
||||
r = client.get(f"{base_url}/health")
|
||||
if r.status_code == 200 and r.json().get("status") == "healthy":
|
||||
return
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
time.sleep(1.0)
|
||||
raise TimeoutError(f"Server did not become healthy within {timeout}s")
|
||||
|
||||
|
||||
def get_model_cached(client: httpx.Client, base_url: str, model_name: str) -> Optional[bool]:
|
||||
try:
|
||||
r = client.get(f"{base_url}/models/status", timeout=30.0)
|
||||
r.raise_for_status()
|
||||
for m in r.json().get("models", []):
|
||||
if m.get("model_name") == model_name:
|
||||
return bool(m.get("downloaded"))
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def create_cloned_profile(client: httpx.Client, base_url: str, wav_path: Path, reference_text: str) -> str:
|
||||
r = client.post(f"{base_url}/profiles", json={
|
||||
"name": "e2e-cloned",
|
||||
"voice_type": "cloned",
|
||||
"language": "en",
|
||||
})
|
||||
r.raise_for_status()
|
||||
profile_id = r.json()["id"]
|
||||
|
||||
with open(wav_path, "rb") as f:
|
||||
r = client.post(
|
||||
f"{base_url}/profiles/{profile_id}/samples",
|
||||
files={"file": (wav_path.name, f, "audio/wav")},
|
||||
data={"reference_text": reference_text},
|
||||
timeout=120.0,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return profile_id
|
||||
|
||||
|
||||
def create_preset_profile(client: httpx.Client, base_url: str, name: str, engine: str, voice_id: str) -> str:
|
||||
r = client.post(f"{base_url}/profiles", json={
|
||||
"name": name,
|
||||
"voice_type": "preset",
|
||||
"language": "en",
|
||||
"preset_engine": engine,
|
||||
"preset_voice_id": voice_id,
|
||||
})
|
||||
r.raise_for_status()
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def run_one_generation(
|
||||
client: httpx.Client,
|
||||
base_url: str,
|
||||
row: MatrixRow,
|
||||
profile_id: str,
|
||||
timeout_s: int,
|
||||
) -> tuple[str, dict]:
|
||||
"""Start a generation and stream its status until done/failed/timeout.
|
||||
|
||||
Returns (status, payload) where status is "completed" | "failed" | "timeout".
|
||||
"""
|
||||
body = {
|
||||
"profile_id": profile_id,
|
||||
"text": TEXT,
|
||||
"language": "en",
|
||||
"engine": row.engine,
|
||||
"seed": 42,
|
||||
"normalize": True,
|
||||
}
|
||||
if row.model_size is not None:
|
||||
body["model_size"] = row.model_size
|
||||
|
||||
r = client.post(f"{base_url}/generate", json=body, timeout=30.0)
|
||||
r.raise_for_status()
|
||||
gen = r.json()
|
||||
gen_id = gen["id"]
|
||||
|
||||
deadline = time.time() + timeout_s
|
||||
last_payload: dict = gen
|
||||
status_url = f"{base_url}/generate/{gen_id}/status"
|
||||
|
||||
while time.time() < deadline:
|
||||
remaining = max(1.0, deadline - time.time())
|
||||
try:
|
||||
with client.stream("GET", status_url, timeout=httpx.Timeout(remaining + 5, read=remaining + 5)) as resp:
|
||||
resp.raise_for_status()
|
||||
for line in resp.iter_lines():
|
||||
if not line or not line.startswith("data: "):
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(line[6:])
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
last_payload = payload
|
||||
status = payload.get("status")
|
||||
if status == "not_found":
|
||||
return "failed", {"error": "generation not found", **payload}
|
||||
if status in ("completed", "failed"):
|
||||
return status, payload
|
||||
if time.time() >= deadline:
|
||||
break
|
||||
except httpx.HTTPError:
|
||||
time.sleep(1.0)
|
||||
continue
|
||||
|
||||
return "timeout", last_payload
|
||||
|
||||
|
||||
def fetch_audio_info(
|
||||
client: httpx.Client, base_url: str, generation_id: str, data_dir: Path
|
||||
) -> tuple[Optional[str], Optional[int]]:
|
||||
"""Return (audio_path, audio_bytes) for a completed generation.
|
||||
|
||||
Server stores audio_path relative to data_dir; resolve it to get a size.
|
||||
"""
|
||||
try:
|
||||
r = client.get(f"{base_url}/history/{generation_id}", timeout=10.0)
|
||||
if r.status_code != 200:
|
||||
return None, None
|
||||
data = r.json()
|
||||
audio_path = data.get("audio_path")
|
||||
if not audio_path:
|
||||
return None, None
|
||||
p = Path(audio_path)
|
||||
if not p.is_absolute():
|
||||
p = data_dir / p
|
||||
if p.exists():
|
||||
return str(p), p.stat().st_size
|
||||
return audio_path, None
|
||||
except httpx.HTTPError:
|
||||
return None, None
|
||||
|
||||
|
||||
# ── Report writers ───────────────────────────────────────────────────
|
||||
|
||||
def write_reports(
|
||||
output_dir: Path,
|
||||
binary: Path,
|
||||
started_at: datetime,
|
||||
finished_at: datetime,
|
||||
results: list[ModelResult],
|
||||
) -> tuple[Path, Path]:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
plat = f"{platform.system().lower()}-{platform.machine().lower()}"
|
||||
ts = started_at.strftime("%Y%m%d-%H%M%S")
|
||||
json_path = output_dir / f"e2e-{plat}-{ts}.json"
|
||||
md_path = output_dir / f"e2e-{plat}-{ts}.md"
|
||||
|
||||
doc = {
|
||||
"platform": plat,
|
||||
"binary": str(binary),
|
||||
"binary_size_mb": round(binary.stat().st_size / (1024 * 1024), 1) if binary.exists() else None,
|
||||
"started_at": started_at.isoformat(),
|
||||
"finished_at": finished_at.isoformat(),
|
||||
"elapsed_seconds": (finished_at - started_at).total_seconds(),
|
||||
"results": [asdict(r) for r in results],
|
||||
}
|
||||
json_path.write_text(json.dumps(doc, indent=2))
|
||||
|
||||
lines = [
|
||||
f"# Voicebox E2E — {plat} — {started_at.strftime('%Y-%m-%d %H:%M UTC')}",
|
||||
"",
|
||||
f"Binary: `{binary}` ",
|
||||
f"Elapsed: {doc['elapsed_seconds']:.1f}s",
|
||||
"",
|
||||
"| Model | Status | Cached | Elapsed | Audio | Error |",
|
||||
"|-------|--------|--------|---------|-------|-------|",
|
||||
]
|
||||
for r in results:
|
||||
status_icon = {"passed": "PASS", "failed": "FAIL", "timeout": "TIMEOUT"}.get(r.status, r.status.upper())
|
||||
cached = "yes" if r.was_cached else ("no" if r.was_cached is False else "?")
|
||||
audio_col = f"{r.audio_duration:.2f}s" if r.audio_duration else ("—" if r.status != "passed" else "?")
|
||||
error_col = (r.error or "").replace("\n", " ")[:120]
|
||||
lines.append(f"| {r.label} | {status_icon} | {cached} | {r.elapsed_seconds:.1f}s | {audio_col} | {error_col} |")
|
||||
|
||||
failed_rows = [r for r in results if r.status != "passed"]
|
||||
if failed_rows:
|
||||
lines.append("")
|
||||
lines.append("## Failures")
|
||||
for r in failed_rows:
|
||||
lines.append("")
|
||||
lines.append(f"### {r.label} — {r.status}")
|
||||
if r.error:
|
||||
lines.append("")
|
||||
lines.append("```")
|
||||
lines.append(r.error)
|
||||
lines.append("```")
|
||||
if r.server_log_tail:
|
||||
lines.append("")
|
||||
lines.append("<details><summary>server log (last lines)</summary>")
|
||||
lines.append("")
|
||||
lines.append("```")
|
||||
lines.extend(r.server_log_tail)
|
||||
lines.append("```")
|
||||
lines.append("</details>")
|
||||
|
||||
md_path.write_text("\n".join(lines) + "\n")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="Voicebox E2E model generation test")
|
||||
p.add_argument("--binary", type=Path, help="Path to voicebox-server binary (overrides auto-detect)")
|
||||
p.add_argument("--skip-build", action="store_true", help="Error if binary missing instead of building")
|
||||
p.add_argument(
|
||||
"--reference-wav",
|
||||
type=Path,
|
||||
default=FIXTURES_DIR / "reference_voice.wav",
|
||||
help="Reference audio for cloning engines",
|
||||
)
|
||||
p.add_argument(
|
||||
"--reference-text",
|
||||
help="Transcription of reference-wav (default: read from fixtures/reference_voice.txt)",
|
||||
)
|
||||
p.add_argument("--only", help="Comma-separated engines to run (e.g. kokoro,qwen)")
|
||||
p.add_argument("--skip", help="Comma-separated engines to skip")
|
||||
p.add_argument("--keep-data-dir", action="store_true", help="Don't delete tempdir after run")
|
||||
p.add_argument("--timeout-cached", type=int, default=DEFAULT_TIMEOUT_CACHED)
|
||||
p.add_argument("--timeout-download", type=int, default=DEFAULT_TIMEOUT_DOWNLOAD)
|
||||
p.add_argument("--port", type=int, help="Override auto-picked port")
|
||||
p.add_argument("--output-dir", type=Path, default=RESULTS_DIR)
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def filter_matrix(args: argparse.Namespace) -> list[MatrixRow]:
|
||||
only = set(x.strip() for x in args.only.split(",")) if args.only else None
|
||||
skip = set(x.strip() for x in args.skip.split(",")) if args.skip else set()
|
||||
rows = []
|
||||
for r in MATRIX:
|
||||
if only is not None and r.engine not in only:
|
||||
continue
|
||||
if r.engine in skip:
|
||||
continue
|
||||
rows.append(r)
|
||||
return rows
|
||||
|
||||
|
||||
def resolve_reference(args: argparse.Namespace) -> tuple[Path, str]:
|
||||
wav = args.reference_wav
|
||||
if not wav.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Reference WAV not found: {wav}\n"
|
||||
f"Place a sample at {FIXTURES_DIR / 'reference_voice.wav'} or pass --reference-wav.\n"
|
||||
f"See backend/tests/fixtures/README.md."
|
||||
)
|
||||
if args.reference_text:
|
||||
text = args.reference_text
|
||||
else:
|
||||
txt_path = wav.with_suffix(".txt")
|
||||
if not txt_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Reference transcription not found: {txt_path}\n"
|
||||
f"Create it next to the WAV, or pass --reference-text."
|
||||
)
|
||||
text = txt_path.read_text().strip()
|
||||
if not text:
|
||||
raise ValueError("Reference transcription is empty")
|
||||
return wav, text
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
rows = filter_matrix(args)
|
||||
if not rows:
|
||||
print("No rows selected after --only/--skip filtering", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# Binary
|
||||
binary = args.binary or find_binary()
|
||||
if binary is None:
|
||||
if args.skip_build:
|
||||
print("No frozen binary found and --skip-build set. Run: python backend/build_binary.py", file=sys.stderr)
|
||||
return 2
|
||||
binary = build_binary()
|
||||
if not binary.exists():
|
||||
print(f"Binary path does not exist: {binary}", file=sys.stderr)
|
||||
return 2
|
||||
print(f"[binary] {binary}", flush=True)
|
||||
|
||||
# Reference audio (only required if any cloning row is in the matrix)
|
||||
needs_reference = any(r.profile_kind == "cloned" for r in rows)
|
||||
ref_wav: Optional[Path] = None
|
||||
ref_text: Optional[str] = None
|
||||
if needs_reference:
|
||||
try:
|
||||
ref_wav, ref_text = resolve_reference(args)
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
print(f"[fixture] {e}", file=sys.stderr)
|
||||
return 2
|
||||
print(f"[fixture] reference WAV: {ref_wav}", flush=True)
|
||||
print(f"[fixture] reference text: {ref_text!r}", flush=True)
|
||||
|
||||
# Tempdir + log path
|
||||
data_dir = Path(tempfile.mkdtemp(prefix="voicebox-e2e-"))
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
log_path = args.output_dir / f"server-{ts}.log"
|
||||
|
||||
port = args.port or pick_free_port()
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
|
||||
server = ServerProcess(binary=binary, port=port, data_dir=data_dir, log_path=log_path)
|
||||
started_at = datetime.now(timezone.utc)
|
||||
results: list[ModelResult] = []
|
||||
|
||||
try:
|
||||
server.start()
|
||||
print(f"[health] waiting for {base_url}/health ...", flush=True)
|
||||
wait_for_health(base_url, server, HEALTH_TIMEOUT)
|
||||
print("[health] ready", flush=True)
|
||||
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
# Profile setup (only create what's needed)
|
||||
cloned_profile_id: Optional[str] = None
|
||||
kokoro_profile_id: Optional[str] = None
|
||||
qwen_cv_profile_id: Optional[str] = None
|
||||
needed_kinds = {r.profile_kind for r in rows}
|
||||
if "cloned" in needed_kinds:
|
||||
assert ref_wav is not None and ref_text is not None
|
||||
print("[profile] creating cloned profile...", flush=True)
|
||||
cloned_profile_id = create_cloned_profile(client, base_url, ref_wav, ref_text)
|
||||
if "preset_kokoro" in needed_kinds:
|
||||
print("[profile] creating kokoro preset...", flush=True)
|
||||
kokoro_profile_id = create_preset_profile(client, base_url, "e2e-kokoro", "kokoro", "af_heart")
|
||||
if "preset_qwen_cv" in needed_kinds:
|
||||
print("[profile] creating qwen_custom_voice preset...", flush=True)
|
||||
qwen_cv_profile_id = create_preset_profile(client, base_url, "e2e-qwen-cv", "qwen_custom_voice", "Ryan")
|
||||
|
||||
profile_lookup = {
|
||||
"cloned": cloned_profile_id,
|
||||
"preset_kokoro": kokoro_profile_id,
|
||||
"preset_qwen_cv": qwen_cv_profile_id,
|
||||
}
|
||||
|
||||
# Matrix loop
|
||||
for row in rows:
|
||||
print(f"\n[run] {row.label} (engine={row.engine}, size={row.model_size})", flush=True)
|
||||
profile_id = profile_lookup[row.profile_kind]
|
||||
assert profile_id is not None
|
||||
was_cached = get_model_cached(client, base_url, row.model_name)
|
||||
timeout_s = args.timeout_cached if was_cached else args.timeout_download
|
||||
print(f"[run] cached={was_cached} timeout={timeout_s}s", flush=True)
|
||||
|
||||
t0 = time.time()
|
||||
result = ModelResult(
|
||||
label=row.label,
|
||||
engine=row.engine,
|
||||
model_size=row.model_size,
|
||||
status="failed",
|
||||
was_cached=was_cached,
|
||||
)
|
||||
try:
|
||||
status, payload = run_one_generation(client, base_url, row, profile_id, timeout_s)
|
||||
result.status = "passed" if status == "completed" else status
|
||||
result.generation_id = payload.get("id")
|
||||
result.audio_duration = payload.get("duration")
|
||||
result.error = payload.get("error")
|
||||
if status == "completed" and result.generation_id:
|
||||
audio_path, audio_bytes = fetch_audio_info(
|
||||
client, base_url, result.generation_id, data_dir
|
||||
)
|
||||
result.audio_path = audio_path
|
||||
result.audio_bytes = audio_bytes
|
||||
if audio_bytes is not None and audio_bytes == 0:
|
||||
result.status = "failed"
|
||||
result.error = (result.error or "") + " (audio file is empty)"
|
||||
except httpx.HTTPStatusError as e:
|
||||
result.status = "failed"
|
||||
result.http_status = e.response.status_code
|
||||
try:
|
||||
detail = e.response.json().get("detail")
|
||||
except Exception:
|
||||
detail = e.response.text
|
||||
result.error = f"HTTP {e.response.status_code}: {detail}"
|
||||
except Exception as e:
|
||||
result.status = "failed"
|
||||
result.error = f"{type(e).__name__}: {e}"
|
||||
|
||||
result.elapsed_seconds = round(time.time() - t0, 2)
|
||||
if result.status != "passed":
|
||||
result.server_log_tail = server.log_tail(100)
|
||||
print(f"[run] {row.label} → {result.status} in {result.elapsed_seconds}s"
|
||||
+ (f" ({result.error})" if result.error else ""), flush=True)
|
||||
results.append(result)
|
||||
finally:
|
||||
finished_at = datetime.now(timezone.utc)
|
||||
server.stop()
|
||||
if not args.keep_data_dir:
|
||||
shutil.rmtree(data_dir, ignore_errors=True)
|
||||
else:
|
||||
print(f"[cleanup] keeping data dir: {data_dir}", flush=True)
|
||||
|
||||
json_path, md_path = write_reports(args.output_dir, binary, started_at, finished_at, results)
|
||||
print(f"\n[report] {json_path}")
|
||||
print(f"[report] {md_path}")
|
||||
print(f"[report] server log: {log_path}")
|
||||
|
||||
passed = sum(1 for r in results if r.status == "passed")
|
||||
failed = len(results) - passed
|
||||
print(f"\n== {passed} passed, {failed} failed ==")
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user