mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-26 21:55:15 -07:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+1
-1
@@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.4.0
|
||||
current_version = 0.4.3
|
||||
commit = True
|
||||
tag = True
|
||||
tag_name = v{new_version}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+87
-1
@@ -7,6 +7,89 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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.
|
||||
@@ -555,7 +638,10 @@ 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.4.0...HEAD
|
||||
[Unreleased]: https://github.com/jamiepine/voicebox/compare/v0.4.3...HEAD
|
||||
[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
|
||||
|
||||
+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
|
||||
|
||||
@@ -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,21 +92,25 @@ 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
|
||||
|
||||
@@ -236,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) |
|
||||
@@ -255,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.4.0",
|
||||
"version": "0.4.3",
|
||||
"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';
|
||||
@@ -26,6 +27,7 @@ export function AppFrame({ children }: AppFrameProps) {
|
||||
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
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMatchRoute } from '@tanstack/react-router';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Loader2, SlidersHorizontal, Sparkles } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||
import {
|
||||
@@ -34,6 +35,7 @@ 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);
|
||||
@@ -146,7 +148,7 @@ export function FloatingGenerateBox({
|
||||
// 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 (presetEngines.has(currentEngine)) {
|
||||
if (currentEngine && presetEngines.has(currentEngine)) {
|
||||
form.setValue('engine', 'qwen');
|
||||
}
|
||||
}
|
||||
@@ -276,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={{
|
||||
@@ -302,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={{
|
||||
@@ -334,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 ? (
|
||||
@@ -348,10 +354,10 @@ 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>
|
||||
|
||||
@@ -379,15 +385,15 @@ export function FloatingGenerateBox({
|
||||
)}
|
||||
aria-label={
|
||||
isInstructExpanded
|
||||
? 'Hide delivery instructions'
|
||||
: 'Show delivery instructions'
|
||||
? 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]">
|
||||
Delivery instructions (tone, emotion, pace)
|
||||
{t('generation.instruct.tooltip')}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -414,7 +420,7 @@ export function FloatingGenerateBox({
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="Delivery instructions — e.g. Speak slowly with warmth, Authoritative and clear..."
|
||||
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}
|
||||
@@ -444,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) => (
|
||||
@@ -498,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) => (
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
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';
|
||||
@@ -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);
|
||||
@@ -671,46 +673,47 @@ export function HistoryTable() {
|
||||
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"
|
||||
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)}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Play
|
||||
{t('history.actions.play')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDownloadAudio(gen.id, gen.text)}
|
||||
disabled={exportGenerationAudio.isPending}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export Audio
|
||||
{t('history.actions.exportAudio')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExportPackage(gen.id, gen.text)}
|
||||
disabled={exportGeneration.isPending}
|
||||
>
|
||||
<FileArchive className="mr-2 h-4 w-4" />
|
||||
Export Package
|
||||
{t('history.actions.exportPackage')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleApplyEffects(gen.id)}>
|
||||
<Wand2 className="mr-2 h-4 w-4" />
|
||||
Apply Effects
|
||||
{t('history.actions.applyEffects')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleRegenerate(gen.id)}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Regenerate
|
||||
{t('history.actions.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
|
||||
{t('common.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -802,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>
|
||||
@@ -816,14 +818,14 @@ 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>
|
||||
@@ -832,23 +834,23 @@ export function HistoryTable() {
|
||||
<Dialog open={clearFailedDialogOpen} onOpenChange={setClearFailedDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Clear failed generations</DialogTitle>
|
||||
<DialogTitle>{t('history.clearFailedDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will permanently delete {failedCount} failed{' '}
|
||||
{failedCount === 1 ? 'generation' : 'generations'} from your history. This cannot be
|
||||
undone.
|
||||
{t('history.clearFailedDialog.body', { count: failedCount })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setClearFailedDialogOpen(false)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleClearFailedConfirm}
|
||||
disabled={clearFailed.isPending}
|
||||
>
|
||||
{clearFailed.isPending ? 'Clearing...' : 'Clear all'}
|
||||
{clearFailed.isPending
|
||||
? t('history.clearFailedDialog.clearing')
|
||||
: t('history.clearFailedDialog.clearAll')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -857,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>
|
||||
@@ -873,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>
|
||||
@@ -888,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) => (
|
||||
@@ -924,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,7 +989,7 @@ export function ModelManagement() {
|
||||
total: 0,
|
||||
progress: 0,
|
||||
status: 'downloading',
|
||||
filename: 'Preparing...',
|
||||
filename: t('models.migrateDialog.preparing'),
|
||||
});
|
||||
try {
|
||||
// Start the migration (background task)
|
||||
@@ -984,8 +1000,8 @@ export function ModelManagement() {
|
||||
setMigrating(false);
|
||||
setMigrationProgress(null);
|
||||
toast({
|
||||
title: 'No models to migrate',
|
||||
description: 'Download at least one model before changing the storage location.',
|
||||
title: t('models.toast.noModelsToMigrate'),
|
||||
description: t('models.toast.noModelsToMigrateDescription'),
|
||||
});
|
||||
setPendingMigrateDir(null);
|
||||
return;
|
||||
@@ -1003,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 */
|
||||
@@ -1011,7 +1027,7 @@ export function ModelManagement() {
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
reject(new Error('Lost connection during migration'));
|
||||
reject(new Error(t('models.toast.migrationConnectionLost')));
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1021,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 {
|
||||
@@ -1038,7 +1055,7 @@ export function ModelManagement() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
Move Models
|
||||
{t('models.migrateDialog.action')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -1050,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 && (
|
||||
@@ -1075,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,6 +177,7 @@ 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;
|
||||
@@ -185,7 +187,9 @@ function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
|
||||
<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);
|
||||
@@ -118,21 +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"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Remove from Story
|
||||
{t('storyContent.itemActions.removeFromStory')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
@@ -48,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
|
||||
@@ -90,10 +92,10 @@ 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>
|
||||
)}
|
||||
@@ -115,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>
|
||||
)}
|
||||
@@ -127,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>
|
||||
@@ -148,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"
|
||||
@@ -156,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';
|
||||
@@ -29,6 +30,7 @@ interface ProfileCardProps {
|
||||
}
|
||||
|
||||
export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const deleteProfile = useDeleteProfile();
|
||||
@@ -41,7 +43,6 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
||||
const isSelected = selectedProfileId === profile.id;
|
||||
|
||||
const handleSelect = () => {
|
||||
// If disabled but already selected, bounce the selection to re-trigger engine auto-switch
|
||||
if (disabled && isSelected) {
|
||||
setSelectedProfileId(null);
|
||||
setTimeout(() => setSelectedProfileId(profile.id), 0);
|
||||
@@ -79,9 +80,10 @@ export function ProfileCard({ profile, disabled }: 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 (
|
||||
<>
|
||||
@@ -105,7 +107,7 @@ export function ProfileCard({ profile, disabled }: 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">
|
||||
@@ -118,7 +120,7 @@ export function ProfileCard({ profile, disabled }: 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 && (
|
||||
@@ -130,7 +132,7 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
||||
icon={Download}
|
||||
onClick={handleExport}
|
||||
disabled={exportProfile.isPending}
|
||||
aria-label="Export profile"
|
||||
aria-label={t('profiles.card.export')}
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Edit}
|
||||
@@ -138,13 +140,13 @@ export function ProfileCard({ profile, disabled }: 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>
|
||||
@@ -153,21 +155,21 @@ export function ProfileCard({ profile, disabled }: 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,5 +1,6 @@
|
||||
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';
|
||||
@@ -11,6 +12,7 @@ import { ProfileForm } from './ProfileForm';
|
||||
const PRESET_ENGINES = new Set(['kokoro', 'qwen_custom_voice']);
|
||||
|
||||
export function ProfileList() {
|
||||
const { t } = useTranslation();
|
||||
const { data: profiles, isLoading, error } = useProfiles();
|
||||
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
const selectedEngine = useUIStore((state) => state.selectedEngine);
|
||||
@@ -45,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>
|
||||
);
|
||||
}
|
||||
@@ -73,12 +77,10 @@ export function ProfileList() {
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Mic className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No voice profiles yet. Create your first profile to get started.
|
||||
</p>
|
||||
<p className="text-muted-foreground mb-4">{t('profiles.list.empty')}</p>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Create Voice
|
||||
{t('profiles.list.createVoice')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -99,7 +101,7 @@ export function ProfileList() {
|
||||
{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>Only supported voice profiles can be selected for the current model.</span>
|
||||
<span>{t('profiles.list.unsupportedNote')}</span>
|
||||
</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>
|
||||
|
||||
@@ -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": "遷移期間連線中斷"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -390,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 }),
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
# Backend package
|
||||
|
||||
__version__ = "0.4.0"
|
||||
__version__ = "0.4.3"
|
||||
|
||||
@@ -177,7 +177,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"
|
||||
|
||||
@@ -45,11 +45,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:
|
||||
@@ -195,6 +193,8 @@ class MLXTTSBackend:
|
||||
|
||||
logger.info("Generating audio for text: %s", text)
|
||||
|
||||
model_name = f"qwen-tts-{self._current_model_size}"
|
||||
|
||||
def _generate_sync():
|
||||
"""Run synchronous generation in thread pool."""
|
||||
# MLX generate() returns a generator yielding GenerationResult objects
|
||||
@@ -220,36 +220,40 @@ 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
|
||||
try:
|
||||
# Try with voice cloning parameters if supported
|
||||
if ref_audio:
|
||||
# Check if generate accepts ref_audio parameter
|
||||
import inspect
|
||||
# Model is loaded → weights are on disk. Force offline so
|
||||
# lazy tokenizer/config lookups inside mlx_audio don't hang
|
||||
# when the user is disconnected (issue #462).
|
||||
with force_offline_if_cached(True, model_name):
|
||||
# Check if model supports voice cloning via generate method
|
||||
# MLX API may support ref_audio parameter directly
|
||||
try:
|
||||
# Try with voice cloning parameters if supported
|
||||
if ref_audio:
|
||||
# Check if generate accepts ref_audio parameter
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(self.model.generate)
|
||||
if "ref_audio" in sig.parameters:
|
||||
# Generate with voice cloning
|
||||
for result in self.model.generate(text, ref_audio=ref_audio, ref_text=ref_text, lang_code=lang):
|
||||
audio_chunks.append(np.array(result.audio))
|
||||
sample_rate = result.sample_rate
|
||||
sig = inspect.signature(self.model.generate)
|
||||
if "ref_audio" in sig.parameters:
|
||||
# Generate with voice cloning
|
||||
for result in self.model.generate(text, ref_audio=ref_audio, ref_text=ref_text, lang_code=lang):
|
||||
audio_chunks.append(np.array(result.audio))
|
||||
sample_rate = result.sample_rate
|
||||
else:
|
||||
# Fallback: generate without voice cloning
|
||||
for result in self.model.generate(text, lang_code=lang):
|
||||
audio_chunks.append(np.array(result.audio))
|
||||
sample_rate = result.sample_rate
|
||||
else:
|
||||
# Fallback: generate without voice cloning
|
||||
# No voice prompt, generate normally
|
||||
for result in self.model.generate(text, lang_code=lang):
|
||||
audio_chunks.append(np.array(result.audio))
|
||||
sample_rate = result.sample_rate
|
||||
else:
|
||||
# No voice prompt, generate normally
|
||||
except Exception as e:
|
||||
# If voice cloning fails, try without it
|
||||
logger.warning("Voice cloning failed, generating without voice prompt: %s", e)
|
||||
for result in self.model.generate(text, lang_code=lang):
|
||||
audio_chunks.append(np.array(result.audio))
|
||||
sample_rate = result.sample_rate
|
||||
except Exception as e:
|
||||
# If voice cloning fails, try without it
|
||||
logger.warning("Voice cloning failed, generating without voice prompt: %s", e)
|
||||
for result in self.model.generate(text, lang_code=lang):
|
||||
audio_chunks.append(np.array(result.audio))
|
||||
sample_rate = result.sample_rate
|
||||
|
||||
# Concatenate all chunks
|
||||
if audio_chunks:
|
||||
@@ -343,6 +347,8 @@ class MLXSTTBackend:
|
||||
"""
|
||||
await self.load_model_async(model_size)
|
||||
|
||||
progress_model_name = f"whisper-{self.model_size}"
|
||||
|
||||
def _transcribe_sync():
|
||||
"""Run synchronous transcription in thread pool."""
|
||||
# MLX Whisper transcription using generate method
|
||||
@@ -351,7 +357,11 @@ class MLXSTTBackend:
|
||||
if language:
|
||||
decode_options["language"] = language
|
||||
|
||||
result = self.model.generate(str(audio_path), **decode_options)
|
||||
# Model is loaded → weights are on disk. Force offline so
|
||||
# lazy tokenizer/config lookups don't hang when the user is
|
||||
# disconnected (issue #462).
|
||||
with force_offline_if_cached(True, progress_model_name):
|
||||
result = self.model.generate(str(audio_path), **decode_options)
|
||||
|
||||
# Extract text from result
|
||||
if isinstance(result, str):
|
||||
|
||||
@@ -172,13 +172,19 @@ class PyTorchTTSBackend:
|
||||
# This shouldn't happen in practice, but handle it
|
||||
return {"prompt": cached_prompt}, True
|
||||
|
||||
model_name = f"qwen-tts-{self._current_model_size}"
|
||||
|
||||
def _create_prompt_sync():
|
||||
"""Run synchronous voice prompt creation in thread pool."""
|
||||
return self.model.create_voice_clone_prompt(
|
||||
ref_audio=str(audio_path),
|
||||
ref_text=reference_text,
|
||||
x_vector_only_mode=False,
|
||||
)
|
||||
# Model is loaded → weights are on disk. Force offline so
|
||||
# lazy tokenizer/config lookups inside qwen_tts don't hang
|
||||
# when the user is disconnected (issue #462).
|
||||
with force_offline_if_cached(True, model_name):
|
||||
return self.model.create_voice_clone_prompt(
|
||||
ref_audio=str(audio_path),
|
||||
ref_text=reference_text,
|
||||
x_vector_only_mode=False,
|
||||
)
|
||||
|
||||
# Run blocking operation in thread pool
|
||||
voice_prompt_items = await asyncio.to_thread(_create_prompt_sync)
|
||||
@@ -221,19 +227,24 @@ class PyTorchTTSBackend:
|
||||
# Load model
|
||||
await self.load_model_async(None)
|
||||
|
||||
model_name = f"qwen-tts-{self._current_model_size}"
|
||||
|
||||
def _generate_sync():
|
||||
"""Run synchronous generation in thread pool."""
|
||||
# Set seed if provided
|
||||
if seed is not None:
|
||||
manual_seed(seed, self.device)
|
||||
|
||||
# Generate audio - this is the blocking operation
|
||||
wavs, sample_rate = self.model.generate_voice_clone(
|
||||
text=text,
|
||||
voice_clone_prompt=voice_prompt,
|
||||
language=LANGUAGE_CODE_TO_NAME.get(language, "auto"),
|
||||
instruct=instruct,
|
||||
)
|
||||
# Model is loaded → weights are on disk. Force offline so
|
||||
# lazy tokenizer/config lookups inside qwen_tts don't hang
|
||||
# when the user is disconnected (issue #462).
|
||||
with force_offline_if_cached(True, model_name):
|
||||
wavs, sample_rate = self.model.generate_voice_clone(
|
||||
text=text,
|
||||
voice_clone_prompt=voice_prompt,
|
||||
language=LANGUAGE_CODE_TO_NAME.get(language, "auto"),
|
||||
instruct=instruct,
|
||||
)
|
||||
return wavs[0], sample_rate
|
||||
|
||||
# Run blocking inference in thread pool to avoid blocking event loop
|
||||
@@ -331,40 +342,46 @@ class PyTorchSTTBackend:
|
||||
"""
|
||||
await self.load_model_async(model_size)
|
||||
|
||||
progress_model_name = f"whisper-{self.model_size}"
|
||||
|
||||
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)
|
||||
|
||||
# Process audio
|
||||
inputs = self.processor(
|
||||
audio,
|
||||
sampling_rate=16000,
|
||||
return_tensors="pt",
|
||||
)
|
||||
inputs = inputs.to(self.device)
|
||||
|
||||
# Generate transcription
|
||||
# If language is provided, force it; otherwise let Whisper auto-detect
|
||||
generate_kwargs = {}
|
||||
if language:
|
||||
forced_decoder_ids = self.processor.get_decoder_prompt_ids(
|
||||
language=language,
|
||||
task="transcribe",
|
||||
# Model is loaded → weights are on disk. Force offline so
|
||||
# `get_decoder_prompt_ids` and any lazy tokenizer lookups
|
||||
# don't hang when the user is disconnected (issue #462).
|
||||
with force_offline_if_cached(True, progress_model_name):
|
||||
# Process audio
|
||||
inputs = self.processor(
|
||||
audio,
|
||||
sampling_rate=16000,
|
||||
return_tensors="pt",
|
||||
)
|
||||
generate_kwargs["forced_decoder_ids"] = forced_decoder_ids
|
||||
inputs = inputs.to(self.device)
|
||||
|
||||
with torch.no_grad():
|
||||
predicted_ids = self.model.generate(
|
||||
inputs["input_features"],
|
||||
**generate_kwargs,
|
||||
)
|
||||
# Generate transcription
|
||||
# If language is provided, force it; otherwise let Whisper auto-detect
|
||||
generate_kwargs = {}
|
||||
if language:
|
||||
forced_decoder_ids = self.processor.get_decoder_prompt_ids(
|
||||
language=language,
|
||||
task="transcribe",
|
||||
)
|
||||
generate_kwargs["forced_decoder_ids"] = forced_decoder_ids
|
||||
|
||||
# Decode
|
||||
transcription = self.processor.batch_decode(
|
||||
predicted_ids,
|
||||
skip_special_tokens=True,
|
||||
)[0]
|
||||
with torch.no_grad():
|
||||
predicted_ids = self.model.generate(
|
||||
inputs["input_features"],
|
||||
**generate_kwargs,
|
||||
)
|
||||
|
||||
# Decode
|
||||
transcription = self.processor.batch_decode(
|
||||
predicted_ids,
|
||||
skip_special_tokens=True,
|
||||
)[0]
|
||||
|
||||
return transcription.strip()
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from .base import (
|
||||
combine_voice_prompts as _combine_voice_prompts,
|
||||
model_load_progress,
|
||||
)
|
||||
from ..utils.hf_offline_patch import force_offline_if_cached
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -104,18 +105,19 @@ class QwenCustomVoiceBackend:
|
||||
model_path = self._get_model_path(model_size)
|
||||
logger.info("Loading Qwen CustomVoice %s on %s...", model_size, self.device)
|
||||
|
||||
if self.device == "cpu":
|
||||
self.model = Qwen3TTSModel.from_pretrained(
|
||||
model_path,
|
||||
torch_dtype=torch.float32,
|
||||
low_cpu_mem_usage=False,
|
||||
)
|
||||
else:
|
||||
self.model = Qwen3TTSModel.from_pretrained(
|
||||
model_path,
|
||||
device_map=self.device,
|
||||
torch_dtype=torch.bfloat16,
|
||||
)
|
||||
with force_offline_if_cached(is_cached, model_name):
|
||||
if self.device == "cpu":
|
||||
self.model = Qwen3TTSModel.from_pretrained(
|
||||
model_path,
|
||||
torch_dtype=torch.float32,
|
||||
low_cpu_mem_usage=False,
|
||||
)
|
||||
else:
|
||||
self.model = Qwen3TTSModel.from_pretrained(
|
||||
model_path,
|
||||
device_map=self.device,
|
||||
torch_dtype=torch.bfloat16,
|
||||
)
|
||||
|
||||
self._current_model_size = model_size
|
||||
self.model_size = model_size
|
||||
@@ -184,6 +186,7 @@ class QwenCustomVoiceBackend:
|
||||
await self.load_model_async(None)
|
||||
|
||||
speaker = voice_prompt.get("preset_voice_id") or QWEN_CV_DEFAULT_SPEAKER
|
||||
model_name = f"qwen-custom-voice-{self._current_model_size}"
|
||||
|
||||
def _generate_sync():
|
||||
if seed is not None:
|
||||
@@ -203,7 +206,11 @@ class QwenCustomVoiceBackend:
|
||||
if instruct:
|
||||
kwargs["instruct"] = instruct
|
||||
|
||||
wavs, sample_rate = self.model.generate_custom_voice(**kwargs)
|
||||
# Model is loaded → weights are on disk. Force offline so
|
||||
# lazy tokenizer/config lookups inside qwen_tts don't hang
|
||||
# when the user is disconnected (issue #462).
|
||||
with force_offline_if_cached(True, model_name):
|
||||
wavs, sample_rate = self.model.generate_custom_voice(**kwargs)
|
||||
return wavs[0], sample_rate
|
||||
|
||||
audio, sample_rate = await asyncio.to_thread(_generate_sync)
|
||||
|
||||
@@ -287,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",
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -46,6 +46,11 @@ 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
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Unit tests for reference-audio preprocessing.
|
||||
|
||||
Covers :func:`backend.utils.audio.preprocess_reference_audio` and
|
||||
:func:`backend.utils.audio.validate_and_load_reference_audio`.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import soundfile as sf
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from utils.audio import ( # noqa: E402
|
||||
preprocess_reference_audio,
|
||||
validate_and_load_reference_audio,
|
||||
)
|
||||
|
||||
|
||||
SR = 24000
|
||||
|
||||
|
||||
def _tone(duration_s: float, amp: float = 0.3, freq: float = 220.0) -> np.ndarray:
|
||||
n = int(duration_s * SR)
|
||||
t = np.arange(n, dtype=np.float32) / SR
|
||||
return (amp * np.sin(2 * np.pi * freq * t)).astype(np.float32)
|
||||
|
||||
|
||||
def test_peak_cap_scales_hot_input():
|
||||
audio = _tone(3.0, amp=0.99)
|
||||
out = preprocess_reference_audio(audio, SR)
|
||||
assert np.abs(out).max() <= 0.951
|
||||
|
||||
|
||||
def test_peak_cap_leaves_moderate_input_untouched():
|
||||
audio = _tone(3.0, amp=0.5)
|
||||
out = preprocess_reference_audio(audio, SR)
|
||||
assert np.isclose(np.abs(out).max(), 0.5, atol=1e-3)
|
||||
|
||||
|
||||
def test_dc_offset_removed():
|
||||
audio = _tone(3.0, amp=0.3) + 0.1
|
||||
out = preprocess_reference_audio(audio, SR)
|
||||
assert abs(float(np.mean(out))) < 1e-3
|
||||
|
||||
|
||||
def test_silence_is_trimmed_with_padding_kept():
|
||||
silence = np.zeros(int(SR * 1.0), dtype=np.float32)
|
||||
speech = _tone(3.0, amp=0.3)
|
||||
audio = np.concatenate([silence, speech, silence])
|
||||
out = preprocess_reference_audio(audio, SR)
|
||||
# Most of the 2s of leading/trailing silence should be gone, but the
|
||||
# 3s of speech plus ~200ms of padding should remain.
|
||||
assert len(audio) - len(out) >= SR, "expected >=1s of silence trimmed"
|
||||
assert len(out) >= int(3.0 * SR), "speech body should be preserved"
|
||||
|
||||
|
||||
def test_clean_audio_is_not_padded_past_original_length():
|
||||
# Well-recorded audio with no edge silence shouldn't get longer after
|
||||
# preprocessing — otherwise a 29.9 s upload could be pushed past the
|
||||
# 30 s max_duration ceiling downstream.
|
||||
audio = _tone(3.0, amp=0.3)
|
||||
out = preprocess_reference_audio(audio, SR)
|
||||
assert len(out) <= len(audio)
|
||||
|
||||
|
||||
def test_empty_input_returns_empty():
|
||||
out = preprocess_reference_audio(np.zeros(0, dtype=np.float32), SR)
|
||||
assert out.size == 0
|
||||
|
||||
|
||||
def test_validate_accepts_previously_rejected_hot_file(tmp_path):
|
||||
audio = _tone(3.0, amp=0.995)
|
||||
path = tmp_path / "hot.wav"
|
||||
sf.write(str(path), audio, SR)
|
||||
|
||||
ok, err, out_audio, out_sr = validate_and_load_reference_audio(str(path))
|
||||
|
||||
assert ok, f"expected pass, got error: {err}"
|
||||
assert out_audio is not None
|
||||
assert out_sr == SR
|
||||
assert np.abs(out_audio).max() <= 0.951
|
||||
|
||||
|
||||
def test_validate_still_rejects_silent_input(tmp_path):
|
||||
audio = np.zeros(int(SR * 3.0), dtype=np.float32)
|
||||
path = tmp_path / "silent.wav"
|
||||
sf.write(str(path), audio, SR)
|
||||
|
||||
ok, err, _, _ = validate_and_load_reference_audio(str(path))
|
||||
|
||||
assert not ok
|
||||
assert err is not None
|
||||
assert "too short" in err.lower() or "quiet" in err.lower()
|
||||
|
||||
|
||||
def test_validate_rejects_too_short(tmp_path):
|
||||
audio = _tone(0.5, amp=0.3)
|
||||
path = tmp_path / "short.wav"
|
||||
sf.write(str(path), audio, SR)
|
||||
|
||||
ok, err, _, _ = validate_and_load_reference_audio(str(path))
|
||||
|
||||
assert not ok
|
||||
assert "too short" in (err or "").lower()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Unit tests for the ``force_offline_if_cached`` helper.
|
||||
|
||||
Verifies that the helper mutates the cached module constants in
|
||||
``huggingface_hub.constants`` and ``transformers.utils.hub`` — not just
|
||||
``os.environ`` — and that concurrent users are refcount-coordinated so
|
||||
one thread's exit can't strip another thread's offline protection.
|
||||
|
||||
NOTE: These tests mutate process-global state in ``huggingface_hub.constants``
|
||||
and ``transformers.utils.hub``. They are not safe under cross-process
|
||||
parallelism (e.g. ``pytest-xdist`` with ``--dist=loadfile``/``loadscope``);
|
||||
run this file serially.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from utils.hf_offline_patch import force_offline_if_cached # noqa: E402
|
||||
|
||||
|
||||
def _hf_const():
|
||||
import huggingface_hub.constants as hf_const
|
||||
|
||||
return hf_const
|
||||
|
||||
|
||||
def _tf_hub():
|
||||
import transformers.utils.hub as tf_hub
|
||||
|
||||
return tf_hub
|
||||
|
||||
|
||||
def test_mutates_cached_huggingface_hub_constant():
|
||||
original = _hf_const().HF_HUB_OFFLINE
|
||||
with force_offline_if_cached(True, "t"):
|
||||
assert _hf_const().HF_HUB_OFFLINE is True
|
||||
assert original == _hf_const().HF_HUB_OFFLINE
|
||||
|
||||
|
||||
def test_mutates_cached_transformers_constant():
|
||||
original = _tf_hub()._is_offline_mode
|
||||
with force_offline_if_cached(True, "t"):
|
||||
assert _tf_hub()._is_offline_mode is True
|
||||
assert original == _tf_hub()._is_offline_mode
|
||||
|
||||
|
||||
def test_sets_env_variable():
|
||||
original = os.environ.get("HF_HUB_OFFLINE")
|
||||
with force_offline_if_cached(True, "t"):
|
||||
assert "1" == os.environ.get("HF_HUB_OFFLINE")
|
||||
assert original == os.environ.get("HF_HUB_OFFLINE")
|
||||
|
||||
|
||||
def test_noop_when_not_cached():
|
||||
before = _hf_const().HF_HUB_OFFLINE
|
||||
with force_offline_if_cached(False, "t"):
|
||||
assert before == _hf_const().HF_HUB_OFFLINE
|
||||
|
||||
|
||||
def test_nested_contexts_respect_refcount():
|
||||
original = _hf_const().HF_HUB_OFFLINE
|
||||
with force_offline_if_cached(True, "outer"):
|
||||
assert _hf_const().HF_HUB_OFFLINE is True
|
||||
with force_offline_if_cached(True, "inner"):
|
||||
assert _hf_const().HF_HUB_OFFLINE is True
|
||||
# inner exit must not restore while outer is still active
|
||||
assert _hf_const().HF_HUB_OFFLINE is True
|
||||
assert original == _hf_const().HF_HUB_OFFLINE
|
||||
|
||||
|
||||
def test_concurrent_threads_share_offline_window():
|
||||
"""A slow thread must keep seeing offline mode even if a peer exits first."""
|
||||
original = _hf_const().HF_HUB_OFFLINE
|
||||
observations: list[bool] = []
|
||||
errors: list[Exception] = []
|
||||
barrier = threading.Barrier(2)
|
||||
fast_exited = threading.Event()
|
||||
|
||||
def slow():
|
||||
try:
|
||||
with force_offline_if_cached(True, "slow"):
|
||||
barrier.wait(timeout=5)
|
||||
assert fast_exited.wait(timeout=5), "fast thread did not exit"
|
||||
observations.append(_hf_const().HF_HUB_OFFLINE)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(exc)
|
||||
|
||||
def fast():
|
||||
try:
|
||||
with force_offline_if_cached(True, "fast"):
|
||||
barrier.wait(timeout=5)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(exc)
|
||||
finally:
|
||||
fast_exited.set()
|
||||
|
||||
t_slow = threading.Thread(target=slow)
|
||||
t_fast = threading.Thread(target=fast)
|
||||
t_slow.start()
|
||||
t_fast.start()
|
||||
t_slow.join(timeout=5)
|
||||
t_fast.join(timeout=5)
|
||||
|
||||
assert not t_slow.is_alive(), "slow thread did not finish"
|
||||
assert not t_fast.is_alive(), "fast thread did not finish"
|
||||
assert not errors, errors
|
||||
assert observations == [True], "slow thread lost offline protection"
|
||||
assert original == _hf_const().HF_HUB_OFFLINE
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -175,7 +175,7 @@ async def main():
|
||||
print(" ✅ Server is running")
|
||||
|
||||
# Test model
|
||||
model_name = "qwen-tts-0.6B" # Note: 0.6B currently maps to 1.7B on MLX
|
||||
model_name = "qwen-tts-0.6B"
|
||||
|
||||
# Check current status
|
||||
print(f"\n📊 Checking status of {model_name}...")
|
||||
|
||||
+71
-9
@@ -199,6 +199,66 @@ def trim_tts_output(
|
||||
return trimmed
|
||||
|
||||
|
||||
def preprocess_reference_audio(
|
||||
audio: np.ndarray,
|
||||
sample_rate: int,
|
||||
peak_target: float = 0.95,
|
||||
trim_top_db: float = 40.0,
|
||||
edge_padding_ms: int = 100,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Clean up a reference-audio sample before validation/storage.
|
||||
|
||||
Removes DC offset, trims leading/trailing silence, and caps the peak so a
|
||||
slightly-hot recording doesn't get rejected downstream as "clipping". The
|
||||
goal is to accept reasonable real-world recordings — not to repair badly
|
||||
distorted ones. True clipping artifacts inside the waveform can't be
|
||||
recovered by peak scaling and will still sound bad.
|
||||
|
||||
Args:
|
||||
audio: Mono audio array.
|
||||
sample_rate: Sample rate of ``audio`` in Hz.
|
||||
peak_target: Peak amplitude cap in [0, 1]. Applied only if the input
|
||||
peak exceeds this value.
|
||||
trim_top_db: Silence threshold for edge trimming, in dB below peak.
|
||||
40 dB sits below normal speech dynamic range (≈30 dB) so soft
|
||||
trailing syllables are preserved, while still catching obvious
|
||||
leading/trailing silence. Lower values are more aggressive;
|
||||
librosa's own default is 60.
|
||||
edge_padding_ms: Milliseconds of padding to add back at each edge
|
||||
*only if* trimming shortened the waveform, so TTS engines have a
|
||||
brief silence to anchor on without ever making the output longer
|
||||
than the input.
|
||||
|
||||
Returns:
|
||||
Preprocessed audio array (float32).
|
||||
"""
|
||||
audio = audio.astype(np.float32, copy=False)
|
||||
|
||||
if audio.size == 0:
|
||||
return audio
|
||||
|
||||
audio = audio - float(np.mean(audio))
|
||||
|
||||
trimmed, _ = librosa.effects.trim(audio, top_db=trim_top_db)
|
||||
if 0 < trimmed.size < audio.size:
|
||||
pad_each = int(sample_rate * edge_padding_ms / 1000)
|
||||
# Never pad past the original length — for near-max-duration uploads
|
||||
# an unconditional pad would push them over the 30 s ceiling and
|
||||
# trigger a spurious "too long" rejection.
|
||||
headroom = (audio.size - trimmed.size) // 2
|
||||
pad = min(pad_each, max(headroom, 0))
|
||||
if pad > 0:
|
||||
trimmed = np.pad(trimmed, (pad, pad), mode="constant")
|
||||
audio = trimmed
|
||||
|
||||
peak = float(np.abs(audio).max())
|
||||
if peak > peak_target and peak > 0:
|
||||
audio = audio * (peak_target / peak)
|
||||
|
||||
return audio
|
||||
|
||||
|
||||
def validate_reference_audio(
|
||||
audio_path: str,
|
||||
min_duration: float = 2.0,
|
||||
@@ -207,13 +267,13 @@ def validate_reference_audio(
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate reference audio for voice cloning.
|
||||
|
||||
|
||||
Args:
|
||||
audio_path: Path to audio file
|
||||
min_duration: Minimum duration in seconds
|
||||
max_duration: Maximum duration in seconds
|
||||
min_rms: Minimum RMS level
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
@@ -231,26 +291,28 @@ def validate_and_load_reference_audio(
|
||||
) -> Tuple[bool, Optional[str], Optional[np.ndarray], Optional[int]]:
|
||||
"""
|
||||
Validate and load reference audio in a single pass.
|
||||
|
||||
|
||||
Applies :func:`preprocess_reference_audio` before checks so that
|
||||
slightly-hot recordings aren't rejected as clipping. Duration and RMS
|
||||
checks run on the preprocessed waveform.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message, audio_array, sample_rate)
|
||||
"""
|
||||
try:
|
||||
audio, sr = load_audio(audio_path)
|
||||
audio = preprocess_reference_audio(audio, sr)
|
||||
duration = len(audio) / sr
|
||||
|
||||
|
||||
if duration < min_duration:
|
||||
return False, f"Audio too short (minimum {min_duration} seconds)", None, None
|
||||
if duration > max_duration:
|
||||
return False, f"Audio too long (maximum {max_duration} seconds)", None, None
|
||||
|
||||
|
||||
rms = np.sqrt(np.mean(audio**2))
|
||||
if rms < min_rms:
|
||||
return False, "Audio is too quiet or silent", None, None
|
||||
|
||||
if np.abs(audio).max() > 0.99:
|
||||
return False, "Audio is clipping (reduce input gain)", None, None
|
||||
|
||||
|
||||
return True, None, audio, sr
|
||||
except Exception as e:
|
||||
return False, f"Error validating audio: {str(e)}", None, None
|
||||
|
||||
@@ -6,6 +6,7 @@ are already downloaded. Must be imported BEFORE mlx_audio.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
@@ -13,13 +14,33 @@ from typing import Optional, Union
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# huggingface_hub reads ``HF_HUB_OFFLINE`` once at import time into
|
||||
# ``huggingface_hub.constants.HF_HUB_OFFLINE``; transformers mirrors that into
|
||||
# ``transformers.utils.hub._is_offline_mode`` at *its* import time. Toggling
|
||||
# ``os.environ`` after either module is imported does not flip those cached
|
||||
# bools, and the hot paths (``_http._default_backend_factory``,
|
||||
# ``transformers.utils.hub.is_offline_mode``) read the bools — not the env.
|
||||
# We mutate the cached constants directly, guarded by a refcount so
|
||||
# concurrent inference threads share a single offline window safely.
|
||||
|
||||
_offline_lock = threading.RLock()
|
||||
_offline_refcount = 0
|
||||
_saved_env: Optional[str] = None
|
||||
_saved_hf_const: Optional[bool] = None
|
||||
_saved_transformers_const: Optional[bool] = None
|
||||
|
||||
|
||||
@contextmanager
|
||||
def force_offline_if_cached(is_cached: bool, model_label: str = ""):
|
||||
"""Context manager that sets ``HF_HUB_OFFLINE=1`` while loading a cached model.
|
||||
"""Force offline mode for the duration of a cached-model operation.
|
||||
|
||||
Flips ``HF_HUB_OFFLINE`` in the process env **and** in the cached bools
|
||||
inside ``huggingface_hub.constants`` / ``transformers.utils.hub`` so HTTP
|
||||
adapters and offline-mode checks actually see the change. Uses a refcount
|
||||
so multiple concurrent inference threads share a single offline window
|
||||
and the last one to exit restores state.
|
||||
|
||||
If *is_cached* is ``False`` the block runs normally (network allowed).
|
||||
If the offline load raises an error containing "offline" we automatically
|
||||
retry with network access so a partially-cached model still works.
|
||||
|
||||
Args:
|
||||
is_cached: Whether the model weights are already on disk.
|
||||
@@ -29,34 +50,96 @@ def force_offline_if_cached(is_cached: bool, model_label: str = ""):
|
||||
yield
|
||||
return
|
||||
|
||||
original_value = os.environ.get("HF_HUB_OFFLINE")
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
logger.info(
|
||||
"[offline-guard] %s is cached — forcing HF_HUB_OFFLINE=1",
|
||||
model_label or "model",
|
||||
)
|
||||
global _offline_refcount, _saved_env, _saved_hf_const, _saved_transformers_const
|
||||
|
||||
with _offline_lock:
|
||||
if _offline_refcount == 0:
|
||||
# Snapshot prior state, apply new state, roll back on *any*
|
||||
# failure. Catching only ImportError here would let a partially
|
||||
# broken install (RuntimeError, AttributeError from a half-init
|
||||
# module, etc.) leave the cached HF constants mutated without
|
||||
# bumping the refcount — a persistent offline leak that outlives
|
||||
# the process and is miserable to debug.
|
||||
prev_env = os.environ.get("HF_HUB_OFFLINE")
|
||||
prev_hf: Optional[bool] = None
|
||||
prev_tf: Optional[bool] = None
|
||||
try:
|
||||
try:
|
||||
import huggingface_hub.constants as hf_const
|
||||
|
||||
prev_hf = hf_const.HF_HUB_OFFLINE
|
||||
hf_const.HF_HUB_OFFLINE = True
|
||||
except ImportError:
|
||||
prev_hf = None
|
||||
|
||||
try:
|
||||
import transformers.utils.hub as tf_hub
|
||||
|
||||
prev_tf = tf_hub._is_offline_mode
|
||||
tf_hub._is_offline_mode = True
|
||||
except ImportError:
|
||||
prev_tf = None
|
||||
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
except BaseException:
|
||||
# Roll back whatever we already changed, then re-raise so
|
||||
# the caller sees the real failure.
|
||||
if prev_hf is not None:
|
||||
try:
|
||||
import huggingface_hub.constants as hf_const
|
||||
|
||||
hf_const.HF_HUB_OFFLINE = prev_hf
|
||||
except ImportError:
|
||||
pass
|
||||
if prev_tf is not None:
|
||||
try:
|
||||
import transformers.utils.hub as tf_hub
|
||||
|
||||
tf_hub._is_offline_mode = prev_tf
|
||||
except ImportError:
|
||||
pass
|
||||
if prev_env is not None:
|
||||
os.environ["HF_HUB_OFFLINE"] = prev_env
|
||||
else:
|
||||
os.environ.pop("HF_HUB_OFFLINE", None)
|
||||
raise
|
||||
|
||||
_saved_env = prev_env
|
||||
_saved_hf_const = prev_hf
|
||||
_saved_transformers_const = prev_tf
|
||||
logger.info(
|
||||
"[offline-guard] %s is cached — forcing offline mode",
|
||||
model_label or "model",
|
||||
)
|
||||
_offline_refcount += 1
|
||||
|
||||
try:
|
||||
yield
|
||||
except Exception as exc:
|
||||
if "offline" in str(exc).lower():
|
||||
logger.warning(
|
||||
"[offline-guard] Offline load failed for %s, retrying with network: %s",
|
||||
model_label or "model",
|
||||
exc,
|
||||
)
|
||||
# Restore original env and retry — caller must wrap the load
|
||||
# inside force_offline_if_cached so retrying here isn't possible.
|
||||
# Instead, propagate a flag via the exception so the caller can
|
||||
# decide. For simplicity we just let it fall through to the
|
||||
# finally block and re-raise.
|
||||
raise
|
||||
raise
|
||||
finally:
|
||||
if original_value is not None:
|
||||
os.environ["HF_HUB_OFFLINE"] = original_value
|
||||
else:
|
||||
os.environ.pop("HF_HUB_OFFLINE", None)
|
||||
with _offline_lock:
|
||||
_offline_refcount -= 1
|
||||
if _offline_refcount == 0:
|
||||
if _saved_env is not None:
|
||||
os.environ["HF_HUB_OFFLINE"] = _saved_env
|
||||
else:
|
||||
os.environ.pop("HF_HUB_OFFLINE", None)
|
||||
if _saved_hf_const is not None:
|
||||
try:
|
||||
import huggingface_hub.constants as hf_const
|
||||
|
||||
hf_const.HF_HUB_OFFLINE = _saved_hf_const
|
||||
except ImportError:
|
||||
pass
|
||||
if _saved_transformers_const is not None:
|
||||
try:
|
||||
import transformers.utils.hub as tf_hub
|
||||
|
||||
tf_hub._is_offline_mode = _saved_transformers_const
|
||||
except ImportError:
|
||||
pass
|
||||
_saved_env = None
|
||||
_saved_hf_const = None
|
||||
_saved_transformers_const = None
|
||||
|
||||
|
||||
def patch_huggingface_hub_offline():
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"app": {
|
||||
"name": "@voicebox/app",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.2",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -49,11 +49,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",
|
||||
@@ -72,9 +75,10 @@
|
||||
},
|
||||
"landing": {
|
||||
"name": "@voicebox/landing",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.2",
|
||||
"dependencies": {
|
||||
"@fontsource/space-grotesk": "^5.2.10",
|
||||
"@icons-pack/react-simple-icons": "^13.13.0",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"autoprefixer": "^10.4.17",
|
||||
@@ -100,7 +104,7 @@
|
||||
},
|
||||
"tauri": {
|
||||
"name": "@voicebox/tauri",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.2",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
@@ -123,7 +127,7 @@
|
||||
},
|
||||
"web": {
|
||||
"name": "@voicebox/web",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.2",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"react": "^18.3.0",
|
||||
@@ -181,6 +185,8 @@
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||
|
||||
"@babel/runtime": ["@babel/[email protected]", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
"@babel/template": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="],
|
||||
@@ -287,6 +293,8 @@
|
||||
|
||||
"@humanwhocodes/object-schema": ["@humanwhocodes/[email protected]", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="],
|
||||
|
||||
"@icons-pack/react-simple-icons": ["@icons-pack/[email protected]", "", { "peerDependencies": { "react": "^16.13 || ^17 || ^18 || ^19" } }, "sha512-B5HhQMIpcSH4z8IZ8HFhD59CboHceKYMpPC9kAwGyKntvPdyJJv26DLu4Z1wAjcCLyrJhf11tMhiQGom9Rxb9g=="],
|
||||
|
||||
"@img/colour": ["@img/[email protected]", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
@@ -783,7 +791,7 @@
|
||||
|
||||
"fraction.js": ["[email protected]", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||
|
||||
"framer-motion": ["framer-motion@12.29.0", "", { "dependencies": { "motion-dom": "^12.29.0", "motion-utils": "^12.27.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1gEFGXHYV2BD42ZPTFmSU9buehppU+bCuOnHU0AD18DKh9j4DuTx47MvqY5ax+NNWRtK32qIcJf1UxKo1WwjWg=="],
|
||||
"framer-motion": ["framer-motion@12.36.0", "", { "dependencies": { "motion-dom": "^12.36.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-4PqYHAT7gev0ke0wos+PyrcFxI0HScjm3asgU8nSYa8YzJFuwgIvdj3/s3ZaxLq0bUSboIn19A2WS/MHwLCvfw=="],
|
||||
|
||||
"fs.realpath": ["[email protected]", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||
|
||||
@@ -811,6 +819,12 @@
|
||||
|
||||
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"html-parse-stringify": ["[email protected]", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
|
||||
|
||||
"i18next": ["[email protected]", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg=="],
|
||||
|
||||
"i18next-browser-languagedetector": ["[email protected]", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="],
|
||||
|
||||
"ignore": ["[email protected]", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"import-fresh": ["[email protected]", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
@@ -907,9 +921,9 @@
|
||||
|
||||
"motion": ["[email protected]", "", { "dependencies": { "framer-motion": "^12.29.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rjB5CP2N9S2ESAyEFnAFMgTec6X8yvfxLNcz8n12gPq3M48R7ZbBeVYkDOTj8SPMwfvGIFI801SiPSr1+HCr9g=="],
|
||||
|
||||
"motion-dom": ["motion-dom@12.29.0", "", { "dependencies": { "motion-utils": "^12.27.2" } }, "sha512-3eiz9bb32yvY8Q6XNM4AwkSOBPgU//EIKTZwsSWgA9uzbPBhZJeScCVcBuwwYVqhfamewpv7ZNmVKTGp5qnzkA=="],
|
||||
"motion-dom": ["motion-dom@12.36.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-Ep1pq8P88rGJ75om8lTCA13zqd7ywPGwCqwuWwin6BKc0hMLkVfcS6qKlRqEo2+t0DwoUcgGJfXwaiFn4AOcQA=="],
|
||||
|
||||
"motion-utils": ["motion-utils@12.27.2", "", {}, "sha512-B55gcoL85Mcdt2IEStY5EEAsrMSVE2sI14xQ/uAdPL+mfQxhKKFaEag9JmfxedJOR4vZpBGoPeC/Gm13I/4g5Q=="],
|
||||
"motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
|
||||
|
||||
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
@@ -985,6 +999,8 @@
|
||||
|
||||
"react-hook-form": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w=="],
|
||||
|
||||
"react-i18next": ["[email protected]", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.0.1", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g=="],
|
||||
|
||||
"react-is": ["[email protected]", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"react-loaders": ["[email protected]", "", { "dependencies": { "classnames": "^2.2.3" }, "peerDependencies": { "prop-types": ">=15.6.0", "react": ">=15" } }, "sha512-4igMNqs9Fb3d4Z+0UHIGQNJsw/37gX0nUO8QxupnEKRn1dtyYC1LGwk5GuaoDciMQCQc/MmPwb4Fn6ZfdoX1FQ=="],
|
||||
@@ -1097,7 +1113,9 @@
|
||||
|
||||
"vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"wavesurfer.js": ["wavesurfer.js@7.12.1", "", {}, "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg=="],
|
||||
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
|
||||
|
||||
"wavesurfer.js": ["[email protected]", "", {}, "sha512-akVYISAHCw2gNw/7n8Pk/zH1Zz91WJyL/2MaNQCLD1XV3A226gKlWoDHWp9UdWqQ3zXnWttDf9ewZQQ3cxbOmQ=="],
|
||||
|
||||
"which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
@@ -1157,20 +1175,20 @@
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"@voicebox/landing/framer-motion": ["[email protected]", "", { "dependencies": { "motion-dom": "^12.36.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-4PqYHAT7gev0ke0wos+PyrcFxI0HScjm3asgU8nSYa8YzJFuwgIvdj3/s3ZaxLq0bUSboIn19A2WS/MHwLCvfw=="],
|
||||
|
||||
"@voicebox/landing/lucide-react": ["[email protected]", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, "sha512-dTmYX1H4IXsRfVcj/KUxworV6814ApTl7iXaS21AimK2RUEl4j4AfOmqD3VR8phe5V91m4vEJ8tCK4uT1jE5nA=="],
|
||||
|
||||
"@voicebox/landing/tailwind-merge": ["[email protected]", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||
|
||||
"@voicebox/landing/tailwindcss": ["[email protected]", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
|
||||
|
||||
"@voicebox/landing/wavesurfer.js": ["[email protected].2", "", {}, "sha512-akVYISAHCw2gNw/7n8Pk/zH1Zz91WJyL/2MaNQCLD1XV3A226gKlWoDHWp9UdWqQ3zXnWttDf9ewZQQ3cxbOmQ=="],
|
||||
"@voicebox/web/wavesurfer.js": ["[email protected].1", "", {}, "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg=="],
|
||||
|
||||
"chokidar/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"motion/framer-motion": ["[email protected]", "", { "dependencies": { "motion-dom": "^12.29.0", "motion-utils": "^12.27.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1gEFGXHYV2BD42ZPTFmSU9buehppU+bCuOnHU0AD18DKh9j4DuTx47MvqY5ax+NNWRtK32qIcJf1UxKo1WwjWg=="],
|
||||
|
||||
"next/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"sharp/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
@@ -1179,8 +1197,8 @@
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"@voicebox/landing/framer-motion/motion-dom": ["motion-dom@12.36.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-Ep1pq8P88rGJ75om8lTCA13zqd7ywPGwCqwuWwin6BKc0hMLkVfcS6qKlRqEo2+t0DwoUcgGJfXwaiFn4AOcQA=="],
|
||||
"motion/framer-motion/motion-dom": ["motion-dom@12.29.0", "", { "dependencies": { "motion-utils": "^12.27.2" } }, "sha512-3eiz9bb32yvY8Q6XNM4AwkSOBPgU//EIKTZwsSWgA9uzbPBhZJeScCVcBuwwYVqhfamewpv7ZNmVKTGp5qnzkA=="],
|
||||
|
||||
"@voicebox/landing/framer-motion/motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
|
||||
"motion/framer-motion/motion-utils": ["motion-utils@12.27.2", "", {}, "sha512-B55gcoL85Mcdt2IEStY5EEAsrMSVE2sI14xQ/uAdPL+mfQxhKKFaEag9JmfxedJOR4vZpBGoPeC/Gm13I/4g5Q=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
# Voicebox Project Status & Roadmap
|
||||
|
||||
> Last updated: 2026-04-18 | Current version: **v0.4.1** | 232 open issues | 12 open PRs
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Architecture Overview](#architecture-overview)
|
||||
2. [Current State](#current-state)
|
||||
3. [Open PRs — Triage & Analysis](#open-prs--triage--analysis)
|
||||
4. [Open Issues — Categorized](#open-issues--categorized)
|
||||
5. [Existing Plan Documents — Status](#existing-plan-documents--status)
|
||||
6. [New Model Integration — Landscape](#new-model-integration--landscape)
|
||||
7. [Architectural Bottlenecks](#architectural-bottlenecks)
|
||||
8. [Recommended Priorities](#recommended-priorities)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
**Tauri shell (Rust)** hosts a **React frontend** (`app/`) that talks over HTTP on `localhost:17493` to a **FastAPI backend** (`backend/`).
|
||||
|
||||
The backend exposes:
|
||||
|
||||
- **`TTSBackend` Protocol** with seven concrete engine implementations:
|
||||
- Qwen3-TTS (PyTorch or MLX depending on platform)
|
||||
- Qwen CustomVoice (predefined speakers with instruct)
|
||||
- LuxTTS (fast, CPU-friendly)
|
||||
- Chatterbox Multilingual (23 languages)
|
||||
- Chatterbox Turbo (English, paralinguistic tags)
|
||||
- TADA (1B English, 3B multilingual via HumeAI)
|
||||
- Kokoro 82M (pre-built voices, CPU realtime)
|
||||
- **`STTBackend` Protocol** for Whisper (PyTorch or MLX-Whisper)
|
||||
- **Profiles / History / Stories** services for persistence and timeline editing
|
||||
|
||||
### Key Files
|
||||
|
||||
| Layer | File | Purpose |
|
||||
|-------|------|---------|
|
||||
| Backend entry | `backend/main.py` | FastAPI app, all API routes (~2850 lines) |
|
||||
| TTS protocol | `backend/backends/__init__.py:32-101` | `TTSBackend` Protocol definition |
|
||||
| Model registry | `backend/backends/__init__.py:17-29,153-366` | `ModelConfig` dataclass + registry helpers |
|
||||
| TTS factory | `backend/backends/__init__.py:382-426` | Thread-safe engine registry (double-checked locking) |
|
||||
| PyTorch TTS | `backend/backends/pytorch_backend.py` | Qwen3-TTS via `qwen_tts` package |
|
||||
| MLX TTS | `backend/backends/mlx_backend.py` | Qwen3-TTS via `mlx_audio.tts` |
|
||||
| LuxTTS | `backend/backends/luxtts_backend.py` | LuxTTS — fast, CPU-friendly |
|
||||
| Chatterbox MTL | `backend/backends/chatterbox_backend.py` | Chatterbox Multilingual — 23 languages |
|
||||
| Chatterbox Turbo | `backend/backends/chatterbox_turbo_backend.py` | Chatterbox Turbo — English, paralinguistic tags |
|
||||
| TADA | `backend/backends/hume_backend.py` | HumeAI TADA — 1B English + 3B Multilingual |
|
||||
| Kokoro | `backend/backends/kokoro_backend.py` | Kokoro 82M — CPU realtime, pre-built voices |
|
||||
| Qwen CustomVoice | `backend/backends/qwen_custom_voice_backend.py` | Qwen CustomVoice — predefined speakers with instruct |
|
||||
| Platform detect | `backend/platform_detect.py` | Apple Silicon → MLX, else → PyTorch |
|
||||
| API types | `backend/models.py` | Pydantic request/response models |
|
||||
| HF progress | `backend/utils/hf_progress.py` | HFProgressTracker (tqdm patching for download progress) |
|
||||
| Audio utils | `backend/utils/audio.py` | `trim_tts_output()`, normalize, load/save audio |
|
||||
| Frontend API | `app/src/lib/api/client.ts` | Hand-written fetch wrapper |
|
||||
| Frontend types | `app/src/lib/api/types.ts` | TypeScript API types |
|
||||
| Engine selector | `app/src/components/Generation/EngineModelSelector.tsx` | Shared engine/model dropdown |
|
||||
| Generation form | `app/src/components/Generation/GenerationForm.tsx` | TTS generation UI |
|
||||
| Floating gen box | `app/src/components/Generation/FloatingGenerateBox.tsx` | Compact generation UI |
|
||||
| Model manager | `app/src/components/ServerSettings/ModelManagement.tsx` | Model download/status/progress UI |
|
||||
| GPU acceleration | `app/src/components/ServerSettings/GpuAcceleration.tsx` | CUDA backend swap UI |
|
||||
| Gen form hook | `app/src/lib/hooks/useGenerationForm.ts` | Form validation + submission |
|
||||
| Language constants | `app/src/lib/constants/languages.ts` | Per-engine language maps |
|
||||
|
||||
### How TTS Generation Works (Current Flow)
|
||||
|
||||
```
|
||||
POST /generate
|
||||
1. Look up voice profile from DB
|
||||
2. Resolve engine from request (qwen | qwen_custom_voice | luxtts | chatterbox | chatterbox_turbo | tada | kokoro)
|
||||
3. Get backend: get_tts_backend_for_engine(engine) # thread-safe singleton per engine
|
||||
4. Check model cache → if missing, trigger background download, return HTTP 202
|
||||
5. Load model (lazy): tts_backend.load_model(model_size)
|
||||
6. Create voice prompt: profiles.create_voice_prompt_for_profile(engine=engine)
|
||||
→ tts_backend.create_voice_prompt(audio_path, reference_text)
|
||||
7. Generate: tts_backend.generate(text, voice_prompt, language, seed, instruct)
|
||||
8. Post-process: trim_tts_output() for Chatterbox engines
|
||||
9. Save WAV → data/generations/{id}.wav
|
||||
10. Insert history record in SQLite
|
||||
11. Return GenerationResponse
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
### What's Shipped (v0.4.x)
|
||||
|
||||
**New since v0.3.0:**
|
||||
- Kokoro 82M TTS engine + voice profile type system (PR #325)
|
||||
- Qwen CustomVoice preset engine — predefined speakers with instruct support (PR #328)
|
||||
- Intel Arc (XPU) GPU support (PR #320)
|
||||
- Blackwell GPU (sm_120) CUDA support (PR #401)
|
||||
- Generation cancellation flow (PR #444)
|
||||
- Frontend quality gates + TypeScript hardening (PR #418)
|
||||
- macOS Intel (x86_64) PyTorch compatibility (PR #416)
|
||||
- Frozen-binary import fixes for Kokoro / Chatterbox Multilingual / scipy / transformers (PR #438)
|
||||
- Linux PipeWire/PulseAudio monitor detection (PR #457)
|
||||
- Server survives GUI close on Windows (PR #402)
|
||||
- GPU arch compatibility warning on startup (catches unsupported PyTorch builds)
|
||||
- cpal Stream playback reliability (PR #405), clip-splitting stability (PR #403)
|
||||
- torch.from_numpy crash with numpy 2.x in frozen binary (PR #361)
|
||||
- Async CUDA download lock (PR #428), NUMBA_CACHE_DIR env var (PR #425)
|
||||
- "Clear failed" history button (PR #412)
|
||||
- External server GUI startup + data refresh (PR #319)
|
||||
- Force offline mode for cached Qwen/Whisper models (PR #318)
|
||||
- macOS 11 ScreenCaptureKit launch crash fix (PR #424)
|
||||
|
||||
**Core TTS (cumulative):**
|
||||
- Qwen3-TTS voice cloning (1.7B and 0.6B models, MLX + PyTorch)
|
||||
- Qwen CustomVoice (preset speakers, instruct)
|
||||
- LuxTTS — fast, CPU-friendly English TTS (PR #254)
|
||||
- Chatterbox Multilingual — 23 languages including Hebrew (PR #257)
|
||||
- Chatterbox Turbo — paralinguistic tags, low latency English (PR #258)
|
||||
- HumeAI TADA — 1B English + 3B Multilingual (PR #296)
|
||||
- Kokoro 82M — CPU-realtime, 8 languages, Apache 2.0 (PR #325)
|
||||
- Multi-engine architecture with thread-safe backend registry (PR #254)
|
||||
- Chunked TTS generation — engine-agnostic, removes ~500 char limit (PR #266)
|
||||
- Async generation queue (PR #269)
|
||||
- Post-processing audio effects system (PR #271)
|
||||
- Voice profile type system (preset vs cloned, engine compatibility gating)
|
||||
- Centralized `ModelConfig` registry — no per-engine dispatch maps
|
||||
- Shared `EngineModelSelector` component
|
||||
|
||||
**Infrastructure (cumulative):**
|
||||
- CUDA backend swap via binary download (PR #252), cu128 upgrade (PR #316), Blackwell/sm_120 (PR #401)
|
||||
- CUDA backend split into independently versioned server + libs archives (PR #298)
|
||||
- Intel Arc XPU support (PR #320)
|
||||
- Docker + web deployment (PR #161)
|
||||
- Backend refactor: modular architecture, style guide, tooling (PR #285)
|
||||
- Settings overhaul: routed sub-tabs, server logs, changelog, about page (PR #294)
|
||||
- Windows support: CUDA detection, cross-platform justfile, server lifecycle (PR #272, #402)
|
||||
- Linux audio capture via pactl monitor detection (PR #457)
|
||||
- macOS Intel x86_64 compatibility (PR #416)
|
||||
- Voice profiles with multi-sample support
|
||||
- Stories editor (multi-track DAW timeline)
|
||||
- Whisper transcription (base, small, medium, large, turbo variants)
|
||||
- Model management UI with inline download progress + folder migration (PR #268)
|
||||
- Download cancel/clear UI with error panel (PR #238)
|
||||
- Generation history with caching and cancellation (PR #444)
|
||||
- Streaming generation endpoint (MLX only)
|
||||
- Audio player freeze fix + UX improvements (PR #293)
|
||||
- CORS restriction to known local origins (PR #88)
|
||||
|
||||
### Abandoned / Backlogged Integrations
|
||||
|
||||
| Model | PR / Branch | Reason |
|
||||
|-------|-------------|--------|
|
||||
| **CosyVoice2/3** | PR #311 | Output quality too poor. Heavy deps, no PyPI, needed 5+ shims. PR should be closed. |
|
||||
| **VoxCPM 1.5 / VoxCPM2** | `voicebox-new-models` research (2026-04-18) | **Backlogged.** See detailed analysis below. |
|
||||
|
||||
#### VoxCPM — Evaluation Notes (2026-04-18)
|
||||
|
||||
**Project:** [OpenBMB/VoxCPM](https://github.com/OpenBMB/VoxCPM) — tokenizer-free TTS, 2B params (VoxCPM2), end-to-end diffusion autoregressive architecture, 30 languages, 48 kHz output, Apache 2.0, `pip install voxcpm`.
|
||||
|
||||
**Why it looked interesting:**
|
||||
- Clean PyPI install (`pip install voxcpm`)
|
||||
- Apache 2.0 — commercially safe
|
||||
- Voice cloning via `reference_wav_path` with optional `prompt_wav_path` + `prompt_text` for "ultimate" cloning
|
||||
- Streaming API via `generate_streaming()`
|
||||
- Zero-shot cloning + style control via parenthetical prefixes in text (`(slightly faster, cheerful tone)...`)
|
||||
- Relatively high-quality output per demos
|
||||
|
||||
**Why we backlogged it:**
|
||||
- **Effectively CUDA-only.** README states `CUDA ≥ 12.0` as hard requirement. Source code's `from_pretrained(device=None|"auto")` claims "preferring CUDA, then MPS, then CPU," but in practice:
|
||||
- **MPS (Apple Silicon) broken upstream** — OpenBMB/VoxCPM issues #232 (`NotImplementedError: Output channels > 65536 not supported at the MPS device`) and #248 (`IndexError` on M3 Mac) are both open with no resolution.
|
||||
- **CPU unsupported in the Python package** — issue #256 shows `voxcpm --device cpu` rejected with `unrecognized arguments`. The only CPU path is the third-party **VoxCPM.cpp** GGML engine, which is a separate ecosystem project, not `pip install voxcpm`.
|
||||
- **macOS source install fails** — issue #233 open with no resolution.
|
||||
- Would require CUDA-only gating in UI (new `requires_cuda` flag on `ModelConfig`, lock icon + "Requires NVIDIA GPU" in `ModelManagement.tsx` / `EngineModelSelector.tsx`) plus a hard error at `load_model()` as safety net. Doable but adds first-class platform gating that doesn't exist for any other engine today.
|
||||
- Voicebox's user base skews Apple Silicon (MLX is a primary backend). Shipping a CUDA-only model sets a precedent worth a separate scoping discussion (see issues #419 engine sprawl, #420 platform tiers, PR #465).
|
||||
|
||||
**What would change the decision:**
|
||||
- Upstream fixes MPS crashes (watch issues #232, #248).
|
||||
- We define an "experimental / CUDA-only" engine tier as part of issue #419 / PR #465, and decide it's acceptable to ship engines that are hidden on non-NVIDIA platforms.
|
||||
- VoxCPM.cpp matures into a viable CPU path we can wrap (currently separate project, C++/GGML, unclear ergonomics).
|
||||
|
||||
**Integration shape if we revive it:** Zero-shot cloning maps naturally to the Chatterbox-style backend (store `ref_audio` + `ref_text` paths in the voice prompt dict, process at generate time). Est. ~250 lines for `voxcpm_backend.py` + one `ModelConfig` entry + engine registration in `backends/__init__.py`. Frontend UI gating is the bigger lift.
|
||||
|
||||
### What's In-Flight
|
||||
|
||||
| Feature | Branch/PR | Status |
|
||||
|---------|-----------|--------|
|
||||
| Platform support tiers | PR #465, issue #420 | Defining tier-1 (supported) vs tier-2 (community) platforms |
|
||||
| Engine sprawl cleanup | issue #419 | First-class vs experimental TTS backends distinction |
|
||||
| Frontend tech-debt burn-down | issue #421 | Biome + a11y debt before gating CI |
|
||||
| Docker registry auto-publish | PR #463, issue #453 | ghcr.io image on tag push |
|
||||
| New model research | `voicebox-new-models` branch | Evaluating Fish Speech, XTTS-v2, Pocket TTS, VibeVoice, Fish Audio S2, index-tts2 |
|
||||
|
||||
### TTS Engine Comparison
|
||||
|
||||
| Engine | Model Name | Profile Type | Languages | Size | Key Features | Instruct Support |
|
||||
|--------|-----------|--------------|-----------|------|-------------|-----------------|
|
||||
| Qwen3-TTS 1.7B | `qwen-tts-1.7B` | Cloned | 10 (zh, en, ja, ko, de, fr, ru, pt, es, it) | ~3.5 GB | Highest quality, voice cloning | None (Base model has no instruct path) |
|
||||
| Qwen3-TTS 0.6B | `qwen-tts-0.6B` | Cloned | 10 | ~1.2 GB | Lighter, faster | None |
|
||||
| Qwen CustomVoice 1.7B | `qwen-custom-voice-1.7B` | Preset | 10 | ~3.5 GB | Predefined speakers, instruct support | **Yes** |
|
||||
| Qwen CustomVoice 0.6B | `qwen-custom-voice-0.6B` | Preset | 10 | ~1.2 GB | Predefined speakers, instruct support | **Yes** |
|
||||
| LuxTTS | `luxtts` | Cloned | English | ~300 MB | CPU-friendly, 48 kHz, fast | None |
|
||||
| Chatterbox | `chatterbox-tts` | Cloned | 23 (incl. Hebrew, Arabic, Hindi, etc.) | ~3.2 GB | Zero-shot cloning, multilingual | Partial — `exaggeration` float (0-1) |
|
||||
| Chatterbox Turbo | `chatterbox-turbo` | Cloned | English | ~1.5 GB | Paralinguistic tags ([laugh], [cough]), 350M params, low latency | Partial — inline tags only |
|
||||
| TADA 1B | `tada-1b` | Cloned | English | ~4 GB | HumeAI speech-language model, 700s+ coherent audio | None |
|
||||
| TADA 3B Multilingual | `tada-3b-ml` | Cloned | 10 (en, ar, zh, de, es, fr, it, ja, pl, pt) | ~8 GB | Multilingual, text-acoustic dual alignment | None |
|
||||
| Kokoro 82M | `kokoro` | Preset | 8 (en, es, fr, hi, it, pt, ja, zh) | ~350 MB | 82M params, CPU realtime, Apache 2.0, pre-built voices | None |
|
||||
|
||||
### Multi-Engine Architecture (Shipped)
|
||||
|
||||
- **Thread-safe backend registry** (`_tts_backends` dict + `_tts_backends_lock`) with double-checked locking
|
||||
- **Per-engine backend instances** — each engine gets its own singleton, loaded lazily
|
||||
- **Engine field on GenerationRequest** — frontend sends `engine: 'qwen' | 'qwen_custom_voice' | 'luxtts' | 'chatterbox' | 'chatterbox_turbo' | 'tada' | 'kokoro'`
|
||||
- **Per-engine language filtering** — `ENGINE_LANGUAGES` map in frontend, backend regex accepts all languages
|
||||
- **Per-engine voice prompts** — `create_voice_prompt_for_profile()` dispatches to the correct backend
|
||||
- **Profile type system** — preset vs cloned profiles, UI grays out incompatible engines and auto-switches on selection
|
||||
- **Trim post-processing** — `trim_tts_output()` for Chatterbox engines (cuts trailing silence/hallucination)
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- **HF XET progress**: Large files downloaded via `hf-xet` (HuggingFace's new transfer backend) report `n=0` in tqdm updates. Progress bars may appear stuck for large `.safetensors` files even though the download is proceeding. This is a known upstream limitation.
|
||||
- **Chatterbox Turbo upstream token bug**: `from_pretrained()` passes `token=os.getenv("HF_TOKEN") or True` which fails without a stored HF token. Our backend works around this by calling `snapshot_download(token=None)` + `from_local()`.
|
||||
- **chatterbox-tts must install with `--no-deps`**: It pins `numpy<1.26`, `torch==2.6.0`, `transformers==4.46.3` — all incompatible with our stack (Python 3.12, torch 2.10, transformers 4.57.3). Sub-deps listed explicitly in `requirements.txt`.
|
||||
- **Instruct parameter partially shipped** (#224, #303): Qwen CustomVoice (PR #328) now provides real instruct support via predefined speakers. Other backends still silently drop the instruct field — the UI exposes the field broadly but most engines ignore it. The floating generate box was patched to restore instruct for CustomVoice (commit `106aec4`).
|
||||
- **Streaming generation** only works for Qwen on MLX. Other engines use the non-streaming `/generate` endpoint.
|
||||
- **dicta-onnx** (Hebrew diacritization) not included — upstream Chatterbox bug requires `model_path` arg but calls `Dicta()` with none. Hebrew works fine without it.
|
||||
- **Blackwell (RTX 50-series) CUDA**: cu128 + sm_120 kernel support shipped (PR #401, #316), but users still report `cudaErrorNoKernelImageForDevice` (#417, #400, #396, #395, #390, #362) — likely a stale CUDA binary on upgraded installs. Needs a follow-up diagnostic / forced re-download path.
|
||||
- **Long text 50k character limit** (#464, #365, #354): Still hit on GPU despite chunking (PR #266). Chunking reliability needs another pass.
|
||||
- **ROCm on RDNA 3/4** (#469): `HSA_OVERRIDE_GFX_VERSION` is hardcoded and harms newer cards.
|
||||
- **`flash-attn is not installed` warning on every platform (cosmetic, common user complaint)**: Our transformer-based engines (Chatterbox / Qwen) emit `Warning: flash-attn is not installed. Will only run the manual PyTorch version. Please install flash-attn for faster inference.` on every startup, on every platform — we don't pin `flash-attn` in requirements because installing it is fragile and version-sensitive. Fallback is PyTorch SDPA, which is near-FA2 throughput on Ampere+ and is what actually runs. **Per-platform reality:** (a) **macOS/Apple Silicon** — FlashAttention is CUDA-only, irrelevant here; MLX has its own attention kernels. (b) **Linux** — `pip install flash-attn --no-build-isolation` works but takes 20+ min to compile. (c) **Windows** — no official support (Dao-AILab README still says only "Might work"; source builds routinely fail on recent CUDA/MSVC, issues #1715, #1828, #2395). Windows users can install community prebuilt wheels from `kingbri1/flash-attention` or `bdashore3/flash-attention` (latest v2.8.3, Aug 2025; `win_amd64` wheels for CUDA 12.4/12.8, Torch 2.6–2.9, Python 3.10–3.13) matching their exact CUDA/Torch/Python, or use WSL2. **Native-Windows alternatives worth considering as a build-time swap:** SageAttention (thu-ml, Apache 2.0, claims 2–5× over FA2) and xformers (official Windows wheels). **Action for us:** troubleshooting doc now covers it (see `docs/content/docs/overview/troubleshooting.mdx`), and we should optionally suppress the warning via `logging.getLogger(...).setLevel(ERROR)` at backend import since the fallback is functionally fine.
|
||||
- **WebAudio playback dies after audio-session interruption** (#41, plus an internal repro where the app is backgrounded long enough): WaveSurfer's `AudioContext` gets suspended by macOS — either because another app grabs the audio output, or because the WKWebView throttles when backgrounded. `play()` resolves and `timeupdate` can still fire, but no audio reaches the output. Only app restart fixes it. **Things already tried that didn't work:** (a) swapping WaveSurfer backend away from WebAudio — introduced more bugs, not an option; (b) remount hook on the player — doesn't help because a freshly-created `AudioContext` is born suspended and only resumes on a user gesture. PR #293 was a prior partial fix that doesn't cover this path. **Next thing to try** (not yet attempted — confirmed via grep of `AudioPlayer.tsx`): call `wavesurfer.getMediaElement().getGainNode().context.resume()` on the play button click (the click itself is a valid user gesture), plus a `visibilitychange` + `statechange` listener as belt-and-suspenders. The `ctx.resume()` pattern already exists in the codebase at `useStoryPlayback.ts:52` — just not wired into the main player.
|
||||
|
||||
---
|
||||
|
||||
## Open PRs — Triage & Analysis
|
||||
|
||||
### Recently Merged (Since Last Update — 2026-03-18 → 2026-04-18)
|
||||
|
||||
| PR | Title | Merged |
|
||||
|----|-------|--------|
|
||||
| **#481** | fix(build): pin transformers in MLX requirements to prevent 5.x upgrade | 2026-04-19 |
|
||||
| **#470** | fix(api-client): declare moved + errors on migrateModels response type | 2026-04-18 |
|
||||
| **#457** | fix(linux): use pactl to detect PipeWire/PulseAudio monitor | 2026-04-18 |
|
||||
| **#450** | docs: clarify paralinguistic tag support in quick start | 2026-04-18 |
|
||||
| **#447** | fix: delete version rows and files in delete_generations_by_profile | 2026-04-18 |
|
||||
| **#444** | Fix generation cancellation flow | 2026-04-18 |
|
||||
| **#440** | fix(paths): strip legacy "data/" prefix when resolving stored paths | 2026-04-18 |
|
||||
| **#439** | Fix migration dialog hanging when no models are present | 2026-04-18 |
|
||||
| **#438** | fix(build): repair frozen-binary imports for kokoro/chatterbox-multilingual/scipy/transformers | 2026-04-18 |
|
||||
| **#433** | fix: warn user when no models to migrate during storage change | 2026-04-18 |
|
||||
| **#425** | Add NUMBA_CACHE_DIR environment variable | 2026-04-16 |
|
||||
| **#424** | fix: avoid ScreenCaptureKit launch crash on macOS 11 | 2026-04-16 |
|
||||
| **#418** | Frontend quality gates + TypeScript hardening | 2026-04-18 |
|
||||
| **#416** | fix(deps): relax PyTorch requirement for macOS Intel (x86_64) | 2026-04-16 |
|
||||
| **#412** | feat(history): add "Clear failed" button | 2026-04-16 |
|
||||
| **#405** | fix: keep cpal Stream alive until playback completes | 2026-04-16 |
|
||||
| **#403** | fix: prevent intermittent clip splitting failures | 2026-04-16 |
|
||||
| **#402** | fix: reliably keep server alive after GUI close on Windows | 2026-04-16 |
|
||||
| **#401** | feat: add Blackwell GPU (sm_120) CUDA support | 2026-04-16 |
|
||||
| **#394** | fix(history): populate status/error/engine fields from DB row | 2026-04-16 |
|
||||
| **#384** | Fix: Resolve ModuleNotFoundError in effects service | 2026-04-16 |
|
||||
| **#361** | fix: torch.from_numpy crash with numpy 2.x in frozen binary | 2026-04-16 |
|
||||
| **#345** | Fix: "Failed to Save" preset error by resolving backend import path | 2026-03-22 |
|
||||
| **#344** | fix: include changelog in docker web build | 2026-03-27 |
|
||||
| **#332** | Fix links in Get Started section of index.mdx | 2026-03-21 |
|
||||
| **#328** | feat: add Qwen CustomVoice preset engine | 2026-03-27 |
|
||||
| **#325** | feat: Kokoro 82M TTS engine + voice profile type system | 2026-03-20 |
|
||||
| **#321** | fix: allows deletion of failed generations | 2026-03-19 |
|
||||
| **#320** | feat: Intel Arc (XPU) GPU support | 2026-03-21 |
|
||||
| **#319** | fix: GUI startup with external server + data refresh on server switch | 2026-03-27 |
|
||||
| **#318** | fix: force offline mode when loading cached models (Qwen TTS & Whisper) | 2026-03-21 |
|
||||
| **#316** | Upgrade CUDA backend from cu126 to cu128, fix GPU settings UI | 2026-03-18 |
|
||||
|
||||
### Currently Open (12 PRs)
|
||||
|
||||
| PR | Title | Status | Notes |
|
||||
|----|-------|--------|-------|
|
||||
| **#465** | docs: define tier-1 and tier-2 platform support targets | Community PR | Pairs with issue #420. Important for scoping. |
|
||||
| **#463** | feat(actions): add docker-registry.yml for automatic ghcr.io publishing | Community PR | Pairs with issue #453. Low risk. |
|
||||
| **#443** | fix: prevent infinite retry loop in offline mode (#434) | Community PR | Fixes reported bug. |
|
||||
| **#430** | feat: add MiniMax TTS provider support | Community PR | Cloud TTS provider — new direction (external API). Superset of #331? |
|
||||
| **#331** | feat: add MiniMax Cloud TTS as a built-in engine | Community PR | Likely superseded by #430. Dedupe. |
|
||||
| **#311** | feat: add CosyVoice2/3 TTS engine | **Close** | Abandoned — output quality too poor. |
|
||||
| **#253** | Enhance speech tokenizer with 48kHz version | Community PR | Qwen tokenizer upgrade. Still worth reviewing. |
|
||||
| **#227** | fix: harden input validation & file safety | Community PR | Coupled to #225 (custom models). |
|
||||
| **#225** | feat: custom HuggingFace voice model support | Community PR | Needs rework for multi-engine arch. |
|
||||
| **#195** | feat: per-profile LoRA fine-tuning | Draft | Complex. 15 new endpoints. |
|
||||
| **#154** | feat: Audiobook tab | Community PR | Chunked generation now shipped (#266). |
|
||||
| **#91** | fix: CoreAudio device enumeration | Draft | macOS audio device handling. |
|
||||
|
||||
---
|
||||
|
||||
## Open Issues — Categorized
|
||||
|
||||
### GPU / Hardware Detection — still the top category
|
||||
|
||||
**RTX 50-series (Blackwell / sm_120) cluster — NEW:** #417, #400, #396, #395, #390, #362 all report `cudaErrorNoKernelImageForDevice` / "no kernel image available." sm_120 support shipped in PR #401 + cu128 in PR #316, but users on upgraded installs still hit it — likely stale CUDA binary. Needs a diagnostic that detects binary/GPU-arch mismatch and prompts re-download.
|
||||
|
||||
**AMD / ROCm — NEW:** #469 `HSA_OVERRIDE_GFX_VERSION` is hardcoded and breaks RDNA 3/4 cards. #313 DirectML on AMD Ryzen AI Max+ 395 not working.
|
||||
|
||||
**Intel Arc:** PR #320 shipped XPU support — may resolve #119.
|
||||
|
||||
**General GPU-not-detected (older):** #368, #310, #330, #324, #326, #355 (multi-GPU / eGPU).
|
||||
|
||||
**Fix path:** CUDA backend swap (PR #252) + cu128 (PR #316) + sm_120 (PR #401) + GPU-arch warning (`73170d0`) are all in. Remaining work is diagnostics + re-download prompts for users whose binary predates the kernel updates.
|
||||
|
||||
### Model Downloads
|
||||
|
||||
Still reported. Users get stuck downloads, can't resume, offline mode edge cases.
|
||||
|
||||
**Key issues:** #475 (MAC CustomVoice install error), #449 (infinite loading macOS), #445 (can't download CustomVoice), #462 (Qwen requires internet even when loaded — regression from #150), #434 (infinite retry loop offline — PR #443 open), #432 (storage location change hangs when empty — partly fixed by PR #439/#433), #348 (TADA 3B Multilingual download fails), #336 (TADA model not listed in app), #275 (`No module named 'chatterbox'` on download), #304 (whisper-base feature extractor load error), #287 (macOS ARM `check_model_inputs` ImportError on new version), #181, #180.
|
||||
|
||||
**Fix path:** PR #443 addresses infinite offline retry. CustomVoice-specific download failures (#475, #445) need triage — likely related to frozen-binary import fixes in PR #438. TADA cluster (#336, #348) and macOS ARM import regressions (#287, #275, #304) need a dedicated triage pass.
|
||||
|
||||
**Qwen 0.6B-downloads-1.7B reports:** **#485** (2026-04-19), **#423** (macOS M1), **#329**. Originally a stale-fallback bug: `mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16` wasn't published when MLX support shipped, so the 0.6B slot was aliased to the 1.7B repo. The 0.6B bf16 conversion is live now and both `backend/backends/mlx_backend.py` and `backend/backends/__init__.py` point at their correct repos. Qwen CustomVoice is unaffected — it runs via PyTorch on all platforms, both sizes always have dedicated repos.
|
||||
|
||||
### Language Requests (ongoing)
|
||||
|
||||
Strong demand: Hungarian (#479), Indonesian (#458, #247), Thai (#455), Bangla (#454), Arabic (#379), Persian (#162), IndicF5 (#339 — Indian languages), Ukrainian (#109), Chinese UI (#392, #261).
|
||||
|
||||
**Fix path:** Chatterbox Multilingual (PR #257) covers Arabic, Danish, German, Greek, Finnish, Hebrew, Hindi, Dutch, Norwegian, Polish, Swedish, Swahili, Turkish. Still missing: Hungarian, Indonesian, Thai, Bangla, Ukrainian. Issue #411 offers a PR for UI i18n foundation.
|
||||
|
||||
### New Model Requests (growing)
|
||||
|
||||
| Issue | Model Requested |
|
||||
|-------|----------------|
|
||||
| #478 | CosyVoice3 (we tried & abandoned CosyVoice2/3 — see #311) |
|
||||
| #407, #347 | RVC-style voice-to-voice / seed voice conversion (STS) |
|
||||
| #385 | Fish Audio S2 |
|
||||
| #380 | OmniVoice |
|
||||
| #370 | index-tts2 |
|
||||
| #364 | Voxtral-TTS |
|
||||
| #335 | Faster-Qwen-TTS |
|
||||
| #346 | Multi-model batch request |
|
||||
| #381 | Microsoft MAI models |
|
||||
| #339 | IndicF5 |
|
||||
| #226 | GGUF support |
|
||||
| #172 | VibeVoice |
|
||||
| #138 | Export to ONNX/Piper format |
|
||||
| #132 | LavaSR (transcription) |
|
||||
| #147 | Facebook Omnilingual ASR |
|
||||
| #338 | Default voices |
|
||||
|
||||
The multi-engine architecture makes integration straightforward — see [`content/docs/developer/tts-engines.mdx`](content/docs/developer/tts-engines.mdx). Platform-specific gating (e.g. VoxCPM CUDA-only) doesn't exist yet and would need design.
|
||||
|
||||
### Platform Scope & Quality Debt — NEW category
|
||||
|
||||
Awareness issues filed this cycle — ties into engine sprawl and platform tier work.
|
||||
|
||||
- **#419** — Engine sprawl: define first-class vs experimental TTS backends
|
||||
- **#420** — Formalize tier-1 vs tier-2 platform support targets (PR #465 open)
|
||||
- **#421** — Track & burn down frontend Biome + a11y debt before gating CI
|
||||
- **#422** — Code-split web build (main bundle > 1 MB)
|
||||
|
||||
### Long-Form / Chunking
|
||||
|
||||
Still reported despite chunking + queue being merged.
|
||||
|
||||
**Key issues:** #464 (50k char limit on GPU despite 16 GB VRAM — v0.4.0), #365 (FR: >50k chars), #363 (smart chunking to prevent robotic artifacts), #354 (50k limit v0.3.0).
|
||||
|
||||
**Fix path:** Chunking (#266) and queue (#269) shipped. Remaining work is raising/removing the 50k guard and tuning chunk boundaries for prosody.
|
||||
|
||||
### Feature Requests (ongoing)
|
||||
|
||||
Notable:
|
||||
- **#480** — Noise removal on uploaded recordings
|
||||
- **#448** — API for non-Qwen models (external integrations)
|
||||
- **#427** — Task status control
|
||||
- **#407, #347** — Voice-to-voice / audio-to-audio conversion
|
||||
- **#387** — Location of downloaded generated voices
|
||||
- **#383** — Concatenate partial reference audio into generated audio
|
||||
- **#382** — Lightning.ai support
|
||||
- **#376** — Remote mode
|
||||
- **#353** — Audio transcoding
|
||||
- **#317** — Voice pitch control
|
||||
- **#189** — "Auto" language option
|
||||
- **#173** — Vocal intonation/inflection control
|
||||
- **#165, #270** — Audiobook mode (PR #154 open)
|
||||
- **#242** — Seed value pinning
|
||||
- **#228** — Always use 0.6B option
|
||||
- **#235** — Finetuned Qwen3-TTS tokenizer (PR #253 open)
|
||||
- **#144** — Copy text to clipboard
|
||||
|
||||
### Housekeeping / Triage Needed
|
||||
|
||||
| Issue | Reason |
|
||||
|-------|--------|
|
||||
| **#431**, **#408** | Spam — Chinese "free Claude API" promos. Close. |
|
||||
| **#398** ("Excelente") | Non-issue. Close. |
|
||||
| **#357** | Informational — project featured in Awesome MLX. Close after acknowledgement. |
|
||||
| **#374**, **#377** | Version-release questions, no bug. Close. |
|
||||
| **#306** ("voice model"), **#389** ("New model"), **#473** ("New functionality") | Title-only issues, no content. Request details or close. |
|
||||
| **#309** | Uninstall/cleanup question. Answer and close. |
|
||||
| **#241** | "How to use in Colab" — support question, not a bug. |
|
||||
| **#423** / **#485** / **#329** | Stale MLX fallback to 1.7B repo — fixed; 0.6B bf16 conversion now live on `mlx-community`, registry points at correct repo on both backends. |
|
||||
| **#336** / **#348** | TADA download/registration cluster — triage together. |
|
||||
| **#287** / **#275** / **#304** | macOS ARM import regressions on new version — likely one root cause. |
|
||||
| **#292**, **#349** | Possibly already fixed by merged PRs (#321/#412 and #345). Verify + close. |
|
||||
|
||||
**~70 older issues (pre-#170) not individually categorized above.** Most are long-tail support questions or duplicates of problems now addressed by the multi-engine / model-registry work. A dedicated backlog-sweep pass is overdue.
|
||||
|
||||
### Bugs (ongoing)
|
||||
|
||||
| Category | Issues |
|
||||
|----------|--------|
|
||||
| Generation failures | #476, #467, #452, #459 (voice clone fetch error), #468 (tada-1b marked error), #437, #300, #301, #282 |
|
||||
| Audio quality | #456 (clipping errors v0.4.0), #436 (emotion labels), #333 (pitch/echo), #307 (by-model breakdown), #340 (all generations say "www...") |
|
||||
| Transcription | #371 (fails every time), #291 (extract transcription from generated audio) |
|
||||
| Effects / presets | #349 ("Failed to save" when creating effects presets — possibly fixed by merged #345) |
|
||||
| File ops | #477 (spacy_pkuseg dict missing on frozen Windows build), #472 (storage location change), #283 (allow longer files for voice creation + in-app trim), #350 (failed to add sample) |
|
||||
| History | #292 (can't delete failed generations — possibly fixed by merged #321/#412) |
|
||||
| Windows | #466 (install problem), #375 (WinError 5 access denied), #273 (port 8000 conflict), #201 (model doesn't stay loaded) |
|
||||
| Linux | #471 (thread-safe PULSE_SOURCE), #413 (Arch build), #409 (Kubuntu build), #351, #341 |
|
||||
| macOS | #441 (older macOS), #369 (malware flag), #334 (microphone permission), #287 (`check_model_inputs` ImportError — regression), #171 (ARM64 binary won't open) |
|
||||
| Profile/UI | #360 (Kokoro profile hides others — partly addressed by auto-switch), #299 (drag-drop on Win11), #329 (size selector state bug), #393 (stuck loading screen after reinstall to new dir) |
|
||||
| Integrations | #397 (SAMMI-bot 422 Unprocessable Entity) |
|
||||
| Audio playback / session | **#41** (macOS: Voicebox goes silent after another app takes audio output; restart restores it) — see deep-dive below |
|
||||
| Database | #174 (sqlite3 IntegrityError) |
|
||||
|
||||
---
|
||||
|
||||
## Existing Plan Documents — Status
|
||||
|
||||
| Document | Target Version | Status | Relevance |
|
||||
|----------|---------------|--------|-----------|
|
||||
| `TTS_PROVIDER_ARCHITECTURE.md` | v0.1.13 | **Partially superseded** by multi-engine arch + CUDA swap | Core concepts implemented differently than planned |
|
||||
| `CUDA_BACKEND_SWAP.md` | — | **Shipped** (PR #252) | CUDA binary download + backend restart |
|
||||
| `CUDA_BACKEND_SWAP_FINAL.md` | — | **Shipped** (PR #252) | Final implementation plan |
|
||||
| `EXTERNAL_PROVIDERS.md` | v0.2.0 | **Not started** | Remote server support |
|
||||
| `MLX_AUDIO.md` | — | **Shipped** | MLX backend is live |
|
||||
| `DOCKER_DEPLOYMENT.md` | v0.2.0 | **Shipped** (PR #161) | Docker + web deployment |
|
||||
| `OPENAI_SUPPORT.md` | v0.2.0 | **Not started** | OpenAI-compatible API layer |
|
||||
| `PR33_CUDA_PROVIDER_REVIEW.md` | — | **Reference** | Analysis of the original provider approach |
|
||||
|
||||
---
|
||||
|
||||
## New Model Integration — Landscape
|
||||
|
||||
### Status Snapshot (2026-04-18)
|
||||
|
||||
| Model | Cloning | Speed | Sample Rate | Languages | VRAM | Instruct | Cross-platform? | Status |
|
||||
|-------|---------|-------|-------------|-----------|------|----------|-----------------|--------|
|
||||
| **Qwen3-TTS** | 10s zero-shot | Medium | 24 kHz | 10 | Medium | None | MLX + PyTorch | **Shipped** |
|
||||
| **Qwen CustomVoice** | Preset speakers | Medium | 24 kHz | 10 | Medium | **Yes** | PyTorch | **Shipped** (PR #328) |
|
||||
| **LuxTTS** | 3s zero-shot | 150x RT, CPU ok | 48 kHz | English | <1 GB | None | All | **Shipped** (PR #254) |
|
||||
| **Chatterbox MTL** | 5s zero-shot | Medium | 24 kHz | 23 | Medium | Partial — `exaggeration` | CPU/CUDA | **Shipped** (PR #257) |
|
||||
| **Chatterbox Turbo** | 5s zero-shot | Fast | 24 kHz | English | Low | Partial — inline tags | CPU/CUDA | **Shipped** (PR #258) |
|
||||
| **HumeAI TADA 1B/3B** | Zero-shot | 5x faster than LLM-TTS | 24 kHz | EN (1B), 10 (3B) | Medium | Partial — prosody | PyTorch | **Shipped** (PR #296) |
|
||||
| **Kokoro-82M** | Preset voices | CPU realtime | 24 kHz | 8 | Tiny (82M) | None | All | **Shipped** (PR #325) |
|
||||
| ~~**CosyVoice2-0.5B**~~ | 3-10s zero-shot | Very fast | 24 kHz | Multilingual | Low | **Yes** | — | **Abandoned** (PR #311) — poor output quality |
|
||||
| ~~**VoxCPM2**~~ | Zero-shot | ~0.15 RTF streaming | 48 kHz | 30 | Medium | Partial — parenthetical style | **CUDA-only in practice** | **Backlogged** (2026-04-18) — see notes above |
|
||||
| **Fish Speech** | 10-30s few-shot | Real-time | 24-44 kHz | 50+ | Medium | **Yes** — word-level inline | All | Candidate — license TBD |
|
||||
| **Fish Audio S2** | — | — | — | — | — | — | — | Candidate (#385) |
|
||||
| **XTTS-v2** | 6s zero-shot | Mid-GPU | 24 kHz | 17+ | Medium | Partial — style transfer from ref | All | Candidate — CPML license likely blocker |
|
||||
| **Pocket TTS** (Kyutai) | Zero-shot + streaming | >1x RT on CPU | — | English + several European (FR/DE/PT/IT/ES added by Feb 2026) | ~100M | None | CPU-first | Candidate — MIT |
|
||||
| **MOSS-TTS-Nano** | Zero-shot | **Realtime on 4 CPU cores** | 48 kHz stereo | 20 | 0.1B | Partial — MOSS-VoiceGenerator companion does text-to-voice design | All (ONNX CPU path dropped 2026-04-17) | **Top candidate** — Apache 2.0, released 2026-04-13, streaming |
|
||||
| **VibeVoice** (Microsoft) | — | — | — | Multi-speaker long-form (up to 90 min, 4 speakers) | 1.5B | — | — | Candidate (#172) — Stories-editor fit |
|
||||
| **index-tts2** | — | — | — | — | — | — | — | Candidate (#370) |
|
||||
| **Voxtral TTS** (Mistral) | Zero-shot (short clips) + 20 preset voices | Single-GPU | — | — | 4B (`Voxtral-4B-TTS-2603`) | Presets + cloning | CUDA (16 GB+ VRAM) | Candidate (#364) — frontier quality claim, open-weight |
|
||||
| **Dia / Dia2** | — | — | — | — | — | — | — | Watch — emotion-forward, but "rough edges" / artifacts per April reviews |
|
||||
| **IndicF5** | — | — | — | Indian languages | — | — | — | Candidate (#339) — fills Indic gap |
|
||||
| **MiniMax Cloud TTS** | — | Cloud | — | — | N/A (API) | — | N/A | Community PR #430, #331 — new direction (external API) |
|
||||
| **OmniVoice** | — | — | — | — | — | — | — | Candidate (#380) |
|
||||
| **RVC voice conversion** | N/A (STS) | — | — | — | — | N/A | All | New modality, not TTS (#407, #347) |
|
||||
|
||||
**Watch list:** MioTTS-2.6B (fast LLM-based EN/JP, vLLM compatible), Oolel-Voices (Soynade Research, expressive modular control), Faster-Qwen-TTS (#335), Orpheus / Sesame CSM (on-device fine-tuning discussions), Fish Audio S2 Pro / Fish Speech V1.5 (benchmark leader but research/non-commercial license — same blocker as Fish Speech).
|
||||
|
||||
**Deep-research pass (2026-04-18):** MOSS-TTS-Nano identified as the freshest high-alignment candidate — verified via [OpenMOSS/MOSS-TTS](https://github.com/OpenMOSS/MOSS-TTS) README (0.1B params, Apache 2.0, 48 kHz stereo, 4-core CPU realtime, streaming, released 2026-04-13). Dedicated repo: [OpenMOSS/MOSS-TTS-Nano](https://github.com/OpenMOSS/MOSS-TTS-Nano). Voxtral TTS verified on HF as `mistralai/Voxtral-4B-TTS-2603`.
|
||||
|
||||
#### Active Evaluation Criteria (learned from cycle)
|
||||
|
||||
1. **Cross-platform first.** MLX is a primary backend for our Apple Silicon user base. CUDA-only models require platform gating that doesn't exist yet — shipping one sets a precedent (see VoxCPM notes, issues #419/#420).
|
||||
2. **PyPI + Apache/MIT licensing preferred.** Heavy deps, git-only installs, and `--no-deps` workarounds are expensive to maintain (Chatterbox taught us this).
|
||||
3. **Output quality is non-negotiable.** CosyVoice was abandoned despite the best instruct API.
|
||||
4. **Instruct support fills a real gap** (#173, #224, #303). Qwen CustomVoice partially addresses it with preset speakers; zero-shot clone-with-instruct is still unmet.
|
||||
5. **Long-form + streaming are user-requested** (#363, #365, #464). Candidates with native streaming (Pocket TTS, Fish Speech) get extra weight.
|
||||
|
||||
### Adding a New Engine (Now Straightforward)
|
||||
|
||||
With the model config registry and shared `EngineModelSelector` component, adding a new TTS engine requires:
|
||||
|
||||
1. **Create `backend/backends/<engine>_backend.py`** — implement `TTSBackend` protocol (~200-300 lines)
|
||||
2. **Register in `backend/backends/__init__.py`** — add `ModelConfig` entry + `TTS_ENGINES` entry + factory elif
|
||||
3. **Update `backend/models.py`** — add engine name to regex
|
||||
4. **Update frontend** — add to engine union type, `EngineModelSelector` options, form schema, language map, profile type gating (icons/labels ~9 files per grep of `kokoro`)
|
||||
|
||||
`main.py` requires **zero changes** — the registry handles all dispatch automatically.
|
||||
|
||||
**Platform gating doesn't exist yet.** If we add a CUDA-only model (e.g. VoxCPM), we need a new `requires_cuda` (or more generally `requires: list[device]`) flag on `ModelConfig`, plumbed through `/models` API and surfaced in `ModelManagement.tsx` and `EngineModelSelector.tsx` as a lock icon + "Requires NVIDIA GPU" state. Backend should hard-error at `load_model()` as a safety net.
|
||||
|
||||
Total effort: **~1 day** for a well-documented model with a PyPI package, cross-platform. **~2 days** if platform gating is required. See [`content/docs/developer/tts-engines.mdx`](content/docs/developer/tts-engines.mdx) for the full guide.
|
||||
|
||||
---
|
||||
|
||||
## Architectural Bottlenecks
|
||||
|
||||
### ~~1. Single Backend Singleton~~ — RESOLVED
|
||||
|
||||
The singleton TTS backend was replaced with a thread-safe per-engine registry in PR #254. Multiple engines can now be loaded simultaneously.
|
||||
|
||||
### ~~2. `main.py` Dispatch Point Duplication~~ — RESOLVED
|
||||
|
||||
Previously, each engine required updates to 6+ hardcoded dispatch maps across `main.py` (~320 lines of if/elif chains). A model config registry in `backend/backends/__init__.py` now centralizes all model metadata (`ModelConfig` dataclass) with helper functions (`load_engine_model()`, `check_model_loaded()`, `engine_needs_trim()`, etc.). Adding a new engine requires zero changes to `main.py`.
|
||||
|
||||
### ~~3. Model Config is Scattered~~ — RESOLVED
|
||||
|
||||
Model identifiers, HF repo IDs, display names, and engine metadata are now consolidated in the `ModelConfig` registry. Backend-aware branching (e.g. MLX vs PyTorch Qwen repo IDs) happens inside the registry. Frontend model options are centralized in `EngineModelSelector.tsx`.
|
||||
|
||||
### 4. Voice Prompt Cache Assumes PyTorch Tensors
|
||||
|
||||
`backend/utils/cache.py` uses `torch.save()` / `torch.load()`. LuxTTS, Chatterbox, and Kokoro backends work around this by storing reference audio paths (or preset voice IDs) instead of tensors in their voice prompt dicts. Not ideal but functional.
|
||||
|
||||
### 5. ~~Frontend Assumes Qwen Model Sizes~~ — RESOLVED
|
||||
|
||||
The generation form now uses a flat model dropdown with engine-based routing. Per-engine language filtering is in place. Model size is only sent for Qwen / Qwen CustomVoice.
|
||||
|
||||
### 6. No Platform Gating on Models — NEW
|
||||
|
||||
`ModelConfig` has no way to express hardware requirements. Every engine is shown to every user, regardless of whether it'll actually load. Users on non-CUDA platforms discover failure at load time (or not at all — some fall back silently to CPU and never complete). Blocks shipping CUDA-only engines (VoxCPM) and would improve the Intel Arc / ROCm / CPU-only UX today. See `ModelConfig` TODO: add `requires: list[Literal["cuda", "mps", "xpu", "cpu", "rocm"]]` or equivalent, plumb through `/models` API, render in `ModelManagement.tsx` + `EngineModelSelector.tsx`.
|
||||
|
||||
### 7. Engine Sprawl — NEW
|
||||
|
||||
Seven TTS engines shipped, more candidates queued. Issue #419 asks for a first-class vs experimental distinction. Related: issue #420 asks for formalized platform support tiers. Combined, these would let us ship more engines more confidently with clearer expectations for users.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Priorities
|
||||
|
||||
### Tier 1 — Ship Now
|
||||
|
||||
| Priority | PR/Item | Impact | Effort |
|
||||
|----------|---------|--------|--------|
|
||||
| 1 | **RTX 50-series / Blackwell diagnostic** — detect stale CUDA binary vs GPU arch, prompt re-download (#417, #400, #396, #395, #390, #362) | Large cluster of user-blocking errors | Medium |
|
||||
| 2 | **CustomVoice download failures** (#475, #445) | New engine blocked on MAC/Win — regression triage | Medium |
|
||||
| 3 | **50k char limit on GPU** (#464) | Regression — chunking should handle this | Medium |
|
||||
| 4 | Close PR #311 (CosyVoice) and dedupe #331/#430 (MiniMax) | Housekeeping | None |
|
||||
| 5 | **PR #443** — infinite offline retry loop | Bug fix, reviewable | Low |
|
||||
| 6 | **PR #465** — define tier-1 / tier-2 platforms | Unblocks engine-sprawl decision (#419) | Low |
|
||||
| 7 | **PR #463** — docker registry auto-publish | Community PR, low risk | Low |
|
||||
| 8 | **#253** — 48kHz speech tokenizer | Quality improvement for Qwen | Medium |
|
||||
| 9 | **Kokoro profile UX** (#360) — partially addressed by auto-switch | Polish | Low |
|
||||
|
||||
### Tier 2 — Feature Work
|
||||
|
||||
| Priority | Item | Impact | Effort |
|
||||
|----------|------|--------|--------|
|
||||
| 1 | **Engine tier system** (#419) — first-class vs experimental, platform gating in `ModelConfig` | Unblocks CUDA-only engines (VoxCPM, etc.) and frontend polish | Medium |
|
||||
| 2 | **Frontend tech-debt burn-down** (#421) + code-split (#422) | Before gating CI on Biome | Medium |
|
||||
| 3 | **#154** — Audiobook tab | Long-form users. Chunking + queue shipped. | Medium |
|
||||
| 4 | **UI i18n** (#411 PR offer, #392, #261) | Chinese UI + general localization | Medium |
|
||||
| 5 | **#225** — Custom HuggingFace models | User-supplied models. Needs rework. | High |
|
||||
| 6 | OpenAI-compatible API (plan doc exists) — see also #448 (API for non-Qwen) | Low effort once API is stable | Low |
|
||||
| 7 | LoRA fine-tuning (PR #195) | Complex, needs rework for multi-engine | Very High |
|
||||
| 8 | Streaming for non-MLX engines | Currently MLX-only | Medium |
|
||||
| 9 | Voice-to-voice / RVC (#407, #347) | New modality — different arch shape | High |
|
||||
|
||||
### Tier 3 — Future Engines (cross-platform preferred)
|
||||
|
||||
| Priority | Item | Notes |
|
||||
|----------|------|-------|
|
||||
| 1 | **MOSS-TTS-Nano** | 0.1B, Apache 2.0, 4-core CPU realtime, 48 kHz stereo, streaming, 20 langs, released 2026-04-13. Best alignment with our criteria. Verify install ergonomics before committing. |
|
||||
| 2 | **Pocket TTS** (Kyutai) | CPU-first 100M model. MIT. Fills streaming gap without CUDA dependency. Several European langs added by Feb 2026. |
|
||||
| 3 | **IndicF5** | Fills Indian-language gap (#339). Closes many language-request issues. |
|
||||
| 4 | **VibeVoice** (Microsoft, #172) | 1.5B, long-form multi-speaker (up to 90 min, 4 speakers). Strong Stories-editor fit. |
|
||||
| 5 | **Voxtral TTS** (Mistral, #364) | 4B presets+cloning. Frontier quality claim, but 16 GB+ VRAM — would need the platform-tier work first. |
|
||||
| 6 | **Fish Speech / Fish Audio S2** | 50+ langs, word-level instruct. **License clarification first.** (#385) |
|
||||
| 7 | **XTTS-v2** | 17+ langs, mature pip. CPML likely kills commercial use — verify. |
|
||||
| 8 | **index-tts2** (#370) | Unvetted. |
|
||||
| — | ~~**VoxCPM2**~~ | **Backlogged** — CUDA-only upstream. Revisit when tier system ships or MPS bugs are fixed upstream. |
|
||||
|
||||
### ~~Previously Prioritized — Now Done~~
|
||||
|
||||
- ~~Kokoro 82M — finish integration~~ **Shipped** (PR #325)
|
||||
- ~~Qwen CustomVoice~~ **Shipped** (PR #328)
|
||||
- ~~Intel Arc (XPU) support~~ **Shipped** (PR #320)
|
||||
- ~~Blackwell CUDA~~ **Shipped** (PR #401, follow-up work open)
|
||||
- ~~Generation cancellation~~ **Shipped** (PR #444)
|
||||
- ~~macOS Intel x86_64~~ **Shipped** (PR #416)
|
||||
|
||||
---
|
||||
|
||||
## Branch Inventory
|
||||
|
||||
| Branch | PR | Status | Notes |
|
||||
|--------|-----|--------|-------|
|
||||
| `voicebox-new-models` | — | **Active** | New model research (Fish Speech, Pocket TTS, VibeVoice, etc.); VoxCPM evaluated & backlogged |
|
||||
| `fix/kokoro-pyinstaller-source-files` | — | Active | Kokoro frozen-build source bundling (parent of `voicebox-new-models`) |
|
||||
| `feat/cosyvoice-engine` | #311 | Open — closing | CosyVoice2/3 — abandoned, poor quality |
|
||||
| `feat/kokoro` | #325 | **Merged** | Kokoro 82M + voice profile type system |
|
||||
| `feat/qwen-custom-voice` | #328 | **Merged** | Qwen CustomVoice preset engine |
|
||||
| `feat/chatterbox-turbo` | #258 | **Merged** | Chatterbox Turbo + per-engine languages |
|
||||
| `feat/chatterbox` | #257 | **Merged** | Chatterbox Multilingual |
|
||||
| `feat/luxtts` | #254 | **Merged** | LuxTTS + multi-engine arch |
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: API Endpoints
|
||||
|
||||
<details>
|
||||
<summary>All current endpoints</summary>
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/health` | GET | Health check, model/GPU status |
|
||||
| `/profiles` | POST, GET | Create/list voice profiles |
|
||||
| `/profiles/{id}` | GET, PUT, DELETE | Profile CRUD |
|
||||
| `/profiles/{id}/samples` | POST, GET | Add/list voice samples |
|
||||
| `/profiles/{id}/avatar` | POST, GET, DELETE | Avatar management |
|
||||
| `/profiles/{id}/export` | GET | Export profile as ZIP |
|
||||
| `/profiles/import` | POST | Import profile from ZIP |
|
||||
| `/generate` | POST | Generate speech (engine param selects TTS backend) |
|
||||
| `/generate/stream` | POST | Stream speech (MLX only) |
|
||||
| `/history` | GET | List generation history |
|
||||
| `/history/{id}` | GET, DELETE | Get/delete generation |
|
||||
| `/history/{id}/export` | GET | Export generation ZIP |
|
||||
| `/history/{id}/export-audio` | GET | Export audio only |
|
||||
| `/transcribe` | POST | Transcribe audio (Whisper) |
|
||||
| `/models/status` | GET | All model statuses (Qwen, LuxTTS, Chatterbox, Chatterbox Turbo, TADA, Whisper) |
|
||||
| `/models/download` | POST | Trigger model download |
|
||||
| `/models/download/cancel` | POST | Cancel/dismiss download |
|
||||
| `/models/{name}` | DELETE | Delete downloaded model |
|
||||
| `/models/load` | POST | Load model into memory |
|
||||
| `/models/unload` | POST | Unload model |
|
||||
| `/models/progress/{name}` | GET | SSE download progress |
|
||||
| `/tasks/active` | GET | Active downloads/generations (with inline progress) |
|
||||
| `/stories` | POST, GET | Create/list stories |
|
||||
| `/stories/{id}` | GET, PUT, DELETE | Story CRUD |
|
||||
| `/stories/{id}/items` | POST, GET | Story items CRUD |
|
||||
| `/stories/{id}/export` | GET | Export story audio |
|
||||
| `/channels` | POST, GET | Audio channel CRUD |
|
||||
| `/channels/{id}` | PUT, DELETE | Channel update/delete |
|
||||
| `/cache/clear` | POST | Clear voice prompt cache |
|
||||
| `/server/cuda/status` | GET | CUDA binary availability |
|
||||
| `/server/cuda/download` | POST | Download CUDA binary |
|
||||
| `/server/cuda/switch` | POST | Switch to CUDA backend |
|
||||
|
||||
</details>
|
||||
+13
-42
@@ -7,61 +7,32 @@ This directory contains the documentation for Voicebox, built with [Fumadocs](ht
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Install Mintlify globally using bun:
|
||||
|
||||
```bash
|
||||
bun add -g mintlify
|
||||
```
|
||||
|
||||
Or use the helper script:
|
||||
|
||||
```bash
|
||||
bun run install:mintlify
|
||||
```
|
||||
|
||||
### Running Locally
|
||||
|
||||
From the `docs/` directory:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
This will start the Mintlify dev server.
|
||||
|
||||
The docs will be available at `http://localhost:3000`
|
||||
The docs will be available at `http://localhost:3000`.
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
docs/
|
||||
├── mint.json # Mintlify configuration
|
||||
├── custom.css # Custom styles
|
||||
├── overview/ # Getting started & feature docs
|
||||
├── guides/ # User guides
|
||||
├── api/ # API reference
|
||||
├── development/ # Developer documentation
|
||||
├── logo/ # Logo assets
|
||||
└── public/ # Static assets
|
||||
```
|
||||
- `content/docs/overview/` — user-facing guides (installation, quick start, feature walkthroughs)
|
||||
- `content/docs/developer/` — architecture, backend internals, and contributor guides
|
||||
- `content/docs/api-reference/` — auto-generated from the backend's OpenAPI schema
|
||||
- `content/docs/index.mdx` — landing page
|
||||
- `public/` — static assets (images, screenshots, videos)
|
||||
|
||||
### Writing Docs
|
||||
|
||||
- Use `.mdx` files for all documentation pages
|
||||
- Follow the existing structure in `mint.json` for navigation
|
||||
- Use Mintlify components for enhanced formatting (Card, CardGroup, Accordion, etc.)
|
||||
- Reference the [Mintlify documentation](https://mintlify.com/docs) for available components
|
||||
- Navigation is generated from `content/docs/meta.json` files
|
||||
- Fumadocs components available: `Callout`, `Cards` / `Card`, `Tabs` / `Tab`, `Steps` / `Step`, `Accordion` / `AccordionGroup`, `Files` / `Folder` / `File`
|
||||
- API reference pages under `api-reference/` are regenerated from the backend's OpenAPI schema — don't edit them by hand
|
||||
|
||||
## Deployment
|
||||
|
||||
Docs are automatically deployed when changes are pushed to the main branch.
|
||||
|
||||
To manually deploy:
|
||||
|
||||
```bash
|
||||
mintlify deploy
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](../CONTRIBUTING.md) for contribution guidelines.
|
||||
Docs are automatically deployed when changes land on `main`.
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
---
|
||||
title: "Troubleshooting Guide"
|
||||
description: "Common issues and solutions for Voicebox"
|
||||
---
|
||||
|
||||
Common issues and solutions for Voicebox.
|
||||
|
||||
## Installation Issues
|
||||
|
||||
### macOS: "Voicebox cannot be opened because it is from an unidentified developer"
|
||||
|
||||
**Solution:**
|
||||
1. Right-click the `.dmg` file
|
||||
2. Select "Open"
|
||||
3. Click "Open" in the security dialog
|
||||
4. Alternatively, go to System Settings → Privacy & Security → Allow Voicebox
|
||||
|
||||
### Windows: "Windows protected your PC"
|
||||
|
||||
**Solution:**
|
||||
1. Click "More info"
|
||||
2. Click "Run anyway"
|
||||
3. Windows Defender may flag new software; this is normal for unsigned apps
|
||||
|
||||
### Linux: AppImage won't run
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
chmod +x voicebox-*.AppImage
|
||||
./voicebox-*.AppImage
|
||||
```
|
||||
|
||||
## Runtime Issues
|
||||
|
||||
### Server won't start
|
||||
|
||||
**Symptoms:** App opens but shows "Server not connected"
|
||||
|
||||
**Solutions:**
|
||||
1. **Check Python installation**
|
||||
```bash
|
||||
python --version # Should be 3.11+
|
||||
```
|
||||
|
||||
2. **Check server binary exists**
|
||||
- Look in `tauri/src-tauri/binaries/` for your platform
|
||||
- Binary should match your system architecture
|
||||
|
||||
3. **Check permissions**
|
||||
```bash
|
||||
# macOS/Linux
|
||||
chmod +x tauri/src-tauri/binaries/voicebox-server-*
|
||||
```
|
||||
|
||||
4. **Check logs**
|
||||
- macOS: Open Console.app and search for "voicebox"
|
||||
- Linux: Check `~/.local/share/voicebox/` for logs
|
||||
- Windows: Check Event Viewer
|
||||
|
||||
### "Model download failed"
|
||||
|
||||
**Symptoms:** First generation fails with download error
|
||||
|
||||
**Solutions:**
|
||||
1. **Check internet connection**
|
||||
- Models download from HuggingFace Hub (~2-4GB)
|
||||
- First download may take several minutes
|
||||
|
||||
2. **Check disk space**
|
||||
- Models are cached in `~/.cache/huggingface/`
|
||||
- Ensure at least 5GB free space
|
||||
|
||||
3. **Manual download** (if automatic fails)
|
||||
```bash
|
||||
pip install huggingface_hub
|
||||
huggingface-cli download Qwen/Qwen3-TTS-12Hz-1.7B-Base
|
||||
```
|
||||
|
||||
### "Out of memory" errors
|
||||
|
||||
**Symptoms:** Generation fails with CUDA/VRAM errors
|
||||
|
||||
**Solutions:**
|
||||
1. **Use smaller model**
|
||||
- Switch to 0.6B model instead of 1.7B
|
||||
- Settings → Model Management → Load 0.6B
|
||||
|
||||
2. **Close other applications**
|
||||
- Free up GPU memory
|
||||
- Close browser tabs, other ML apps
|
||||
|
||||
3. **Use CPU mode**
|
||||
- Slower but works without GPU
|
||||
- Backend automatically falls back to CPU
|
||||
|
||||
### MLX "Failed to load the default metallib" error (Apple Silicon)
|
||||
|
||||
**Symptoms:** Generation fails with "library not found" or "metallib" errors
|
||||
|
||||
**Solutions:**
|
||||
1. **Rebuild server binary**
|
||||
```bash
|
||||
bun run build:server
|
||||
```
|
||||
The build script should automatically include MLX Metal shader libraries.
|
||||
|
||||
2. **Check MLX installation**
|
||||
```bash
|
||||
pip install -r backend/requirements-mlx.txt
|
||||
```
|
||||
|
||||
3. **Verify backend detection**
|
||||
- Check server logs for "Backend: MLX"
|
||||
- If showing "Backend: PYTORCH", MLX may not be installed correctly
|
||||
|
||||
### Audio playback issues
|
||||
|
||||
**Symptoms:** Generated audio won't play
|
||||
|
||||
**Solutions:**
|
||||
1. **Check audio format**
|
||||
- Audio is saved as WAV files
|
||||
- Ensure your system supports WAV playback
|
||||
|
||||
2. **Try downloading audio**
|
||||
- Right-click → Download
|
||||
- Play in external player
|
||||
|
||||
3. **Check browser permissions** (web version)
|
||||
- Allow audio autoplay in browser settings
|
||||
|
||||
### Slow generation
|
||||
|
||||
**Symptoms:** Generation takes >30 seconds
|
||||
|
||||
**Solutions:**
|
||||
1. **Check backend type** (Apple Silicon)
|
||||
- Check Settings → Server Status
|
||||
- Should show "Backend: MLX" on Apple Silicon
|
||||
- If showing "Backend: PYTORCH", install MLX: `pip install -r backend/requirements-mlx.txt`
|
||||
- MLX provides 4-5x faster inference on Apple Silicon
|
||||
|
||||
2. **Use GPU** (if available)
|
||||
- Check Settings → Server Status
|
||||
- Should show "GPU available: true"
|
||||
- Apple Silicon: Should show "Metal (Apple Silicon via MLX)"
|
||||
- Windows/Linux: Should show "CUDA" if GPU available
|
||||
|
||||
3. **Enable caching**
|
||||
- Voice prompts are cached automatically
|
||||
- Second generation with same voice should be faster
|
||||
|
||||
4. **Use smaller model**
|
||||
- 0.6B model is faster than 1.7B
|
||||
- Quality difference is minimal for most voices
|
||||
|
||||
5. **Check system resources**
|
||||
- Close other CPU/GPU intensive apps
|
||||
- Ensure adequate RAM (8GB+ recommended)
|
||||
|
||||
## API Issues
|
||||
|
||||
### "Connection refused" when using API
|
||||
|
||||
**Solutions:**
|
||||
1. **Check server is running**
|
||||
```bash
|
||||
curl http://localhost:17493/health
|
||||
```
|
||||
|
||||
2. **Check remote mode**
|
||||
- If connecting remotely, ensure server is started with `--host 0.0.0.0`
|
||||
- Check firewall settings
|
||||
|
||||
3. **Check port availability**
|
||||
- The current local app and dev workflow uses port 17493 by default
|
||||
- Ensure no other service is using it
|
||||
|
||||
### CORS errors in browser
|
||||
|
||||
**Solutions:**
|
||||
1. **Use desktop app** (recommended)
|
||||
- Desktop app doesn't have CORS restrictions
|
||||
|
||||
2. **Configure CORS** (for web deployment)
|
||||
- Update `backend/main.py` CORS settings
|
||||
- Add your domain to allowed origins
|
||||
|
||||
## Update Issues
|
||||
|
||||
### "Update check failed"
|
||||
|
||||
**Solutions:**
|
||||
1. **Check internet connection**
|
||||
- Updates are fetched from GitHub releases
|
||||
|
||||
2. **Check GitHub access**
|
||||
- Ensure `github.com` is accessible
|
||||
- Check firewall/proxy settings
|
||||
|
||||
3. **Manual update**
|
||||
- Download latest release from GitHub
|
||||
- Install manually
|
||||
|
||||
### "Invalid signature" error
|
||||
|
||||
**Solutions:**
|
||||
1. **Re-download installer**
|
||||
- Signature may be corrupted
|
||||
- Download fresh copy from GitHub
|
||||
|
||||
2. **Check release integrity**
|
||||
- Verify `.sig` file matches installer
|
||||
- Report issue if signature is invalid
|
||||
|
||||
## Data Issues
|
||||
|
||||
### Profiles disappeared
|
||||
|
||||
**Solutions:**
|
||||
1. **Check data directory**
|
||||
- macOS: `~/Library/Application Support/voicebox/`
|
||||
- Windows: `%APPDATA%/voicebox/`
|
||||
- Linux: `~/.local/share/voicebox/`
|
||||
|
||||
2. **Check database**
|
||||
- Database: `data/voicebox.db`
|
||||
- Ensure file exists and is readable
|
||||
|
||||
3. **Restore from backup**
|
||||
- Profiles can be exported/imported
|
||||
- Check for backup files
|
||||
|
||||
### "Database locked" error
|
||||
|
||||
**Solutions:**
|
||||
1. **Close other instances**
|
||||
- Ensure only one Voicebox instance is running
|
||||
|
||||
2. **Restart app**
|
||||
- Close and reopen Voicebox
|
||||
|
||||
3. **Check file permissions**
|
||||
- Ensure database file is writable
|
||||
- Check directory permissions
|
||||
|
||||
## Development Issues
|
||||
|
||||
### Build fails
|
||||
|
||||
**Solutions:**
|
||||
1. **Check Rust installation**
|
||||
```bash
|
||||
rustc --version
|
||||
rustup update
|
||||
```
|
||||
|
||||
2. **Check Tauri dependencies**
|
||||
```bash
|
||||
cd tauri
|
||||
bun install
|
||||
```
|
||||
|
||||
3. **Clean build**
|
||||
```bash
|
||||
cd tauri/src-tauri
|
||||
cargo clean
|
||||
cd ../..
|
||||
bun run build
|
||||
```
|
||||
|
||||
### API client generation fails
|
||||
|
||||
**Solutions:**
|
||||
1. **Start backend server**
|
||||
```bash
|
||||
bun run dev:server
|
||||
```
|
||||
|
||||
2. **Check OpenAPI endpoint**
|
||||
```bash
|
||||
curl http://localhost:17493/openapi.json
|
||||
```
|
||||
|
||||
3. **Regenerate client**
|
||||
```bash
|
||||
bun run generate:api
|
||||
```
|
||||
|
||||
## Still Having Issues?
|
||||
|
||||
1. **Check existing issues**
|
||||
- Search GitHub issues for similar problems
|
||||
- Check closed issues for solutions
|
||||
|
||||
2. **Create new issue**
|
||||
- Include:
|
||||
- OS and version
|
||||
- Voicebox version
|
||||
- Steps to reproduce
|
||||
- Error messages/logs
|
||||
- Screenshots (if applicable)
|
||||
|
||||
3. **Get help**
|
||||
- Check documentation in `docs/`
|
||||
- Review `backend/README.md` for API details
|
||||
- See `CONTRIBUTING.md` for development help
|
||||
|
||||
---
|
||||
|
||||
For more help, open an issue on [GitHub](https://github.com/jamiepine/voicebox/issues).
|
||||
@@ -9,9 +9,9 @@ Voicebox uses a client-server architecture with a React frontend and Python back
|
||||
|
||||
**Frontend Layer:** A React application that handles the UI components, state management with Zustand, and data fetching with React Query (TanStack Query).
|
||||
|
||||
**Backend Layer:** A Python FastAPI server that provides the REST API, runs the TTS engine (Qwen3-TTS), manages the SQLite database, and handles audio processing.
|
||||
**Backend Layer:** A Python FastAPI server that hosts the REST API, runs a pluggable registry of TTS and STT engines, manages the SQLite database, and handles audio processing.
|
||||
|
||||
These two layers communicate via HTTP, with the frontend making API requests to the backend.
|
||||
These two layers communicate via HTTP on `localhost:17493`, with the frontend making API requests to the backend. In production the backend is compiled with PyInstaller and launched as a Tauri sidecar; in development it's run manually via `uvicorn`.
|
||||
|
||||
## Frontend Architecture
|
||||
|
||||
@@ -29,43 +29,33 @@ These two layers communicate via HTTP, with the frontend making API requests to
|
||||
<Files>
|
||||
<Folder name="app/src" defaultOpen>
|
||||
<Folder name="components">
|
||||
<File name="profiles/" />
|
||||
<File name="generation/" />
|
||||
<File name="stories/" />
|
||||
<File name="shared/" />
|
||||
<File name="Profiles/" />
|
||||
<File name="Generation/" />
|
||||
<File name="Stories/" />
|
||||
<File name="ServerSettings/" />
|
||||
</Folder>
|
||||
<Folder name="lib">
|
||||
<File name="api/" />
|
||||
<File name="constants/" />
|
||||
<File name="hooks/" />
|
||||
<File name="utils/" />
|
||||
</Folder>
|
||||
<Folder name="hooks" />
|
||||
<Folder name="stores" />
|
||||
</Folder>
|
||||
</Files>
|
||||
|
||||
### State Management
|
||||
|
||||
```typescript
|
||||
// Example: Profile store
|
||||
const useProfileStore = create((set) => ({
|
||||
profiles: [],
|
||||
selectedProfile: null,
|
||||
setProfiles: (profiles) => set({ profiles }),
|
||||
selectProfile: (id) => set({ selectedProfile: id })
|
||||
}))
|
||||
```
|
||||
|
||||
## Backend Architecture
|
||||
|
||||
### Tech Stack
|
||||
|
||||
- **Framework**: FastAPI (Python 3.11+)
|
||||
- **TTS Model**: Qwen3-TTS
|
||||
- **Transcription**: Whisper
|
||||
- **Database**: SQLite
|
||||
- **Audio**: librosa, soundfile
|
||||
- **TTS Engines**: Qwen3-TTS, Qwen CustomVoice, LuxTTS, Chatterbox, Chatterbox Turbo, TADA, Kokoro
|
||||
- **Transcription**: Whisper (PyTorch or MLX-Whisper)
|
||||
- **Inference Backends**: MLX (Apple Silicon), PyTorch (CUDA / ROCm / XPU / DirectML / CPU)
|
||||
- **Database**: SQLite via SQLAlchemy
|
||||
- **Audio**: librosa, soundfile, Pedalboard
|
||||
|
||||
### API Structure
|
||||
### Layout
|
||||
|
||||
<Files>
|
||||
<Folder name="backend" defaultOpen>
|
||||
@@ -74,21 +64,31 @@ const useProfileStore = create((set) => ({
|
||||
<File name="config.py" />
|
||||
<File name="models.py" />
|
||||
<File name="server.py" />
|
||||
<File name="build_binary.py" />
|
||||
<Folder name="routes">
|
||||
<File name="profiles.py" />
|
||||
<File name="generate.py" />
|
||||
<File name="history.py" />
|
||||
<File name="..." />
|
||||
<File name="models.py" />
|
||||
<File name="channels.py" />
|
||||
</Folder>
|
||||
<Folder name="services">
|
||||
<File name="generation.py" />
|
||||
<File name="task_queue.py" />
|
||||
<File name="..." />
|
||||
<File name="profiles.py" />
|
||||
<File name="channels.py" />
|
||||
</Folder>
|
||||
<Folder name="backends">
|
||||
<File name="__init__.py" />
|
||||
<File name="base.py" />
|
||||
<File name="..." />
|
||||
<File name="pytorch_backend.py" />
|
||||
<File name="mlx_backend.py" />
|
||||
<File name="qwen_custom_voice_backend.py" />
|
||||
<File name="luxtts_backend.py" />
|
||||
<File name="chatterbox_backend.py" />
|
||||
<File name="chatterbox_turbo_backend.py" />
|
||||
<File name="hume_backend.py" />
|
||||
<File name="kokoro_backend.py" />
|
||||
</Folder>
|
||||
<Folder name="database">
|
||||
<File name="models.py" />
|
||||
@@ -97,49 +97,75 @@ const useProfileStore = create((set) => ({
|
||||
<Folder name="utils">
|
||||
<File name="audio.py" />
|
||||
<File name="effects.py" />
|
||||
<File name="..." />
|
||||
</Folder>
|
||||
</Folder>
|
||||
</Files>
|
||||
|
||||
### Request Flow
|
||||
|
||||
HTTP request → **routes/** (validate input, parse params) → **services/** (business logic, orchestration) → **backends/** (TTS/STT inference) → **utils/** (audio processing)
|
||||
An HTTP request enters a **route handler**, which validates input and delegates to a **service** function. The service calls into the appropriate **engine backend** via the registry, which runs the actual inference. Audio post-processing runs through **utils** (trim, resample, effects).
|
||||
|
||||
Route handlers are intentionally thin. They validate input, delegate to a service function, and format the response. All business logic lives in `services/`.
|
||||
Route handlers are intentionally thin — they validate input, delegate to a service function, and format the response. All business logic lives in `services/`.
|
||||
|
||||
### Multi-Engine Registry
|
||||
|
||||
The backend is designed so that adding a new TTS engine only requires touching the `backends/` directory and the central registry. There is no per-engine branching in routes or services.
|
||||
|
||||
- **`TTSBackend` Protocol** (`backends/__init__.py`) — defines the contract every engine implements: `load_model`, `create_voice_prompt`, `combine_voice_prompts`, `generate`, `unload_model`, `is_loaded`, `_get_model_path`.
|
||||
- **`ModelConfig` dataclass** — central metadata record for each model variant: `model_name`, `display_name`, `engine`, `hf_repo_id`, `size_mb`, `needs_trim`, `languages`, `supports_instruct`, etc.
|
||||
- **`TTS_ENGINES` dict** — maps engine name (`"qwen"`, `"kokoro"`, etc.) to display name.
|
||||
- **`get_tts_backend_for_engine(engine)`** — thread-safe factory that lazily instantiates and caches the backend for an engine using double-checked locking.
|
||||
|
||||
Shipped engines:
|
||||
|
||||
| Engine key | Display name | Profile type |
|
||||
|------------|--------------|--------------|
|
||||
| `qwen` | Qwen TTS | Cloned |
|
||||
| `qwen_custom_voice` | Qwen CustomVoice | Preset |
|
||||
| `luxtts` | LuxTTS | Cloned |
|
||||
| `chatterbox` | Chatterbox TTS | Cloned |
|
||||
| `chatterbox_turbo` | Chatterbox Turbo | Cloned |
|
||||
| `tada` | TADA | Cloned |
|
||||
| `kokoro` | Kokoro | Preset |
|
||||
|
||||
See [TTS Engines](/developer/tts-engines) for the full contract and integration phases, and [PROJECT_STATUS.md](https://github.com/jamiepine/voicebox/blob/main/docs/PROJECT_STATUS.md) for candidates under evaluation.
|
||||
|
||||
### Key Modules
|
||||
|
||||
- **app.py** — FastAPI app factory, CORS, lifecycle events
|
||||
- **main.py** — Entry point (imports app, runs uvicorn)
|
||||
- **server.py** — Tauri sidecar launcher, parent-pid watchdog
|
||||
- **services/generation.py** — Single function handling all generation modes (generate, retry, regenerate)
|
||||
- **services/task_queue.py** — Serial generation queue for GPU inference
|
||||
- **backends/__init__.py** — Protocol definitions and backend factory
|
||||
- **backends/base.py** — Shared utilities across all engine implementations
|
||||
- **`app.py`** — FastAPI app factory, CORS, lifecycle events
|
||||
- **`main.py`** — Entry point (imports app, runs uvicorn)
|
||||
- **`server.py`** — Tauri sidecar launcher, parent-pid watchdog, frozen-build environment setup
|
||||
- **`services/generation.py`** — Single function handling all generation modes (generate, retry, regenerate)
|
||||
- **`services/task_queue.py`** — Serial generation queue for GPU inference
|
||||
- **`backends/__init__.py`** — Protocol definitions, `ModelConfig` registry, and engine factory
|
||||
- **`backends/base.py`** — Shared utilities across all engine implementations (device selection, progress tracking, output trimming)
|
||||
|
||||
### Backend Selection
|
||||
### Inference Backend Selection
|
||||
|
||||
The server detects the best inference backend at startup:
|
||||
The server detects the best inference backend at startup and uses it for all engines that support it:
|
||||
|
||||
| Platform | Backend | Acceleration |
|
||||
|----------|---------|-------------|
|
||||
|----------|---------|--------------|
|
||||
| macOS (Apple Silicon) | MLX | Metal / Neural Engine |
|
||||
| Windows / Linux (NVIDIA) | PyTorch | CUDA |
|
||||
| Windows / Linux (NVIDIA) | PyTorch | CUDA (cu128) |
|
||||
| Linux (AMD) | PyTorch | ROCm |
|
||||
| Intel Arc | PyTorch | IPEX / XPU |
|
||||
| Windows (any GPU) | PyTorch | DirectML |
|
||||
| Windows / Linux (Intel Arc) | PyTorch | XPU (IPEX) |
|
||||
| Windows (other GPU) | PyTorch | DirectML |
|
||||
| Any | PyTorch | CPU fallback |
|
||||
|
||||
See [GPU Acceleration](/overview/gpu-acceleration) for platform-specific notes and manual overrides.
|
||||
|
||||
### Data Model
|
||||
|
||||
The database uses three main tables:
|
||||
Core tables (see `backend/database/models.py`):
|
||||
|
||||
**Profile Table:** Stores voice profiles with fields for id, name, and language.
|
||||
- **`profiles`** — Voice profiles with `voice_type` discriminator (`cloned` | `preset` | `designed`), `preset_engine`, `preset_voice_id`, and `default_engine`.
|
||||
- **`profile_samples`** — Reference audio clips + transcripts for cloned profiles. Empty for preset profiles.
|
||||
- **`generations`** — Generated audio with text, engine, model, language, seed, and duration.
|
||||
- **`generation_versions`** — Processed variants of a generation with different effects chains applied.
|
||||
- **`audio_channels`** + **`channel_device_mappings`** + **`profile_channel_mappings`** — Multi-output routing.
|
||||
|
||||
**Sample Table:** Stores audio samples linked to profiles via profile_id, with fields for audio_path and duration.
|
||||
|
||||
**Generation Table:** Stores generated audio with fields for id, profile_id, text, and audio_path.
|
||||
See [Voice Profiles](/developer/voice-profiles) and [Effects Pipeline](/developer/effects-pipeline) for details.
|
||||
|
||||
## Desktop App (Tauri)
|
||||
|
||||
@@ -148,6 +174,7 @@ The database uses three main tables:
|
||||
<Files>
|
||||
<Folder name="tauri/src-tauri" defaultOpen>
|
||||
<File name="Cargo.toml" />
|
||||
<File name="tauri.conf.json" />
|
||||
<File name="src/" />
|
||||
<Folder name="binaries" />
|
||||
</Folder>
|
||||
@@ -158,82 +185,74 @@ The database uses three main tables:
|
||||
- Launch Python backend as sidecar process
|
||||
- Native file dialogs
|
||||
- System tray integration
|
||||
- Auto-updates
|
||||
- OS-specific features
|
||||
- Auto-updates (Tauri updater + custom CUDA backend swap)
|
||||
- Parent-PID watchdog so the backend exits if the app crashes
|
||||
|
||||
## Build Process
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Frontend (Vite dev server)
|
||||
cd app && bun run dev
|
||||
|
||||
# Backend (manual start)
|
||||
cd backend && uvicorn main:app --reload
|
||||
|
||||
# Desktop app (connects to manual backend)
|
||||
bun run dev
|
||||
just dev # Starts backend + Tauri app
|
||||
just dev-web # Starts backend + web app (no Tauri)
|
||||
just dev-backend # Backend only
|
||||
just dev-frontend # Tauri app only (backend must be running)
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
# Build everything (server binary + Tauri app)
|
||||
bun run build
|
||||
|
||||
# Or build separately:
|
||||
# 1. Build server binary (PyInstaller)
|
||||
bun run build:server
|
||||
|
||||
# 2. Build Tauri app (includes server)
|
||||
cd tauri && bun run tauri build
|
||||
just build # CPU server binary + Tauri installer
|
||||
just build-local # CPU + CUDA binaries + Tauri installer (Windows)
|
||||
just build-server # Server binary only
|
||||
just build-tauri # Tauri app only
|
||||
```
|
||||
|
||||
See [Building](/developer/building) for what PyInstaller does and how the CUDA binary is split and packaged separately.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Generation Flow
|
||||
|
||||
When a user generates speech, the data flows through the following stages:
|
||||
|
||||
1. **User Input** - User enters text in a React component
|
||||
2. **State Update** - Text is stored in Zustand state
|
||||
3. **API Request** - React Query mutation triggers an API call via fetch
|
||||
4. **Backend Processing** - FastAPI endpoint receives the request
|
||||
5. **TTS Generation** - Qwen3-TTS model generates the audio
|
||||
6. **Storage** - Audio file is saved to disk and a database record is created
|
||||
7. **Response** - Backend returns the audio URL
|
||||
8. **Cache Update** - React Query updates its cache with the response
|
||||
9. **UI Update** - Component re-renders with new data
|
||||
10. **Playback** - User can play the generated audio
|
||||
1. **User Input** — text entered in a React component, engine + profile selected
|
||||
2. **State Update** — Zustand generation form store records the request
|
||||
3. **API Request** — React Query mutation hits `POST /generate`
|
||||
4. **Route** — `routes/generate.py` validates input, dispatches to `services/generation.py`
|
||||
5. **Voice Prompt** — the service creates or retrieves a cached voice prompt via the engine's backend
|
||||
6. **Queue** — `services/task_queue.py` serializes generation to avoid GPU contention
|
||||
7. **Inference** — the engine backend runs `generate()` and returns audio + sample rate
|
||||
8. **Post-process** — optional trim (for engines that need it), effects chain applied per generation version
|
||||
9. **Storage** — audio written to the generations directory, metadata saved to SQLite
|
||||
10. **Response** — backend returns the generation record; frontend updates React Query cache and plays audio
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Frontend
|
||||
|
||||
- **Code splitting** - Lazy load routes
|
||||
- **Memoization** - React.memo for heavy components
|
||||
- **Virtual scrolling** - For large lists
|
||||
- **Debouncing** - Search and input handling
|
||||
- **Code splitting** — lazy-load routes
|
||||
- **Memoization** — `React.memo` for heavy components
|
||||
- **Virtual scrolling** — for large lists
|
||||
- **Debouncing** — search and input handling
|
||||
|
||||
### Backend
|
||||
|
||||
- **Async operations** - All I/O is async
|
||||
- **Model caching** - Keep TTS model in memory
|
||||
- **Voice prompt caching** - Reuse embeddings
|
||||
- **Connection pooling** - Database connections
|
||||
- **Async I/O** — all I/O is async; inference runs in `asyncio.to_thread`
|
||||
- **Serial task queue** — avoids multiple engines fighting for the GPU
|
||||
- **Voice prompt caching** — engine-specific, keyed by audio hash + reference text
|
||||
- **Model pinning** — only one model per engine loaded at a time; switching unloads the previous one
|
||||
- **Per-engine backend cache** — engines are only instantiated once per process
|
||||
|
||||
## Security
|
||||
|
||||
### Current
|
||||
|
||||
- Local-only by default
|
||||
- Local-only by default (bound to `127.0.0.1:17493`)
|
||||
- No authentication (localhost trust)
|
||||
- File system sandboxing via Tauri
|
||||
|
||||
### Planned
|
||||
|
||||
- API key authentication
|
||||
- API key authentication for remote mode
|
||||
- User accounts
|
||||
- Rate limiting
|
||||
- HTTPS support
|
||||
@@ -248,17 +267,20 @@ When a user generates speech, the data flows through the following stages:
|
||||
|
||||
### Remote Mode
|
||||
|
||||
- Backend on separate machine
|
||||
- Frontend connects via HTTP
|
||||
- Shared infrastructure possible
|
||||
- Backend on a separate machine (Docker or bare host)
|
||||
- Frontend (desktop or web) connects over HTTP
|
||||
- See [Remote Mode](/overview/remote-mode) and [Docker](/overview/docker)
|
||||
|
||||
## Next Steps
|
||||
|
||||
<Cards>
|
||||
<Card title="Development Setup" href="/development/setup">
|
||||
<Card title="Development Setup" href="/developer/setup">
|
||||
Set up your dev environment
|
||||
</Card>
|
||||
<Card title="Contributing" href="/development/contributing">
|
||||
<Card title="TTS Engines" href="/developer/tts-engines">
|
||||
How to add a new engine
|
||||
</Card>
|
||||
<Card title="Contributing" href="/developer/contributing">
|
||||
Contribute to Voicebox
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
@@ -145,64 +145,55 @@ The updater only works in production Tauri builds. It doesn't run during `just d
|
||||
|
||||
## CUDA Backend Updates
|
||||
|
||||
The CUDA-enabled backend is distributed separately from the main app due to its large size (~2.43 GB). Unlike the Tauri auto-updater, this uses a custom download system built into the Python backend.
|
||||
The CUDA-enabled backend is distributed separately from the main app because bundling CUDA would bloat the installer by several gigabytes for users who don't have an NVIDIA GPU. Unlike the Tauri auto-updater, the CUDA backend uses a custom download system built into the Python server.
|
||||
|
||||
**Size comparison:**
|
||||
- Standard app bundle: ~410 MB
|
||||
- CUDA backend binary: ~2.43 GB (6× larger)
|
||||
**Size comparison (approximate):**
|
||||
- Standard CPU bundle (in the installer): ~200–400 MB
|
||||
- CUDA server core: ~945 MB (versioned with each Voicebox release)
|
||||
- CUDA libs (NVIDIA runtime DLLs): ~1.7 GB (versioned independently, cached across upgrades)
|
||||
|
||||
### Why Split?
|
||||
### Two-archive split
|
||||
|
||||
GitHub Releases has file size limits, and the CUDA-enabled `voicebox-server` binary is too large to include in the main Tauri bundle. Instead:
|
||||
Since v0.4, the CUDA binary is packaged as **two archives** instead of one:
|
||||
|
||||
- **Standard release**: Includes CPU-only backend (~50MB)
|
||||
- **CUDA release**: Split into multiple parts and downloaded on-demand by users who need GPU acceleration
|
||||
- **Server core** (`voicebox-server-cuda.tar.gz`) — the Python server + PyTorch code, changes every release.
|
||||
- **CUDA libs** (`cuda-libs-cu128-v1.tar.gz`) — the heavy NVIDIA CUDA/cuDNN DLLs, only re-downloaded when the CUDA toolkit major version changes.
|
||||
|
||||
This means most Voicebox upgrades only re-download the ~945 MB server core, not the full ~2.5 GB bundle.
|
||||
|
||||
### Download Process
|
||||
|
||||
When a user clicks "Enable CUDA" in the settings:
|
||||
When a user clicks "Install CUDA backend" in Settings → GPU:
|
||||
|
||||
1. **Manifest Fetch** - Backend fetches `{version}/voicebox-server-cuda.manifest` from GitHub Releases
|
||||
2. **Part Download** - Downloads each split part sequentially (e.g., `voicebox-server-cuda.part1`, `.part2`, etc.)
|
||||
3. **Assembly** - Concatenates parts into a single binary
|
||||
4. **Verification** - SHA-256 checksum verification (optional, if `.sha256` file exists)
|
||||
5. **Placement** - Binary moved to `{data_dir}/backends/voicebox-server-cuda.exe`
|
||||
6. **Restart** - Backend must restart to use the CUDA binary
|
||||
1. **Server-core archive** — Downloaded from GitHub Releases and extracted.
|
||||
2. **CUDA libs archive** — Downloaded separately (or reused if the installed version still matches).
|
||||
3. **Verification** — SHA-256 checksum verification for integrity.
|
||||
4. **Placement** — Extracted into `{data_dir}/backends/cuda/`.
|
||||
5. **Restart** — The Voicebox server restarts and swaps in the CUDA backend.
|
||||
|
||||
### Auto-Update on Startup
|
||||
|
||||
On server startup, `check_and_update_cuda_binary()` compares the installed CUDA binary version with the app version:
|
||||
|
||||
```python
|
||||
# backend/services/cuda.py
|
||||
cuda_version = get_cuda_binary_version() # runs `voicebox-server-cuda --version`
|
||||
current_version = __version__
|
||||
|
||||
if cuda_version != current_version:
|
||||
await download_cuda_binary() # Auto-download in background
|
||||
```
|
||||
|
||||
If versions mismatch, the backend automatically downloads the matching CUDA binary version without user intervention.
|
||||
On startup, the backend compares the installed CUDA server-core version with the current app version. If they differ, the core archive is pulled in the background. If the libs version pinned by the new release also differs (rare — e.g. on a cu126 → cu128 bump), the user is prompted to confirm the larger download.
|
||||
|
||||
### Storage Location
|
||||
|
||||
Downloaded CUDA binaries are stored in the app's data directory:
|
||||
Downloaded CUDA binaries live in the app's data directory:
|
||||
|
||||
```
|
||||
{data_dir}/
|
||||
backends/
|
||||
voicebox-server-cuda.exe # Windows
|
||||
voicebox-server-cuda # macOS/Linux
|
||||
{data_dir}/backends/cuda/
|
||||
voicebox-server-cuda.exe # Windows
|
||||
voicebox-server-cuda # macOS/Linux
|
||||
<NVIDIA CUDA runtime DLLs>
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/backend/cuda-status` | GET | Check if CUDA binary available/active |
|
||||
| `/backend/download-cuda` | POST | Start download |
|
||||
| `/backend/cuda-status` | GET | Check if the CUDA backend is available/active and which versions are installed |
|
||||
| `/backend/download-cuda` | POST | Trigger server-core + libs download |
|
||||
| `/backend/cuda-progress` | GET | SSE stream of download progress |
|
||||
| `/backend/cuda` | DELETE | Remove downloaded binary |
|
||||
| `/backend/cuda` | DELETE | Remove the downloaded CUDA backend |
|
||||
|
||||
### Progress Tracking
|
||||
|
||||
@@ -212,15 +203,16 @@ Downloads report progress via Server-Sent Events (SSE):
|
||||
GET /backend/cuda-progress
|
||||
|
||||
event: progress
|
||||
data: {"current": 52428800, "total": 104857600, "filename": "Downloading CUDA backend (2/4)", "status": "downloading"}
|
||||
data: {"current": 52428800, "total": 945000000, "filename": "voicebox-server-cuda.tar.gz", "status": "downloading"}
|
||||
```
|
||||
|
||||
The frontend subscribes to this endpoint to show real-time download progress in the UI.
|
||||
The frontend subscribes to this endpoint to show real-time progress, including which archive (server core vs libs) is currently downloading.
|
||||
|
||||
### Release Artifacts
|
||||
|
||||
For each release, these CUDA-related files are uploaded to GitHub:
|
||||
For each CUDA-capable release, these files are uploaded to GitHub:
|
||||
|
||||
- `voicebox-server-cuda.manifest` - List of split part filenames
|
||||
- `voicebox-server-cuda.part1` through `voicebox-server-cuda.partN` - Binary chunks
|
||||
- `voicebox-server-cuda.sha256` - SHA-256 checksum for integrity verification
|
||||
- `voicebox-server-cuda.tar.gz` — server-core archive
|
||||
- `voicebox-server-cuda.tar.gz.sha256` — checksum
|
||||
- `cuda-libs-cu128-v1.tar.gz` — CUDA runtime libs (only when the libs version bumps)
|
||||
- `cuda-libs-cu128-v1.tar.gz.sha256` — checksum
|
||||
|
||||
@@ -17,9 +17,10 @@ Thank you for your interest in contributing to Voicebox! This guide will help yo
|
||||
Before you start contributing, make sure you have:
|
||||
|
||||
1. **Read the documentation** to understand how Voicebox works
|
||||
2. **Set up your development environment** - see [Development Setup](/development/setup)
|
||||
2. **Set up your development environment** — see [Development Setup](/developer/setup)
|
||||
3. **Explored the codebase** to understand the project structure
|
||||
4. **Checked existing issues** to see if someone else is working on something similar
|
||||
4. **Checked [`docs/PROJECT_STATUS.md`](https://github.com/jamiepine/voicebox/blob/main/docs/PROJECT_STATUS.md)** — the living engineering roadmap that tracks prioritized tasks (Tier 1 → 3), architectural bottlenecks, and candidate TTS engines under evaluation (including why some are backlogged)
|
||||
5. **Checked existing issues** to see if someone else is working on something similar
|
||||
|
||||
## Ways to Contribute
|
||||
|
||||
@@ -173,10 +174,15 @@ When creating a pull request:
|
||||
<File name="stores/" />
|
||||
</Folder>
|
||||
<Folder name="backend">
|
||||
<File name="app.py" />
|
||||
<File name="main.py" />
|
||||
<File name="tts.py" />
|
||||
<File name="database.py" />
|
||||
<File name="server.py" />
|
||||
<File name="models.py" />
|
||||
<Folder name="routes" />
|
||||
<Folder name="services" />
|
||||
<Folder name="backends" />
|
||||
<Folder name="database" />
|
||||
<Folder name="utils" />
|
||||
</Folder>
|
||||
<Folder name="tauri">
|
||||
<File name="src-tauri/" />
|
||||
@@ -197,9 +203,10 @@ When creating a pull request:
|
||||
|
||||
### New Features
|
||||
|
||||
- Check the [roadmap](https://github.com/jamiepine/voicebox#roadmap) for planned features
|
||||
- Check [`docs/PROJECT_STATUS.md`](https://github.com/jamiepine/voicebox/blob/main/docs/PROJECT_STATUS.md) and the [roadmap](https://github.com/jamiepine/voicebox#roadmap) before proposing work — the status doc 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
|
||||
- Adding a new TTS engine? See [TTS Engines](/developer/tts-engines) for the phased workflow
|
||||
|
||||
### Documentation
|
||||
|
||||
@@ -253,7 +260,7 @@ When adding new API endpoints:
|
||||
|
||||
<Step title="Regenerate Client">
|
||||
```bash
|
||||
bun run generate:api
|
||||
just generate-api
|
||||
```
|
||||
|
||||
This updates the TypeScript client with type-safe bindings.
|
||||
@@ -263,7 +270,7 @@ When adding new API endpoints:
|
||||
The API documentation is automatically generated from the OpenAPI schema. Ensure your endpoint has proper docstrings and type hints, then regenerate the docs:
|
||||
|
||||
```bash
|
||||
bun run generate:api
|
||||
just generate-api
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
@@ -324,8 +331,9 @@ By contributing, you agree that your contributions will be licensed under the MI
|
||||
If you have questions:
|
||||
|
||||
1. Check the [documentation](/overview/introduction)
|
||||
2. Search [existing issues](https://github.com/jamiepine/voicebox/issues)
|
||||
3. Open a new issue or discussion
|
||||
4. See [CONTRIBUTING.md](https://github.com/jamiepine/voicebox/blob/main/CONTRIBUTING.md) in the repo
|
||||
2. Read [`docs/PROJECT_STATUS.md`](https://github.com/jamiepine/voicebox/blob/main/docs/PROJECT_STATUS.md) for current engineering priorities
|
||||
3. Search [existing issues](https://github.com/jamiepine/voicebox/issues)
|
||||
4. Open a new issue or discussion
|
||||
5. See [CONTRIBUTING.md](https://github.com/jamiepine/voicebox/blob/main/CONTRIBUTING.md) in the repo
|
||||
|
||||
Thank you for contributing to Voicebox! 🎉
|
||||
|
||||
@@ -15,17 +15,24 @@ The history module tracks all generated audio, providing a searchable record of
|
||||
class Generation(Base):
|
||||
__tablename__ = "generations"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
profile_id = Column(String, ForeignKey("profiles.id"))
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
|
||||
text = Column(Text, nullable=False)
|
||||
language = Column(String, default="en")
|
||||
audio_path = Column(String, nullable=False)
|
||||
duration = Column(Float, nullable=False)
|
||||
audio_path = Column(String, nullable=True)
|
||||
duration = Column(Float, nullable=True)
|
||||
seed = Column(Integer)
|
||||
instruct = Column(Text)
|
||||
created_at = Column(DateTime)
|
||||
engine = Column(String, default="qwen")
|
||||
model_size = Column(String, nullable=True)
|
||||
status = Column(String, default="completed") # pending | completed | failed
|
||||
error = Column(Text, nullable=True)
|
||||
is_favorited = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
```
|
||||
|
||||
Each generation can also have multiple **generation versions** — processed variants with different effects chains applied. The original (`clean`) version plus any number of processed versions live in a separate `generation_versions` table. See [Effects Pipeline](/developer/effects-pipeline).
|
||||
|
||||
## File Storage
|
||||
|
||||
Generated audio is stored in:
|
||||
@@ -230,7 +237,12 @@ GET /history?profile_id=uuid&search=hello&limit=50&offset=0
|
||||
"duration": 1.5,
|
||||
"seed": 42,
|
||||
"instruct": null,
|
||||
"created_at": "2024-01-15T10:30:00Z"
|
||||
"engine": "qwen",
|
||||
"model_size": "1.7B",
|
||||
"status": "completed",
|
||||
"error": null,
|
||||
"is_favorited": false,
|
||||
"created_at": "2026-04-18T10:30:00Z"
|
||||
}
|
||||
],
|
||||
"total": 150
|
||||
|
||||
@@ -1,341 +1,199 @@
|
||||
---
|
||||
title: "Model Management"
|
||||
description: "How model downloading, loading, and status tracking works in Voicebox"
|
||||
description: "How model downloading, loading, and status tracking works across all engines"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Voicebox manages two types of models:
|
||||
Voicebox manages two categories of models:
|
||||
|
||||
**TTS Models:** Qwen3-TTS for voice cloning (0.6B and 1.7B variants).
|
||||
**TTS Models** — Seven engines covering zero-shot cloning and preset voices. Each engine may have one or more size variants.
|
||||
|
||||
**ASR Models:** Whisper for transcription (tiny through large).
|
||||
**ASR Models** — Whisper for transcription. Five sizes, plus MLX-Whisper on Apple Silicon for ~8× faster transcription.
|
||||
|
||||
Models are downloaded from HuggingFace Hub on first use and cached locally.
|
||||
Every model is described by a `ModelConfig` entry in `backend/backends/__init__.py`. Models are downloaded from HuggingFace Hub on first use and cached in the platform-standard HF cache.
|
||||
|
||||
## Available Models
|
||||
## Available TTS Models
|
||||
|
||||
### TTS Models
|
||||
| Model | Engine | HuggingFace Repo | Size | VRAM | Languages |
|
||||
|-------|--------|------------------|------|------|-----------|
|
||||
| **Qwen TTS 1.7B** | `qwen` | `Qwen/Qwen3-TTS-12Hz-1.7B-Base` | 3.5 GB | ~6 GB | 10 |
|
||||
| **Qwen TTS 0.6B** | `qwen` | `Qwen/Qwen3-TTS-12Hz-0.6B-Base` | 1.2 GB | ~2 GB | 10 |
|
||||
| **Qwen CustomVoice 1.7B** | `qwen_custom_voice` | `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` | 3.5 GB | ~6 GB | 10 |
|
||||
| **Qwen CustomVoice 0.6B** | `qwen_custom_voice` | `Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice` | 1.2 GB | ~2 GB | 10 |
|
||||
| **LuxTTS** | `luxtts` | `YatharthS/LuxTTS` | 300 MB | ~1 GB | English |
|
||||
| **Chatterbox Multilingual** | `chatterbox` | `ResembleAI/chatterbox` | 3.2 GB | ~3 GB | 23 |
|
||||
| **Chatterbox Turbo** | `chatterbox_turbo` | `ResembleAI/chatterbox-turbo` | 1.5 GB | ~1.5 GB | English |
|
||||
| **TADA 1B** | `tada` | `HumeAI/tada-1b` | 4 GB | ~4 GB | English |
|
||||
| **TADA 3B Multilingual** | `tada` | `HumeAI/tada-3b-ml` | 8 GB | ~8 GB | 10 |
|
||||
| **Kokoro 82M** | `kokoro` | `hexgrad/Kokoro-82M` | 350 MB | ~150 MB | 8 |
|
||||
|
||||
| Model | HuggingFace ID | Size | VRAM |
|
||||
|-------|----------------|------|------|
|
||||
| 0.6B | `Qwen/Qwen3-TTS-12Hz-0.6B-Base` | ~1.2GB | ~2GB |
|
||||
| 1.7B | `Qwen/Qwen3-TTS-12Hz-1.7B-Base` | ~3.4GB | ~6GB |
|
||||
On Apple Silicon, Qwen TTS uses MLX-optimized repos from `mlx-community` instead of the PyTorch repos. The backend picks automatically via `get_backend_type()`.
|
||||
|
||||
### Whisper Models
|
||||
## Available Whisper Models
|
||||
|
||||
| Model | HuggingFace ID | Size | VRAM |
|
||||
|-------|----------------|------|------|
|
||||
| tiny | `openai/whisper-tiny` | ~150MB | ~1GB |
|
||||
| base | `openai/whisper-base` | ~300MB | ~1GB |
|
||||
| small | `openai/whisper-small` | ~500MB | ~2GB |
|
||||
| medium | `openai/whisper-medium` | ~1.5GB | ~5GB |
|
||||
| large | `openai/whisper-large` | ~3GB | ~10GB |
|
||||
| Model | HuggingFace Repo | Size |
|
||||
|-------|------------------|------|
|
||||
| **Whisper Base** | `openai/whisper-base` | ~300 MB |
|
||||
| **Whisper Small** | `openai/whisper-small` | ~500 MB |
|
||||
| **Whisper Medium** | `openai/whisper-medium` | ~1.5 GB |
|
||||
| **Whisper Large** | `openai/whisper-large-v3` | ~3 GB |
|
||||
| **Whisper Turbo** | `openai/whisper-large-v3-turbo` | ~1.5 GB |
|
||||
|
||||
On Apple Silicon, MLX-Whisper is preferred automatically — see [Transcription](/developer/transcription).
|
||||
|
||||
## Model Storage
|
||||
|
||||
Models are cached in the HuggingFace cache directory:
|
||||
Models live in the platform HuggingFace cache:
|
||||
|
||||
<Files>
|
||||
<Folder name="~/.cache/huggingface/hub" defaultOpen>
|
||||
<File name="models--Qwen--Qwen3-TTS-12Hz-1.7B-Base/" />
|
||||
<File name="models--Qwen--Qwen3-TTS-12Hz-0.6B-Base/" />
|
||||
<File name="models--openai--whisper-base/" />
|
||||
</Folder>
|
||||
</Files>
|
||||
| Platform | Path |
|
||||
|----------|------|
|
||||
| macOS | `~/.cache/huggingface/hub/` |
|
||||
| Linux | `~/.cache/huggingface/hub/` |
|
||||
| Windows | `%USERPROFILE%\.cache\huggingface\hub\` |
|
||||
| Docker | `/home/voicebox/.cache/huggingface/hub` (volume-mounted) |
|
||||
|
||||
Set `VOICEBOX_MODELS_DIR` to override.
|
||||
|
||||
## Progress Tracking
|
||||
|
||||
### Progress Manager
|
||||
Downloads stream progress to the frontend via Server-Sent Events. The progress pipeline has three pieces:
|
||||
|
||||
Tracks download progress across all models:
|
||||
**`ProgressManager`** (`backend/utils/progress.py`) — in-memory map of `model_name → {current, total, filename, status}`.
|
||||
|
||||
**`HFProgressTracker`** — context manager that intercepts HuggingFace Hub downloads to emit byte-level progress. Needed because `huggingface_hub` silently disables tqdm in frozen PyInstaller builds.
|
||||
|
||||
**SSE endpoint** — `GET /models/progress/{model_name}` streams updates until `status` is `complete` or `error`.
|
||||
|
||||
```python
|
||||
class ProgressManager:
|
||||
def __init__(self):
|
||||
self._progress = {} # model_name -> progress_info
|
||||
|
||||
def update_progress(
|
||||
self,
|
||||
model_name: str,
|
||||
current: int,
|
||||
total: int,
|
||||
filename: str,
|
||||
status: str,
|
||||
):
|
||||
self._progress[model_name] = {
|
||||
"current": current,
|
||||
"total": total,
|
||||
"filename": filename,
|
||||
"status": status, # downloading, complete, error
|
||||
"updated_at": datetime.utcnow(),
|
||||
}
|
||||
|
||||
def get_progress(self, model_name: str) -> Optional[dict]:
|
||||
return self._progress.get(model_name)
|
||||
```
|
||||
|
||||
### HuggingFace Progress Callback
|
||||
|
||||
Hooks into HuggingFace's download system:
|
||||
|
||||
```python
|
||||
class HFProgressTracker:
|
||||
def __init__(self, callback):
|
||||
self.callback = callback
|
||||
|
||||
@contextmanager
|
||||
def patch_download(self):
|
||||
"""Context manager to intercept HF downloads."""
|
||||
original_download = hf_hub_download
|
||||
|
||||
def patched_download(*args, **kwargs):
|
||||
# Intercept progress
|
||||
result = original_download(*args, **kwargs)
|
||||
self.callback(progress_info)
|
||||
return result
|
||||
|
||||
# Apply patch
|
||||
with patch('huggingface_hub.hf_hub_download', patched_download):
|
||||
yield
|
||||
```
|
||||
|
||||
### Server-Sent Events (SSE)
|
||||
|
||||
Progress is streamed to the frontend:
|
||||
|
||||
```python
|
||||
@app.get("/models/progress/{model_name}")
|
||||
async def get_model_progress(model_name: str):
|
||||
async def event_generator():
|
||||
while True:
|
||||
progress = progress_manager.get_progress(model_name)
|
||||
if progress:
|
||||
yield f"data: {json.dumps(progress)}\n\n"
|
||||
|
||||
if progress and progress["status"] in ["complete", "error"]:
|
||||
break
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream"
|
||||
)
|
||||
```
|
||||
|
||||
## Task Manager
|
||||
|
||||
Tracks active downloads and generations:
|
||||
|
||||
```python
|
||||
class TaskManager:
|
||||
def __init__(self):
|
||||
self._active_downloads = {}
|
||||
self._active_generations = {}
|
||||
|
||||
def start_download(self, model_name: str):
|
||||
self._active_downloads[model_name] = {
|
||||
"status": "downloading",
|
||||
"started_at": datetime.utcnow(),
|
||||
}
|
||||
|
||||
def complete_download(self, model_name: str):
|
||||
if model_name in self._active_downloads:
|
||||
del self._active_downloads[model_name]
|
||||
|
||||
def get_active_tasks(self) -> dict:
|
||||
return {
|
||||
"downloads": list(self._active_downloads.values()),
|
||||
"generations": list(self._active_generations.values()),
|
||||
}
|
||||
# Frontend
|
||||
const eventSource = new EventSource(`/models/progress/${modelName}`);
|
||||
eventSource.onmessage = (event) => {
|
||||
const { current, total, status } = JSON.parse(event.data);
|
||||
updateProgressBar(current / total);
|
||||
if (status === "complete") eventSource.close();
|
||||
};
|
||||
```
|
||||
|
||||
## Model Status
|
||||
|
||||
Check which models are downloaded and loaded:
|
||||
|
||||
```python
|
||||
@app.get("/models/status")
|
||||
async def get_model_status() -> ModelStatusListResponse:
|
||||
models = []
|
||||
|
||||
# Check TTS models
|
||||
for size, hf_id in [("1.7B", "Qwen/Qwen3-TTS-12Hz-1.7B-Base"), ...]:
|
||||
downloaded = is_model_downloaded(hf_id)
|
||||
loaded = tts_model._current_model_size == size
|
||||
|
||||
models.append(ModelStatus(
|
||||
model_name=f"qwen-tts-{size}",
|
||||
display_name=f"Qwen3-TTS {size}",
|
||||
downloaded=downloaded,
|
||||
size_mb=get_model_size_mb(hf_id),
|
||||
loaded=loaded,
|
||||
))
|
||||
|
||||
# Check Whisper models
|
||||
for size in ["tiny", "base", "small", "medium", "large"]:
|
||||
hf_id = f"openai/whisper-{size}"
|
||||
downloaded = is_model_downloaded(hf_id)
|
||||
|
||||
models.append(ModelStatus(
|
||||
model_name=f"whisper-{size}",
|
||||
display_name=f"Whisper {size}",
|
||||
downloaded=downloaded,
|
||||
size_mb=get_model_size_mb(hf_id),
|
||||
loaded=False, # Whisper is loaded on-demand
|
||||
))
|
||||
|
||||
return ModelStatusListResponse(models=models)
|
||||
```
|
||||
|
||||
## Manual Model Operations
|
||||
|
||||
### Load Model
|
||||
|
||||
```python
|
||||
@app.post("/models/load")
|
||||
async def load_model(model_size: str = "1.7B"):
|
||||
tts_model = get_tts_model()
|
||||
await tts_model.load_model_async(model_size)
|
||||
return {"status": "loaded", "model_size": model_size}
|
||||
```
|
||||
|
||||
### Unload Model
|
||||
|
||||
```python
|
||||
@app.post("/models/unload")
|
||||
async def unload_model():
|
||||
tts_model = get_tts_model()
|
||||
tts_model.unload_model()
|
||||
return {"status": "unloaded"}
|
||||
```
|
||||
|
||||
### Trigger Download
|
||||
|
||||
```python
|
||||
@app.post("/models/download")
|
||||
async def trigger_model_download(request: ModelDownloadRequest):
|
||||
# This triggers the download in background
|
||||
# Progress is tracked via /models/progress/{model_name}
|
||||
|
||||
if request.model_name.startswith("qwen-tts"):
|
||||
size = request.model_name.split("-")[-1]
|
||||
asyncio.create_task(download_tts_model(size))
|
||||
elif request.model_name.startswith("whisper"):
|
||||
size = request.model_name.split("-")[-1]
|
||||
asyncio.create_task(download_whisper_model(size))
|
||||
|
||||
return {"status": "downloading"}
|
||||
```
|
||||
|
||||
### Delete Model
|
||||
|
||||
```python
|
||||
@app.delete("/models/{model_name}")
|
||||
async def delete_model(model_name: str):
|
||||
# Find and delete from HuggingFace cache
|
||||
cache_dir = Path.home() / ".cache" / "huggingface" / "hub"
|
||||
|
||||
model_dirs = list(cache_dir.glob(f"models--*--{model_name}*"))
|
||||
for model_dir in model_dirs:
|
||||
shutil.rmtree(model_dir)
|
||||
|
||||
return {"status": "deleted"}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/models/status` | Get status of all models |
|
||||
| POST | `/models/load` | Load TTS model |
|
||||
| POST | `/models/unload` | Unload TTS model |
|
||||
| POST | `/models/download` | Trigger model download |
|
||||
| GET | `/models/progress/{name}` | Stream download progress (SSE) |
|
||||
| DELETE | `/models/{name}` | Delete downloaded model |
|
||||
| GET | `/tasks/active` | Get active downloads/generations |
|
||||
|
||||
## Response Schemas
|
||||
|
||||
### ModelStatus
|
||||
`GET /models/status` returns every registered model's current state:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "qwen-tts-1.7B",
|
||||
"display_name": "Qwen3-TTS 1.7B",
|
||||
"downloaded": true,
|
||||
"size_mb": 3400,
|
||||
"loaded": true
|
||||
}
|
||||
```
|
||||
|
||||
### ActiveTasksResponse
|
||||
|
||||
```json
|
||||
{
|
||||
"downloads": [
|
||||
"models": [
|
||||
{
|
||||
"model_name": "whisper-medium",
|
||||
"status": "downloading",
|
||||
"started_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
],
|
||||
"generations": [
|
||||
{
|
||||
"task_id": "uuid",
|
||||
"profile_id": "uuid",
|
||||
"text_preview": "Hello world...",
|
||||
"started_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
"model_name": "qwen-tts-1.7B",
|
||||
"display_name": "Qwen TTS 1.7B",
|
||||
"engine": "qwen",
|
||||
"downloaded": true,
|
||||
"size_mb": 3500,
|
||||
"loaded": true
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend Integration
|
||||
The handler iterates `get_all_model_configs()` and calls `check_model_loaded(config)` for each entry, so new engines appear automatically once they're registered in `ModelConfig`.
|
||||
|
||||
### Progress Display
|
||||
## Manual Model Operations
|
||||
|
||||
```typescript
|
||||
// Subscribe to download progress via SSE
|
||||
const eventSource = new EventSource(`/models/progress/${modelName}`);
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/models/status` | Status of every registered model |
|
||||
| POST | `/models/load` | Load a TTS model into memory |
|
||||
| POST | `/models/unload` | Unload a TTS model from memory |
|
||||
| POST | `/models/download` | Trigger a background download |
|
||||
| GET | `/models/progress/{name}` | Stream download progress (SSE) |
|
||||
| DELETE | `/models/{name}` | Delete a downloaded model from cache |
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const progress = JSON.parse(event.data);
|
||||
updateProgressBar(progress.current / progress.total);
|
||||
|
||||
if (progress.status === 'complete') {
|
||||
eventSource.close();
|
||||
}
|
||||
};
|
||||
### Load
|
||||
|
||||
```http
|
||||
POST /models/load
|
||||
{
|
||||
"model_name": "qwen-tts-1.7B"
|
||||
}
|
||||
```
|
||||
|
||||
### Model Status UI
|
||||
The route looks up the config, dispatches to `get_model_load_func(config)`, and returns once the model is ready.
|
||||
|
||||
```typescript
|
||||
// Fetch model status
|
||||
const { data: models } = useQuery({
|
||||
queryKey: ['models', 'status'],
|
||||
queryFn: () => api.getModelStatus(),
|
||||
});
|
||||
### Unload
|
||||
|
||||
// Display download/load buttons based on status
|
||||
models.map(model => (
|
||||
<ModelCard
|
||||
name={model.display_name}
|
||||
downloaded={model.downloaded}
|
||||
loaded={model.loaded}
|
||||
onDownload={() => triggerDownload(model.model_name)}
|
||||
onLoad={() => loadModel(model.model_name)}
|
||||
/>
|
||||
));
|
||||
```http
|
||||
POST /models/unload
|
||||
{
|
||||
"model_name": "chatterbox-tts"
|
||||
}
|
||||
```
|
||||
|
||||
Calls `unload_model_by_config(config)`, which routes to the right backend's `unload_model()` and frees GPU memory.
|
||||
|
||||
### Download
|
||||
|
||||
```http
|
||||
POST /models/download
|
||||
{
|
||||
"model_name": "kokoro"
|
||||
}
|
||||
```
|
||||
|
||||
Fires off an async download task. Progress is available via the SSE endpoint. Download is triggered automatically on first generation, so this is only needed for pre-warming.
|
||||
|
||||
## Preset Voice Seeding
|
||||
|
||||
For engines that use preset voices (Kokoro, Qwen CustomVoice), the backend auto-creates a voice profile per preset voice after the model is downloaded. This is driven by `seed_preset_profiles(engine)` in `backend/services/profiles.py`, called from the models route once download completes.
|
||||
|
||||
Preset profiles have:
|
||||
|
||||
- `voice_type = "preset"`
|
||||
- `preset_engine` = engine name (`"kokoro"`, `"qwen_custom_voice"`)
|
||||
- `preset_voice_id` = engine-specific voice ID (`"am_adam"`, `"f000001"`, etc.)
|
||||
- No `profile_samples` rows — no audio to store
|
||||
|
||||
See [Voice Profiles](/developer/voice-profiles) for the schema.
|
||||
|
||||
## Adding a New Model
|
||||
|
||||
To add a new size variant of an existing engine, just add another `ModelConfig`:
|
||||
|
||||
```python
|
||||
ModelConfig(
|
||||
model_name="qwen-tts-3B",
|
||||
display_name="Qwen TTS 3B",
|
||||
engine="qwen",
|
||||
hf_repo_id="Qwen/Qwen3-TTS-12Hz-3B-Base",
|
||||
model_size="3B",
|
||||
size_mb=7000,
|
||||
languages=["zh", "en", ...],
|
||||
),
|
||||
```
|
||||
|
||||
The frontend picks it up via `/models/status`; download/load flow works without further changes.
|
||||
|
||||
Adding a whole new engine is a bigger lift — see [TTS Engines](/developer/tts-engines) for the full phased workflow.
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error | Cause | Solution |
|
||||
|-------|-------|----------|
|
||||
| Download failed | Network issue | Retry download |
|
||||
| OOM on load | Model too large | Use smaller model |
|
||||
| Model not found | Cache corrupted | Re-download |
|
||||
| Slow download | HF rate limit | Wait and retry |
|
||||
| Error | Cause | Fix |
|
||||
|-------|-------|-----|
|
||||
| Download failed | Network / HF rate limit | Retry |
|
||||
| OOM on load | Not enough VRAM | Use a smaller variant, unload other engines |
|
||||
| Model not found | Corrupt cache | Re-download via `/models/download` |
|
||||
| Stuck progress bar in frozen build | `huggingface_hub` tqdm silenced | `HFProgressTracker` force-enables the internal counter |
|
||||
| GPU architecture unsupported | PyTorch wheel doesn't target your GPU | See [GPU Acceleration](/overview/gpu-acceleration) |
|
||||
|
||||
## Next Steps
|
||||
|
||||
<Cards>
|
||||
<Card title="TTS Generation" href="/developer/tts-generation">
|
||||
How generation flows through the registry
|
||||
</Card>
|
||||
<Card title="TTS Engines" href="/developer/tts-engines">
|
||||
Add a new engine end-to-end
|
||||
</Card>
|
||||
<Card title="Transcription" href="/developer/transcription">
|
||||
Whisper and MLX-Whisper integration
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
@@ -59,24 +59,64 @@ Ensure you have these installed:
|
||||
|
||||
## Just Commands
|
||||
|
||||
Run `just --list` to see all available commands:
|
||||
Run `just --list` to see all available commands. Highlights:
|
||||
|
||||
### Setup
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `just setup` | Full setup (Python venv + JS deps + dev sidecar). Detects Apple Silicon for MLX and NVIDIA/Intel Arc on Windows for accelerated PyTorch. |
|
||||
| `just setup-python` | Python venv + dependencies only |
|
||||
| `just setup-js` | `bun install` only |
|
||||
|
||||
### Development
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `just dev` | Start backend + Tauri desktop app (reuses a running backend if one exists) |
|
||||
| `just dev-web` | Start backend + web app (no Tauri/Rust build) |
|
||||
| `just dev-backend` | Backend only |
|
||||
| `just dev-frontend` | Tauri app only (backend must already be running) |
|
||||
| `just kill` | Stop all dev processes |
|
||||
|
||||
### Build
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `just build` | CPU server binary + Tauri installer |
|
||||
| `just build-local` | **Windows:** CPU + CUDA server binaries + Tauri installer |
|
||||
| `just build-server` | CPU server binary only |
|
||||
| `just build-server-cuda` | **Windows:** CUDA server binary only, placed in `%APPDATA%/sh.voicebox.app/backends/cuda` for local testing |
|
||||
| `just build-tauri` | Tauri app only |
|
||||
| `just build-web` | Web app only |
|
||||
|
||||
### Quality
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `just check` | Lint + format + typecheck (Biome + ruff) |
|
||||
| `just fix` | Auto-fix lint + format issues |
|
||||
| `just lint` / `just format` | Lint or format only |
|
||||
| `just test` | Run Python tests (pytest) |
|
||||
| `just test-models` | End-to-end generation against every TTS engine using the frozen binary |
|
||||
|
||||
### Database
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `just setup` | Full setup (Python venv + JS deps) |
|
||||
| `just dev` | Start backend + desktop app |
|
||||
| `just dev-web` | Start backend + web app (no Tauri) |
|
||||
| `just dev-backend` | Start backend only |
|
||||
| `just dev-frontend` | Start desktop app only (backend must be running) |
|
||||
| `just build` | Build desktop app for production |
|
||||
| `just build-web` | Build web app for production |
|
||||
| `just check` | Run all checks (JS + Python lint + format) |
|
||||
| `just fix` | Fix lint + format issues |
|
||||
| `just test` | Run Python tests |
|
||||
| `just db-init` | Initialize SQLite database |
|
||||
| `just db-reset` | Reset database (delete + reinit) |
|
||||
| `just clean` | Clean build artifacts |
|
||||
| `just clean-all` | Nuclear clean (includes node_modules) |
|
||||
| `just db-reset` | Delete and reinitialize the database |
|
||||
|
||||
### Utilities
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `just generate-api` | Generate TypeScript API client from the backend's OpenAPI schema |
|
||||
| `just docs` | Open `http://localhost:17493/docs` in your browser |
|
||||
| `just logs` | Tail backend logs |
|
||||
| `just clean` | Remove build artifacts |
|
||||
| `just clean-python` | Remove the Python venv + `__pycache__` |
|
||||
| `just clean-all` | Nuclear clean (includes all `node_modules`) |
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -133,10 +173,12 @@ HTTP request → **routes/** (validate input) → **services/** (business logic)
|
||||
|
||||
## Model Downloads
|
||||
|
||||
Models are automatically downloaded from HuggingFace Hub on first use:
|
||||
Models are automatically downloaded from HuggingFace Hub on first use, with live progress streamed to the UI:
|
||||
|
||||
- **Whisper** (transcription): Auto-downloads on first transcription
|
||||
- **Qwen3-TTS** (voice cloning): Auto-downloads on first generation (~2-4GB)
|
||||
- **Whisper** (transcription) — auto-downloads on first transcription
|
||||
- **TTS engines** — auto-download on first generation. Sizes range from 82 M (Kokoro, ~350 MB) to 3 B (TADA, ~8 GB)
|
||||
|
||||
See [Model Management](/developer/model-management) for the full list.
|
||||
|
||||
<Callout type="warn">
|
||||
First-time usage will be slower due to model downloads, but subsequent runs will use cached models.
|
||||
@@ -150,7 +192,7 @@ After starting the backend server, generate the TypeScript API client:
|
||||
just generate-api
|
||||
```
|
||||
|
||||
This downloads the OpenAPI schema and generates the TypeScript client in `app/src/lib/api/`
|
||||
This downloads the OpenAPI schema and generates the TypeScript client in `app/src/lib/api/`.
|
||||
|
||||
## Manual Setup (Advanced)
|
||||
|
||||
@@ -186,8 +228,17 @@ pip install -r requirements.txt
|
||||
# Apple Silicon: install MLX dependencies
|
||||
pip install -r requirements-mlx.txt
|
||||
|
||||
# Install Qwen3-TTS
|
||||
# Chatterbox pins numpy<1.26 / torch==2.6 which break on Python 3.12+
|
||||
pip install --no-deps chatterbox-tts
|
||||
|
||||
# HumeAI TADA pins torch>=2.7,<2.8 which conflicts with our torch>=2.1
|
||||
pip install --no-deps hume-tada
|
||||
|
||||
# Install Qwen3-TTS from source
|
||||
pip install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
|
||||
# PyInstaller and linting tools
|
||||
pip install pyinstaller ruff pytest pytest-asyncio
|
||||
```
|
||||
|
||||
### 3. Start Development
|
||||
@@ -208,17 +259,17 @@ bun run tauri dev
|
||||
## Next Steps
|
||||
|
||||
<Cards>
|
||||
<Card title="Architecture" href="/development/architecture">
|
||||
<Card title="Architecture" href="/developer/architecture">
|
||||
Understand the system architecture
|
||||
</Card>
|
||||
<Card title="Contributing" href="/development/contributing">
|
||||
<Card title="Contributing" href="/developer/contributing">
|
||||
Read the contribution guidelines
|
||||
</Card>
|
||||
<Card title="Building" href="/development/building">
|
||||
<Card title="Building" href="/developer/building">
|
||||
Learn how to build production releases
|
||||
</Card>
|
||||
<Card title="API Reference" href="/api-reference">
|
||||
Explore the REST API
|
||||
<Card title="TTS Engines" href="/developer/tts-engines">
|
||||
Add a new TTS engine end-to-end
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
|
||||
@@ -50,34 +50,15 @@ class StoryItem(Base):
|
||||
|
||||
### Start Time
|
||||
|
||||
`start_time_ms` defines when an item begins on the timeline:
|
||||
|
||||
```
|
||||
Timeline (ms): 0----1000----2000----3000----4000
|
||||
Item 1: [======]
|
||||
Item 2: [==========]
|
||||
Item 3: [====]
|
||||
```
|
||||
`start_time_ms` is the absolute position on the timeline where an item begins playing. Items on the same track cannot overlap; items on different tracks can.
|
||||
|
||||
### Tracks
|
||||
|
||||
Multiple tracks allow overlapping audio:
|
||||
|
||||
```
|
||||
Track 0: [Item 1] [Item 3]
|
||||
Track 1: [Item 2]
|
||||
```
|
||||
A `track` is an integer (0-indexed) that identifies the horizontal row an item sits on. Audio on separate tracks plays concurrently, so tracks are the primary way to layer multiple voices or sound effects.
|
||||
|
||||
### Trimming
|
||||
|
||||
Trim values cut audio from the start or end without destroying the original:
|
||||
|
||||
```
|
||||
Original: [=========AUDIO=========]
|
||||
trim_start: ^^
|
||||
trim_end: ^^
|
||||
Result: [=====AUDIO=====]
|
||||
```
|
||||
`trim_start_ms` and `trim_end_ms` hide the leading/trailing portions of the source generation without modifying the underlying audio file. The effective playback length is `generation.duration * 1000 - trim_start_ms - trim_end_ms`. Trimming is non-destructive — the same generation can be trimmed differently in different stories.
|
||||
|
||||
## Core Operations
|
||||
|
||||
|
||||
@@ -5,250 +5,91 @@ description: "How Whisper-based audio transcription works in Voicebox"
|
||||
|
||||
## Overview
|
||||
|
||||
Voicebox uses OpenAI's Whisper model for automatic speech recognition (ASR). This powers the transcription feature for creating reference text from audio recordings.
|
||||
Voicebox uses OpenAI's Whisper for automatic speech recognition (ASR). Transcription powers two flows:
|
||||
|
||||
1. **Reference-text auto-fill** — when a user records or uploads a voice sample, the backend transcribes it and populates the `reference_text` field so cloning can use it.
|
||||
2. **On-demand transcription** — a user-facing `/transcribe` endpoint for arbitrary audio.
|
||||
|
||||
On Apple Silicon, the transcription path runs through **MLX-Whisper** (from `mlx-audio`) for ~8× faster inference than PyTorch. Everywhere else it runs through PyTorch's `transformers` Whisper.
|
||||
|
||||
## Architecture
|
||||
|
||||
The transcription system is built around the `WhisperModel` class:
|
||||
|
||||
**Model Loading:** Lazy loading with HuggingFace Hub download.
|
||||
|
||||
**Audio Processing:** Resampling and preprocessing for Whisper.
|
||||
|
||||
**Inference:** Running transcription with optional language hints.
|
||||
|
||||
## WhisperModel Class
|
||||
Transcription is wired through the same backend abstraction as TTS. The `STTBackend` protocol lives in `backend/backends/__init__.py`:
|
||||
|
||||
```python
|
||||
class WhisperModel:
|
||||
def __init__(self, model_size: str = "base"):
|
||||
self.model = None
|
||||
self.processor = None
|
||||
self.model_size = model_size
|
||||
self.device = self._get_device()
|
||||
@runtime_checkable
|
||||
class STTBackend(Protocol):
|
||||
async def load_model(self, model_size: str) -> None: ...
|
||||
async def transcribe(
|
||||
self,
|
||||
audio_path: str,
|
||||
language: Optional[str] = None,
|
||||
model_size: Optional[str] = None,
|
||||
) -> str: ...
|
||||
def unload_model(self) -> None: ...
|
||||
def is_loaded(self) -> bool: ...
|
||||
```
|
||||
|
||||
### Model Sizes
|
||||
Two implementations ship today:
|
||||
|
||||
| Size | Parameters | VRAM | Speed | Quality |
|
||||
|------|------------|------|-------|---------|
|
||||
| tiny | 39M | ~1GB | Fastest | Basic |
|
||||
| base | 74M | ~1GB | Fast | Good |
|
||||
| small | 244M | ~2GB | Medium | Better |
|
||||
| medium | 769M | ~5GB | Slow | High |
|
||||
| large | 1550M | ~10GB | Slowest | Best |
|
||||
- **`MLXSTTBackend`** (`backends/mlx_backend.py`) — uses `mlx_audio.stt.load()`. Default on Apple Silicon.
|
||||
- **`PyTorchSTTBackend`** (`backends/pytorch_backend.py`) — uses `transformers.WhisperForConditionalGeneration`. Default everywhere else.
|
||||
|
||||
Default is `base` for balance of speed and quality.
|
||||
`get_stt_backend()` picks the right one based on `get_backend_type()`. `backend/services/transcribe.py` is a thin wrapper that delegates to the backend.
|
||||
|
||||
## Model Sizes
|
||||
|
||||
Five Whisper variants are registered in `ModelConfig`:
|
||||
|
||||
| Model | HuggingFace Repo | Size | Notes |
|
||||
|-------|------------------|------|-------|
|
||||
| **Base** | `openai/whisper-base` | ~300 MB | Default; fast, decent quality |
|
||||
| **Small** | `openai/whisper-small` | ~500 MB | Better quality, still fast |
|
||||
| **Medium** | `openai/whisper-medium` | ~1.5 GB | High quality |
|
||||
| **Large** | `openai/whisper-large-v3` | ~3 GB | Best quality, slow on CPU |
|
||||
| **Turbo** | `openai/whisper-large-v3-turbo` | ~1.5 GB | Large-tier quality, ~5× faster than Large |
|
||||
|
||||
The `tiny` model is **not** exposed — the quality gap to `base` wasn't worth the download.
|
||||
|
||||
`Turbo` + MLX-Whisper on Apple Silicon dropped user-facing transcription latency from ~20s to ~2-3s in v0.1.10.
|
||||
|
||||
## Language Hints
|
||||
|
||||
Whisper can auto-detect language, but providing a hint improves accuracy on short clips:
|
||||
|
||||
```python
|
||||
text = await backend.transcribe(audio_path, language="en")
|
||||
```
|
||||
|
||||
Accepted language codes are the standard Whisper set (99+ languages). The frontend typically passes the profile's language if available, or lets Whisper detect otherwise.
|
||||
|
||||
## Model Loading
|
||||
|
||||
Models are downloaded from HuggingFace Hub:
|
||||
Both backends are lazy: the model is loaded on first use and cached in memory. Switching sizes unloads the previous model.
|
||||
|
||||
On MLX, the model is loaded via `mlx_audio.stt.load(hf_repo)`. On PyTorch, via:
|
||||
|
||||
```python
|
||||
def load_model(self, model_size: Optional[str] = None):
|
||||
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
||||
|
||||
model_name = f"openai/whisper-{model_size}"
|
||||
|
||||
# Track download progress
|
||||
progress_manager = get_progress_manager()
|
||||
task_manager = get_task_manager()
|
||||
task_manager.start_download(f"whisper-{model_size}")
|
||||
|
||||
# Load processor and model
|
||||
with tracker.patch_download():
|
||||
self.processor = WhisperProcessor.from_pretrained(model_name)
|
||||
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
|
||||
|
||||
self.model.to(self.device)
|
||||
|
||||
# Mark complete
|
||||
progress_manager.mark_complete(f"whisper-{model_size}")
|
||||
task_manager.complete_download(f"whisper-{model_size}")
|
||||
WhisperProcessor.from_pretrained(hf_repo)
|
||||
WhisperForConditionalGeneration.from_pretrained(hf_repo).to(device)
|
||||
```
|
||||
|
||||
### Async Loading
|
||||
|
||||
Like TTS, loading runs in a thread pool:
|
||||
|
||||
```python
|
||||
async def load_model_async(self, model_size: Optional[str] = None):
|
||||
if self.model is not None and self.model_size == model_size:
|
||||
return
|
||||
await asyncio.to_thread(self.load_model, model_size)
|
||||
```
|
||||
|
||||
## Transcription
|
||||
|
||||
### Basic Transcription
|
||||
|
||||
```python
|
||||
async def transcribe(
|
||||
self,
|
||||
audio_path: str,
|
||||
language: Optional[str] = None,
|
||||
) -> str:
|
||||
await self.load_model_async()
|
||||
|
||||
def _transcribe_sync():
|
||||
# Load and resample to 16kHz (Whisper requirement)
|
||||
audio, sr = load_audio(audio_path, sample_rate=16000)
|
||||
|
||||
# Process audio
|
||||
inputs = self.processor(
|
||||
audio,
|
||||
sampling_rate=16000,
|
||||
return_tensors="pt",
|
||||
)
|
||||
inputs = inputs.to(self.device)
|
||||
|
||||
# Set language hint if provided
|
||||
forced_decoder_ids = None
|
||||
if language:
|
||||
forced_decoder_ids = self.processor.get_decoder_prompt_ids(
|
||||
language=language,
|
||||
task="transcribe",
|
||||
)
|
||||
|
||||
# Generate
|
||||
with torch.no_grad():
|
||||
predicted_ids = self.model.generate(
|
||||
inputs["input_features"],
|
||||
forced_decoder_ids=forced_decoder_ids,
|
||||
)
|
||||
|
||||
# Decode
|
||||
transcription = self.processor.batch_decode(
|
||||
predicted_ids,
|
||||
skip_special_tokens=True,
|
||||
)[0]
|
||||
|
||||
return transcription.strip()
|
||||
|
||||
return await asyncio.to_thread(_transcribe_sync)
|
||||
```
|
||||
|
||||
### Supported Languages
|
||||
|
||||
Whisper supports 99+ languages. Common ones in Voicebox:
|
||||
|
||||
| Code | Language |
|
||||
|------|----------|
|
||||
| en | English |
|
||||
| zh | Chinese |
|
||||
| ja | Japanese |
|
||||
| ko | Korean |
|
||||
| de | German |
|
||||
| fr | French |
|
||||
| ru | Russian |
|
||||
| pt | Portuguese |
|
||||
| es | Spanish |
|
||||
| it | Italian |
|
||||
|
||||
### Language Detection
|
||||
|
||||
When no language is specified, Whisper auto-detects:
|
||||
|
||||
```python
|
||||
# Without language hint - auto-detect
|
||||
transcription = await whisper.transcribe(audio_path)
|
||||
|
||||
# With language hint - more accurate for short clips
|
||||
transcription = await whisper.transcribe(audio_path, language="en")
|
||||
```
|
||||
|
||||
## Transcription with Timestamps
|
||||
|
||||
For advanced use cases, word-level timestamps are available:
|
||||
|
||||
```python
|
||||
async def transcribe_with_timestamps(
|
||||
self,
|
||||
audio_path: str,
|
||||
language: Optional[str] = None,
|
||||
) -> List[Dict[str, any]]:
|
||||
await self.load_model_async()
|
||||
|
||||
def _transcribe_timestamps_sync():
|
||||
audio, sr = load_audio(audio_path, sample_rate=16000)
|
||||
inputs = self.processor(audio, sampling_rate=16000, return_tensors="pt")
|
||||
|
||||
with torch.no_grad():
|
||||
predicted_ids = self.model.generate(
|
||||
inputs["input_features"],
|
||||
return_timestamps=True,
|
||||
)
|
||||
|
||||
# Parse timestamps
|
||||
return [
|
||||
{
|
||||
"text": transcription,
|
||||
"start": 0.0,
|
||||
"end": len(audio) / sr,
|
||||
}
|
||||
]
|
||||
|
||||
return await asyncio.to_thread(_transcribe_timestamps_sync)
|
||||
```
|
||||
|
||||
## Memory Management
|
||||
|
||||
### Unloading
|
||||
|
||||
Free memory when not needed:
|
||||
|
||||
```python
|
||||
def unload_model(self):
|
||||
if self.model is not None:
|
||||
del self.model
|
||||
del self.processor
|
||||
self.model = None
|
||||
self.processor = None
|
||||
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
```
|
||||
|
||||
### Global Instance
|
||||
|
||||
A singleton pattern manages the model:
|
||||
|
||||
```python
|
||||
_whisper_model: Optional[WhisperModel] = None
|
||||
|
||||
def get_whisper_model() -> WhisperModel:
|
||||
global _whisper_model
|
||||
if _whisper_model is None:
|
||||
_whisper_model = WhisperModel()
|
||||
return _whisper_model
|
||||
```
|
||||
Both load paths use `model_load_progress()` from `backends/base.py` so the frontend sees live download progress on the first use.
|
||||
|
||||
## Audio Preprocessing
|
||||
|
||||
### Resampling
|
||||
Whisper expects mono 16 kHz audio. The audio utility in `backend/utils/audio.py` handles resampling and format conversion transparently:
|
||||
|
||||
Whisper requires 16kHz audio:
|
||||
- **Formats:** WAV, MP3, FLAC, OGG, M4A (via soundfile / librosa)
|
||||
- **Target:** mono, 16 kHz, float32
|
||||
|
||||
```python
|
||||
audio, sr = load_audio(audio_path, sample_rate=16000)
|
||||
```
|
||||
|
||||
### Format Support
|
||||
|
||||
The `load_audio` utility handles:
|
||||
- WAV
|
||||
- MP3
|
||||
- FLAC
|
||||
- OGG
|
||||
- M4A
|
||||
|
||||
All formats are converted to mono 16kHz.
|
||||
Files longer than Whisper's 30-second window are handled by the underlying library's chunking logic — no explicit splitting in Voicebox code.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/transcribe` | Transcribe audio file |
|
||||
| POST | `/transcribe` | Transcribe an uploaded audio file |
|
||||
|
||||
### Request
|
||||
|
||||
@@ -259,7 +100,8 @@ POST /transcribe
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file: <audio_file>
|
||||
language: en (optional)
|
||||
language: en # optional
|
||||
model_size: base # optional (default: "base")
|
||||
```
|
||||
|
||||
### Response
|
||||
@@ -275,25 +117,44 @@ language: en (optional)
|
||||
|
||||
### Reference Text for Voice Cloning
|
||||
|
||||
1. User records audio sample
|
||||
2. Audio is sent to `/transcribe`
|
||||
3. Transcription becomes `reference_text`
|
||||
4. Both are added to voice profile
|
||||
Adding a voice sample triggers transcription automatically:
|
||||
|
||||
1. User uploads or records audio.
|
||||
2. The backend writes the audio file and calls `/transcribe` internally (or the frontend calls it separately).
|
||||
3. The returned text becomes `reference_text` on the new `profile_samples` row.
|
||||
4. Cloning engines that need reference text (Chatterbox, TADA, etc.) read it from there.
|
||||
|
||||
### Quality Tips
|
||||
|
||||
- Provide language hint for short audio
|
||||
- Use clean audio with minimal noise
|
||||
- Longer audio (>5s) improves accuracy
|
||||
- Consider `small` or `medium` model for better quality
|
||||
- Provide a language hint for short clips (under 5 seconds) — auto-detection is unreliable on little audio.
|
||||
- Use Turbo or Large for noisy audio — Base can hallucinate on hard inputs.
|
||||
- Prefer clean audio; transcription errors become reference-text errors, which become cloning errors.
|
||||
|
||||
## Memory Management
|
||||
|
||||
`unload_model()` drops the model reference and clears the CUDA cache if applicable. `/models/unload` wires this up for manual control.
|
||||
|
||||
A singleton per backend is returned by `get_stt_backend()` — multiple callers share one Whisper instance.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Common issues:
|
||||
|
||||
| Error | Cause | Solution |
|
||||
|-------|-------|----------|
|
||||
| Model not found | First run, download failed | Retry with network |
|
||||
| OOM | Model too large | Use smaller model |
|
||||
| Empty result | No speech detected | Check audio has speech |
|
||||
| Wrong language | Auto-detect failed | Provide language hint |
|
||||
| Model not found | First run + network failure | Retry; check connectivity |
|
||||
| OOM on load | Large model on low-VRAM GPU | Switch to Small or Turbo |
|
||||
| Empty result | No speech in audio | Confirm input has voice; check trim |
|
||||
| Wrong language | Auto-detect misfired | Pass `language` hint |
|
||||
|
||||
## Next Steps
|
||||
|
||||
<Cards>
|
||||
<Card title="Model Management" href="/developer/model-management">
|
||||
Download / load / unload any model
|
||||
</Card>
|
||||
<Card title="Voice Profiles" href="/developer/voice-profiles">
|
||||
How reference text is stored alongside samples
|
||||
</Card>
|
||||
<Card title="GPU Acceleration" href="/overview/gpu-acceleration">
|
||||
Platform-specific acceleration including MLX-Whisper
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
@@ -604,18 +604,25 @@ for name, mod in [("dac", types.ModuleType("dac")),
|
||||
- Do NOT use `@torch.jit.script` in the shim (see above)
|
||||
- Only reimplement what the model actually uses — check the import chain carefully
|
||||
|
||||
## Upcoming Engines
|
||||
## Candidate Engines
|
||||
|
||||
Based on the current model landscape, these are candidates for future integration:
|
||||
The [`docs/PROJECT_STATUS.md`](https://github.com/jamiepine/voicebox/blob/main/docs/PROJECT_STATUS.md) file is the canonical, living list of candidates under evaluation — including why some have been backlogged (e.g. VoxCPM, which is effectively CUDA-only upstream).
|
||||
|
||||
| Model | Languages | Size | Key Features | Status |
|
||||
|-------|-----------|------|--------------|--------|
|
||||
| **CosyVoice2-0.5B** | Multilingual | ~500MB | Instruct support (`inference_instruct2()`) | Ready |
|
||||
| **Fish Speech** | 50+ | Medium | Word-level control via inline text | Ready |
|
||||
| **Kokoro-82M** | English | 82M | CPU realtime, Apache 2.0 | Ready |
|
||||
| **XTTS-v2** | 17+ | Medium | Zero-shot cloning | Ready |
|
||||
| **MOSS-TTS** | Multilingual | Medium | Text-to-voice design, multi-speaker dialogue | Needs vetting |
|
||||
| **Pocket TTS** | English | ~100M | CPU-first, >1× realtime | Needs vetting |
|
||||
At a glance, current top candidates:
|
||||
|
||||
| Model | Tier | Size | Cross-platform? | Key Features |
|
||||
|-------|------|------|-----------------|--------------|
|
||||
| **MOSS-TTS-Nano** | 1 | 0.1 B | Yes (CPU realtime) | 48 kHz stereo, Apache 2.0, released 2026-04-13 |
|
||||
| **Voxtral TTS** | 2 | 4 B | Likely | `mistralai/Voxtral-4B-TTS-2603` — presets + cloning |
|
||||
| **VibeVoice** | 2 | ~500 M | Yes | Podcast-style multi-speaker dialogue |
|
||||
| **Dia2** | 3 | TBD | TBD | Successor to the original Dia |
|
||||
| **Fish Audio S2 Pro** | 3 | Medium | Yes | Word-level control via inline text |
|
||||
|
||||
**Backlogged:**
|
||||
|
||||
- **VoxCPM** (2B, Apache 2.0) — CUDA ≥12 required upstream; MPS broken in issues #232/#248; CPU path rejected by maintainers (#256). Keep watching for a PR that relaxes the device requirement.
|
||||
|
||||
Update `PROJECT_STATUS.md` when you pick one up or mark one as shipped/backlogged.
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
|
||||
@@ -1,283 +1,251 @@
|
||||
---
|
||||
title: "TTS Generation"
|
||||
description: "How text-to-speech generation works in Voicebox"
|
||||
description: "How text-to-speech generation works across Voicebox's multi-engine backend"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Voicebox uses Qwen3-TTS for voice cloning and text-to-speech generation. The TTS module handles model loading, voice prompt creation, and audio synthesis.
|
||||
Voicebox ships seven TTS engines — Qwen3-TTS, Qwen CustomVoice, LuxTTS, Chatterbox Multilingual, Chatterbox Turbo, TADA, and Kokoro — behind a single `TTSBackend` Protocol. All of them expose the same async interface so the routes and services don't need per-engine branching.
|
||||
|
||||
## Architecture
|
||||
This page covers how generation flows through that abstraction. For the step-by-step guide to adding a new engine, see [TTS Engines](/developer/tts-engines).
|
||||
|
||||
The TTS system is built around the `TTSModel` class which manages:
|
||||
## The `TTSBackend` Protocol
|
||||
|
||||
**Model Loading:** Lazy loading with automatic HuggingFace Hub download.
|
||||
|
||||
**Voice Prompts:** Converting reference audio into embeddings.
|
||||
|
||||
**Generation:** Synthesizing speech from text using voice prompts.
|
||||
|
||||
## TTSModel Class
|
||||
Every engine implements the same contract (defined in `backend/backends/__init__.py`):
|
||||
|
||||
```python
|
||||
class TTSModel:
|
||||
def __init__(self, model_size: str = "1.7B"):
|
||||
self.model = None
|
||||
self.model_size = model_size
|
||||
self.device = self._get_device() # cuda, mps, or cpu
|
||||
@runtime_checkable
|
||||
class TTSBackend(Protocol):
|
||||
async def load_model(self, model_size: str) -> None: ...
|
||||
async def create_voice_prompt(
|
||||
self, audio_path: str, reference_text: str, use_cache: bool = True
|
||||
) -> Tuple[dict, bool]: ...
|
||||
async def combine_voice_prompts(
|
||||
self, audio_paths: List[str], reference_texts: List[str]
|
||||
) -> Tuple[np.ndarray, str]: ...
|
||||
async def generate(
|
||||
self,
|
||||
text: str,
|
||||
voice_prompt: dict,
|
||||
language: str = "en",
|
||||
seed: Optional[int] = None,
|
||||
instruct: Optional[str] = None,
|
||||
) -> Tuple[np.ndarray, int]: ...
|
||||
def unload_model(self) -> None: ...
|
||||
def is_loaded(self) -> bool: ...
|
||||
```
|
||||
|
||||
### Device Selection
|
||||
## The `ModelConfig` Registry
|
||||
|
||||
The model automatically selects the best available device:
|
||||
Each downloadable model variant is described by a `ModelConfig` dataclass:
|
||||
|
||||
```python
|
||||
def _get_device(self) -> str:
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
||||
return "cpu" # MPS can have issues, use CPU for stability
|
||||
return "cpu"
|
||||
@dataclass
|
||||
class ModelConfig:
|
||||
model_name: str # "luxtts", "qwen-tts-1.7B", "kokoro"
|
||||
display_name: str # "LuxTTS (Fast, CPU-friendly)"
|
||||
engine: str # "luxtts", "qwen", "kokoro"
|
||||
hf_repo_id: str # "YatharthS/LuxTTS"
|
||||
model_size: str = "default"
|
||||
size_mb: int = 0
|
||||
needs_trim: bool = False
|
||||
supports_instruct: bool = False
|
||||
languages: list[str] = field(default_factory=lambda: ["en"])
|
||||
```
|
||||
|
||||
## Model Loading
|
||||
Registry helpers in `backends/__init__.py` replace what used to be per-engine `if/elif` chains:
|
||||
|
||||
Models are downloaded from HuggingFace Hub on first use:
|
||||
- `get_all_model_configs()` — every TTS + STT variant
|
||||
- `get_tts_model_configs()` — only TTS variants
|
||||
- `get_model_config(model_name)` — lookup by name
|
||||
- `engine_needs_trim(engine)` — whether output should run through `trim_tts_output()`
|
||||
- `load_engine_model(engine, model_size)` — downloads + loads, handles engines with multiple sizes
|
||||
- `get_tts_backend_for_engine(engine)` — thread-safe backend factory with double-checked locking
|
||||
|
||||
The `TTS_ENGINES` dict is the canonical list of shipped engine names:
|
||||
|
||||
```python
|
||||
def load_model(self, model_size: Optional[str] = None):
|
||||
# Model IDs on HuggingFace Hub
|
||||
hf_model_map = {
|
||||
"1.7B": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
|
||||
"0.6B": "Qwen/Qwen3-TTS-12Hz-0.6B-Base",
|
||||
}
|
||||
|
||||
# Load with progress tracking
|
||||
with tracker.patch_download():
|
||||
self.model = Qwen3TTSModel.from_pretrained(
|
||||
model_path,
|
||||
device_map=self.device,
|
||||
torch_dtype=torch.bfloat16, # float32 on CPU
|
||||
)
|
||||
TTS_ENGINES = {
|
||||
"qwen": "Qwen TTS",
|
||||
"qwen_custom_voice": "Qwen CustomVoice",
|
||||
"luxtts": "LuxTTS",
|
||||
"chatterbox": "Chatterbox TTS",
|
||||
"chatterbox_turbo": "Chatterbox Turbo",
|
||||
"tada": "TADA",
|
||||
"kokoro": "Kokoro",
|
||||
}
|
||||
```
|
||||
|
||||
### Async Loading
|
||||
## Voice Prompt Patterns
|
||||
|
||||
Loading runs in a thread pool to avoid blocking the event loop:
|
||||
Each engine chooses how to represent a voice in the prompt dict returned from `create_voice_prompt()`. Three patterns are in use today:
|
||||
|
||||
**Pattern A — Pre-computed tensors** (Qwen3-TTS, LuxTTS)
|
||||
|
||||
```python
|
||||
async def load_model_async(self, model_size: Optional[str] = None):
|
||||
if self.model is not None and self._current_model_size == model_size:
|
||||
return
|
||||
await asyncio.to_thread(self.load_model, model_size)
|
||||
encoded = model.encode_prompt(audio_path)
|
||||
return encoded, False # (prompt_dict, was_cached)
|
||||
```
|
||||
|
||||
## Voice Prompt Creation
|
||||
|
||||
Voice prompts are created from reference audio and cached for reuse:
|
||||
**Pattern B — Deferred file paths** (Chatterbox, Chatterbox Turbo, TADA)
|
||||
|
||||
```python
|
||||
async def create_voice_prompt(
|
||||
self,
|
||||
audio_path: str,
|
||||
reference_text: str,
|
||||
use_cache: bool = True,
|
||||
) -> Tuple[dict, bool]:
|
||||
await self.load_model_async()
|
||||
|
||||
# Check cache
|
||||
if use_cache:
|
||||
cache_key = get_cache_key(audio_path, reference_text)
|
||||
cached = get_cached_voice_prompt(cache_key)
|
||||
if cached:
|
||||
return cached, True
|
||||
|
||||
# Create prompt (blocking, run in thread pool)
|
||||
voice_prompt = await asyncio.to_thread(
|
||||
self.model.create_voice_clone_prompt,
|
||||
ref_audio=audio_path,
|
||||
ref_text=reference_text,
|
||||
)
|
||||
|
||||
# Cache the result
|
||||
cache_voice_prompt(cache_key, voice_prompt)
|
||||
return voice_prompt, False
|
||||
return {"ref_audio": audio_path, "ref_text": reference_text}, False
|
||||
```
|
||||
|
||||
### Combining Multiple Samples
|
||||
|
||||
When a profile has multiple samples, they're combined:
|
||||
**Pattern C — Preset voice pointer** (Kokoro, Qwen CustomVoice)
|
||||
|
||||
```python
|
||||
async def combine_voice_prompts(
|
||||
self,
|
||||
audio_paths: List[str],
|
||||
reference_texts: List[str],
|
||||
) -> Tuple[np.ndarray, str]:
|
||||
combined_audio = []
|
||||
|
||||
for audio_path in audio_paths:
|
||||
audio, sr = load_audio(audio_path)
|
||||
audio = normalize_audio(audio)
|
||||
combined_audio.append(audio)
|
||||
|
||||
# Concatenate and normalize
|
||||
mixed = np.concatenate(combined_audio)
|
||||
mixed = normalize_audio(mixed)
|
||||
|
||||
# Combine texts
|
||||
combined_text = " ".join(reference_texts)
|
||||
|
||||
return mixed, combined_text
|
||||
return {
|
||||
"voice_type": "preset",
|
||||
"preset_engine": "kokoro",
|
||||
"preset_voice_id": "am_adam",
|
||||
}, False
|
||||
```
|
||||
|
||||
## Speech Generation
|
||||
Pattern C is the shape used for profiles where `voice_type == "preset"` — there's no cloning step; the engine looks up a baked-in voice by ID.
|
||||
|
||||
The core generation function:
|
||||
Engines that cache voice prompts prefix their cache keys to avoid collisions:
|
||||
|
||||
```python
|
||||
async def generate(
|
||||
self,
|
||||
text: str,
|
||||
voice_prompt: dict,
|
||||
language: str = "en",
|
||||
seed: Optional[int] = None,
|
||||
instruct: Optional[str] = None,
|
||||
) -> Tuple[np.ndarray, int]:
|
||||
await self.load_model_async()
|
||||
|
||||
def _generate_sync():
|
||||
# Set seed for reproducibility
|
||||
if seed is not None:
|
||||
torch.manual_seed(seed)
|
||||
|
||||
# Generate audio
|
||||
wavs, sample_rate = self.model.generate_voice_clone(
|
||||
text=text,
|
||||
voice_clone_prompt=voice_prompt,
|
||||
instruct=instruct, # Natural language delivery control
|
||||
)
|
||||
return wavs[0], sample_rate
|
||||
|
||||
# Run in thread pool
|
||||
return await asyncio.to_thread(_generate_sync)
|
||||
cache_key = f"{engine}_{hash(audio_path, reference_text)}"
|
||||
```
|
||||
|
||||
### Instruct Feature
|
||||
## Device Selection
|
||||
|
||||
The `instruct` parameter allows natural language control over speech delivery:
|
||||
Engines pick their device through `get_torch_device()` in `backends/base.py`, which layers:
|
||||
|
||||
1. `VOICEBOX_FORCE_CPU` environment override
|
||||
2. CUDA (if compiled and available)
|
||||
3. XPU (Intel Arc via IPEX)
|
||||
4. MPS (Apple Silicon) — **only for engines that support it**; some (Chatterbox, older Qwen paths) skip MPS and fall back to CPU due to upstream operator gaps
|
||||
5. CPU
|
||||
|
||||
Qwen TTS uses MLX directly on Apple Silicon instead of going through PyTorch — see `mlx_backend.py`.
|
||||
|
||||
## Generation Flow
|
||||
|
||||
The request path from frontend to audio file:
|
||||
|
||||
1. **Request** — `POST /generate` with `GenerationRequest`:
|
||||
```json
|
||||
{
|
||||
"profile_id": "uuid",
|
||||
"text": "...",
|
||||
"language": "en",
|
||||
"seed": 42,
|
||||
"model_size": "1.7B",
|
||||
"instruct": "warm, slightly amused",
|
||||
"engine": "qwen",
|
||||
"max_chunk_chars": 800
|
||||
}
|
||||
```
|
||||
The `engine` field is validated against the regex `^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$`.
|
||||
|
||||
2. **Route** — `routes/generate.py` validates input and delegates.
|
||||
|
||||
3. **Service** — `services/generation.py` fetches the profile, resolves the engine backend via `get_tts_backend_for_engine(engine)`, and ensures the model is loaded (downloading it on first use with live progress).
|
||||
|
||||
4. **Voice prompt** — the service calls `create_voice_prompt()` (or the preset equivalent). For cloned profiles with multiple samples, it calls `combine_voice_prompts()` first to merge reference audio.
|
||||
|
||||
5. **Queue** — the request is serialized through `services/task_queue.py` to avoid multiple generations fighting for the GPU.
|
||||
|
||||
6. **Inference** — the engine's `generate()` returns `(audio_array, sample_rate)`.
|
||||
|
||||
7. **Post-process** — if `engine_needs_trim(engine)` is True, `trim_tts_output()` strips trailing silence. Effects chains (if any) are applied per generation version, not the clean version.
|
||||
|
||||
8. **Persist** — audio is written to the generations directory, a row is inserted into the `generations` table, and the response includes the generation metadata.
|
||||
|
||||
## Chunking for Long Text
|
||||
|
||||
Text longer than `max_chunk_chars` (default 800, range 100–5000) is split at sentence boundaries, generated in sequence, and crossfaded together. The chunking behavior is engine-agnostic — it lives in the service layer, not in individual backends.
|
||||
|
||||
## Instruct Mode
|
||||
|
||||
Two engines support natural-language delivery control via the `instruct` kwarg:
|
||||
|
||||
- **Qwen CustomVoice** — `supports_instruct=True`, fully wired to the model's instruct head.
|
||||
- **Qwen Base** — silently drops the instruct text (`supports_instruct=False`). The frontend hides the instruct input for Base profiles.
|
||||
|
||||
```python
|
||||
# Examples:
|
||||
instruct = "Speak slowly and clearly"
|
||||
instruct = "Sound excited and enthusiastic"
|
||||
instruct = "Whisper softly"
|
||||
# Good instruct prompts:
|
||||
"warm and conversational, slight smile"
|
||||
"whisper, intimate and close"
|
||||
"authoritative, broadcast quality"
|
||||
```
|
||||
|
||||
## Caching Strategy
|
||||
|
||||
Voice prompts are cached to avoid recomputation:
|
||||
|
||||
```python
|
||||
def get_cache_key(audio_path: str, reference_text: str) -> str:
|
||||
"""Generate cache key from audio hash and text."""
|
||||
audio_hash = hashlib.md5(Path(audio_path).read_bytes()).hexdigest()
|
||||
text_hash = hashlib.md5(reference_text.encode()).hexdigest()
|
||||
return f"{audio_hash}_{text_hash}"
|
||||
```
|
||||
|
||||
Cache is stored in `data/cache/voice_prompts/`.
|
||||
Other engines ignore `instruct` entirely.
|
||||
|
||||
## Memory Management
|
||||
|
||||
### Unloading Models
|
||||
|
||||
Free VRAM/RAM when not needed:
|
||||
Models are loaded lazily on first use and kept in memory. Switching between model sizes (e.g. Qwen 1.7B ↔ 0.6B) unloads the previous model before loading the new one to avoid OOM:
|
||||
|
||||
```python
|
||||
def unload_model(self):
|
||||
if self.model is not None:
|
||||
del self.model
|
||||
self.model = None
|
||||
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
```
|
||||
|
||||
### Model Switching
|
||||
|
||||
When switching between model sizes (1.7B ↔ 0.6B):
|
||||
|
||||
```python
|
||||
# Unload existing model first
|
||||
if self.model is not None and self._current_model_size != model_size:
|
||||
self.unload_model()
|
||||
```
|
||||
|
||||
## Generation Flow
|
||||
|
||||
1. **Request** → Validate text and profile ID
|
||||
2. **Profile** → Load profile samples from database
|
||||
3. **Voice Prompt** → Create or retrieve cached prompt
|
||||
4. **Generate** → Run TTS inference
|
||||
5. **Save** → Write audio to generations directory
|
||||
6. **Record** → Create history entry in database
|
||||
7. **Response** → Return audio path and metadata
|
||||
The model management API (`/models/load`, `/models/unload`) lets users free VRAM manually — see [Model Management](/developer/model-management).
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/generate` | Generate speech from text |
|
||||
| GET | `/audio/{id}` | Serve generated audio file |
|
||||
| GET | `/audio/{generation_id}` | Serve generated audio file |
|
||||
|
||||
### Request Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"profile_id": "uuid",
|
||||
"text": "Text to synthesize",
|
||||
"language": "en",
|
||||
"seed": 42,
|
||||
"model_size": "1.7B",
|
||||
"instruct": "Speak clearly"
|
||||
}
|
||||
```
|
||||
|
||||
### Response Schema
|
||||
### Response schema
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "generation_uuid",
|
||||
"profile_id": "profile_uuid",
|
||||
"text": "Text to synthesize",
|
||||
"text": "...",
|
||||
"language": "en",
|
||||
"audio_path": "/path/to/audio.wav",
|
||||
"duration": 3.5,
|
||||
"seed": 42,
|
||||
"instruct": "Speak clearly",
|
||||
"created_at": "2024-01-15T10:30:00Z"
|
||||
"engine": "qwen",
|
||||
"model_size": "1.7B",
|
||||
"instruct": "...",
|
||||
"created_at": "2026-04-18T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### GPU Acceleration
|
||||
- **CUDA** is the fastest backend for every PyTorch-based engine. Apple Silicon MLX is competitive with CUDA for Qwen TTS specifically.
|
||||
- **Serial queue** — only one generation runs at a time per process; concurrent requests are queued.
|
||||
- **Voice prompt caching** saves ~1-2s on repeated generations from the same profile.
|
||||
- **Model pinning** — the first load is slow (download + load), subsequent generations reuse the cached model in memory.
|
||||
|
||||
- CUDA provides fastest inference
|
||||
- MPS (Apple Silicon) has stability issues, uses CPU fallback
|
||||
- CPU inference is slower but always works
|
||||
### Per-engine VRAM (approximate, on CUDA)
|
||||
|
||||
### Batch Size
|
||||
| Engine | VRAM |
|
||||
|--------|------|
|
||||
| Kokoro | ~150 MB |
|
||||
| LuxTTS | ~1 GB |
|
||||
| Chatterbox Turbo | ~1.5 GB |
|
||||
| Qwen 0.6B / Qwen CustomVoice 0.6B | ~2 GB |
|
||||
| Chatterbox Multilingual | ~3 GB |
|
||||
| Qwen 1.7B / Qwen CustomVoice 1.7B | ~6 GB |
|
||||
| TADA 1B | ~4 GB |
|
||||
| TADA 3B | ~8 GB |
|
||||
|
||||
Currently generates one utterance at a time. For long texts, consider:
|
||||
- Splitting into sentences
|
||||
- Sequential generation
|
||||
- Concatenating results
|
||||
## Next Steps
|
||||
|
||||
### Memory Usage
|
||||
|
||||
| Model | VRAM/RAM Required |
|
||||
|-------|-------------------|
|
||||
| 0.6B | ~2GB |
|
||||
| 1.7B | ~6GB |
|
||||
<Cards>
|
||||
<Card title="TTS Engines" href="/developer/tts-engines">
|
||||
Add a new engine — full phased workflow
|
||||
</Card>
|
||||
<Card title="Model Management" href="/developer/model-management">
|
||||
Downloading, loading, and unloading models
|
||||
</Card>
|
||||
<Card title="Voice Profiles" href="/developer/voice-profiles">
|
||||
Cloned vs preset profile schema
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
@@ -179,8 +179,8 @@ async def create_voice_prompt_for_profile(
|
||||
Reference audio is validated before being accepted:
|
||||
|
||||
- **Duration:** 3-30 seconds recommended
|
||||
- **Format:** WAV, MP3, FLAC, OGG supported
|
||||
- **Sample Rate:** Resampled to 24kHz
|
||||
- **Format:** WAV, MP3, FLAC, OGG, M4A supported
|
||||
- **Sample Rate:** Engine-specific — the audio utility resamples to whatever the active engine expects (Whisper uses 16 kHz, most TTS engines use 24 kHz, LuxTTS outputs 48 kHz). Resampling happens on the fly; the stored sample retains its original rate.
|
||||
- **Channels:** Converted to mono if stereo
|
||||
|
||||
## Export/Import
|
||||
|
||||
@@ -3,15 +3,16 @@ title: "Voicebox Documentation"
|
||||
description: "Voicebox is a local-first voice cloning studio -- a free and open-source alternative to ElevenLabs."
|
||||
---
|
||||
|
||||
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, 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 50+ curated preset voices via Kokoro and Qwen CustomVoice
|
||||
- **23 languages** -- from English to Arabic, Japanese, Hindi, Swahili, and more
|
||||
- **Post-processing effects** -- pitch shift, reverb, delay, chorus, compression, and filters
|
||||
- **Expressive speech** -- paralinguistic tags like `[laugh]`, `[sigh]`, `[gasp]` via Chatterbox Turbo
|
||||
- **Expressive speech** -- paralinguistic tags like `[laugh]`, `[sigh]`, `[gasp]` via Chatterbox Turbo; natural-language delivery control via Qwen CustomVoice
|
||||
- **Unlimited length** -- auto-chunking with crossfade for scripts, articles, and chapters
|
||||
- **Stories editor** -- multi-track timeline for conversations, podcasts, and narratives
|
||||
- **API-first** -- REST API for integrating voice synthesis into your own projects
|
||||
|
||||
@@ -27,7 +27,7 @@ Use this when you want to replicate a specific person's voice from a recording.
|
||||
10-30 seconds of clear speech, minimal background noise. See [Voice Cloning](/overview/voice-cloning) for the engine catalog.
|
||||
</Step>
|
||||
<Step title="Create Profile">
|
||||
**Profiles** → **+ New Profile** → choose a cloning engine (Qwen3-TTS, Chatterbox, LuxTTS, or TADA)
|
||||
**Profiles** → **+ New Profile** → choose a cloning engine (Qwen3-TTS, Chatterbox Multilingual, Chatterbox Turbo, LuxTTS, or TADA)
|
||||
</Step>
|
||||
<Step title="Upload or Record Sample">
|
||||
Drag in an audio file, or record directly with the in-app recorder
|
||||
|
||||
@@ -79,9 +79,9 @@ Drag generations to the Stories Editor timeline.
|
||||
|
||||
History is stored locally:
|
||||
|
||||
- **macOS**: `~/Library/Application Support/com.voicebox.app/data/`
|
||||
- **Windows**: `%APPDATA%/com.voicebox.app/data/`
|
||||
- **Linux**: `~/.config/com.voicebox.app/data/`
|
||||
- **macOS**: `~/Library/Application Support/sh.voicebox.app/data/`
|
||||
- **Windows**: `%APPDATA%/sh.voicebox.app/data/`
|
||||
- **Linux**: `~/.config/sh.voicebox.app/data/`
|
||||
|
||||
<Callout type="warn">
|
||||
Deleting the data directory will remove all history. Export important files first.
|
||||
|
||||
@@ -68,11 +68,11 @@ Voicebox is available for macOS and Windows, with Linux builds coming soon.
|
||||
|
||||
When you launch Voicebox for the first time:
|
||||
|
||||
1. **Model Download** — Qwen3-TTS model (~2-4GB) will download automatically on first use
|
||||
1. **Model Download** — The TTS engine you generate with first will download its model automatically. Sizes range from ~350 MB (Kokoro) to ~8 GB (TADA 3B). Most users start with Qwen 1.7B (~3.5 GB).
|
||||
2. **Data Directory** — Voice profiles and generated audio are stored in:
|
||||
- macOS: `~/Library/Application Support/com.voicebox.app/`
|
||||
- Windows: `%APPDATA%/com.voicebox.app/`
|
||||
- Linux: `~/.config/com.voicebox.app/`
|
||||
- macOS: `~/Library/Application Support/sh.voicebox.app/`
|
||||
- Windows: `%APPDATA%/sh.voicebox.app/`
|
||||
- Linux: `~/.config/sh.voicebox.app/`
|
||||
|
||||
3. **Backend Server** — The bundled Python server starts automatically
|
||||
|
||||
|
||||
@@ -5,13 +5,14 @@ description: "Voicebox is a local-first voice cloning studio -- a free and open-
|
||||
|
||||
## 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
|
||||
@@ -20,15 +21,17 @@ Voicebox is a **local-first voice cloning studio** -- a free and open-source alt
|
||||
|
||||
## TTS Engines
|
||||
|
||||
Five engines with different strengths, switchable per-generation:
|
||||
Seven engines with different strengths, switchable per-generation:
|
||||
|
||||
| Engine | Languages | Strengths |
|
||||
|--------|-----------|-----------|
|
||||
| **Qwen3-TTS** (0.6B / 1.7B) | 10 | High-quality multilingual cloning, delivery instructions |
|
||||
| **LuxTTS** | English | Lightweight (~1GB VRAM), 48kHz output, 150x realtime on CPU |
|
||||
| **Chatterbox Multilingual** | 23 | Broadest language coverage |
|
||||
| **Chatterbox Turbo** | English | Fast 350M model with paralinguistic emotion/sound tags |
|
||||
| **TADA** (1B / 3B) | 10 | HumeAI speech-language model -- 700s+ coherent audio |
|
||||
| Engine | Profile Type | Languages | Strengths |
|
||||
|--------|--------------|-----------|-----------|
|
||||
| **Qwen3-TTS** (0.6B / 1.7B) | Cloned | 10 | High-quality multilingual cloning |
|
||||
| **Qwen CustomVoice** (0.6B / 1.7B) | Preset (9 voices) | 10 | Natural-language delivery control (tone, emotion, pace) |
|
||||
| **LuxTTS** | Cloned | English | Lightweight (~1GB VRAM), 48kHz output, 150x realtime on CPU |
|
||||
| **Chatterbox Multilingual** | Cloned | 23 | Broadest language coverage |
|
||||
| **Chatterbox Turbo** | Cloned | English | Fast 350M model with paralinguistic emotion/sound tags |
|
||||
| **TADA** (1B / 3B) | Cloned | 10 | HumeAI speech-language model -- 700s+ coherent audio |
|
||||
| **Kokoro** | Preset (50 voices) | 9 | 82M parameters, CPU realtime, lowest VRAM of any engine |
|
||||
|
||||
## GPU Support
|
||||
|
||||
@@ -57,7 +60,7 @@ Five engines with different strengths, switchable per-generation:
|
||||
| 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) |
|
||||
|
||||
@@ -31,8 +31,16 @@ In Remote Mode, the Voicebox desktop app (running on your local machine) communi
|
||||
|
||||
# Install Python dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Engines with incompatible transitive pins — install with --no-deps
|
||||
pip install --no-deps chatterbox-tts
|
||||
pip install --no-deps hume-tada
|
||||
|
||||
# Qwen3-TTS from source
|
||||
pip install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
```
|
||||
|
||||
Or just run `just setup` from the repo root, which handles all of this.
|
||||
</Step>
|
||||
|
||||
<Step title="Start the Server">
|
||||
@@ -135,4 +143,4 @@ Expected performance on various GPUs:
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
See the [Troubleshooting Guide](/guides/troubleshooting#remote-mode-issues) for common remote mode issues.
|
||||
See the [Troubleshooting Guide](/overview/troubleshooting) for common issues.
|
||||
|
||||
@@ -29,6 +29,14 @@ Windows SmartScreen may warn that the app is unrecognized.
|
||||
This is expected for unsigned applications. We're working on code signing for future releases.
|
||||
</Callout>
|
||||
|
||||
### Linux: AppImage Won't Run
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
chmod +x voicebox-*.AppImage
|
||||
./voicebox-*.AppImage
|
||||
```
|
||||
|
||||
## Server Issues
|
||||
|
||||
### Backend Server Won't Start
|
||||
@@ -66,7 +74,7 @@ Windows SmartScreen may warn that the app is unrecognized.
|
||||
|
||||
```bash
|
||||
# macOS/Linux
|
||||
chmod +x ~/Library/Application\ Support/com.voicebox.app/backend/voicebox-server
|
||||
chmod +x ~/Library/Application\ Support/sh.voicebox.app/backend/voicebox-server
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
@@ -75,16 +83,62 @@ Windows SmartScreen may warn that the app is unrecognized.
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
tail -f ~/Library/Application\ Support/com.voicebox.app/logs/server.log
|
||||
tail -f ~/Library/Application\ Support/sh.voicebox.app/logs/server.log
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
type %APPDATA%\com.voicebox.app\logs\server.log
|
||||
type %APPDATA%\sh.voicebox.app\logs\server.log
|
||||
```
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### `flash-attn is not installed` Warning in Server Logs
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
Warning: flash-attn is not installed. Will only run the manual PyTorch version.
|
||||
Please install flash-attn for faster inference.
|
||||
```
|
||||
|
||||
**This is harmless.** The warning is emitted by our transformer-based engines (Chatterbox / Qwen) on every startup. FlashAttention is an optional acceleration library — when it's not present, PyTorch's built-in scaled-dot-product attention (SDPA) runs instead, which is near-FA2 throughput on modern GPUs. Generation works normally.
|
||||
|
||||
**Why it shows up on every platform:**
|
||||
- **Windows:** `flash-attn` has no official Windows support. The upstream project (Dao-AILab/flash-attention) still only says it *might* work, and source builds typically fail on recent CUDA/MSVC combinations.
|
||||
- **macOS (Apple Silicon):** FlashAttention is CUDA-only and doesn't apply here at all. MLX has its own optimized attention kernels.
|
||||
- **Linux:** It's not pinned in our requirements because installing it is fragile and version-sensitive; users who want it install it themselves.
|
||||
|
||||
**Solutions (all optional):**
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Ignore it (recommended)">
|
||||
PyTorch SDPA is what actually runs the model, and on Ampere/Ada/Hopper GPUs it's within a few percent of FA2 for our workloads. You won't notice a meaningful speed difference.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Install flash-attn on Linux">
|
||||
```bash
|
||||
pip install flash-attn --no-build-isolation
|
||||
```
|
||||
|
||||
Requires a matching CUDA toolkit. Build can take 20+ minutes.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Install flash-attn on Windows (community wheels)">
|
||||
Official builds don't exist, but community maintainers publish prebuilt wheels:
|
||||
|
||||
- [kingbri1/flash-attention releases](https://github.com/kingbri1/flash-attention/releases)
|
||||
- [bdashore3/flash-attention releases](https://github.com/bdashore3/flash-attention/releases)
|
||||
|
||||
Pick the wheel matching your exact CUDA + PyTorch + Python combination. Example:
|
||||
|
||||
```bash
|
||||
pip install https://github.com/kingbri1/flash-attention/releases/download/v2.8.3/flash_attn-2.8.3+cu128torch2.8.0cxx11abiFALSE-cp312-cp312-win_amd64.whl
|
||||
```
|
||||
|
||||
Alternatively, run Voicebox's backend inside WSL2 and use the standard Linux wheels.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### Connection Timeout
|
||||
|
||||
**Symptoms:**
|
||||
@@ -105,12 +159,13 @@ Windows SmartScreen may warn that the app is unrecognized.
|
||||
- Progress indicator stuck at "Loading model..."
|
||||
|
||||
**Explanation:**
|
||||
This is expected behavior. The first generation downloads the Qwen3-TTS model (~2-4GB) and initializes it.
|
||||
This is expected behavior. The first generation downloads the selected TTS engine's model and initializes it. Sizes range from 350 MB (Kokoro) to 8 GB (TADA 3B).
|
||||
|
||||
**Solution:**
|
||||
- Wait for the initial download to complete
|
||||
- Subsequent generations will be much faster
|
||||
- Wait for the initial download to complete (progress is shown in Settings → Models)
|
||||
- Subsequent generations reuse the cached model and are much faster
|
||||
- Check your internet connection
|
||||
- For low-bandwidth setups, start with Kokoro (~350 MB) or LuxTTS (~300 MB)
|
||||
|
||||
### Poor Voice Quality
|
||||
|
||||
@@ -173,6 +228,34 @@ This is expected behavior. The first generation downloads the Qwen3-TTS model (~
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### MLX "Failed to load the default metallib" (Apple Silicon)
|
||||
|
||||
**Symptoms:**
|
||||
- Generation fails with "library not found" or "metallib" errors
|
||||
- Server logs reference missing Metal shader libraries
|
||||
|
||||
**Solutions:**
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Rebuild the Server Binary">
|
||||
```bash
|
||||
just build-server
|
||||
```
|
||||
|
||||
The build script bundles MLX Metal shader libraries on Apple Silicon automatically.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Reinstall MLX Dependencies">
|
||||
```bash
|
||||
pip install -r backend/requirements-mlx.txt
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Verify Backend Detection">
|
||||
Check Settings → Server Status. Should show **Backend: MLX** on Apple Silicon. If it shows **Backend: PYTORCH**, MLX isn't installed correctly.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Audio Issues
|
||||
|
||||
### No Audio Playback
|
||||
@@ -202,7 +285,7 @@ This is expected behavior. The first generation downloads the Qwen3-TTS model (~
|
||||
### Backend Won't Start in Dev Mode
|
||||
|
||||
**Symptoms:**
|
||||
- `bun run dev:server` fails
|
||||
- `just dev-backend` or `just dev` fails
|
||||
- Import errors or module not found
|
||||
|
||||
**Solutions:**
|
||||
@@ -233,11 +316,19 @@ This is expected behavior. The first generation downloads the Qwen3-TTS model (~
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Dependencies">
|
||||
Reinstall dependencies:
|
||||
Reinstall dependencies — easiest via `just`:
|
||||
|
||||
```bash
|
||||
just setup
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
pip install --no-deps chatterbox-tts
|
||||
pip install --no-deps hume-tada
|
||||
pip install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
```
|
||||
</Accordion>
|
||||
@@ -310,8 +401,8 @@ bun run tauri build
|
||||
- Delete the lock file:
|
||||
```bash
|
||||
# macOS
|
||||
rm ~/Library/Application\ Support/com.voicebox.app/data/voicebox.db-shm
|
||||
rm ~/Library/Application\ Support/com.voicebox.app/data/voicebox.db-wal
|
||||
rm ~/Library/Application\ Support/sh.voicebox.app/data/voicebox.db-shm
|
||||
rm ~/Library/Application\ Support/sh.voicebox.app/data/voicebox.db-wal
|
||||
```
|
||||
|
||||
### Corrupted Database
|
||||
@@ -328,10 +419,10 @@ bun run tauri build
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
rm ~/Library/Application\ Support/com.voicebox.app/data/voicebox.db
|
||||
rm ~/Library/Application\ Support/sh.voicebox.app/data/voicebox.db
|
||||
|
||||
# Windows
|
||||
del %APPDATA%\com.voicebox.app\data\voicebox.db
|
||||
del %APPDATA%\sh.voicebox.app\data\voicebox.db
|
||||
```
|
||||
|
||||
Restart the app to create a fresh database.
|
||||
@@ -348,7 +439,12 @@ Restart the app to create a fresh database.
|
||||
- Check your internet connection
|
||||
- Check HuggingFace Hub status
|
||||
- Try using a VPN if HuggingFace is blocked in your region
|
||||
- Manually download and place in cache directory
|
||||
- Manually download via the HuggingFace CLI and place in the cache directory:
|
||||
|
||||
```bash
|
||||
pip install huggingface_hub
|
||||
huggingface-cli download Qwen/Qwen3-TTS-12Hz-1.7B-Base
|
||||
```
|
||||
|
||||
### Wrong Model Version
|
||||
|
||||
@@ -357,10 +453,10 @@ Restart the app to create a fresh database.
|
||||
- Different voice output
|
||||
|
||||
**Solutions:**
|
||||
Clear the model cache and re-download:
|
||||
Clear the model cache and re-download. Replace the `Qwen*` glob with the engine org prefix for other engines (`ResembleAI*` for Chatterbox, `HumeAI*` for TADA, `hexgrad*` for Kokoro, etc.) or use `DELETE /models/{name}` via the API.
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
# macOS / Linux
|
||||
rm -rf ~/.cache/huggingface/hub/models--Qwen*
|
||||
|
||||
# Windows
|
||||
@@ -395,6 +491,16 @@ rmdir /s %USERPROFILE%\.cache\huggingface\hub\models--Qwen*
|
||||
<Accordion title="Update GPU Drivers">
|
||||
Outdated drivers can cause performance issues. Update to the latest NVIDIA drivers.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Apple Silicon: Confirm MLX Backend">
|
||||
Check Settings → Server Status. Should show **Backend: MLX** on Apple Silicon — MLX is 4–5× faster than PyTorch here. If it shows **Backend: PYTORCH**, reinstall MLX:
|
||||
|
||||
```bash
|
||||
pip install -r backend/requirements-mlx.txt
|
||||
```
|
||||
|
||||
GPU availability should read "Metal (Apple Silicon via MLX)".
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### High Memory Usage
|
||||
@@ -408,6 +514,21 @@ rmdir /s %USERPROFILE%\.cache\huggingface\hub\models--Qwen*
|
||||
- Clear generation history
|
||||
- Restart the app periodically
|
||||
|
||||
## Update Issues
|
||||
|
||||
### "Update Check Failed"
|
||||
|
||||
**Solutions:**
|
||||
- Confirm your internet connection — updates are fetched from GitHub releases.
|
||||
- Ensure `github.com` is accessible and not blocked by a firewall or proxy.
|
||||
- As a fallback, download the latest release from GitHub and install manually.
|
||||
|
||||
### "Invalid Signature" Error
|
||||
|
||||
**Solutions:**
|
||||
- Re-download the installer — the signature may have been corrupted in transit.
|
||||
- Verify the `.sig` file matches the installer; if it doesn't, file an issue.
|
||||
|
||||
## Remote Mode Issues
|
||||
|
||||
### Can't Connect to Remote Server
|
||||
@@ -473,5 +594,3 @@ python --version
|
||||
# GPU info (if generation issues)
|
||||
nvidia-smi # NVIDIA GPUs
|
||||
```
|
||||
|
||||
For more detailed troubleshooting, see the [TROUBLESHOOTING.md](https://github.com/jamiepine/voicebox/blob/main/docs/TROUBLESHOOTING.md) file in the repository.
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
# Code Review: `backend/` Post-Refactor
|
||||
|
||||
**Date:** 2026-03-16
|
||||
**Scope:** Full review of `backend/` after major refactor
|
||||
|
||||
## Overall Assessment
|
||||
|
||||
The refactor is well-executed. The codebase follows a clean layered architecture (routes -> services -> backends) with good separation of concerns. The code is readable, the module boundaries are sensible, and the migration strategy is pragmatic for a desktop app. Below are findings organized by severity.
|
||||
|
||||
---
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### 1. Double `init_db()` in bundled entry point
|
||||
|
||||
**File:** `server.py:261`
|
||||
|
||||
`server.py:261` calls `database.init_db()` explicitly, but `app.py:140` also calls `database.init_db()` inside the `startup` event handler. When running via `server.py`, the database gets initialized twice -- once before uvicorn starts and once during the startup event. This is likely benign (idempotent migrations), but the second call recreates the engine and `SessionLocal`, which could cause subtle issues if any sessions were opened between the two calls.
|
||||
|
||||
**Recommendation:** Remove the explicit `init_db()` call in `server.py:260-262` and rely solely on the startup event in `app.py`. The same issue exists in `main.py:38`.
|
||||
|
||||
### 2. SSE endpoint holds DB session open indefinitely
|
||||
|
||||
**File:** `routes/generations.py:179-212`
|
||||
|
||||
The `get_generation_status` SSE endpoint receives a `db` session via `Depends(get_db)` but keeps it open for the lifetime of the SSE stream (polling every 1 second). This ties up a SQLite connection for potentially minutes. With SQLite's single-writer model, this is a contention risk.
|
||||
|
||||
**Recommendation:** Open and close a short-lived session on each poll iteration instead of holding one via dependency injection:
|
||||
|
||||
```python
|
||||
async def event_stream():
|
||||
while True:
|
||||
db = next(get_db())
|
||||
try:
|
||||
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
|
||||
...
|
||||
finally:
|
||||
db.close()
|
||||
await asyncio.sleep(1)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## High Severity
|
||||
|
||||
### 3. `_save_retry` creates no version record
|
||||
|
||||
**File:** `services/generation.py:201-214`
|
||||
|
||||
`_save_retry` writes the audio file but creates no `GenerationVersion` entry. If the generation previously had versions (from an initial generate that failed mid-effects, for example), the retry result won't appear in the versions list. This creates an inconsistency: some generations have versions, retried ones don't.
|
||||
|
||||
**Recommendation:** Create a "clean" version in `_save_retry` the same way `_save_generate` does.
|
||||
|
||||
### 4. `datetime.utcnow()` is deprecated
|
||||
|
||||
**File:** `services/stories.py` and others
|
||||
|
||||
`datetime.utcnow()` is deprecated as of Python 3.12 and returns a naive datetime. Used throughout `services/stories.py` (lines 95, 96, 193, 307, 360, 404, 457, 529, 537, 598, 610, 652, 716, 775) and possibly other service files.
|
||||
|
||||
**Recommendation:** Replace with `datetime.now(datetime.UTC)` or `datetime.now(timezone.utc)`.
|
||||
|
||||
### 5. `list_stories` N+1 query
|
||||
|
||||
**File:** `services/stories.py:122-132`
|
||||
|
||||
`list_stories` issues one `COUNT(*)` query per story inside a loop. For N stories, that's N+1 queries.
|
||||
|
||||
**Recommendation:** Use a subquery or a single aggregated query:
|
||||
|
||||
```python
|
||||
from sqlalchemy import func
|
||||
counts = dict(
|
||||
db.query(DBStoryItem.story_id, func.count(DBStoryItem.id))
|
||||
.group_by(DBStoryItem.story_id)
|
||||
.all()
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Medium Severity
|
||||
|
||||
### 6. `create_story` queries item count immediately after creation
|
||||
|
||||
**File:** `services/stories.py:103`
|
||||
|
||||
Line 103 queries the item count for a story that was just created -- it will always be 0. This is wasted I/O.
|
||||
|
||||
### 7. Bare `except Exception` with silent `pass`
|
||||
|
||||
**File:** `routes/generations.py:69-70`
|
||||
|
||||
When parsing a profile's stored `effects_chain` JSON, exceptions are silently swallowed. A corrupt JSON blob would result in no effects being applied with no logging.
|
||||
|
||||
**Recommendation:** Log the exception at warning level.
|
||||
|
||||
### 8. `update_story_item_times` uses `generation_id` as key
|
||||
|
||||
**File:** `services/stories.py:643-649`
|
||||
|
||||
`item_map` is keyed by `generation_id`, but the same generation can appear in a story multiple times (via split/duplicate). This would cause key collisions, and only the last item per generation_id would be updatable.
|
||||
|
||||
**Recommendation:** Key by `item_id` instead, and change the `StoryItemUpdateTime` model to use `item_id`.
|
||||
|
||||
### 9. Thread safety gap in `get_stt_backend`
|
||||
|
||||
**File:** `backends/__init__.py:499-520`
|
||||
|
||||
`get_stt_backend()` uses no locking (unlike `get_tts_backend_for_engine` which uses `_tts_backends_lock`). A race condition could create duplicate STT backend instances.
|
||||
|
||||
**Recommendation:** Add a lock or use the same double-checked locking pattern.
|
||||
|
||||
### 10. Unused `_tts_backend` global
|
||||
|
||||
**File:** `backends/__init__.py:156`
|
||||
|
||||
`_tts_backend` is declared but never read or written outside of `reset_backends()`. All TTS access goes through `_tts_backends` dict. Dead code.
|
||||
|
||||
### 11. `trim_story_item` returns `None` for validation errors
|
||||
|
||||
**File:** `services/stories.py:448`
|
||||
|
||||
Returning `None` for "item not found" and "invalid trim values" is ambiguous. The route handler can't distinguish between a 404 and a 400 response.
|
||||
|
||||
**Recommendation:** Raise specific exceptions (e.g., `ValueError` for invalid trim) so the route can return the appropriate HTTP status.
|
||||
|
||||
### 12. `load_engine_model` calls different method names
|
||||
|
||||
**File:** `backends/__init__.py:340-346`
|
||||
|
||||
For Qwen, it calls `load_model_async(model_size)`. For others, it calls `load_model()` with no arguments. But the `TTSBackend` protocol defines `load_model(self, model_size: str)`. This means the protocol signature doesn't match actual usage for either path.
|
||||
|
||||
**Recommendation:** Align the protocol definition with actual backend implementations, or add `load_model_async` to the protocol.
|
||||
|
||||
---
|
||||
|
||||
## Low Severity / Style
|
||||
|
||||
### 13. Inconsistent `async` usage in services
|
||||
|
||||
Functions like `create_story`, `list_stories`, etc. in `services/stories.py` are `async def` but contain no `await` expressions. They do synchronous SQLAlchemy I/O. While this works (the functions are awaitable), it's misleading -- these will block the event loop during DB access.
|
||||
|
||||
This is a known tradeoff with synchronous SQLAlchemy + FastAPI, and acceptable for a single-user desktop app with SQLite, but worth noting for documentation.
|
||||
|
||||
### 14. `getattr(item, "version_id", None)` pattern
|
||||
|
||||
**File:** `services/stories.py:57, 504, 524, etc.`
|
||||
|
||||
Multiple places use `getattr(item, "version_id", None)` on a DB model that has `version_id` as a declared column (from migrations). After the migration runs, this is always a real attribute. The defensive `getattr` is cargo-culted.
|
||||
|
||||
**Recommendation:** Access `item.version_id` directly. If the column is missing, the ORM will raise a clear error.
|
||||
|
||||
### 15. `reorder_story_items` ignores trim values
|
||||
|
||||
**File:** `services/stories.py:707`
|
||||
|
||||
When recalculating timecodes, it uses the full `generation.duration` rather than the effective (trimmed) duration. Trimmed items will have larger gaps than intended.
|
||||
|
||||
### 16. Module-level `import torch` in `app.py:44`
|
||||
|
||||
`import torch` at module level in `app.py` means torch loads on every import of the app module. This is intentional (AMD env vars must be set first), but the comment on line 38 should mention that this is why the import is here and not at the top.
|
||||
|
||||
### 17. f-strings in logging in `server.py`
|
||||
|
||||
`server.py` uses f-strings in logging calls (e.g., lines 63-66, 252, 256, 264). This evaluates the string even when the log level is filtered out. The rest of the codebase correctly uses `%s` style (e.g., `app.py:131`).
|
||||
|
||||
---
|
||||
|
||||
## Architecture Observations (Not Issues)
|
||||
|
||||
- **Clean layered design**: routes -> services -> backends with Pydantic models as the API contract.
|
||||
- **Backend abstraction** with `Protocol` classes and a config registry is a solid pattern.
|
||||
- **Serial generation queue** (`task_queue.py`) is simple and effective for single-GPU serialization.
|
||||
- **Migration approach** is pragmatic for the use case. The idempotent, check-then-act pattern is reliable.
|
||||
- **The `generation.py` refactor** (collapsing three closures into `run_generation` with a mode parameter) is a clear improvement.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| Critical | 2 |
|
||||
| High | 3 |
|
||||
| Medium | 7 |
|
||||
| Low/Style | 5 |
|
||||
|
||||
The refactor achieved its goals: clear module boundaries, reduced duplication (especially in `generation.py`), and a well-organized backend abstraction. The critical items (double init_db and SSE session leak) should be addressed first, followed by the version consistency issue in retry and the N+1 query.
|
||||
@@ -1,87 +0,0 @@
|
||||
# Documentation Migration: Mintlify → Fumadocs
|
||||
|
||||
This document summarizes the migration of documentation from `/docs` (Mintlify) to `/docs2` (Fumadocs).
|
||||
|
||||
## What Was Done
|
||||
|
||||
### 1. Files Copied
|
||||
- ✅ All 29 MDX files from `/docs` folders (overview, api, developer, plans)
|
||||
- ✅ All 4 root-level markdown files (AUTOUPDATER.md, AUTOUPDATER_QUICKSTART.md, TROUBLESHOOTING.md, README.md)
|
||||
- ✅ All images (3 webp files) → `public/images/`
|
||||
- ✅ All logo files (2 png files) → `public/logo/`
|
||||
|
||||
### 2. Component Migration
|
||||
Created compatibility layer in `components/mintlify-compat.tsx` that maps Mintlify components to Fumadocs equivalents:
|
||||
|
||||
- `<Frame>` → Simple div wrapper (images are zoomable by default in Fumadocs)
|
||||
- `<CardGroup>` → `<Cards>` (Fumadocs component)
|
||||
- `<Card>` → `<Card>` (with icon string → Lucide icon mapping)
|
||||
- `<Steps>` / `<Step>` → Direct mapping to Fumadocs components
|
||||
- `<Tip>`, `<Note>`, `<Info>` → `<Callout type="info">`
|
||||
- `<Warning>` → `<Callout type="warn">`
|
||||
- `<Danger>` → `<Callout type="error">`
|
||||
- `<AccordionGroup>` / `<Accordion>` → HTML `<details>` / `<summary>` elements
|
||||
|
||||
### 3. Navigation Structure
|
||||
Created `meta.json` files for each folder:
|
||||
- `content/docs/meta.json` - Root documentation
|
||||
- `content/docs/overview/meta.json` - Overview pages
|
||||
- `content/docs/api/meta.json` - API reference
|
||||
- `content/docs/developer/meta.json` - Developer docs
|
||||
- `content/docs/plans/meta.json` - Plans/roadmap
|
||||
|
||||
### 4. Link Fixes
|
||||
- Fixed incorrect `/guides/...` paths → `/overview/...`
|
||||
- All internal links now use correct paths
|
||||
|
||||
### 5. Branding
|
||||
- Updated `lib/layout.shared.tsx` to use "Voicebox" as the nav title
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
docs2/
|
||||
├── components/
|
||||
│ └── mintlify-compat.tsx # Mintlify → Fumadocs component mappings
|
||||
├── content/docs/
|
||||
│ ├── meta.json # Root navigation
|
||||
│ ├── overview/ # 12 MDX files
|
||||
│ ├── api/ # 5 MDX files
|
||||
│ ├── developer/ # 12 MDX files
|
||||
│ ├── plans/ # 4 MD files
|
||||
│ └── *.md # 4 root markdown files
|
||||
├── public/
|
||||
│ ├── images/ # 3 webp files
|
||||
│ └── logo/ # 2 png files
|
||||
└── mdx-components.tsx # MDX component configuration
|
||||
```
|
||||
|
||||
## Icon Mapping
|
||||
|
||||
The following icon strings are mapped to Lucide icons:
|
||||
- `microphone` → Mic
|
||||
- `film` → Film
|
||||
- `code` → Code
|
||||
- `shield` → Shield
|
||||
- `download` → Download
|
||||
- `rocket` → Rocket
|
||||
- `apple` → Apple
|
||||
- `windows` → Windows
|
||||
- `server` → Server
|
||||
- `user` → User
|
||||
- `waveform` → Waveform
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Test the build**: Run `npm run build` (requires Node.js >= 20.9.0)
|
||||
2. **Start dev server**: Run `npm run dev` to preview
|
||||
3. **Customize styling**: Update `app/global.css` if needed
|
||||
4. **Add more icons**: Extend `iconMap` in `mintlify-compat.tsx` as needed
|
||||
5. **Review navigation**: Adjust `meta.json` files to customize page order
|
||||
|
||||
## Notes
|
||||
|
||||
- Image paths (`/images/...`) work as-is since Next.js serves from `public/`
|
||||
- All Mintlify components are now compatible with Fumadocs
|
||||
- Navigation structure follows Fumadocs conventions
|
||||
- No breaking changes to content - all MDX files work with compatibility layer
|
||||
@@ -1,482 +0,0 @@
|
||||
# Voicebox Project Status & Roadmap
|
||||
|
||||
> Last updated: 2026-03-18 | Current version: **v0.3.0** | 13.4k stars | ~136 open issues | 9 open PRs
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Architecture Overview](#architecture-overview)
|
||||
2. [Current State](#current-state)
|
||||
3. [Open PRs — Triage & Analysis](#open-prs--triage--analysis)
|
||||
4. [Open Issues — Categorized](#open-issues--categorized)
|
||||
5. [Existing Plan Documents — Status](#existing-plan-documents--status)
|
||||
6. [New Model Integration — Landscape](#new-model-integration--landscape)
|
||||
7. [Architectural Bottlenecks](#architectural-bottlenecks)
|
||||
8. [Recommended Priorities](#recommended-priorities)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Tauri Shell (Rust) │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ React Frontend (app/) │ │
|
||||
│ │ Zustand stores · API client · Generation UI │ │
|
||||
│ │ Stories Editor · Voice Profiles · Model Mgmt │ │
|
||||
│ └──────────────────────┬────────────────────────┘ │
|
||||
│ │ HTTP :17493 │
|
||||
│ ┌──────────────────────▼────────────────────────┐ │
|
||||
│ │ FastAPI Backend (backend/) │ │
|
||||
│ │ ┌─────────────────────────────────────────┐ │ │
|
||||
│ │ │ TTSBackend Protocol │ │ │
|
||||
│ │ │ ┌──────────┐ ┌───────┐ ┌───────────┐ │ │ │
|
||||
│ │ │ │ Qwen3-TTS│ │LuxTTS │ │Chatterbox │ │ │ │
|
||||
│ │ │ │(Py/MLX) │ │ │ │(MTL+Turbo)│ │ │ │
|
||||
│ │ │ └──────────┘ └───────┘ └───────────┘ │ │ │
|
||||
│ │ │ ┌──────────┐ │ │ │
|
||||
│ │ │ │ TADA │ │ │ │
|
||||
│ │ │ │(1B / 3B) │ │ │ │
|
||||
│ │ │ └──────────┘ │ │ │
|
||||
│ │ └─────────────────────────────────────────┘ │ │
|
||||
│ │ ┌───────────┐ ┌─────────┐ │ │
|
||||
│ │ │ STTBackend│ │ Profiles│ │ │
|
||||
│ │ │ (Whisper) │ │ History │ │ │
|
||||
│ │ └───────────┘ │ Stories │ │ │
|
||||
│ │ └─────────┘ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Files
|
||||
|
||||
| Layer | File | Purpose |
|
||||
|-------|------|---------|
|
||||
| Backend entry | `backend/main.py` | FastAPI app, all API routes (~2850 lines) |
|
||||
| TTS protocol | `backend/backends/__init__.py:32-101` | `TTSBackend` Protocol definition |
|
||||
| Model registry | `backend/backends/__init__.py:17-29,153-366` | `ModelConfig` dataclass + registry helpers |
|
||||
| TTS factory | `backend/backends/__init__.py:382-426` | Thread-safe engine registry (double-checked locking) |
|
||||
| PyTorch TTS | `backend/backends/pytorch_backend.py` | Qwen3-TTS via `qwen_tts` package |
|
||||
| MLX TTS | `backend/backends/mlx_backend.py` | Qwen3-TTS via `mlx_audio.tts` |
|
||||
| LuxTTS | `backend/backends/luxtts_backend.py` | LuxTTS — fast, CPU-friendly |
|
||||
| Chatterbox MTL | `backend/backends/chatterbox_backend.py` | Chatterbox Multilingual — 23 languages |
|
||||
| Chatterbox Turbo | `backend/backends/chatterbox_turbo_backend.py` | Chatterbox Turbo — English, paralinguistic tags |
|
||||
| TADA | `backend/backends/hume_backend.py` | HumeAI TADA — 1B English + 3B Multilingual |
|
||||
| Platform detect | `backend/platform_detect.py` | Apple Silicon → MLX, else → PyTorch |
|
||||
| API types | `backend/models.py` | Pydantic request/response models |
|
||||
| HF progress | `backend/utils/hf_progress.py` | HFProgressTracker (tqdm patching for download progress) |
|
||||
| Audio utils | `backend/utils/audio.py` | `trim_tts_output()`, normalize, load/save audio |
|
||||
| Frontend API | `app/src/lib/api/client.ts` | Hand-written fetch wrapper |
|
||||
| Frontend types | `app/src/lib/api/types.ts` | TypeScript API types |
|
||||
| Engine selector | `app/src/components/Generation/EngineModelSelector.tsx` | Shared engine/model dropdown |
|
||||
| Generation form | `app/src/components/Generation/GenerationForm.tsx` | TTS generation UI |
|
||||
| Floating gen box | `app/src/components/Generation/FloatingGenerateBox.tsx` | Compact generation UI |
|
||||
| Model manager | `app/src/components/ServerSettings/ModelManagement.tsx` | Model download/status/progress UI |
|
||||
| GPU acceleration | `app/src/components/ServerSettings/GpuAcceleration.tsx` | CUDA backend swap UI |
|
||||
| Gen form hook | `app/src/lib/hooks/useGenerationForm.ts` | Form validation + submission |
|
||||
| Language constants | `app/src/lib/constants/languages.ts` | Per-engine language maps |
|
||||
|
||||
### How TTS Generation Works (Current Flow)
|
||||
|
||||
```
|
||||
POST /generate
|
||||
1. Look up voice profile from DB
|
||||
2. Resolve engine from request (qwen | luxtts | chatterbox | chatterbox_turbo | tada)
|
||||
3. Get backend: get_tts_backend_for_engine(engine) # thread-safe singleton per engine
|
||||
4. Check model cache → if missing, trigger background download, return HTTP 202
|
||||
5. Load model (lazy): tts_backend.load_model(model_size)
|
||||
6. Create voice prompt: profiles.create_voice_prompt_for_profile(engine=engine)
|
||||
→ tts_backend.create_voice_prompt(audio_path, reference_text)
|
||||
7. Generate: tts_backend.generate(text, voice_prompt, language, seed, instruct)
|
||||
8. Post-process: trim_tts_output() for Chatterbox engines
|
||||
9. Save WAV → data/generations/{id}.wav
|
||||
10. Insert history record in SQLite
|
||||
11. Return GenerationResponse
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
### What's Shipped (v0.3.0)
|
||||
|
||||
**Core TTS:**
|
||||
- Qwen3-TTS voice cloning (1.7B and 0.6B models)
|
||||
- MLX backend for Apple Silicon, PyTorch for everything else
|
||||
- Multi-engine TTS architecture with thread-safe backend registry (PR #254)
|
||||
- LuxTTS integration — fast, CPU-friendly English TTS (PR #254)
|
||||
- Chatterbox Multilingual TTS — 23 languages including Hebrew (PR #257)
|
||||
- Chatterbox Turbo — paralinguistic tags, low latency English (PR #258)
|
||||
- HumeAI TADA integration — 1B English + 3B Multilingual speech-language model (PR #296)
|
||||
- Chunked TTS generation for long text — engine-agnostic, removes ~500 char limit (PR #266)
|
||||
- Async generation queue (PR #269)
|
||||
- Post-processing audio effects system (PR #271)
|
||||
- Centralized model config registry (`ModelConfig` dataclass) — no per-engine dispatch maps
|
||||
- Shared `EngineModelSelector` component — engine/model dropdown defined once, used in both generation forms
|
||||
|
||||
**Infrastructure:**
|
||||
- CUDA backend swap via binary download and restart (PR #252), upgraded to cu128 (PR #316)
|
||||
- CUDA backend split into independently versioned server + libs archives (PR #298)
|
||||
- Docker + web deployment (PR #161)
|
||||
- Backend refactor: modular architecture, style guide, tooling (PR #285)
|
||||
- Settings overhaul: routed sub-tabs, server logs, changelog, about page (PR #294)
|
||||
- Windows support: CUDA detection, cross-platform justfile, clean server shutdown (PR #272)
|
||||
- Voice profiles with multi-sample support
|
||||
- Stories editor (multi-track DAW timeline)
|
||||
- Whisper transcription (base, small, medium, large variants)
|
||||
- Model management UI with inline download progress bars + folder migration (PR #268)
|
||||
- Download cancel/clear UI with error panel (PR #238)
|
||||
- Generation history with caching
|
||||
- Streaming generation endpoint (MLX only)
|
||||
- Audio player freeze fix + UX improvements (PR #293)
|
||||
- CORS restriction to known local origins (PR #88)
|
||||
|
||||
### Abandoned Integrations
|
||||
|
||||
| Model | PR | Reason |
|
||||
|-------|----|--------|
|
||||
| **CosyVoice2/3** | PR #311 | Output quality too poor. Heavy deps, no PyPI, needed 5+ shims. |
|
||||
|
||||
### What's In-Flight
|
||||
|
||||
| Feature | Branch/PR | Status |
|
||||
|---------|-----------|--------|
|
||||
| Kokoro 82M TTS engine | WIP | In development — 82M CPU-realtime engine, 8 languages |
|
||||
|
||||
### TTS Engine Comparison
|
||||
|
||||
| Engine | Model Name | Languages | Size | Key Features | Instruct Support |
|
||||
|--------|-----------|-----------|------|-------------|-----------------|
|
||||
| Qwen3-TTS 1.7B | `qwen-tts-1.7B` | 10 (zh, en, ja, ko, de, fr, ru, pt, es, it) | ~3.5 GB | Highest quality, voice cloning | None (Base model has no instruct path) |
|
||||
| Qwen3-TTS 0.6B | `qwen-tts-0.6B` | 10 | ~1.2 GB | Lighter, faster | None |
|
||||
| LuxTTS | `luxtts` | English | ~300 MB | CPU-friendly, 48 kHz, fast | None |
|
||||
| Chatterbox | `chatterbox-tts` | 23 (incl. Hebrew, Arabic, Hindi, etc.) | ~3.2 GB | Zero-shot cloning, multilingual | Partial — `exaggeration` float (0-1) for expressiveness |
|
||||
| Chatterbox Turbo | `chatterbox-turbo` | English | ~1.5 GB | Paralinguistic tags ([laugh], [cough]), 350M params, low latency | Partial — inline tags only, no separate instruct param |
|
||||
| TADA 1B | `tada-1b` | English | ~4 GB | HumeAI speech-language model, 700s+ coherent audio | None |
|
||||
| TADA 3B Multilingual | `tada-3b-ml` | 10 (en, ar, zh, de, es, fr, it, ja, pl, pt) | ~8 GB | Multilingual, text-acoustic dual alignment | None |
|
||||
| Kokoro 82M | `kokoro` | 8 (en, es, fr, hi, it, pt, ja, zh) | ~350 MB | 82M params, CPU realtime, Apache 2.0, pre-built voices | None |
|
||||
|
||||
### Multi-Engine Architecture (Shipped)
|
||||
|
||||
The singleton TTS backend blocker described in the previous version of this doc has been **resolved**. The architecture now supports:
|
||||
|
||||
- **Thread-safe backend registry** (`_tts_backends` dict + `_tts_backends_lock`) with double-checked locking
|
||||
- **Per-engine backend instances** — each engine gets its own singleton, loaded lazily
|
||||
- **Engine field on GenerationRequest** — frontend sends `engine: 'qwen' | 'luxtts' | 'chatterbox' | 'chatterbox_turbo' | 'tada'`
|
||||
- **Per-engine language filtering** — `ENGINE_LANGUAGES` map in frontend, backend regex accepts all languages
|
||||
- **Per-engine voice prompts** — `create_voice_prompt_for_profile()` dispatches to the correct backend
|
||||
- **Trim post-processing** — `trim_tts_output()` for Chatterbox engines (cuts trailing silence/hallucination)
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- **HF XET progress**: Large files downloaded via `hf-xet` (HuggingFace's new transfer backend) report `n=0` in tqdm updates. Progress bars may appear stuck for large `.safetensors` files even though the download is proceeding. This is a known upstream limitation.
|
||||
- **Chatterbox Turbo upstream token bug**: `from_pretrained()` passes `token=os.getenv("HF_TOKEN") or True` which fails without a stored HF token. Our backend works around this by calling `snapshot_download(token=None)` + `from_local()`.
|
||||
- **chatterbox-tts must install with `--no-deps`**: It pins `numpy<1.26`, `torch==2.6.0`, `transformers==4.46.3` — all incompatible with our stack (Python 3.12, torch 2.10, transformers 4.57.3). Sub-deps listed explicitly in `requirements.txt`.
|
||||
- **Instruct parameter is non-functional** (#224): The UI exposes an instruct text field, but it's silently dropped by every backend. The Qwen3-TTS Base model we ship only supports voice cloning — instruct requires the separate CustomVoice model variant (`Qwen3-TTS-12Hz-1.7B-CustomVoice`), which uses predefined speakers instead of ref audio. The instruct UI should be hidden until a backend with real support is integrated.
|
||||
- **Streaming generation** only works for Qwen on MLX. Other engines use the non-streaming `/generate` endpoint.
|
||||
- **dicta-onnx** (Hebrew diacritization) not included — upstream Chatterbox bug requires `model_path` arg but calls `Dicta()` with none. Hebrew works fine without it.
|
||||
|
||||
---
|
||||
|
||||
## Open PRs — Triage & Analysis
|
||||
|
||||
### Recently Merged (Since Last Update)
|
||||
|
||||
| PR | Title | Merged |
|
||||
|----|-------|--------|
|
||||
| **#316** | Upgrade CUDA backend from cu126 to cu128, fix GPU settings UI | 2026-03-18 |
|
||||
| **#305** | fix: bundle qwen_tts source files in PyInstaller build | 2026-03-17 |
|
||||
| **#298** | feat: split CUDA backend into independently versioned server + libs archives | 2026-03-17 |
|
||||
| **#296** | Add HumeAI TADA TTS engine (1B English + 3B Multilingual) | 2026-03-17 |
|
||||
| **#295** | fix: batch of bug fixes from issue tracker | 2026-03-17 |
|
||||
| **#293** | Fix audio player freezing and improve UX | 2026-03-17 |
|
||||
| **#294** | Settings overhaul: routed sub-tabs, server logs, changelog, about page | 2026-03-16 |
|
||||
| **#288** | Better docs | 2026-03-16 |
|
||||
| **#285** | Backend refactor: modular architecture, style guide, tooling | 2026-03-16 |
|
||||
| **#274** | Landing page v0.2.0 redesign | 2026-03-15 |
|
||||
| **#272** | Windows support: CUDA detection, cross-platform justfile, clean server shutdown | 2026-03-15 |
|
||||
| **#271** | Add post-processing audio effects system | 2026-03-14 |
|
||||
| **#269** | feat: async generation queue | 2026-03-13 |
|
||||
| **#268** | feat: model management improvements and folder migration | 2026-03-13 |
|
||||
| **#266** | feat: chunked TTS generation for long text (engine-agnostic) | 2026-03-13 |
|
||||
| **#265** | feat: paralinguistic tag autocomplete for Chatterbox Turbo | 2026-03-13 |
|
||||
| **#264** | fix: Chatterbox float64 dtype mismatch + model unload button | 2026-03-13 |
|
||||
| **#258** | feat: Chatterbox Turbo engine + per-engine language lists | 2026-03-13 |
|
||||
| **#230** | docs: fix README grammar | 2026-03-13 |
|
||||
| **#161** | feat: Docker + web deployment | 2026-03-13 |
|
||||
| **#88** | security: restrict CORS to known local origins | 2026-03-13 |
|
||||
|
||||
### Currently Open (9 PRs)
|
||||
|
||||
| PR | Title | Status | Notes |
|
||||
|----|-------|--------|-------|
|
||||
| **#311** | feat: add CosyVoice2/3 TTS engine | **Will close** | Model quality too poor. See Abandoned Integrations. |
|
||||
| **#253** | Enhance speech tokenizer with 48kHz version | Community PR | Qwen tokenizer upgrade. Worth reviewing. |
|
||||
| **#237** | fix: bundle qwen_tts source files in PyInstaller | Superseded | Our PR #305 shipped this. Can close. |
|
||||
| **#227** | fix: harden input validation & file safety | Community PR | Coupled to #225 (custom models). |
|
||||
| **#225** | feat: custom HuggingFace model support | Community PR | Needs rework for multi-engine arch. |
|
||||
| **#218** | fix: unify qwen tts cache dir on Windows | Community PR | Windows-specific path fix. Still relevant. |
|
||||
| **#195** | feat: per-profile LoRA fine-tuning | Draft | Complex. 15 new endpoints. |
|
||||
| **#154** | feat: Audiobook tab | Community PR | Chunked generation now shipped (#266). |
|
||||
| **#91** | fix: CoreAudio device enumeration | Draft | macOS audio device handling. |
|
||||
|
||||
---
|
||||
|
||||
## Open Issues — Categorized
|
||||
|
||||
### GPU / Hardware Detection (19 issues)
|
||||
|
||||
The single most reported category. Users on Windows with NVIDIA GPUs frequently report "GPU not detected."
|
||||
|
||||
**Root causes (likely):**
|
||||
- PyInstaller binary doesn't bundle CUDA correctly → falls back to CPU
|
||||
- DirectML/Vulkan path not implemented (AMD on Windows)
|
||||
- Binary size limit means CUDA can't ship in the main release
|
||||
|
||||
**Key issues:** #239, #222, #220, #217, #208, #198, #192, #167, #164, #141, #130, #127
|
||||
|
||||
**Fix path:** PR #252 (CUDA backend swap) is now merged. Users can download the CUDA binary separately from the GPU acceleration settings. Many of these issues may now be resolvable — needs triage to confirm.
|
||||
|
||||
### Model Downloads (20 issues)
|
||||
|
||||
Second most reported. Users get stuck downloads, can't resume, no offline fallback.
|
||||
|
||||
**Key issues:** #249, #240, #221, #216, #212, #181, #180, #159, #150, #149, #145, #143, #135, #134
|
||||
|
||||
**Fix path:** PR #238 (cancel/clear UI) is now merged. PR #152 (offline crash fix) still open. Inline progress bars now show for all engines. Resume support not yet addressed.
|
||||
|
||||
### Language Requests (18 issues)
|
||||
|
||||
Strong demand for: Hindi (#245), Indonesian (#247), Dutch (#236), Hebrew (#199), Greek (#188), Portuguese (#183), Persian (#162), and many more.
|
||||
|
||||
**Key issues:** #247, #245, #236, #211, #205, #199, #189, #188, #187, #183, #179, #162
|
||||
|
||||
**Fix path:** Chatterbox Multilingual (merged via #257) now supports 23 languages including many of the requested ones: Arabic, Danish, German, Greek, Finnish, Hebrew, Hindi, Dutch, Norwegian, Polish, Swedish, Swahili, Turkish. Per-engine language filtering (PR #258) ensures the UI shows correct options. Several of these issues may be closeable.
|
||||
|
||||
### New Model Requests (5 explicit issues)
|
||||
|
||||
| Issue | Model Requested |
|
||||
|-------|----------------|
|
||||
| #226 | GGUF support |
|
||||
| #172 | VibeVoice |
|
||||
| #138 | Export to ONNX/Piper format |
|
||||
| #132 | LavaSR (transcription) |
|
||||
| #76 | (General model expansion) |
|
||||
|
||||
Community also requests: XTTS-v2, Fish Speech, Kokoro. CosyVoice was tried and abandoned. The multi-engine architecture is in place, making new model integration straightforward.
|
||||
|
||||
### Long-Form / Chunking (5 issues)
|
||||
|
||||
Users hitting the ~500 character practical limit.
|
||||
|
||||
**Key issues:** #234 (queue system), #203 (500 char limit), #191 (auto-split), #111, #69
|
||||
|
||||
**Fix path:** **Mostly resolved.** PR #266 (engine-agnostic chunked TTS) and PR #269 (async generation queue) are both merged. PR #154 (Audiobook tab) is still open.
|
||||
|
||||
### Feature Requests (23 issues)
|
||||
|
||||
Notable requests:
|
||||
- **#234** — Queue system for batch generation
|
||||
- **#182** — Concurrent/multi-thread generation
|
||||
- **#173** — Vocal intonation/inflection control
|
||||
- **#165** — Audiobook mode
|
||||
- **#144** — Copy text to clipboard
|
||||
- **#184** — Cancel button for progress bar
|
||||
- **#242** — Seed value pinning for consistency
|
||||
- **#228** — Always use 0.6B option
|
||||
- **#233** — Transcribe audio API improvements
|
||||
- **#235** — Finetuned Qwen3-TTS tokenizer
|
||||
|
||||
### Bugs (19 issues)
|
||||
|
||||
| Category | Issues |
|
||||
|----------|--------|
|
||||
| Generation failures | #248 (broken pipe), #219 (unsupported scalarType), #202 (clipping error), #170 (load failed) |
|
||||
| UI bugs | #231 (history not updating), #190 (mobile landing), #169 (blank interface) |
|
||||
| File operations | #207 (transcribe file error), #168 (no such file), #142 (download audio fail) |
|
||||
| Server lifecycle | #166 (server processes remain), #164 (no auto-update) |
|
||||
| Database | #174 (sqlite3 IntegrityError) |
|
||||
| Dependency | #131 (numpy ABI mismatch), #209 (import error) |
|
||||
|
||||
---
|
||||
|
||||
## Existing Plan Documents — Status
|
||||
|
||||
| Document | Target Version | Status | Relevance |
|
||||
|----------|---------------|--------|-----------|
|
||||
| `TTS_PROVIDER_ARCHITECTURE.md` | v0.1.13 | **Partially superseded** by multi-engine arch + CUDA swap | Core concepts implemented differently than planned |
|
||||
| `CUDA_BACKEND_SWAP.md` | — | **Shipped** (PR #252) | CUDA binary download + backend restart |
|
||||
| `CUDA_BACKEND_SWAP_FINAL.md` | — | **Shipped** (PR #252) | Final implementation plan |
|
||||
| `EXTERNAL_PROVIDERS.md` | v0.2.0 | **Not started** | Remote server support |
|
||||
| `MLX_AUDIO.md` | — | **Shipped** | MLX backend is live |
|
||||
| `DOCKER_DEPLOYMENT.md` | v0.2.0 | **Shipped** (PR #161) | Docker + web deployment |
|
||||
| `OPENAI_SUPPORT.md` | v0.2.0 | **Not started** | OpenAI-compatible API layer |
|
||||
| `PR33_CUDA_PROVIDER_REVIEW.md` | — | **Reference** | Analysis of the original provider approach |
|
||||
|
||||
---
|
||||
|
||||
## New Model Integration — Landscape
|
||||
|
||||
### Models Worth Supporting (2026 SOTA — updated March 18)
|
||||
|
||||
| Model | Cloning | Speed | Sample Rate | Languages | VRAM | Instruct Support | Integration Ease | Status |
|
||||
|-------|---------|-------|-------------|-----------|------|-----------------|-----------------|--------|
|
||||
| **Qwen3-TTS** | 10s zero-shot | Medium | 24 kHz | 10 | Medium | None (Base); Yes (CustomVoice variant, predefined speakers only) | **Shipped** | v0.1.13 |
|
||||
| **LuxTTS** | 3s zero-shot | 150x RT, CPU ok | 48 kHz | English | <1 GB | None | **Shipped** | PR #254 |
|
||||
| **Chatterbox MTL** | 5s zero-shot | Medium | 24 kHz | 23 | Medium | Partial — `exaggeration` float | **Shipped** | PR #257 |
|
||||
| **Chatterbox Turbo** | 5s zero-shot | Fast | 24 kHz | English | Low | Partial — inline tags only | **Shipped** | PR #258 |
|
||||
| **HumeAI TADA 1B/3B** | Zero-shot | 5x faster than LLM-TTS | 24 kHz | EN (1B), Multilingual (3B) | Medium | Partial — automatic prosody | **Shipped** | PR #296 |
|
||||
| **Kokoro-82M** | Pre-built voices | CPU realtime | 24 kHz | 8 | Tiny (82M) | None | **In progress** | Apache 2.0, pip install, ~350MB |
|
||||
| ~~**CosyVoice2-0.5B**~~ | 3-10s zero-shot | Very fast | 24 kHz | Multilingual | Low | Yes — `inference_instruct2()` | **Abandoned** | PR #311 — poor output quality |
|
||||
| **Fish Speech** | 10-30s few-shot | Real-time | 24-44 kHz | 50+ | Medium | **Yes** — inline text descriptions, word-level control | Ready | Needs license clarification |
|
||||
| **XTTS-v2** | 6s zero-shot | Mid-GPU | 24 kHz | 17+ | Medium | Partial — style transfer from ref audio only | Ready | Mature pip package |
|
||||
| **Pocket TTS** | Zero-shot + streaming | >1x RT on CPU | — | English | ~100M params, CPU-first | None | Ready | MIT, Kyutai Labs |
|
||||
| **MOSS-TTS Family** | Zero-shot | — | — | Multilingual | Medium | **Yes** — text prompts for style + timbre design | Needs vetting | Apache 2.0 |
|
||||
| **VoxCPM 1.5** | Zero-shot (seconds) | ~0.15 RTF streaming | — | Bilingual (EN/ZH) | Medium | Partial — automatic context-aware prosody | Needs vetting | Apache 2.0 |
|
||||
|
||||
#### Notes on Candidates (March 2026)
|
||||
|
||||
- **CosyVoice2-0.5B** — **Tried and abandoned** (PR #311). Despite having the best instruct API, output quality was poor. No PyPI package, needed 5+ shims, heavy deps. Not worth it.
|
||||
- **HumeAI TADA** — **Shipped** (PR #296). 700+ seconds coherent audio. [GitHub: HumeAI/tada](https://github.com/HumeAI/tada)
|
||||
- **Kokoro-82M** — **In progress.** 82M params, CPU realtime, Apache 2.0, clean `pip install kokoro`. Uses pre-built voice styles (not zero-shot cloning from arbitrary audio). [GitHub: hexgrad/kokoro](https://github.com/hexgrad/kokoro)
|
||||
- **Fish Speech** — Word-level fine-grained control. License needs clarification. [fish.audio blog](https://fish.audio/blog/fish-audio-s2-fine-grained-ai-voice-control-at-the-word-level)
|
||||
- **XTTS-v2** — Coqui's multilingual cloning. 17+ languages, pip-installable. [GitHub: coqui-ai/TTS](https://github.com/coqui-ai/TTS)
|
||||
- **Pocket TTS** — 100M param CPU-first model from Kyutai Labs. [GitHub: kyutai-labs/pocket-tts](https://github.com/kyutai-labs/pocket-tts)
|
||||
- **Watch list:** MioTTS-2.6B (fast LLM-based EN/JP, vLLM compatible), Oolel-Voices (Soynade Research, expressive modular control)
|
||||
|
||||
### Adding a New Engine (Now Straightforward)
|
||||
|
||||
With the model config registry and shared `EngineModelSelector` component, adding a new TTS engine requires:
|
||||
|
||||
1. **Create `backend/backends/<engine>_backend.py`** — implement `TTSBackend` protocol (~200-300 lines)
|
||||
2. **Register in `backend/backends/__init__.py`** — add `ModelConfig` entry + `TTS_ENGINES` entry + factory elif
|
||||
3. **Update `backend/models.py`** — add engine name to regex
|
||||
4. **Update frontend** — add to engine union type, `EngineModelSelector` options, form schema, language map (4 files)
|
||||
|
||||
`main.py` requires **zero changes** — the registry handles all dispatch automatically.
|
||||
|
||||
Total effort: **~1 day** for a well-documented model with a PyPI package. See `docs/plans/ADDING_TTS_ENGINES.md` for the full guide.
|
||||
|
||||
---
|
||||
|
||||
## Architectural Bottlenecks
|
||||
|
||||
### ~~1. Single Backend Singleton~~ — RESOLVED
|
||||
|
||||
The singleton TTS backend was replaced with a thread-safe per-engine registry in PR #254. Multiple engines can now be loaded simultaneously.
|
||||
|
||||
### ~~2. `main.py` Dispatch Point Duplication~~ — RESOLVED
|
||||
|
||||
Previously, each engine required updates to 6+ hardcoded dispatch maps across `main.py` (~320 lines of if/elif chains). A model config registry in `backend/backends/__init__.py` now centralizes all model metadata (`ModelConfig` dataclass) with helper functions (`load_engine_model()`, `check_model_loaded()`, `engine_needs_trim()`, etc.). Adding a new engine requires zero changes to `main.py`.
|
||||
|
||||
### ~~3. Model Config is Scattered~~ — RESOLVED
|
||||
|
||||
Model identifiers, HF repo IDs, display names, and engine metadata are now consolidated in the `ModelConfig` registry. Backend-aware branching (e.g. MLX vs PyTorch Qwen repo IDs) happens inside the registry. Frontend model options are centralized in `EngineModelSelector.tsx`.
|
||||
|
||||
### 4. Voice Prompt Cache Assumes PyTorch Tensors
|
||||
|
||||
`backend/utils/cache.py` uses `torch.save()` / `torch.load()`. LuxTTS and Chatterbox backends work around this by storing reference audio paths instead of tensors in their voice prompt dicts. Not ideal but functional.
|
||||
|
||||
### 5. ~~Frontend Assumes Qwen Model Sizes~~ — RESOLVED
|
||||
|
||||
The generation form now uses a flat model dropdown with engine-based routing. Per-engine language filtering is in place. Model size is only sent for Qwen.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Priorities
|
||||
|
||||
### Tier 1 — Ship Now
|
||||
|
||||
| Priority | PR/Item | Impact | Effort |
|
||||
|----------|---------|--------|--------|
|
||||
| 1 | **Kokoro 82M** — finish integration | New engine, CPU-friendly, 8 langs | Low (nearly done) |
|
||||
| 2 | Close PR #311 (CosyVoice) and #237 (superseded by #305) | Housekeeping | None |
|
||||
| 3 | **#218** — Windows HF cache dir fix | Windows-specific pain | Low |
|
||||
| 4 | **#253** — 48kHz speech tokenizer | Quality improvement for Qwen | Medium |
|
||||
|
||||
### Tier 2 — Feature Work
|
||||
|
||||
| Priority | Item | Impact | Effort |
|
||||
|----------|------|--------|--------|
|
||||
| 1 | **#154** — Audiobook tab | Long-form users. Chunking + queue now shipped. | Medium |
|
||||
| 2 | **#225** — Custom HuggingFace models | User-supplied models. Needs rework. | High |
|
||||
| 3 | OpenAI-compatible API (plan doc exists) | Low effort once API is stable | Low |
|
||||
| 4 | LoRA fine-tuning (PR #195) | Complex, needs rework for multi-engine | Very High |
|
||||
| 5 | Streaming for non-MLX engines | Currently MLX-only | Medium |
|
||||
|
||||
### Tier 3 — Future Engines
|
||||
|
||||
| Priority | Item | Notes |
|
||||
|----------|------|-------|
|
||||
| 1 | **Fish Speech** | 50+ langs, word-level instruct. License TBD. |
|
||||
| 2 | **XTTS-v2** | 17+ langs, mature pip package. Best multilingual cloning. |
|
||||
| 3 | **Pocket TTS** (Kyutai) | CPU-first 100M model. MIT. |
|
||||
| 4 | **MOSS-TTS** | Text-to-voice design. Multi-speaker dialogue for Stories. |
|
||||
| 5 | **VoxCPM 1.5** | Tokenizer-free streaming. Uncertain integration surface. |
|
||||
|
||||
### ~~Previously Prioritized — Now Done~~
|
||||
|
||||
- ~~#258 — Chatterbox Turbo~~ **Merged**
|
||||
- ~~#99 — Chunked TTS~~ **Superseded by #266, merged**
|
||||
- ~~#88 — CORS restriction~~ **Merged**
|
||||
- ~~#161 — Docker deployment~~ **Merged**
|
||||
- ~~#234 — Queue system~~ **Addressed by #269, merged**
|
||||
- ~~HumeAI TADA~~ **Shipped** (PR #296)
|
||||
- ~~Kokoro-82M~~ **In progress**
|
||||
|
||||
---
|
||||
|
||||
## Branch Inventory
|
||||
|
||||
| Branch | PR | Status | Notes |
|
||||
|--------|-----|--------|-------|
|
||||
| `feat/cosyvoice-engine` | #311 | Open — closing | CosyVoice2/3 — abandoned, poor quality |
|
||||
| `feat/chatterbox-turbo` | #258 | **Merged** | Chatterbox Turbo + per-engine languages |
|
||||
| `feat/chatterbox` | #257 | **Merged** | Chatterbox Multilingual |
|
||||
| `feat/luxtts` | #254 | **Merged** | LuxTTS + multi-engine arch |
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: API Endpoints
|
||||
|
||||
<details>
|
||||
<summary>All current endpoints</summary>
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/health` | GET | Health check, model/GPU status |
|
||||
| `/profiles` | POST, GET | Create/list voice profiles |
|
||||
| `/profiles/{id}` | GET, PUT, DELETE | Profile CRUD |
|
||||
| `/profiles/{id}/samples` | POST, GET | Add/list voice samples |
|
||||
| `/profiles/{id}/avatar` | POST, GET, DELETE | Avatar management |
|
||||
| `/profiles/{id}/export` | GET | Export profile as ZIP |
|
||||
| `/profiles/import` | POST | Import profile from ZIP |
|
||||
| `/generate` | POST | Generate speech (engine param selects TTS backend) |
|
||||
| `/generate/stream` | POST | Stream speech (MLX only) |
|
||||
| `/history` | GET | List generation history |
|
||||
| `/history/{id}` | GET, DELETE | Get/delete generation |
|
||||
| `/history/{id}/export` | GET | Export generation ZIP |
|
||||
| `/history/{id}/export-audio` | GET | Export audio only |
|
||||
| `/transcribe` | POST | Transcribe audio (Whisper) |
|
||||
| `/models/status` | GET | All model statuses (Qwen, LuxTTS, Chatterbox, Chatterbox Turbo, TADA, Whisper) |
|
||||
| `/models/download` | POST | Trigger model download |
|
||||
| `/models/download/cancel` | POST | Cancel/dismiss download |
|
||||
| `/models/{name}` | DELETE | Delete downloaded model |
|
||||
| `/models/load` | POST | Load model into memory |
|
||||
| `/models/unload` | POST | Unload model |
|
||||
| `/models/progress/{name}` | GET | SSE download progress |
|
||||
| `/tasks/active` | GET | Active downloads/generations (with inline progress) |
|
||||
| `/stories` | POST, GET | Create/list stories |
|
||||
| `/stories/{id}` | GET, PUT, DELETE | Story CRUD |
|
||||
| `/stories/{id}/items` | POST, GET | Story items CRUD |
|
||||
| `/stories/{id}/export` | GET | Export story audio |
|
||||
| `/channels` | POST, GET | Audio channel CRUD |
|
||||
| `/channels/{id}` | PUT, DELETE | Channel update/delete |
|
||||
| `/cache/clear` | POST | Clear voice prompt cache |
|
||||
| `/server/cuda/status` | GET | CUDA binary availability |
|
||||
| `/server/cuda/download` | POST | Download CUDA binary |
|
||||
| `/server/cuda/switch` | POST | Switch to CUDA backend |
|
||||
|
||||
</details>
|
||||
@@ -1,163 +0,0 @@
|
||||
# Voicebox v0.2.0 -- Release Notes
|
||||
|
||||
## The story
|
||||
|
||||
Voicebox v0.1.x shipped as a single-engine voice cloning app built around Qwen3-TTS. It worked, but it was limited: one model family, 10 languages, English-centric emotion, a synchronous generation pipeline that locked the UI, and a hard ceiling on how much text you could generate at once.
|
||||
|
||||
v0.2.0 is a ground-up rethink. Voicebox is now a **multi-engine voice cloning platform**. Four TTS engines. 23 languages. Expressive paralinguistic controls. A full post-processing effects pipeline. Unlimited generation length. Asynchronous everything. And it runs on every major GPU vendor -- NVIDIA, AMD, Intel Arc, Apple Silicon -- plus Docker for headless deployment.
|
||||
|
||||
This is the release where Voicebox stops being a proof of concept and starts being a real tool.
|
||||
|
||||
---
|
||||
|
||||
## Major New Features
|
||||
|
||||
### Multi-Engine Architecture
|
||||
Voicebox now supports **four TTS engines**, each with different strengths. Switch between them per-generation from a single unified interface:
|
||||
|
||||
| Engine | Languages | Strengths |
|
||||
|--------|-----------|-----------|
|
||||
| **Qwen3-TTS** (0.6B / 1.7B) | 10 | High-quality multilingual cloning, delivery instructions ("speak slowly", "whisper") |
|
||||
| **LuxTTS** | English | Lightweight (~1GB VRAM), 48kHz output, 150x realtime on CPU |
|
||||
| **Chatterbox Multilingual** | 23 | Broadest language coverage -- Arabic, Danish, Finnish, Greek, Hebrew, Hindi, Malay, Norwegian, Polish, Swahili, Swedish, Turkish and more |
|
||||
| **Chatterbox Turbo** | English | Fast 350M model with paralinguistic emotion/sound tags |
|
||||
|
||||
### Emotions and Paralinguistic Tags (Chatterbox Turbo)
|
||||
Type `/` in the text input to open an autocomplete for **9 expressive tags** that the model synthesizes inline with speech:
|
||||
|
||||
`[laugh]` `[chuckle]` `[gasp]` `[cough]` `[sigh]` `[groan]` `[sniff]` `[shush]` `[clear throat]`
|
||||
|
||||
Tags render as inline badges in a rich text editor and serialize cleanly to the API. This makes generated speech sound natural and expressive in a way that plain TTS can't.
|
||||
|
||||
### 23 Languages via Chatterbox Multilingual
|
||||
The Chatterbox Multilingual engine brings zero-shot voice cloning to **23 languages**: Arabic, Chinese, Danish, Dutch, English, Finnish, French, German, Greek, Hebrew, Hindi, Italian, Japanese, Korean, Malay, Norwegian, Polish, Portuguese, Russian, Spanish, Swahili, Swedish, and Turkish. The language dropdown dynamically filters to show only languages supported by the selected engine.
|
||||
|
||||
### Unlimited Generation Length (Auto-Chunking)
|
||||
Previously, long text would hit model context limits and degrade. Now, text is **automatically split at sentence boundaries** and each chunk is generated independently, then crossfaded back together. This is fully engine-agnostic and works with all four engines.
|
||||
|
||||
- **Auto-chunking limit slider** (100-5,000 chars, default 800) -- controls when text gets split
|
||||
- **Crossfade slider** (0-200ms, default 50ms) -- blends chunk boundaries smoothly, or set to 0 for a hard cut
|
||||
- **Max text length raised to 50,000 characters** -- generate entire scripts, chapters, or articles in one go
|
||||
- Smart splitting respects abbreviations (Dr., e.g., a.m.), CJK punctuation, and never breaks inside paralinguistic `[tags]`
|
||||
|
||||
### Asynchronous Generation Queue
|
||||
Generation is now fully **non-blocking**. Submit a generation and immediately start typing the next one -- no more frozen UI waiting for inference to complete.
|
||||
|
||||
- Serial execution queue prevents GPU contention across all backends
|
||||
- Real-time SSE status streaming (`generating` -> `completed` / `failed`)
|
||||
- Failed generations can be retried without re-entering text
|
||||
- Stale generations from crashes are auto-recovered on startup
|
||||
- Generating status pill shown inline in the story editor
|
||||
|
||||
### Post-Processing Effects Pipeline
|
||||
A full audio effects system powered by Spotify's `pedalboard` library. Apply effects after generation, preview them in real time, and build reusable presets -- all without leaving the app.
|
||||
|
||||
**8 effects available:**
|
||||
|
||||
| Effect | What it does |
|
||||
|--------|-------------|
|
||||
| **Pitch Shift** | Shift pitch up or down by up to 12 semitones |
|
||||
| **Reverb** | Room reverb with configurable size, damping, and wet/dry mix |
|
||||
| **Delay** | Echo with adjustable delay time, feedback, and mix |
|
||||
| **Chorus / Flanger** | Modulated delay -- short for metallic flanger, longer for lush chorus |
|
||||
| **Compressor** | Dynamic range compression with threshold, ratio, attack, and release |
|
||||
| **Gain** | Volume adjustment from -40 to +40 dB |
|
||||
| **High-Pass Filter** | Remove low frequencies below a configurable cutoff |
|
||||
| **Low-Pass Filter** | Remove high frequencies above a configurable cutoff |
|
||||
|
||||
**Effects presets** -- Four built-in presets ship out of the box (Robotic, Radio, Echo Chamber, Deep Voice), and you can create unlimited custom presets. Presets are drag-and-drop chains of effects with per-parameter sliders.
|
||||
|
||||
**Per-profile default effects** -- Assign an effects chain to a voice profile and it applies automatically to every generation with that voice. Override per-generation from the generate box.
|
||||
|
||||
**Live preview** -- Audition any effects chain against an existing generation before committing. The preview streams processed audio without saving anything.
|
||||
|
||||
### Generation Versions
|
||||
Every generation now supports **multiple versions** with full provenance tracking:
|
||||
|
||||
- **Original** -- the clean, unprocessed TTS output (always preserved)
|
||||
- **Effects versions** -- apply different effects chains to create new versions from any source version
|
||||
- **Takes** -- regenerate with the same text and voice but a new seed for variation
|
||||
- **Source tracking** -- each version records which version it was derived from
|
||||
- **Version pinning in stories** -- pin a specific version to a track clip in the story editor, independent of the generation's default
|
||||
- **Favorites** -- star generations to mark them for quick access
|
||||
|
||||
---
|
||||
|
||||
## New Platform Support
|
||||
|
||||
### Linux (Native)
|
||||
Full Linux support with `.deb` and `.rpm` packages. Includes PulseAudio/PipeWire audio capture for voice sample recording.
|
||||
|
||||
### AMD ROCm GPU Acceleration
|
||||
AMD GPU users now get hardware-accelerated inference via ROCm, with automatic `HSA_OVERRIDE_GFX_VERSION` configuration for GPUs not officially in the ROCm compatibility list (e.g., RX 6600).
|
||||
|
||||
### NVIDIA CUDA Backend Swap
|
||||
The CPU-only release can download and swap in a CUDA-accelerated backend binary from within the app -- no reinstall required. Handles GitHub's 2GB asset limit by downloading split parts and verifying SHA-256 checksums.
|
||||
|
||||
### Intel Arc (XPU) and DirectML
|
||||
PyTorch backend also supports Intel Arc GPUs via IPEX/XPU and Windows any-GPU via DirectML.
|
||||
|
||||
### Docker + Web Deployment
|
||||
Run Voicebox headless as a Docker container with the full web UI:
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
3-stage build, non-root runtime, health checks, persistent model cache across rebuilds. Binds to localhost only by default.
|
||||
|
||||
---
|
||||
|
||||
## Model Management
|
||||
- **Per-model unload** -- free GPU memory without deleting downloaded models
|
||||
- **Custom models directory** -- set `VOICEBOX_MODELS_DIR` to store models anywhere
|
||||
- **Model folder migration** -- move all models to a new location with progress tracking
|
||||
- **Whisper Turbo** -- added `openai/whisper-large-v3-turbo` as a transcription model option
|
||||
- **Download cancel/clear UI** -- cancel in-progress downloads, VS Code-style problems panel for errors
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
- **CORS hardening** -- replaced wildcard `*` with an explicit allowlist of local origins; extensible via `VOICEBOX_CORS_ORIGINS` env var
|
||||
- **Network access toggle** -- fully disable outbound network requests for air-gapped deployments
|
||||
|
||||
## Accessibility
|
||||
- Comprehensive screen reader support (tested with NVDA/Narrator) across all major UI surfaces
|
||||
- Keyboard navigation for voice cards, history rows, model management, and story editor
|
||||
- State-aware `aria-label` attributes on all interactive controls
|
||||
|
||||
## Reliability
|
||||
- **Atomic audio saves** -- two-phase write prevents corrupted files on crash/interrupt
|
||||
- **Filesystem health endpoint** -- proactive disk space and directory writability checks
|
||||
- **Errno-specific error messages** -- clear feedback for permission denied, disk full, missing directory
|
||||
|
||||
## UX Polish
|
||||
- Responsive layout with horizontal-scroll voice cards on mobile
|
||||
- App version shown in sidebar
|
||||
- Voice card heights normalized
|
||||
- Audio player title hidden at narrow widths to prevent overflow
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **macOS (Apple Silicon)** | `Voicebox_0.2.0_aarch64.dmg` |
|
||||
| **macOS (Intel)** | `Voicebox_0.2.0_x64.dmg` |
|
||||
| **Windows** | `Voicebox_0.2.0_x64_en-US.msi` or `x64-setup.exe` |
|
||||
| **Linux** | `.deb` / `.rpm` packages |
|
||||
| **Docker** | `docker compose up` |
|
||||
|
||||
The app includes automatic updates -- future patches will be installed automatically.
|
||||
|
||||
---
|
||||
|
||||
## Video Script Beats
|
||||
|
||||
For the marketing video, focus on these six beats:
|
||||
|
||||
1. **"Four engines, one app"** -- show the engine dropdown switching between Qwen, LuxTTS, Chatterbox, and Turbo
|
||||
2. **"23 languages"** -- generate the same voice clone in Arabic, Japanese, Hindi, etc.
|
||||
3. **"Make it expressive"** -- type `/laugh` and `/sigh` with Chatterbox Turbo, play back the result
|
||||
4. **"Shape your sound"** -- apply the Robotic or Deep Voice preset, preview it live, then build a custom effects chain with drag-and-drop
|
||||
5. **"No limits"** -- paste a long script, show it auto-chunk and generate seamlessly
|
||||
6. **"Queue and go"** -- fire off multiple generations back-to-back without waiting
|
||||
@@ -1,67 +0,0 @@
|
||||
# Voicebox Issue Pain Points (Snapshot)
|
||||
|
||||
## Scope
|
||||
|
||||
- Dataset: **128 total issues** (**107 open**, **21 closed**)
|
||||
- Source: GitHub issues in `jamiepine/voicebox`
|
||||
- Classification: keyword/theme clustering
|
||||
- Note: counts below are **non-exclusive** (one issue can belong to multiple pain points)
|
||||
|
||||
## Most Common Pain Points (Open Issues)
|
||||
|
||||
| Rank | Pain Point | Open Issues | What users are reporting |
|
||||
|---|---|---:|---|
|
||||
| 1 | Model download & offline reliability | **32** | Downloads failing/stalling, cache/offline behavior inconsistent, wrong model size selected, Errno issues |
|
||||
| 2 | GPU/backend compatibility | **22** | GPU not detected, backend fallback surprises, platform-specific runtime failures (Windows/Mac) |
|
||||
| 3 | Export/save/file persistence | **15** | Export fails, "failed to fetch/download audio", samples/profiles not saving |
|
||||
| 4 | Language/accent quality & coverage | **14** | Missing language support, accent mismatch, robotic outputs |
|
||||
| 5 | Update/restart safety + long-op controls | **4** | Auto-restart without warning, update confusion, lack of cancel/pause controls |
|
||||
|
||||
## Representative Issues by Pain Point
|
||||
|
||||
### 1) Model download & offline reliability (32)
|
||||
|
||||
- [#159](https://github.com/jamiepine/voicebox/issues/159) - Qwen download fails with Errno 22
|
||||
- [#151](https://github.com/jamiepine/voicebox/issues/151) - Model loading hangs / server crashes
|
||||
- [#150](https://github.com/jamiepine/voicebox/issues/150) - Internet required despite downloaded models
|
||||
- [#149](https://github.com/jamiepine/voicebox/issues/149) - Cancel/pause controls for large downloads
|
||||
- [#96](https://github.com/jamiepine/voicebox/issues/96) - 0.6B selection still uses/downloads 1.7B
|
||||
|
||||
### 2) GPU/backend compatibility (22)
|
||||
|
||||
- [#164](https://github.com/jamiepine/voicebox/issues/164) - Windows: no GPU usage + multiple breakages
|
||||
- [#141](https://github.com/jamiepine/voicebox/issues/141) - Using CPU only, GPU not used
|
||||
- [#131](https://github.com/jamiepine/voicebox/issues/131) - Numpy ABI mismatch in bundled app
|
||||
- [#130](https://github.com/jamiepine/voicebox/issues/130) - Intel Mac tensor/padding generation error
|
||||
- [#127](https://github.com/jamiepine/voicebox/issues/127) - GPU not found
|
||||
|
||||
### 3) Export/save/file persistence (15)
|
||||
|
||||
- [#148](https://github.com/jamiepine/voicebox/issues/148) - Japanese export fails on 0.1.12
|
||||
- [#143](https://github.com/jamiepine/voicebox/issues/143) - Samples not saving
|
||||
- [#134](https://github.com/jamiepine/voicebox/issues/134) - Can't save profile
|
||||
- [#105](https://github.com/jamiepine/voicebox/issues/105) - Export audio fails (failed to fetch)
|
||||
- [#49](https://github.com/jamiepine/voicebox/issues/49) - Export filename/location ignored on Windows
|
||||
|
||||
### 4) Language/accent quality & coverage (14)
|
||||
|
||||
- [#162](https://github.com/jamiepine/voicebox/issues/162) - Persian audio request/problem
|
||||
- [#117](https://github.com/jamiepine/voicebox/issues/117) - Arabic language support
|
||||
- [#113](https://github.com/jamiepine/voicebox/issues/113) - Polish language support
|
||||
- [#109](https://github.com/jamiepine/voicebox/issues/109) - Ukrainian support
|
||||
- [#100](https://github.com/jamiepine/voicebox/issues/100) - Non-US accent quality issues
|
||||
|
||||
### 5) Update/restart safety + controls (4)
|
||||
|
||||
- [#164](https://github.com/jamiepine/voicebox/issues/164) - Update behavior + usability failures
|
||||
- [#136](https://github.com/jamiepine/voicebox/issues/136) - Auto-restart without warning
|
||||
- [#86](https://github.com/jamiepine/voicebox/issues/86) - Unexpected restart with no confirmation
|
||||
- [#149](https://github.com/jamiepine/voicebox/issues/149) - Need pause/cancel and pre-download confirmation
|
||||
|
||||
## Additional Signal
|
||||
|
||||
- There is also a large **feature-request/misc** bucket (**36 open**) that is competing with stability triage (audiobook, Linux build, additional ASR/TTS models, integrations).
|
||||
|
||||
## Takeaway
|
||||
|
||||
Most user pain is concentrated in four stability areas: **download/offline path**, **GPU/backend detection**, **save/export reliability**, and **language/accent correctness**. Addressing those first should reduce the majority of current support friction.
|
||||
@@ -1,428 +0,0 @@
|
||||
# Voicebox API Refactor Plan
|
||||
|
||||
Date: 2026-03-19
|
||||
Status: Proposed
|
||||
Scope: Backend HTTP API structure, schemas, docs, and compatibility strategy
|
||||
|
||||
## Goals
|
||||
|
||||
- Make the API easier to understand and automate against.
|
||||
- Improve endpoint consistency without breaking the desktop app or existing local integrations.
|
||||
- Align generated docs and checked-in OpenAPI artifacts with the actual backend.
|
||||
- Separate app-facing resources from internal or operational actions.
|
||||
- Create a migration path toward a cleaner `v2` resource model while preserving `v1` routes during transition.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Rewriting backend business logic or generation internals.
|
||||
- Introducing authentication for all deployment modes in the first pass.
|
||||
- Changing storage models or database schema unless required for API correctness.
|
||||
- Removing current routes immediately.
|
||||
|
||||
## Current Pain Points
|
||||
|
||||
- Mixed endpoint styles: resource-oriented (`/profiles`) and command-oriented (`/generate`, `/tasks/clear`) coexist.
|
||||
- Related generation resources are split across multiple namespaces: `/generate`, `/history`, `/audio`, `/effects`, and `/generations/.../versions`.
|
||||
- Response payloads vary widely: typed models, raw dicts with `message`, booleans, and `HTTPException(detail=...)` payloads.
|
||||
- Some async flows use exception-shaped `202` responses instead of first-class task contracts.
|
||||
- Checked-in OpenAPI output can drift from actual backend models.
|
||||
- Operational endpoints such as `/shutdown` are exposed in the same surface as user workflows.
|
||||
|
||||
## Guiding Principles
|
||||
|
||||
1. Prefer additive changes before destructive changes.
|
||||
2. Keep `v1` behavior working until the app and docs fully migrate.
|
||||
3. Add compatibility shims close to the routing layer, not deep in services.
|
||||
4. Treat OpenAPI as a release artifact that must be kept in sync.
|
||||
5. Standardize public contracts before renaming everything.
|
||||
|
||||
## Target API Shape
|
||||
|
||||
This is the intended end state, not the immediate first milestone.
|
||||
|
||||
### Core Resources
|
||||
|
||||
- `/profiles`
|
||||
- `/profiles/{profile_id}/samples`
|
||||
- `/profiles/{profile_id}/avatar`
|
||||
- `/profiles/{profile_id}/effects`
|
||||
- `/generations`
|
||||
- `/generations/{generation_id}`
|
||||
- `/generations/{generation_id}/status`
|
||||
- `/generations/{generation_id}/audio`
|
||||
- `/generations/{generation_id}/versions`
|
||||
- `/generations/{generation_id}/versions/{version_id}`
|
||||
- `/generations/{generation_id}/versions/{version_id}/audio`
|
||||
- `/stories`
|
||||
- `/stories/{story_id}/items`
|
||||
- `/effects/presets`
|
||||
- `/models`
|
||||
- `/models/{model_name}`
|
||||
- `/tasks`
|
||||
|
||||
### Operational or Internal Endpoints
|
||||
|
||||
Move under an explicit namespace and disable where appropriate:
|
||||
|
||||
- `/admin/shutdown`
|
||||
- `/admin/watchdog/disable`
|
||||
- `/admin/cache/clear`
|
||||
- `/admin/tasks/clear`
|
||||
|
||||
### Response Contract Direction
|
||||
|
||||
- Resource reads and writes return typed resource models.
|
||||
- Delete and action endpoints return small typed action result models.
|
||||
- Errors use a consistent structure.
|
||||
- Async actions return explicit task metadata instead of overloading `detail`.
|
||||
|
||||
## Migration Strategy Overview
|
||||
|
||||
The refactor is split into six phases. Phases 1-3 are the highest impact and safest to ship first.
|
||||
|
||||
| Phase | Focus | Est. Duration | Risk | Backward Compatibility |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Documentation and contract correctness | 2-3 days | Low | Full |
|
||||
| 2 | Response and error consistency | 3-5 days | Low-Medium | Full |
|
||||
| 3 | Router structure and internal organization | 3-4 days | Low | Full |
|
||||
| 4 | Additive `v2` resource endpoints | 1-2 weeks | Medium | Full |
|
||||
| 5 | Client migration and deprecation rollout | 1 week | Medium | Full during rollout |
|
||||
| 6 | Cleanup and optional removals | 1-2 releases | Medium-High | Partial after notice |
|
||||
|
||||
## Phase 1: Fix Contract Drift First
|
||||
|
||||
Priority: Highest
|
||||
Outcome: The documented API matches the running backend.
|
||||
|
||||
### Problems Addressed
|
||||
|
||||
- `docs/openapi.json` can become stale.
|
||||
- Generated API reference pages may describe outdated request bodies.
|
||||
- App metadata still frames the backend too narrowly.
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Update FastAPI app metadata in `backend/app.py`.
|
||||
- Replace the old Qwen-specific description with a multi-engine Voicebox API description.
|
||||
- Add tags metadata for major domains if desired.
|
||||
2. Regenerate OpenAPI from the running app using the existing docs script flow.
|
||||
3. Compare `backend/models.py` to the checked-in schema.
|
||||
- Verify `GenerationRequest`, effects endpoints, stories endpoints, and model endpoints.
|
||||
4. Regenerate or refresh API reference pages under `docs/content/docs/api-reference/`.
|
||||
5. Add a CI check that fails if `docs/openapi.json` is out of date.
|
||||
6. Add a short maintainer note describing when schema regeneration is required.
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
- No route changes.
|
||||
- No payload changes.
|
||||
- Safe to release immediately.
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- `docs/openapi.json` matches the live app.
|
||||
- Generated docs include all currently supported generate parameters.
|
||||
- No frontend code changes required.
|
||||
|
||||
## Phase 2: Standardize Responses and Errors
|
||||
|
||||
Priority: High
|
||||
Outcome: Clients can handle responses predictably.
|
||||
|
||||
### Problems Addressed
|
||||
|
||||
- Delete endpoints return ad hoc message dicts.
|
||||
- Toggle endpoints return special one-off payloads.
|
||||
- `202` async responses are encoded as `HTTPException(detail=...)` in some places.
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Add shared response models in `backend/models.py`.
|
||||
- `ActionResult`
|
||||
- `DeleteResult`
|
||||
- `ToggleFavoriteResponse`
|
||||
- `AcceptedTaskResponse`
|
||||
- `ApiError`
|
||||
2. Convert routes that currently return raw dicts to explicit `response_model`s.
|
||||
- `DELETE /profiles/{profile_id}`
|
||||
- `DELETE /history/{generation_id}`
|
||||
- `DELETE /stories/{story_id}`
|
||||
- `POST /tasks/clear`
|
||||
- `POST /cache/clear`
|
||||
- similar endpoints across routes
|
||||
3. Replace exception-shaped `202` responses in `transcription.py` with an explicit accepted response body.
|
||||
- Return `JSONResponse(status_code=202, content=...)` or typed FastAPI response model.
|
||||
4. Add a global exception handler for known API errors if helpful.
|
||||
- Normalize `ValueError` to `400` with a consistent error body.
|
||||
- Preserve FastAPI validation errors for now, or wrap them in a consistent top-level shape in a later pass.
|
||||
5. Document the stable error contract in the docs.
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
- Keep field names inside successful payloads compatible where possible.
|
||||
- For existing dict responses, preserve the current keys while introducing typed models with the same shape.
|
||||
- For `202` flows, support both old and new client handling for one release if needed.
|
||||
|
||||
### Timeline Estimate
|
||||
|
||||
- 3-5 engineering days including tests and docs refresh.
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- All mutation endpoints declare response models.
|
||||
- Clients can programmatically distinguish success, accepted, and error cases without special casing `detail` payloads.
|
||||
|
||||
## Phase 3: Normalize Router Structure Internally
|
||||
|
||||
Priority: High
|
||||
Outcome: The backend becomes easier to maintain before public path changes begin.
|
||||
|
||||
### Problems Addressed
|
||||
|
||||
- Route files hardcode full paths and are all mounted at root.
|
||||
- There is no consistent use of router prefixes or tags.
|
||||
- Route grouping in code does not cleanly express the public API shape.
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Add prefixes and tags to routers.
|
||||
- `profiles`: `prefix="/profiles"`
|
||||
- `generations`: `prefix="/generate"` for now or split additive aliases carefully
|
||||
- `history`: `prefix="/history"`
|
||||
- `effects`: `prefix="/effects"`
|
||||
- and so on
|
||||
2. Convert route declarations to relative paths within each router.
|
||||
3. Introduce a small route compatibility layer for routes that are likely to move later.
|
||||
- Example: helper functions that can be mounted under both old and new paths.
|
||||
4. Add explicit route tags so Swagger/OpenAPI groups are coherent.
|
||||
5. Document the intended public ownership of each namespace.
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
- No public path changes yet if existing paths are preserved through prefixes and aliases.
|
||||
- Mostly internal refactoring.
|
||||
|
||||
### Timeline Estimate
|
||||
|
||||
- 3-4 engineering days.
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- All route modules use prefixes and tags.
|
||||
- Route registration in `backend/routes/__init__.py` becomes simpler.
|
||||
- OpenAPI groups read cleanly by domain.
|
||||
|
||||
## Phase 4: Introduce Additive `v2` Resource Endpoints
|
||||
|
||||
Priority: High
|
||||
Outcome: A cleaner API exists without breaking the current one.
|
||||
|
||||
### Problems Addressed
|
||||
|
||||
- Generation-related resources are fragmented.
|
||||
- Sample and audio endpoints are not consistently modeled as resources.
|
||||
- Command-style naming makes the API harder to reason about.
|
||||
|
||||
### New Endpoints to Add
|
||||
|
||||
These should be introduced alongside current endpoints, not as replacements.
|
||||
|
||||
- `POST /generations` -> alias for current `/generate`
|
||||
- `GET /generations` -> alias for current `/history`
|
||||
- `GET /generations/{id}` -> alias for current `/history/{id}`
|
||||
- `POST /generations/{id}/retry` -> alias for current `/generate/{id}/retry`
|
||||
- `POST /generations/{id}/regenerate` -> alias for current `/generate/{id}/regenerate`
|
||||
- `GET /generations/{id}/status` -> alias for current `/generate/{id}/status`
|
||||
- `POST /generations/stream` -> alias for current `/generate/stream`
|
||||
- `GET /generations/{id}/audio` -> alias for current `/audio/{generation_id}`
|
||||
- `GET /generations/{id}/export` -> alias for current `/history/{generation_id}/export`
|
||||
- `GET /generations/{id}/export-audio` -> alias for current `/history/{generation_id}/export-audio`
|
||||
- `GET /profiles/{profile_id}/samples/{sample_id}` or `GET /samples/{sample_id}` as a consciously chosen model
|
||||
- `PUT /profiles/{profile_id}/samples/{sample_id}` -> alias for current sample update route
|
||||
- `DELETE /profiles/{profile_id}/samples/{sample_id}` -> alias for current sample delete route
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Create new handler entry points that call the existing service functions.
|
||||
2. Keep old handlers in place, but mark them deprecated in OpenAPI.
|
||||
3. Add `summary` and `description` text clarifying preferred routes.
|
||||
4. Update frontend and docs examples to use new endpoints first.
|
||||
5. Add tests proving both old and new paths return equivalent responses.
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
- Old paths remain functional for at least one stable release cycle.
|
||||
- New docs and client examples use `v2-style` resource routes immediately.
|
||||
- Include deprecation headers where feasible, for example:
|
||||
- `Deprecation: true`
|
||||
- `Sunset: <date>`
|
||||
- `Link: <new-doc-url>; rel="successor-version"`
|
||||
|
||||
### Timeline Estimate
|
||||
|
||||
- 1-2 weeks depending on test coverage and frontend updates.
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- All major generation workflows are accessible through resource-oriented routes.
|
||||
- Old routes still work unchanged.
|
||||
|
||||
## Phase 5: Migrate First-Party Clients and Publish Deprecations
|
||||
|
||||
Priority: Medium
|
||||
Outcome: Voicebox itself stops depending on legacy paths.
|
||||
|
||||
### Problems Addressed
|
||||
|
||||
- The desktop app and docs may continue to reinforce old route shapes.
|
||||
- Third-party consumers need a visible migration path.
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Update `app/src/lib/api/client.ts` to use the new preferred endpoints.
|
||||
2. Regenerate or refresh any generated API clients.
|
||||
3. Update docs examples, tutorials, and code snippets to use preferred routes only.
|
||||
4. Add a changelog entry describing the migration path.
|
||||
5. Add runtime deprecation logging for legacy route usage in development mode.
|
||||
6. If feasible, expose a small `/health` or `/meta` field showing API version and deprecation window.
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
- Keep old endpoints available but clearly documented as legacy.
|
||||
- Publish a mapping table from old route to new route.
|
||||
- Do not change request or response payloads during the same phase unless necessary.
|
||||
|
||||
### Timeline Estimate
|
||||
|
||||
- About 1 week including docs and app verification.
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- First-party app no longer depends on legacy route names.
|
||||
- Docs do not advertise deprecated paths as the primary interface.
|
||||
|
||||
## Phase 6: Cleanup, Namespace Hardening, and Optional Breaking Changes
|
||||
|
||||
Priority: Medium
|
||||
Outcome: The API surface is cleaner and safer for remote or Docker use.
|
||||
|
||||
### Problems Addressed
|
||||
|
||||
- Internal/admin endpoints are mixed into the public API.
|
||||
- Legacy aliases increase maintenance cost forever if never retired.
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Move operational endpoints under `/admin` or `/internal`.
|
||||
- `/shutdown`
|
||||
- `/watchdog/disable`
|
||||
- `/tasks/clear`
|
||||
- `/cache/clear`
|
||||
2. Gate these endpoints behind configuration for non-local deployments.
|
||||
- Example: `VOICEBOX_ENABLE_ADMIN_API=true`
|
||||
3. Decide whether to remove or keep legacy aliases.
|
||||
- If removing, do so only after a published deprecation window.
|
||||
4. Remove deprecated docs pages and old examples.
|
||||
5. Tighten route-level tests to prevent accidental reintroduction of legacy patterns.
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
- For desktop-only local use, aliases may remain indefinitely if removal cost outweighs benefit.
|
||||
- For published remote API guidance, hide admin endpoints from default docs even if they still exist.
|
||||
|
||||
### Timeline Estimate
|
||||
|
||||
- 1-2 releases after the additive migration is complete.
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- Public docs expose a coherent resource API.
|
||||
- Operational endpoints are clearly separate or disabled in remote contexts.
|
||||
|
||||
## Cross-Cutting Work Items
|
||||
|
||||
These should happen throughout the migration, not only in a single phase.
|
||||
|
||||
### Testing
|
||||
|
||||
- Add route equivalence tests for old and new paths.
|
||||
- Add schema snapshot tests for OpenAPI generation.
|
||||
- Add response-shape tests for common mutations and async workflows.
|
||||
- Add contract tests for `202 Accepted` flows.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Maintain an old-to-new endpoint mapping table.
|
||||
- Add per-endpoint examples for create profile, generate, apply effects, transcribe, and stories operations.
|
||||
- Explicitly document which endpoints are app-facing vs admin-facing.
|
||||
|
||||
### Observability
|
||||
|
||||
- Add warning logs when deprecated endpoints are used.
|
||||
- Track usage counts in development or optional telemetry-free local logs.
|
||||
|
||||
### Release Management
|
||||
|
||||
- Mention API changes in `CHANGELOG.md`.
|
||||
- Ensure docs and app updates ship in the same release as new preferred routes.
|
||||
|
||||
## Recommended Execution Order
|
||||
|
||||
If engineering time is limited, implement in this exact order:
|
||||
|
||||
1. Fix OpenAPI and docs drift.
|
||||
2. Standardize response models and accepted-task responses.
|
||||
3. Add router prefixes and tags internally.
|
||||
4. Add `/generations` aliases and sample path aliases.
|
||||
5. Migrate the first-party app to preferred routes.
|
||||
6. Deprecate or hide legacy/admin routes.
|
||||
|
||||
## Old-to-New Route Mapping
|
||||
|
||||
| Current Route | Preferred Route |
|
||||
| --- | --- |
|
||||
| `POST /generate` | `POST /generations` |
|
||||
| `POST /generate/stream` | `POST /generations/stream` |
|
||||
| `POST /generate/{id}/retry` | `POST /generations/{id}/retry` |
|
||||
| `POST /generate/{id}/regenerate` | `POST /generations/{id}/regenerate` |
|
||||
| `GET /generate/{id}/status` | `GET /generations/{id}/status` |
|
||||
| `GET /history` | `GET /generations` |
|
||||
| `GET /history/{id}` | `GET /generations/{id}` |
|
||||
| `GET /audio/{id}` | `GET /generations/{id}/audio` |
|
||||
| `GET /history/{id}/export` | `GET /generations/{id}/export` |
|
||||
| `GET /history/{id}/export-audio` | `GET /generations/{id}/export-audio` |
|
||||
| `PUT /profiles/samples/{sample_id}` | `PUT /profiles/{profile_id}/samples/{sample_id}` |
|
||||
| `DELETE /profiles/samples/{sample_id}` | `DELETE /profiles/{profile_id}/samples/{sample_id}` |
|
||||
| `POST /tasks/clear` | `POST /admin/tasks/clear` |
|
||||
| `POST /cache/clear` | `POST /admin/cache/clear` |
|
||||
| `POST /shutdown` | `POST /admin/shutdown` |
|
||||
| `POST /watchdog/disable` | `POST /admin/watchdog/disable` |
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
### Risk: App regressions during endpoint migration
|
||||
|
||||
- Mitigation: Add new routes before changing client usage.
|
||||
- Mitigation: Keep payloads identical while paths change.
|
||||
|
||||
### Risk: Docs still drift after cleanup
|
||||
|
||||
- Mitigation: Add CI enforcement and a release checklist step.
|
||||
|
||||
### Risk: Third-party local scripts break on removal
|
||||
|
||||
- Mitigation: Prefer indefinite aliases for one-person local workflows unless maintenance becomes painful.
|
||||
|
||||
### Risk: Admin endpoints remain dangerous in remote mode
|
||||
|
||||
- Mitigation: Hide and gate them before promoting remote deployment more broadly.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
The refactor can be considered complete when all of the following are true:
|
||||
|
||||
- OpenAPI, checked-in docs, and backend models match.
|
||||
- The preferred public API is resource-oriented and documented consistently.
|
||||
- The Voicebox app uses preferred routes exclusively.
|
||||
- Legacy routes are either deprecated with a timeline or intentionally retained as compatibility aliases.
|
||||
- Operational endpoints are clearly separated from the public app API.
|
||||
@@ -1,173 +0,0 @@
|
||||
# CUDA Libs as a Bolt-On Addon
|
||||
|
||||
## Problem
|
||||
|
||||
Every time we bump `__version__` (even for a UI tweak or bugfix), the exact-match version check in both `main.rs:222` and `cuda.py:237` invalidates the user's ~2.4GB CUDA binary, forcing a full redownload. The CUDA binary is the entire server rebuilt with NVIDIA libs included -- there's no separation between app logic and the CUDA runtime.
|
||||
|
||||
## Why This Is Hard With `--onefile`
|
||||
|
||||
The core tension is PyInstaller `--onefile` mode (`build_binary.py:39`). In onefile mode, everything -- Python code, all dependencies, torch, the NVIDIA `.dll`/`.so` files -- gets packed into a single self-extracting archive. There's no concept of "swap out one part." The binary IS the server.
|
||||
|
||||
## Options
|
||||
|
||||
### Option A: Switch to `--onedir` for the CUDA Build (Recommended)
|
||||
|
||||
Instead of `--onefile`, build the CUDA variant as a directory (a folder with the exe + all the shared libs alongside it). Then split the distribution into two archives:
|
||||
|
||||
1. **`voicebox-server-cuda` executable + non-NVIDIA deps** (~200-400MB) -- versioned with the app, redownloaded on every app update.
|
||||
2. **`cuda-libs-cu126.tar.gz`** (~2GB) -- the `nvidia.*` packages (cublas, cudnn, cuda_runtime, etc.), versioned independently (e.g., `cuda-libs-cu126-v1`). Only redownloaded when we bump the CUDA toolkit version or torch's CUDA dependency changes.
|
||||
|
||||
#### How it would work at runtime
|
||||
|
||||
- Tauri downloads the server binary archive and extracts it to `{data_dir}/backends/cuda/`
|
||||
- On first CUDA setup (or when cuda-libs version bumps), downloads and extracts the libs archive into the same directory
|
||||
- The CUDA server exe finds the `.dll`/`.so` files next to it (standard PyInstaller onedir behavior)
|
||||
- Version check becomes two checks: server version + cuda-libs version
|
||||
|
||||
#### Independent versioning
|
||||
|
||||
Add a `cuda-libs.json` manifest:
|
||||
|
||||
```json
|
||||
{"version": "cu126-v1", "torch_compat": ">=2.6.0,<2.8.0"}
|
||||
```
|
||||
|
||||
The server checks this on startup. The Tauri side checks it before launching. Only bump `cu126-v1` -> `cu126-v2` when we actually change the CUDA toolkit or torch major version.
|
||||
|
||||
#### Build pipeline changes
|
||||
|
||||
The CI `build-cuda-windows` job would build with `--onedir`, then separate the output into two archives. The CUDA libs archive could be built less frequently (only when torch/CUDA version changes) and stored as a pinned release asset.
|
||||
|
||||
#### Download experience
|
||||
|
||||
- First-time CUDA setup: ~2.4GB total (same as today)
|
||||
- Subsequent app updates: ~200-400MB for the server, CUDA libs stay cached
|
||||
- CUDA toolkit bump: ~2GB for just the libs
|
||||
|
||||
#### Pros
|
||||
|
||||
- PyInstaller `--onedir` natively produces this structure -- NVIDIA DLLs end up as discrete files in the output directory
|
||||
- The separation is natural: PyInstaller puts torch's NVIDIA deps in predictable paths (`nvidia/cublas/lib/`, etc.)
|
||||
- CUDA libs are highly stable -- only rebundle when changing CUDA toolkit version (e.g., cu126 -> cu128) or major torch version
|
||||
- Server updates become ~200-400MB instead of ~2.4GB
|
||||
- No library path hacking needed -- torch finds NVIDIA DLLs because they're in the same directory tree
|
||||
|
||||
#### Cons
|
||||
|
||||
- Onedir means a folder with hundreds of files instead of a single exe -- more complex to manage, extract, and clean up
|
||||
- Need to modify download/assembly logic in `cuda.py` to handle two separate archives
|
||||
- The Tauri side (`main.rs`) needs to point at an exe inside a directory rather than a standalone binary
|
||||
- Users who manually manage the file may find the folder structure confusing
|
||||
|
||||
#### TTS engine compatibility
|
||||
|
||||
No issues. The TTS engines are pure Python + torch. They don't care whether NVIDIA libs are inside the binary or sitting next to it -- torch's dynamic loader finds them either way.
|
||||
|
||||
---
|
||||
|
||||
### Option B: Keep `--onefile` but Externalize CUDA Libs via Library Path
|
||||
|
||||
Keep the server as a single `--onefile` binary (with NVIDIA packages excluded, same as the CPU build). Ship the CUDA libs as a separate download that gets extracted to `{data_dir}/backends/cuda-libs/`. Before launching, set the library search path to include that directory.
|
||||
|
||||
**Important caveat:** The CPU torch wheel (`whl/cpu`) doesn't have CUDA kernels compiled in -- it's a fundamentally different build. So the binary would need to be built with CUDA-compiled torch but with the NVIDIA runtime libraries excluded. The runtime libs (cublas, cudnn, etc.) would be provided externally.
|
||||
|
||||
#### How it would work
|
||||
|
||||
- Build ONE "CUDA-ready" server binary with CUDA-compiled torch but NVIDIA runtime packages excluded
|
||||
- Ship `cuda-libs-cu126-v1.tar.gz` separately (~2GB of `.dll`/`.so` files)
|
||||
- When launching, Tauri sets `PATH` (Windows) or `LD_LIBRARY_PATH` (Linux) to include the cuda-libs directory
|
||||
|
||||
#### Pros
|
||||
|
||||
- Single server binary for both CPU and CUDA users -- simplifies build pipeline enormously
|
||||
- True bolt-on CUDA libs with fully independent versioning
|
||||
- Server updates are always small (~150MB for the onefile binary)
|
||||
|
||||
#### Cons
|
||||
|
||||
- **Fragile on Windows.** PyInstaller `--onefile` extracts to a temp directory at runtime and the internal torch may not find externally-placed NVIDIA libs. DLL resolution on Windows is notoriously unreliable in this scenario.
|
||||
- `os.add_dll_directory()` only affects `LoadLibraryEx` with `LOAD_LIBRARY_SEARCH_USER_DIRS` flag -- not all DLL loads go through this path
|
||||
- PyInstaller's onefile bootloader may configure DLL search paths before Python code runs
|
||||
- Could work on Linux but is fragile on Windows
|
||||
|
||||
---
|
||||
|
||||
### Option C: Hybrid -- `--onefile` Server + Dynamic CUDA Lib Loading at Runtime
|
||||
|
||||
Build the server as `--onefile` with CUDA-compiled torch but with NVIDIA packages excluded. At startup, before torch initializes CUDA, explicitly load the NVIDIA shared libraries using `ctypes.CDLL` or `os.add_dll_directory()`.
|
||||
|
||||
In `server.py`, before any torch imports:
|
||||
|
||||
```python
|
||||
cuda_libs_dir = os.environ.get("VOICEBOX_CUDA_LIBS")
|
||||
if cuda_libs_dir and os.path.isdir(cuda_libs_dir):
|
||||
if sys.platform == "win32":
|
||||
os.add_dll_directory(cuda_libs_dir)
|
||||
os.environ["PATH"] = cuda_libs_dir + os.pathsep + os.environ.get("PATH", "")
|
||||
else:
|
||||
os.environ["LD_LIBRARY_PATH"] = cuda_libs_dir + ":" + os.environ.get("LD_LIBRARY_PATH", "")
|
||||
```
|
||||
|
||||
#### Pros
|
||||
|
||||
- Single server binary, true bolt-on CUDA libs
|
||||
- Clean separation of concerns
|
||||
- Independent versioning
|
||||
|
||||
#### Cons
|
||||
|
||||
- Needs careful testing with each torch version -- CUDA initialization happens deep in C++ extension layer
|
||||
- On Windows, `os.add_dll_directory()` may not cover all DLL load paths
|
||||
- PyInstaller's onefile bootloader may have already configured DLL search paths before Python code runs
|
||||
- Most complex to get right and maintain
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Option A (`--onedir` with split archives)** is the most reliable path:
|
||||
|
||||
1. **It actually works.** `--onedir` puts all files on disk as regular files. Torch finds NVIDIA DLLs because they're in the same directory tree, exactly as they would be in a normal pip install.
|
||||
2. **Natural separation.** PyInstaller's `--onedir` output already separates the NVIDIA `.dll`/`.so` files into `nvidia/` subdirectories. We can split the output directory into "core" and "nvidia-libs" archives after building.
|
||||
3. **Independent versioning is straightforward.** A `cuda-libs.json` manifest controls when redownloads are needed.
|
||||
4. **Build pipeline simplification.** Build CUDA libs archive less frequently, store as a pinned release asset.
|
||||
|
||||
The main cost is managing a directory instead of a single file, but we already have sophisticated download/assembly infrastructure in `cuda.py` with manifests and split parts. Extending that to handle two archives is incremental work.
|
||||
|
||||
## Tauri Compatibility (Validated)
|
||||
|
||||
Tauri handles PyInstaller `--onedir` with no issues. The key insight is that we're **not** using a static sidecar for CUDA -- we're downloading and extracting at runtime (the existing `cuda.py` + `main.rs` flow). For runtime-launched processes, Tauri's `tauri::shell::Command` supports arbitrary directories natively.
|
||||
|
||||
### The critical change in `main.rs`
|
||||
|
||||
The only Tauri-side change needed is adding `.current_dir()` when spawning the CUDA backend:
|
||||
|
||||
```rust
|
||||
let cuda_dir = data_dir.join("backends/cuda");
|
||||
let exe_path = cuda_dir.join("voicebox-server-cuda.exe");
|
||||
|
||||
let mut cmd = app.shell().command(exe_path.to_str().unwrap());
|
||||
cmd = cmd.current_dir(&cuda_dir); // PyInstaller finds all DLLs relative to exe
|
||||
cmd = cmd.args(["--data-dir", &data_dir_str, "--port", &port_str, "--parent-pid", &parent_pid_str]);
|
||||
```
|
||||
|
||||
`.current_dir()` tells the PyInstaller bootloader that everything (DLLs, `nvidia/cublas/lib/`, `_internal/`, torch extensions, etc.) lives relative to the exe. Torch finds the NVIDIA libs exactly as it does in a normal `pip install` or dev environment -- no `LD_LIBRARY_PATH` hacks, no `os.add_dll_directory` gymnastics.
|
||||
|
||||
### Community evidence
|
||||
|
||||
- Multiple Tauri users run this exact pattern: Nuitka folders (exe + pythonXX.dll + supporting files), multi-file .NET apps, and PyInstaller onedir backends (GitHub issues #5719, discussion #5206).
|
||||
- The shell plugin explicitly supports `cwd` in both Rust and JS APIs.
|
||||
- No reports of torch/CUDA-specific breakage -- the onedir layout is identical to what PyInstaller produces in normal usage.
|
||||
|
||||
### Known gotcha: process termination on Windows
|
||||
|
||||
PyInstaller onedir creates a parent bootloader + child Python process on Windows. `child.kill()` only hits the outer process in some cases (Tauri issue #11686). Mitigation: keep a reference to the parent PID or use `taskkill /F /T` for clean shutdown. This is not a blocker -- our existing `--parent-pid` watchdog mechanism in `server.py` already handles orphan cleanup.
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Prototype: Build the current CUDA binary with `--onedir` and verify torch CUDA works from the output directory
|
||||
2. Measure the size split: how much is NVIDIA libs vs everything else
|
||||
3. Design the two-archive download flow and dual version checking
|
||||
4. Update `cuda.py` for dual-archive extraction (server core + cuda-libs)
|
||||
5. Update `main.rs`: change launch path to `backends/cuda/` dir + add `.current_dir()`
|
||||
6. Add `ensure_cuda_structure()` helper in Rust to verify exe + nvidia/ subdirs exist before spawning
|
||||
7. Update CI pipeline: `build-cuda-windows` produces two archives instead of split parts
|
||||
8. ~~Update `split_binary.py` or replace with archive-based distribution~~ Done: replaced with `package_cuda.py`
|
||||
@@ -295,6 +295,15 @@ fix-python: _ensure-venv
|
||||
test: _ensure-venv
|
||||
{{ venv_bin }}/python -m pytest {{ backend_dir }}/tests -v
|
||||
|
||||
# E2E: generate with every TTS model against the frozen binary (pass extra flags like --only kokoro)
|
||||
[unix]
|
||||
test-models *ARGS: _ensure-venv
|
||||
{{ venv_bin }}/python {{ backend_dir }}/tests/test_all_models_e2e.py {{ ARGS }}
|
||||
|
||||
[windows]
|
||||
test-models *ARGS: _ensure-venv
|
||||
& "{{ python }}" {{ backend_dir }}/tests/test_all_models_e2e.py {{ ARGS }}
|
||||
|
||||
# ─── Database ─────────────────────────────────────────────────────────
|
||||
|
||||
# Initialize SQLite database
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "@voicebox/landing",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.3",
|
||||
"description": "Landing page for voicebox.sh",
|
||||
"scripts": {
|
||||
"dev": "bun --bun next dev --turbo",
|
||||
"dev": "next dev --turbo",
|
||||
"build": "bun --bun next build",
|
||||
"start": "bun --bun next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/space-grotesk": "^5.2.10",
|
||||
"@icons-pack/react-simple-icons": "^13.13.0",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"autoprefixer": "^10.4.17",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user