Compare commits

..
Author SHA1 Message Date
James PineandClaude Opus 4.7 4ffbc03d15 fix(audio): raise trim threshold, cap pad at net-neutral
Review feedback on the preprocessor:

1. ``trim_top_db=30`` was labelled "conservative" in the docstring but is
   actually *more* aggressive than librosa's default of 60. Normal
   speech dynamic range sits around 30 dB, so 30 dB would eat quiet
   trailing syllables and soft consonants. Raise the default to 40 dB —
   below normal speech dynamic range but still catching obvious edge
   silence — and fix the docstring.

2. Unconditional 100 ms edge padding ran even when ``librosa.effects.trim``
   removed nothing. For a well-recorded 29.9 s upload that path would
   push the waveform past the 30 s ceiling and trigger a spurious "too
   long" rejection. Only pad when trimming actually shortened the
   audio, and cap the pad so the output never exceeds the input length.

Adds a regression test for the net-neutral length behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-19 18:25:52 -07:00
James PineandClaude Opus 4.7 a49cc6afbb fix(audio): preprocess reference samples instead of rejecting them
Uploaded/recorded voice samples were rejected outright whenever the peak
exceeded 0.99 ("Audio is clipping (reduce input gain)"). That wasn't
actionable: a recording in the app has no pre-gain control, and an
already-captured file can't be re-taken by the user. The Settings
"Normalize audio" toggle only affects generated TTS output, so users who
enabled it expecting it to help with sample uploads were still blocked.

Replace the hard reject with a small, always-on preprocess step that
runs right after load:
  - DC-offset removal
  - Conservative edge-silence trim (top_db=30) with 100 ms padding kept
  - Peak cap at 0.95 if the input peak exceeds that

Duration and RMS checks now run on the preprocessed waveform, so
samples that were previously rejected for being "hot" are accepted and
stored with safe headroom. True in-waveform clipping artifacts still
can't be repaired — peak scaling only prevents downstream re-clipping
during multi-sample combination and TTS inference.

Adds a unit-test file (previously none existed for audio.py).

Fixes #456.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-19 16:41:10 -07:00
Shekhar KumarandGitHub e3f7cd9d00 fix(landing): use public origin for download redirects behind proxies (#498)
Prefer x-forwarded host/proto for redirect URL construction so users are not sent to internal localhost origins.

Fixes #496
2026-04-19 15:57:58 -07:00
27a5a62581 fix(landing): API example + new /download page (no more dumping users on GitHub) (#487)
* fix(landing): use qwen_custom_voice in API example (instruct is CustomVoice-only)

The curl snippet showed engine: "qwen" alongside an instruct field, but base
Qwen3-TTS has no instruct path — that's a Qwen CustomVoice feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(landing): use a realistic UUID for profile_id in API example

Profile IDs are str(uuid.uuid4()), not slugs (see backend/services/profiles.py:175).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* feat(landing): add polished /download page — no more dumping users on GitHub

Users were clicking download, landing on the GitHub releases page, and filing
confused comments along the lines of "I ended up on some blog site called
GitHub." We now route every download CTA through a dedicated /download page
that auto-triggers the platform-specific download and gives users a polished
post-click experience with donate + docs + AI help prompts.

- New /download page:
  - Big app logo + "Your download has started" messaging.
  - Auto-detects platform from ?platform=X or navigator.userAgent.
  - Programmatically clicks a hidden anchor to trigger the file download
    without leaving the page.
  - Platform-specific buttons as a visible fallback for "download not
    working" / manual-pick.
  - Personal donate spiel + Buy Me a Coffee button.
  - Resources grid: docs, DeepWiki ("got questions? ask AI"), GitHub.
- Landing page download section cards now link to /download?platform=X
  instead of the asset URL directly.
- /download/[platform] (used by README/docs links) now redirects to the
  /download page rather than straight to the asset or to GitHub on error.
- Drops unused downloadLinks state from the landing page.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(landing): use official platform brand icons via simple-icons

The hand-rolled Linux SVG path wasn't actually Tux — it was a symmetric
placeholder shape. Apple/Windows were close but not canonical either.

- Apple + Linux: pulled from @icons-pack/react-simple-icons (SiApple, SiLinux).
- Windows: simple-icons drops the Microsoft mark over trademark policy, so
  the Windows 11 flag is inlined from Microsoft's public brand guidance.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(landing): route Download CTAs to /download page, not the section anchor

Hero CTA, navbar link, and footer link were all scrolling to #download
(the section at the bottom of the page) instead of going to the new
/download page that triggers the actual download.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* chore(landing): run dev server on Node instead of Bun runtime

Bun runtime + Next 16 Turbopack dev server intermittently trips a
JavaScriptCore allocator panic ('pas panic: deallocation did fail ...
Alloc bit not set') after a few requests. Dropping --bun keeps Bun as
the package manager but runs next dev on Node, which is stable.

Build + start keep --bun since one-shot invocations don't exhibit the
allocator drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(landing): route Linux users to /linux-install instead of attempting download

No prebuilt Linux binary exists yet (see /linux-install for build-from-source
instructions). The /download page previously treated Linux like the other
platforms — auto-triggering a non-existent AppImage and offering a dead
manual button.

- /download page: if platform resolves to 'linux' via ?platform or UA detect,
  window.location.replace('/linux-install') — never try to auto-download.
- Manual Linux card: label changed to "Build from source" and links to
  /linux-install (no download attribute, no asset URL).
- /download/linux pretty URL: 307s straight to /linux-install.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* docs: consolidate troubleshooting into the MDX docs site + status updates

- Delete docs/TROUBLESHOOTING.md; the canonical troubleshooting guide now
  lives under docs/content/docs/overview/troubleshooting.mdx so it's served
  from docs.voicebox.sh alongside the rest of the docs.
- CONTRIBUTING.md + README.md: repoint "Troubleshooting" references to the
  new MDX path. README gets a top-level callout so users hit the guide
  before filing an issue.
- PROJECT_STATUS.md: refresh issue/PR counts, document the flash-attn
  warning (cosmetic on all platforms; CUDA-only, fallback is PyTorch SDPA
  which is near-FA2 on Ampere+) with per-platform context + community
  Windows wheels + SageAttention/xformers alternatives, add WebAudio
  audio-session bug note (tracked separately in PR #486), and expand the
  Qwen 0.6B→1.7B MLX fallback explanation for triage.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(landing): address PR #487 review feedback

- Preserve canonical camelCase platform aliases (macArm, macIntel) in the
  /download/[platform] redirect so those URLs don't lose their platform param.
- Add accessible title + role="img" to the inline Windows SVG so it passes
  Biome's a11y rule and announces to screen readers.
- On /api/releases fetch failure, show an explicit error state with a single
  intentional link to GitHub releases — no more silent GitHub fallback or
  disabled-button UX lie. Keeps normies off GitHub unless they opt in.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-18 23:32:32 -07:00
d3a44338a2 fix(audio): prevent WKWebView audio session teardown after backgrounding (#41) (#486)
Keep a silent looping <audio> element mounted at the app root so macOS
never tears down the CoreAudio session. Without this, backgrounding the
app long enough leaves WaveSurfer's AudioContext in a state where play()
resolves and timeupdate fires, but no audio reaches the output — and not
even cmd+R (full JS reload) restores it, only a full app relaunch.

Uses a zero-PCM WAV blob at full volume rather than a muted element,
since WebKit can optimize muted media away and defeat the purpose.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-18 22:43:59 -07:00
James Pine 28aa963b09 readme update 2026-04-18 21:19:51 -07:00
ae91aa9a88 docs: audit mdx docs against multi-engine backend (#484)
* docs: audit mdx docs against multi-engine backend and refresh stale content

Rewrote developer-facing docs that predated the TTSBackend Protocol /
ModelConfig registry refactor (architecture, tts-generation,
model-management, transcription). Updated user-facing docs to reflect all
seven shipped engines (Qwen, Qwen CustomVoice, LuxTTS, Chatterbox,
Chatterbox Turbo, TADA, Kokoro) instead of the outdated "5 engines" claim.

Also fixes:
- Stale app identifier (com.voicebox.app → sh.voicebox.app)
- CUDA backend update flow (now two-archive split, not N-way chunks)
- Whisper model list (removed tiny, added turbo)
- Broken /development/ and /guides/ route links
- Stale just commands and install steps (missing --no-deps chatterbox/tada)
- Removed ASCII art diagrams from README and stories.mdx
- History Generation schema sync with DB model

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* docs: add DeepWiki badge to README

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* docs: address PR review feedback

- architecture.mdx: fix backends/ file list (remove nonexistent qwen_backend.py, rename tada_backend.py → hume_backend.py)
- model-management.mdx: Kokoro language count 9 → 8 (matches ModelConfig)
- model-management.mdx: ProgressManager path services/ → utils/
- tts-generation.mdx: ModelConfig example uses field(default_factory=...) — mutable default would raise at runtime
- tts-generation.mdx: "1080p samples" → "on CUDA" (1080p is video, not audio)
- PROJECT_STATUS.md: replace ASCII architecture diagram with prose (matches no-ASCII-art rule)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(app): guard against undefined engine in FloatingGenerateBox preset check

form.getValues('engine') returns string | undefined; Set<string>.has()
rejects undefined under strict mode. Added a truthy guard before the
preset lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-18 21:06:06 -07:00
da6070155e landing: three more tutorials, mobile navbar + hero CTA fixes (#483)
- Add three tutorial cards (Danish Sofi, StinkyScrublet, mikbes)
- Navbar: switch parent to flex/justify-between on mobile (grid on sm+),
  unhide Donate button so both CTAs sit on the right, matching desktop
- Hero CTAs: keep Download and GitHub side-by-side on mobile instead of
  stacking vertically

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-18 19:21:46 -07:00
3c1e8512b9 fix(build): install mlx-audio/mlx-lm with --no-deps to bypass transformers 5.x conflict (#482)
The previous fix (#481) capped transformers at 4.57.6 in requirements-mlx.txt,
but pip's clean resolver in CI can't satisfy that alongside mlx-audio>=0.3.1
(declares `transformers==5.0.0rc3` or `>=5.0.0`) — it backtracks through every
transformers and tokenizers version and exits with `ResolutionImpossible`.

The dev install worked only because mlx-audio 0.4.1 was already present, so
pip never tried to re-resolve.

mlx-audio 0.4.1 + mlx-lm 0.31.1 both declare transformers>=5.x but the API
surface we actually use works fine on 4.57.x in practice (verified across all
engines in dev). Install both --no-deps to bypass the resolver; transitive
runtime deps (huggingface_hub, librosa, numpy, numba, pyloudnorm, etc.) are
already pulled in by requirements.txt.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-18 17:43:31 -07:00
bf58750447 fix(build): pin transformers in MLX requirements to prevent 5.x upgrade (#481)
mlx-audio depends on `transformers` with no upper bound. Installing
requirements-mlx.txt after requirements.txt lets pip upgrade transformers
past the 4.57.x cap to 5.x, which breaks three engines in the frozen MLX
bundle:

- qwen-custom-voice: `check_model_inputs` was rewritten to take `func` as
  positional, so `@check_model_inputs()` factory calls fail with
  `TypeError: missing 1 required positional argument: 'func'`
- tada-1b: `PretrainedConfig.__init_subclass__` now applies `@dataclass`,
  which rejects tada's `strides: list = []` mutable default
- luxtts: Whisper init hits `AssertionError` in `torch._refs.normal_`

Restating the same constraint here keeps mlx-audio's transformers
dependency from quietly winning the resolver.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-18 17:05:36 -07:00
Jamie PineandGitHub 2d56309bdd Change 'About' link text to 'Models' 2026-04-18 16:46:23 -07:00
James Pine 0445be295c tests and better website 2026-04-18 16:19:12 -07:00
James Pine 8d550a5f7c Bump version: 0.4.0 → 0.4.1 2026-04-18 15:15:58 -07:00
Esteban FraccasciaandGitHub 795bd54381 fix(linux): use pactl to detect PipeWire/PulseAudio monitor for system audio capture (#457)
cpal 0.15 uses ALSA as its Linux backend, which does not expose
PulseAudio/PipeWire monitor sources. The previous approach searched
for 'monitor' in cpal device names, which never matched on most
Linux systems, silently falling back to the microphone input.

This fix:
- Detects the correct monitor source via 'pactl get-default-sink'
  and 'pactl list short sources'
- Sets PULSE_SOURCE env var before cpal initialization so PulseAudio's
  ALSA plugin routes the default input through the monitor
- Preserves the original name-based search as fallback when pactl is
  unavailable
- No new dependencies added

Tested on PipeWire 1.0.5 with Realtek ALC897 (HD-Audio Generic).
2026-04-18 03:15:05 -07:00
a6ab5f3858 Add initial frontend quality gates and TS hardening (#418)
Co-authored-by: Erion De Andrade <[email protected]>
2026-04-18 03:14:43 -07:00
9d7e4a417e fix(api-client): declare moved + errors on migrateModels response type (#470)
ModelManagement.tsx reads migrationResult.moved (added in #433) but
apiClient.migrateModels() was typed as returning only { source, destination }.
The backend actually returns { moved: int, errors: list[str], source, destination }
(backend/routes/models.py:140, 168). Widen the TS return type so the check
typechecks under the new CI gate from #418.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-18 03:14:08 -07:00
882cabc7d2 fix: warn user when no models to migrate during storage change (#433)
When user attempts to change model storage location with no models
downloaded, the migration API returns moved=0 early. Previously the UI
would still call setCustomModelsDir() and restart the server, causing
unexpected behavior (hang/connection lost).

This change checks migrationResult.moved === 0 and shows a helpful
toast message instead of proceeding with the storage change.

Fixes: #426

Co-authored-by: fuleinist <[email protected]>
2026-04-18 03:13:06 -07:00
9c76b5de2c docs: clarify paralinguistic tag support in quick start (#450)
Co-authored-by: txhno <[email protected]>
2026-04-18 03:12:46 -07:00
Cocoon-BreakandGitHub 4560b7378a fix: delete version rows and files in delete_generations_by_profile (Closes #446) (#447)
Signed-off-by: Cocoon-Break <[email protected]>
2026-04-18 03:12:39 -07:00
高巨龙andGitHub abd9943430 Fix migration dialog hanging when no models are present (#439)
When migrating model path with an empty cache, backend returned early without emitting migration completion SSE, causing frontend overlay to hang. This patch emits complete status for empty migrations.
2026-04-18 03:12:32 -07:00
c8cb12f1bc fix(build): repair frozen-binary imports for kokoro, chatterbox-multilingual, scipy, transformers (#438)
* fix(build): bundle kokoro source files for transformers runtime introspection

transformers opens .py source files at runtime to check attention/MoE
implementation via regex (e.g. _can_set_attn_implementation). PyInstaller's
--hidden-import only bundles .pyc bytecode, so kokoro/modules.py was missing
from the bundle causing a FileNotFoundError on Kokoro model load.

Switch from individual --hidden-import entries to --collect-all kokoro in both
build_binary.py and voicebox-server.spec. The kokoro package is 172K so no
meaningful bundle size impact.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(build): use SPECPATH for runtime hook instead of hardcoded absolute path

The linter expanded runtime_hooks=[] to an absolute /Users/... path which
would break CI and other dev machines. Use os.path.join(SPECPATH, ...) to
mirror the relative approach in build_binary.py.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(build): runtime hook to work around PyInstaller + Python 3.12 import breakages

Four distinct bundling-specific crashes blocked Kokoro and Qwen CustomVoice
from loading in the frozen binary:

1. torch._dynamo import triggered via class-body decorators
   (@torch._dynamo.allow_in_graph on PreTrainedModel,
   @torch.compiler.disable in flex_attention) pulls in torch._numpy._ufuncs
   which crashes on module load with NameError: name 'name' is not defined.

2. AlbertModel (Kokoro) triggers @auto_docstring -> modeling_auto ->
   GenerationMixin -> candidate_generator -> sklearn -> scipy, which hits
   the same class of bug in scipy.stats._distn_infrastructure (NameError:
   name 'obj' is not defined).

3. AutoModel (Qwen) pulls the same sklearn -> scipy chain directly.

4. librosa (required by most TTS engines) -> scipy.signal -> scipy.stats
   hits the _distn_infrastructure crash regardless of the transformers
   stubs above.

The root cause of (1) and (4) is that PyInstaller's frozen importer runs
module-level `for X in [<list-comp using dir()>]:` loops with an empty
iterable, leaving the loop variable unbound. Trailing `del obj` / unrelated
references then crash.

Fix: a single runtime hook (pyi_rth_torch_compiler_disable.py) installs:

- sys.modules stubs for torch._dynamo and torch._dynamo.config, plus a
  meta-path finder for torch._dynamo.* submodules — voicebox never uses
  torch.compile/dynamo for inference, so a permissive no-op stub (callable
  as decorator, falsey as predicate, context-manager-safe for
  TransformGetItemToIndex) is drop-in safe.
- meta-path finder stubs for transformers.utils.auto_docstring and
  transformers.generation.candidate_generator — both import-chain
  short-circuits; docstrings and speculative decoding aren't used for TTS.
- meta-path finder for scipy.stats._distn_infrastructure that reads the
  real .py source via the wrapped loader's get_source(), replaces the
  bundling-broken `del obj` with `globals().pop('obj', None)`, and
  compile+exec's the patched source. This keeps the real scipy module
  intact so librosa and everything downstream works normally.

Supporting changes:

- backend/pyi_hooks/hook-scipy.stats._distn_infrastructure.py sets
  module_collection_mode = "pyz+py" so the .py source is actually in the
  bundle for the runtime patcher to read.
- build_binary.py and voicebox-server.spec register the runtime hook and
  the new hooks dir.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(build): force transformers torch<2.6 mask path and bundle spacy_pkuseg

- patch transformers.masking_utils to set _is_torch_greater_or_equal_than_2_6
  = False, forcing sdpa_mask_older_torch and avoiding the vmap .item() crash
  that breaks Qwen CustomVoice generation (our torch._dynamo stub can't
  reproduce TransformGetItemToIndex's graph transform).
- add PyInstaller hook to bundle transformers.masking_utils .py source so the
  runtime finder can source-patch it.
- --collect-all spacy_pkuseg so Chatterbox Multilingual can load its Chinese
  segmenter (dicts/default.pkl + native .so extensions).
- add per-finder install diagnostics + _HOOK_VERSION marker to make future
  bundle-only regressions easier to triage.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(build): pass PyInstaller hook paths relative so .spec is portable

Absolute paths ended up in the auto-regenerated voicebox-server.spec
because build_binary.py prefixed every --runtime-hook and
--additional-hooks-dir with str(backend_dir / ...). That broke builds
on any machine whose checkout wasn't at /Users/jamie/... and anyone
invoking pyinstaller voicebox-server.spec directly.

os.chdir(backend_dir) already runs before PyInstaller (same reason
server.py works as a bare filename), so the backend_dir prefix is
unnecessary. Drop it so the generated spec references pyi_hooks/,
pyi_rth_numpy_compat.py, pyi_rth_torch_compiler_disable.py as repo-
relative paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-18 03:12:13 -07:00
Andrew BarnesandGitHub 54a3bf322e fix: add generation cancellation flow (#444) 2026-04-18 02:51:39 -07:00
476abe07fc fix(paths): strip legacy "data/" prefix when resolving stored paths (#440)
0.3.0 sometimes stored relative media paths with the data-dir name baked in
(e.g. "data/profiles/<uuid>/sample.wav"). resolve_storage_path joined those
directly with _data_dir, producing "<data_dir>/data/profiles/..." — a
spurious double nest that breaks file reads after upgrading to 0.4.0.

The 0.4.0 startup migration didn't catch it because resolve_storage_path
produced the buggy double-nested path, to_storage_path saw "data" at the
first (legitimate) index, and the normalized value matched the stored value
so the row was skipped.

Strip any leading "data/" component before joining. This unblocks runtime
reads and lets _normalize_storage_paths rewrite the affected rows on next
startup — no manual migration needed.

Fixes "No such file or directory: '<data_dir>/data/profiles/...'" and
associated 404s on GET /audio/<id> after upgrading from 0.3.0 to 0.4.0.

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-17 17:53:11 -07:00
James PineandClaude Opus 4.6 67bf8e906a docs/landing: update for 0.4.0 — new engines, GPU docs, donate button, voice docs restructure
Docs:
- Add gpu-acceleration.mdx (all 9 platform/GPU combos, CUDA backend swap, Blackwell, XPU, troubleshooting)
- Add preset-voices.mdx (Kokoro 50 voices, Qwen CustomVoice 9 voices, instruct mode docs)
- Restructure voice-cloning.mdx to cover all 5 cloning engines with comparison table
- Restructure creating-voice-profiles.mdx around cloned vs preset workflows
- Update voice-profiles.mdx schema with voice_type discriminator, preset/design columns

Landing:
- Add 3 new engine cards (Qwen CustomVoice, HumeAI TADA, Kokoro) to Multi-Engine section
- Add Donate button (Buy Me a Coffee) to navbar and footer
- Add DONATE_URL constant

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 19:51:43 -07:00
James Pine 625e1ba549 Bump version: 0.3.1 → 0.4.0 2026-04-16 03:16:06 -07:00
James PineandClaude Opus 4.6 cfe6770639 style: apply biome format to drifted authored source
Catches format drift that accumulated across 13 authored files in
app/src and docs/. Auto-generated artifacts (tauri/src-tauri/gen,
docs/openapi.json, docs/cli.json, app/src/lib/api) were left alone
since the build regenerates them on each run — baking their formatted
state into git just causes churn next build.

No behavioral changes. Trailing commas, line wrapping, and indentation
only.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 03:13:45 -07:00
James PineandClaude Opus 4.6 00452b51a8 style(changelog): make entry version headings bigger in settings UI
The in-app changelog viewer rendered each entry's version number at the
same size as body text (text-sm font-medium), so visually there was no
clear anchor for where one release's notes ended and the next began.

Bump the version heading to text-xl font-semibold tracking-tight and
widen the bottom margin so each release reads as a proper section
header.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 03:12:09 -07:00
James PineandClaude Opus 4.6 106aec46a8 feat(generate): restore instruct toggle in floating generate box (Qwen CustomVoice)
Before 0.4 every engine was a cloning model, so the instruct UI in the
floating generate box applied the same way everywhere. Commit 3187344
hid the instruct toggle because the mix of new engines landing in 0.4
made it unclear which ones honored the kwarg. With Qwen CustomVoice
now shipping as the only engine actually tuned for instruct-style
control, bring the button back — conditionally, and only for that
engine.

Changes:
  • FloatingGenerateBox: SlidersHorizontal toggle button appears left
    of Generate when the box is expanded AND engine is
    qwen_custom_voice. Clicking it reveals an additive instruct
    textarea below the main text field (not a modal swap like the old
    version). State persists across engine switches so the toggle
    remembers its last position.
  • GenerationForm: narrow the instruct FormField's conditional from
    `qwen || qwen_custom_voice` to just `qwen_custom_voice`.
  • useGenerationForm: narrow supportsInstruct for the same reason.
    Base Qwen3-TTS accepts the kwarg but the model itself doesn't honor
    it — only CustomVoice was trained for instruction-based style
    control.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 03:09:55 -07:00
James PineandClaude Opus 4.6 c9e5c5d9a7 fix: clean up scroll effect timers and fix disabled+selected card toggle
- Add cleanup for requestAnimationFrame and setTimeout in scroll effect
  to prevent stale DOM writes on unmount or rapid selection changes
- Fix disabled+selected card click: bounce the selection to re-trigger
  the engine auto-switch instead of deselecting

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 02:56:45 -07:00
James PineandClaude Opus 4.6 48cd1f369a feat: gray out unsupported profiles instead of filtering, auto-switch engine on selection
- Show all voice profiles with unsupported ones grayed out (opacity) instead of hidden
- Clicking a grayed-out profile selects it and auto-switches the engine to a compatible one
- Sort supported profiles first, with info tip about compatibility at the bottom
- Scroll to selected profile after engine/sort changes with safe margin
- Fix engine desync on tab navigation by initializing form engine from store

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 02:56:45 -07:00
James PineandClaude Opus 4.6 2bfe400457 feat(skills): add triage-prs skill for pre-release PR speedruns
Immortalizes the workflow used to clear the open-PR backlog before
0.4.0: classify every open PR into merge / candidate / supersede /
defer tiers, write a working triage doc, then run the merge loop —
rebasing where needed, merging in batches, applying post-merge
follow-ups, and closing superseded PRs with credit.

Captures the gotchas that matter most:
  • Never review a stale branch via `git diff main..HEAD` — it shows
    every intermediate main commit as a deletion and makes a 3-line
    PR look like a 700-line revert
  • Always rebase before squash-merging; GitHub's squash computes
    diff(PR-head, merge-base), so a stale branch will revert
    in-between work
  • Route-ordering, weak-framework linking, dependency floors, !Send
    audio types, and why PyTorch nightly isn't shippable

Paired with draft-release-notes and release-bump: triage → draft →
bump.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 02:22:57 -07:00
0aa19a9994 feat(history): add "Clear failed" button to wipe failed generations (#412)
When the model wasn't loaded, the app was closed mid-run, or a
generation otherwise errored out, the resulting "Failed" rows
accumulate in history and there was no way to remove them in bulk —
individual delete was the only option.

Adds a header row above the history list (only rendered when at
least one failed generation is present) with a "Clear failed" button
that opens a confirmation dialog, then calls a new
DELETE /history/failed endpoint which sweeps all status='failed'
rows (plus their version files / audio files on disk).

Closes jamiepine/voicebox#410

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-04-16 02:12:30 -07:00
73170d0e92 feat(health): warn when GPU arch isn't supported by PyTorch build
Applies the compatibility-checker portion of #367. Adds a
check_cuda_compatibility() helper that compares the current device's
compute capability against torch.cuda._get_arch_list() and returns a
human-readable warning if the PyTorch build doesn't support it.

Wired into three places:
  • HealthResponse gains a gpu_compatibility_warning field so clients
    can surface the issue in the UI
  • Startup logs the warning as WARN level
  • _get_gpu_status() appends "[UNSUPPORTED - see logs]" to the GPU
    label shown in settings

Skipped #367's other half — the switch from stable to nightly cu128
wheels across release.yml, build_binary.py, and justfile. That's
redundant with #401's TORCH_CUDA_ARCH_LIST=...12.0+PTX approach and
would introduce non-deterministic builds from shifting nightly
releases.

Co-Authored-By: nyzxor <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 02:11:23 -07:00
James PineandClaude Opus 4.6 0317626677 fix(qwen): unify HF cache dir to avoid split cache on Windows
Applies the cache_dir portion of #218. On Windows local setups, model
assets can split between .hf-cache/hub and .hf-cache/transformers when
Qwen3TTSModel.from_pretrained doesn't explicitly pin the cache root —
speech_tokenizer and preprocessor_config.json then fail to resolve
during load, causing 500s at generation time.

Routes both HF Hub and Transformers through hf_constants.HF_HUB_CACHE.

Skipped the torch_dtype= → dtype= rename from #218: transformers 4.36
(our minimum) doesn't accept the dtype alias, only 4.46+. Once we bump
the minimum we can make that change.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 02:08:59 -07:00
a5d5c780c2 fix: avoid ScreenCaptureKit launch crash on macOS 11 (#424)
Co-authored-by: txhno <[email protected]>
2026-04-16 01:58:29 -07:00
James PineandClaude Opus 4.6 7184a25e44 fix(watchdog): clear stale .keep-running sentinel on startup
Follow-up to #402. The sentinel is only removed inside the grace-period
"sentinel found" branch. When the HTTP /watchdog/disable request wins
the race (normal case on macOS/Linux, occasional on Windows), the
_watchdog_disabled=True check returns first and the sentinel is left on
disk indefinitely.

If a later session spawns a fresh server and the user exits without
"keep running", the new watchdog would find that stale sentinel during
its grace period and keep the server alive against user intent.

Wipe any pre-existing sentinel when the watchdog starts so only signals
written during this session's lifetime can influence grace-period
decisions.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 01:57:05 -07:00
479bc7fc5e fix: reliably keep server alive after GUI close on Windows (#402)
The HTTP /watchdog/disable request races with process exit on Windows,
causing the watchdog to kill the server before the request arrives.

Added a .keep-running sentinel file as a reliable fallback:
- Tauri writes the file to data_dir before sending the HTTP request
- The watchdog checks for it during the grace period after detecting
  parent death
- The file is removed after being read to avoid stale state

This approach works regardless of HTTP timing because file writes
complete synchronously before the Tauri process exits.

Fixes #372

Co-authored-by: Matt Van Horn <[email protected]>
2026-04-16 01:56:30 -07:00
Cocoon-BreakandGitHub 3e7727d1d2 fix: keep cpal Stream alive until playback completes (Closes #404) (#405)
The cpal Stream was created and play() called but then immediately
dropped when play_to_device() returned. When a cpal Stream is dropped,
audio output stops immediately. This caused silent playback.

Fix: add a spin-wait loop that holds the Stream in scope until all
samples have been consumed (or stop_flag is set).
2026-04-16 01:54:31 -07:00
JunghwanandGitHub be7c0cec12 fix: add asyncio.Lock to prevent concurrent CUDA downloads (#428)
* fix: add asyncio.Lock to prevent concurrent CUDA downloads

The startup auto-update task and the manual download endpoint can both
invoke download_cuda_binary() concurrently. Without mutual exclusion,
both coroutines write to the same temp file path, corrupting the
download. The progress-manager status check is a TOCTOU race because
the status is not set until after several synchronous checks complete.

Add a module-level asyncio.Lock acquired at the top of
download_cuda_binary() so only one download can proceed at a time.

* fix: fast-reject duplicate CUDA download when lock is held

Address CodeRabbit review feedback: check _download_lock.locked()
before awaiting the lock so concurrent callers return immediately
instead of queueing behind the first download. This prevents the
route handler from returning "started" to multiple callers when only
one download actually proceeds.
2026-04-16 01:51:21 -07:00
c9d8142a78 feat: add Blackwell GPU (sm_120) CUDA support (#401)
Set TORCH_CUDA_ARCH_LIST in the CUDA build step to include 12.0+PTX
for forward compatibility with Blackwell GPUs (RTX 5070 Ti, 5080, etc).

Pre-built PyTorch cu128 wheels only ship native kernels for sm_80/86/89/90.
Without this, Blackwell GPU users get "no kernel image is available for
execution on the device" at runtime.

Fixes #386
Related: #395, #396, #399, #400

Co-authored-by: Matt Van Horn <[email protected]>
2026-04-16 01:51:18 -07:00
13ba5f1aa6 fix: prevent intermittent clip splitting failures (#403)
Two changes to address the race condition causing "Failed to split clip":

Backend (stories.py): Added with_for_update() to the item query in
split_story_item so concurrent requests for the same clip are
serialized via a row lock instead of racing.

Frontend (StoryTrackEditor.tsx): Guard handleSplit with
splitItem.isPending to prevent rapid double-clicks from firing
multiple mutations before the first completes.

Fixes #366

Co-authored-by: Matt Van Horn <[email protected]>
2026-04-16 01:51:15 -07:00
9a3c307c75 fix(history): populate status/error/engine fields from DB row (#394)
* fix(history): populate status/error/engine/model_size/is_favorited from DB

GET /history/{generation_id} was constructing HistoryResponse without
passing status, error, engine, model_size, or is_favorited from the
DB row. Since HistoryResponse.status defaults to "completed" in the
Pydantic model (models.py:141), this endpoint returned
status="completed" for every generation regardless of the actual DB
state — including jobs still in "loading_model" or "generating", and
even "failed" jobs.

This breaks any client polling /history/{id} for job completion:
the API lies about the status, so the only trustworthy success
signal becomes `audio_path` being non-empty. All other fields left
at their model defaults were similarly masked.

Fix: pass all fields through from the DB row, matching the pattern
used elsewhere in the codebase. The DB model (Generation in
database/models.py) already has all these columns.

* fix(history): apply NULL fallbacks to match list endpoint

Align the defensive mappings with services/history.py:206-223 so
both the single-item and list history endpoints handle legacy rows
with NULL status/engine/is_favorited identically. Without this,
HistoryResponse's non-Optional str/bool fields would raise a
pydantic ValidationError (500) on any row where these columns are
NULL — possible from direct SQL updates or past migrations.

Addresses review feedback on PR #394.

---------

Co-authored-by: malletfils <[email protected]>
2026-04-16 01:51:12 -07:00
JunghwanandGitHub 1da16cfc57 fix: harden voice prompt cache loading and SPA path guard (#429)
Two small safety improvements:

1. Voice prompt cache (cache.py): add weights_only=True to torch.load()
   so cached .prompt files are loaded using the safe unpickler instead of
   the unrestricted pickle deserializer. This follows the PyTorch 2.6+
   best practice of opting in to safe loading for all torch.load() calls.

2. SPA catch-all (app.py): replace str.startswith() path guard with
   Path.is_relative_to(). The string prefix check passes for sibling
   paths like /app/frontend_evil/ that share the /app/frontend prefix.
   is_relative_to() correctly tests directory containment.
2026-04-16 01:49:19 -07:00
Luis SambranoandGitHub a1807be04d fix(deps): relax torch requirement for macOS x86_64 compatibility (#416) 2026-04-16 01:49:16 -07:00
Khaled SolimanandGitHub fdba18e9ee fix: resolve ModuleNotFoundError by using relative import for utils (#384) 2026-04-16 01:49:13 -07:00
MaxandGitHub 07a845cece Add NUMBA_CACHE_DIR environment variable (#425) 2026-04-16 01:49:10 -07:00
James PineandClaude Opus 4.6 615d604ceb fix(numpy-compat): raise on unknown dtype + add fp16/complex
Follow-up to #361. The original fallback silently mapped unknown numpy
dtypes to torch.float32, which would reinterpret the memcpy'd bytes in
the wrong dtype and corrupt data (e.g. fp16 tensors from some TTS
engines) rather than erroring loudly.

- Hoist dtype_map out of the inner function so it's built once
- Add float16, complex64, complex128 mappings
- Raise TypeError on unknown dtype instead of silent float32 fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-16 01:47:22 -07:00
a383ff6863 fix: torch.from_numpy crash with numpy 2.x in frozen binary (#361)
torch is compiled against numpy 1.x. numpy 2.x changed the ABI version
returned by PyArray_GetNDArrayCVersion() (0x01000009 → 0x02000000), so
torch's is_numpy_available() always returns False and torch.from_numpy()
raises RuntimeError. This causes TTS generation to fail with:

  ValueError: Unable to create tensor, you should probably activate
  padding with 'padding=True'

Two fixes:

1. Pin numpy<2.0 in requirements.txt so new builds bundle a compatible
   numpy version. (The existing comment already flagged this intention
   but the upper bound was never added.)

2. Add a PyInstaller runtime hook (pyi_rth_numpy_compat.py) that installs
   a ctypes memmove fallback for torch.from_numpy() at startup. Runtime
   hooks run after FrozenImporter is registered so frozen torch is
   importable. The fallback catches RuntimeError from the C-level ABI
   check and copies the numpy array into a new tensor via raw memory copy,
   bypassing the check entirely. This is a belt-and-suspenders fix that
   works regardless of the bundled numpy version.

Co-authored-by: aimaaaimaa <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-04-16 01:46:40 -07:00
Jamie PineandGitHub 75abbb02c3 Merge pull request #344 from pandego/fix/308-docker-compose-startup
fix: include changelog in docker web build
2026-03-26 23:06:47 -07:00
Jamie PineandGitHub b49f14a814 Merge pull request #319 from jamiepine/fix/startup-and-server-switch
fix: GUI startup with external server + data refresh on server switch
2026-03-26 23:06:29 -07:00
Jamie PineandGitHub 05686efbfd Merge pull request #345 from ArfianID/fix/backend-import-error
Fix: "Failed to Save" preset error by resolving backend import path resolution
2026-03-22 09:45:25 -07:00
Arfian e2c03fef9a Fix: move lazy imports to top-level and use absolute paths to resolve ModuleNotFoundError in production 2026-03-22 22:29:25 +07:00
pandego 4347eaed4c fix: include changelog in docker web build 2026-03-22 09:32:32 +01:00
James Pine 60aac279ce fix: address PR #319 review feedback — health validation, error handling, queryClient decoupling
- Rust: Replace fragile body.contains("status") with proper JSON
  deserialization validating status=="healthy", model_loaded (bool),
  and gpu_available (bool) to prevent misidentifying non-Voicebox services

- Frontend: Validate health response has Voicebox-specific fields before
  marking server as ready during fallback polling

- Frontend: Discriminate port-in-use errors (poll for external server) from
  real startup failures (missing sidecar, signing issues) — surface errors
  immediately with a startupError state and Retry button in the UI

- Frontend: Set explicit startup-error state when 2-minute polling timeout
  expires so the loading screen shows actionable feedback instead of hanging

- Architecture: Extract QueryClient to standalone side-effect-free module
  (lib/queryClient.ts) to decouple serverStore from React bootstrap entrypoint
2026-03-21 10:24:15 -07:00
James Pine 8b1c7552be Merge remote-tracking branch 'origin/fix/startup-and-server-switch' into pr-319 2026-03-21 10:18:45 -07:00
Jamie PineandGitHub 9a955a77d2 Merge pull request #320 from jamiepine/feat/intel-xpu-support
feat: Intel Arc (XPU) GPU support
2026-03-21 08:39:51 -07:00
Jamie PineandGitHub c18591c0c3 Merge pull request #318 from jamiepine/fix/offline-model-loading
fix: force offline mode when loading cached models (Qwen TTS & Whisper)
2026-03-21 08:38:08 -07:00
Jamie PineandGitHub ea3469f2dc Merge pull request #332 from nicoschtein/patch-1
Fix links in Get Started section of index.mdx
2026-03-21 08:37:00 -07:00
James Pine b108bb1cb1 fix: store media paths relative to data dir 2026-03-20 15:06:07 -07:00
Nicolas SchteinschraberandGitHub 8b796bc6b4 Fix links in Get Started section of index.mdx
Updated links in the Get Started section for correct paths.
2026-03-20 13:58:53 -03:00
James Pine e6f419cd70 fix: show all engines in floating generator 2026-03-19 19:52:08 -07:00
James Pine 72c13fd3fc fix: enforce preset profile engine compatibility 2026-03-19 19:51:53 -07:00
James Pine 4e0c731db8 feat: add Qwen CustomVoice preset engine 2026-03-19 19:48:50 -07:00
James Pine d70b878b71 fix: tighten kokoro profile handling 2026-03-19 19:32:49 -07:00
Jamie PineandGitHub a71011741d Merge pull request #325 from jamiepine/feat/kokoro-engine
feat: Kokoro 82M TTS engine + voice profile type system
2026-03-19 19:21:15 -07:00
James Pine d6f48ace3e Mirror the regular /generate endpoint behavior more closely 2026-03-19 16:11:38 -07:00
James Pine 0fc2192204 fix: resolve relative paths using configured data dir, not CWD 2026-03-19 10:37:11 -07:00
James Pine 9e726ad048 fix: remove engine dropdown filtering — profile grid handles it 2026-03-19 10:14:33 -07:00
James Pine 3584283d84 feat: Kokoro 82M TTS engine + voice profile type system
Add Kokoro-82M as a new TTS engine — 82M params, CPU realtime, 8 languages,
Apache 2.0. Unlike cloning engines, Kokoro uses pre-built voice styles, which
required a new profile type system to support non-cloning engines cleanly.

Kokoro engine:
- New kokoro_backend.py implementing TTSBackend protocol
- 50 built-in voices across en/es/fr/hi/it/pt/ja/zh
- KPipeline API with language-aware G2P routing via misaki
- PyInstaller bundling for misaki, language_tags, espeakng_loader, en_core_web_sm

Voice profile type system:
- New voice_type column: 'cloned' | 'preset' | 'designed' (future)
- Preset profiles store engine + voice ID instead of audio samples
- default_engine field on profiles — auto-selects engine on profile pick
- Create Voice dialog: toggle between 'Clone from audio' and 'Built-in voice'
- Edit dialog shows preset voice info instead of sample list for preset profiles
- Engine selector locks to preset engine when preset profile is selected
- Profile grid filters by engine — shows Kokoro voices when Kokoro selected
- Custom empty state when no preset profiles exist for selected engine

Bug fixes:
- Fix relative audio paths in DB causing 404s in production builds
- config.set_data_dir() now resolves to absolute paths
- Startup migration converts existing relative paths to absolute

Also updates PROJECT_STATUS.md and tts-engines.mdx developer guide.
2026-03-19 10:09:48 -07:00
Jamie PineandGitHub e4def9365f Merge pull request #321 from liorshahverdi/fix/delete-failed-generations-292
fix/allows deletion of failed generations 292
2026-03-19 09:36:55 -07:00
James Pine 707046237c fix: complete Intel XPU support — device-aware seeding, GPU status reporting, and setup detection
Address CodeRabbit review feedback and user-reported GPU acceleration failure:

- Use shared manual_seed() in chatterbox, chatterbox_turbo, and luxtts
  backends so XPU (and future accelerators) get proper device seeding
- Add XPU branch to _get_gpu_status() so startup log reports Intel Arc
  GPUs instead of 'None (CPU only)'
- Add XPU VRAM reporting and correct backend_variant fallback in the
  /health endpoint
- Switch justfile GPU detection from Get-WmiObject to Get-CimInstance,
  simplify the Arc regex to match 'Arc' (not 'Intel.*Arc'), log
  detected GPUs, and print manual install instructions on miss

Resolves the root cause where IPEX was silently not installed due to
WMI detection failure, causing CPU-only fallback on Intel Arc systems.
2026-03-18 17:01:12 -07:00
Lior Shahverdi 12ed2d51ce Adds a trash icon button alongside the existing retry button for
failed generations, giving users a way to clean up failed entries
  without having to retry them first.
2026-03-18 15:44:37 -04:00
James Pine 83ebababe7 feat: add Intel Arc (XPU) GPU support across all backends
Auto-detect Intel Arc GPUs during Windows setup and install PyTorch
with XPU support + intel-extension-for-pytorch. Enable allow_xpu=True
on all TTS backends (Chatterbox, Chatterbox Turbo, Hume TADA, LuxTTS)
that previously only supported CUDA. Add shared empty_device_cache()
and manual_seed() helpers in base.py to handle XPU memory management
and reproducible seeding alongside CUDA.
2026-03-18 11:24:51 -07:00
James Pine eb5869e59f fix: GUI startup with external server + data refresh on server switch
Two fixes for issue #312:

1. GUI stuck on loading screen when backend is already running externally
   (e.g. via python/uvicorn/Docker):

   - Rust: add HTTP health check fallback when the process on the port
     doesn't have 'voicebox' in its name. If /health responds with a
     valid Voicebox response, reuse the server instead of erroring.
   - Frontend: when startServer() fails, fall back to polling the
     health endpoint every 2s instead of permanently blocking.

2. No data refresh when switching server URLs in settings:

   - serverStore.setServerUrl() now invalidates all React Query caches
     when the URL actually changes, so profiles/history/models/stories
     are re-fetched from the new server.
   - Export queryClient from main.tsx for store-level cache invalidation.

Fixes #312
2026-03-18 10:59:59 -07:00
164 changed files with 8883 additions and 3773 deletions
+299
View File
@@ -0,0 +1,299 @@
---
name: triage-prs
description: Use this skill to triage the open PR queue before a release. Classifies every open PR into must-merge, candidate, superseded, or deferred; writes a working triage doc; and runs the merge loop end-to-end. Designed for the pre-release "PR speedrun" pass where a solo maintainer wants to clear the inbound backlog in a single session.
---
# Triage PRs
## Goal
Turn a backlog of open PRs into a shipped set of merges in a single focused session. Produce a tracked, resumable plan (`<VERSION>_PR_TRIAGE.md`), then work it — rebasing where needed, merging in isolation-safe batches, applying post-merge follow-ups, and closing superseded or partially-applicable PRs with credit to their authors.
This skill pairs with `draft-release-notes` and `release-bump`: triage first, then draft notes against the new main, then cut the release.
## When to use
- Before a minor or major release when 10+ open PRs have accumulated
- When you want to unblock merging without losing the narrative of what's landing
- When you know you can't personally review every PR deeply, but need to land the critical subset fast
## Prerequisites
- `gh` CLI authenticated against the repo
- A dedicated worktree for PR review (avoid contaminating `main` with checkouts of contributor branches)
- Clarity on the target version — the triage doc is named after it (e.g. `0.4.0_PR_TRIAGE.md`)
## Workflow
### 1. Set up an isolated PR-review worktree
```bash
git worktree list # check for stale ones first
git worktree prune
git worktree add ../voicebox-pr-review -b pr-review-<VERSION> main
```
Keep the main worktree for release-prep work (changelog drafts, direct-to-main follow-ups). Keep the review worktree for `gh pr checkout` — each checkout moves HEAD to a contributor branch, which you don't want to do in the main worktree.
### 2. Gather metadata for every open PR
```bash
gh pr list --state open --limit 50 --json \
number,title,author,isDraft,mergeable,mergeStateStatus,files,additions,deletions,reviewDecision,statusCheckRollup,maintainerCanModify \
--jq '.[] | {num: .number, title, author: .author.login, mergeable, state: .mergeStateStatus, canModify: .maintainerCanModify, changes: "+\(.additions)/-\(.deletions)", files: [.files[].path]}'
```
You want, for each PR:
- Size (`+additions/-deletions`)
- Mergeable state (`CLEAN`, `UNSTABLE`, `DIRTY` = conflicts, `UNKNOWN` = GitHub still computing)
- Whether maintainer edits are allowed on the branch (needed later if you rebase for the author)
- File paths touched (helps spot overlaps between PRs)
`UNKNOWN` is common right after a push to main — just try the merge and see.
### 3. Classify into tiers
Sort each PR into exactly one bucket:
**Tier 1 — Merge:** small, mergeable, fixes a real bug, clean CI, low review cost. One-liners, dependency relaxations, targeted safety hardening. These are the easy wins.
**Tier 2 — Candidate, review:** medium size (50-200 lines), touches more surface area, looks sound but needs a closer read. New user-facing features that fit the product direction.
**Supersede:** the fix or feature is already covered by something merged. Close with a comment pointing to the superseding PR. Check carefully — "similar title" isn't proof; compare the actual diffs.
**Defer to next release:** big features, dirty conflicts, draft PRs, anything touching the release pipeline in ways that would introduce risk. Don't merge these in a speedrun — they need dedicated focus.
### 4. Write the triage doc
Create `<VERSION>_PR_TRIAGE.md` in the PR-review worktree root. Structure:
```markdown
# <Repo> <VERSION> — PR Triage
Working doc for tracking which open PRs land in <VERSION>. Delete after release cut.
Last updated: <DATE>
## Progress
**Tier 1: 0 / N merged**
**Tier 2: 0 / M handled**
**Supersede triage: pending**
---
## Merge for <VERSION> — critical bug fixes
| PR | Status | Size | What it fixes | Why must-have |
|---|---|---|---|---|
| [#123](url) | [ ] | +5/-0 | ... | ... |
## Strong candidate — needs a quick review
| PR | Status | Size | Summary |
|---|---|---|---|
## Close as superseded
| PR | Status | Reason |
|---|---|---|
## Defer to <NEXT_VERSION>
- [#xxx](url) ... — reason
---
## Order of attack
1. Close superseded PRs (one-liner comments)
2. Merge tier-1 in dependency-free batches — check file paths don't overlap
3. Review tier-2 individually
4. Rerun `draft-release-notes` to pick up everything
5. Run `release-bump`
```
The **Progress** header is the most important part — it's your scoreboard and lets you resume cleanly if the session gets interrupted.
### 5. Work the loop — per PR
For each PR in the tier-1 / tier-2 list:
**a. Checkout in the review worktree:**
```bash
cd ../voicebox-pr-review
git checkout pr-review-<VERSION> # reset to neutral base
gh pr checkout <N>
```
**b. Read the *actual* commit, not `main..HEAD`:**
```bash
git show HEAD # the PR's actual changes
git show --stat HEAD # files touched + line counts
```
**Do NOT review via `git diff main..HEAD`** if the PR branch is older than main. That diff includes *every commit that landed on main after the PR was forked* as `-` (deletion) lines. A 3-line PR can look like a 700-line revert. This is the single easiest way to misjudge a PR.
**c. Evaluate concerns:** correctness, scope, interaction with already-merged work, version compatibility (e.g. can't use an API that requires a dependency version we don't yet pin).
**d. Rebase if the branch is behind main:**
```bash
git fetch origin main
git rebase origin/main
```
This is **essential** before squash-merging. GitHub's squash computes `diff(PR-head, merge-base)` — on a stale branch, that diff includes reverting every in-between commit. Rebasing moves the merge-base forward so the squash is clean.
**e. If maintainer edits are allowed, push the rebase back to the contributor's fork:**
```bash
git remote add <author> https://github.com/<author>/<repo>.git
git fetch <author> <branch> # get their ref first
git push <author> HEAD:<branch> --force-with-lease
```
This keeps GitHub's PR UI in sync with the rebased state and makes the merge clean from the GitHub side.
**f. Merge:**
```bash
gh pr merge <N> --squash
```
**g. Update the triage doc** — flip the checkbox to `✅ merged <sha>` (use the short SHA from `gh pr view <N> --json mergeCommit --jq '.mergeCommit.oid[0:7]'`). Update the Progress header.
### 6. Batch tiny fixes
PRs with ≤5 line changes, clean CI, non-overlapping file paths, and obviously-correct intent (e.g. one-line dependency relax, env var add, import path fix) can be merged in a single loop without the review-per-PR ceremony:
```bash
for pr in 425 384 416 429; do
echo "=== Merging PR $pr ==="
gh pr merge $pr --squash
done
```
Verify afterward that each landed cleanly:
```bash
for pr in 425 384 416 429; do
gh pr view $pr --json state,mergeCommit --jq "{pr: $pr, state, sha: .mergeCommit.oid[0:7]}"
done
```
### 7. Post-merge follow-ups
Sometimes a PR is worth merging despite a known minor issue (e.g. incomplete dtype map, stale sentinel cleanup). Don't block the merge; apply the follow-up as a normal branch + PR right after:
```bash
cd <main-worktree>
git pull --ff-only origin main
git checkout -b fix/<short-name>
# edit...
git commit -m "fix(<area>): <one-liner>"
git push -u origin fix/<short-name>
gh pr create --title "..." --body "Follow-up to #<N>. ..."
```
Record both SHAs in the triage doc (`✅ merged <pr-sha> + follow-up <pr>`).
**Direct-to-main exception:** only under an explicit, scoped policy (e.g. "release speedrun"). Don't default to it.
### 8. Supersede: close with a credit-pointing comment
```bash
gh pr close <N> --comment "Closing — superseded by merged #<M> which landed <brief description>. Thanks!"
```
Check the diffs first — "similar title" is not enough. If the PR is *partially* superseded (the diagnosis is right but only half the changes are still needed), do a partial-apply instead.
### 9. Partial-apply pattern
When a PR has both valuable and questionable changes bundled:
```bash
cd <main-worktree>
git pull --ff-only origin main
# Cherry-pick specific files from the PR branch
git checkout <pr-commit-sha> -- <file1> <file2>
# Review the staged changes, adjust as needed
git diff --cached
# Apply any surgical edits to files you don't want to bulk-replace
# (e.g. the PR's file predates a recent main commit you need to preserve)
# Commit with a trailer crediting the original author
git commit -m "$(cat <<'EOF'
<subject>
<body explaining what was kept vs dropped>
Co-Authored-By: <author> <[email protected]>
EOF
)"
git push ... # branch + PR, unless under the direct-to-main exception
```
Then close the PR with a comment explaining what was applied and what was dropped, referencing the commit SHA.
### 10. Keep the doc current
Every merge, every close, every follow-up → update `<VERSION>_PR_TRIAGE.md`. The doc is your session log. If you're interrupted and resume tomorrow, the doc is the only source of truth for "where am I."
### 11. When triage is done
- Every PR in the doc has a terminal status (✅ merged / ✅ closed / deferred)
- Progress header shows N/N for each tier
- Next skill to run is `draft-release-notes` (to regenerate `[Unreleased]` against the new main), then `release-bump`
You can delete the triage doc after the release ships, or keep it in version history as a record.
## Gotchas
- **`main..HEAD` on a stale branch lies.** It shows everything main gained since the branch split as deletions. Always review via `git show HEAD` for the PR's actual commit.
- **Squash-merging an unrebased branch reverts in-between work.** The squash computes `diff(PR-head, merge-base)`. Rebase moves the merge-base forward.
- **`mergeable=UNKNOWN`** is transient — GitHub is recomputing after a push. Just try the merge.
- **Route ordering matters (FastAPI and similar):** `DELETE /history/failed` must be registered *before* `DELETE /history/{id}`, or the parameterized path will consume `"failed"` as an ID.
- **Apple's `-weak_framework` overrides `-framework`** for the same framework, regardless of order — use it via `cargo:rustc-link-arg=-Wl,-weak_framework,Name` when a dependency hard-links something optional.
- **Dependency version floors constrain what you can apply.** Before accepting a kwarg rename like `torch_dtype=` → `dtype=`, check the min-version pin supports it. Sometimes the right move is to cherry-pick half the PR.
- **`cpal::Stream` and similar `!Send` audio types** can't cross `await` points or `spawn_blocking`. Sometimes a "not-ideal but correct" sync wait is the best available fix; flag but don't block.
- **PyTorch nightly builds are not shippable for releases** — non-deterministic, can regress between runs. If a PR suggests switching to nightly to fix a GPU issue, prefer `TORCH_CUDA_ARCH_LIST=...+PTX` or wait for stable support instead.
## Canonical commands reference
```bash
# Bulk PR metadata
gh pr list --state open --limit 50 --json number,title,author,mergeable,mergeStateStatus,additions,deletions,maintainerCanModify,files
# Detailed single-PR view
gh pr view <N> --json body,author,headRefName,baseRefName,mergeable,maintainerCanModify,files,statusCheckRollup
# The actual commit, not the branch-vs-main diff
git show HEAD
git show --stat HEAD
gh pr diff <N>
# Rebase contributor branch onto current main
git fetch origin main && git rebase origin/main
# Push rebase back to contributor fork (maintainerCanModify=true required)
git remote add <author> https://github.com/<author>/<repo>.git
git fetch <author> <branch>
git push <author> HEAD:<branch> --force-with-lease
# Merge
gh pr merge <N> --squash
# Confirm merge SHA for triage doc
gh pr view <N> --json state,mergeCommit --jq '{state, sha: .mergeCommit.oid[0:7]}'
# Close superseded
gh pr close <N> --comment "Closing — superseded by merged #<M>. Thanks!"
```
## Notes
- **Never review a stale branch via `main..HEAD`.** This is the single most important line in this skill.
- **The triage doc is the session state.** Lose the doc, lose the session. Update it after every action.
- **Credit contributors even on partial-applies.** Use `Co-Authored-By:` trailers and close comments that link to the applied commit.
- **Don't let perfect be the enemy of shipped.** A fix that goes from "broken" to "works with a minor known issue" is a strict improvement. Flag the issue, file a follow-up, merge the fix.
+1 -1
View File
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.3.1
current_version = 0.4.1
commit = True
tag = True
tag_name = v{new_version}
-1
View File
@@ -38,7 +38,6 @@ biome.json
.bumpversion.cfg
.npmrc
Makefile
CHANGELOG.md
CONTRIBUTING.md
SECURITY.md
LICENSE
+26
View File
@@ -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
+15
View File
@@ -68,6 +68,15 @@ 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;
# the rest (sounddevice, miniaudio, 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'
@@ -203,6 +212,12 @@ jobs:
- name: Build CUDA server binary (onedir)
shell: bash
working-directory: backend
env:
# Include Blackwell (sm_120) via PTX forward compatibility.
# Pre-built PyTorch cu128 wheels ship native kernels for sm_80/86/89/90
# but not sm_120. Setting this env var causes torch.utils.cpp_extension
# (and any JIT-compiled kernels) to target Blackwell GPUs as well.
TORCH_CUDA_ARCH_LIST: "8.0;8.6;8.9;9.0;12.0+PTX"
run: python build_binary.py --cuda
- name: Package into server core + CUDA libs archives
+5
View File
@@ -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
+155 -1
View File
@@ -7,6 +7,157 @@
## [Unreleased]
## [0.4.1] - 2026-04-18
A fast follow-up to 0.4.0 focused on making the new engines actually load in the production binary — plus generation cancellation, Linux system-audio capture, and the repo's first PR-time type check. Five first-time contributors shipped in this release.
0.4.0 introduced three new TTS engines, but the frozen PyInstaller binary tripped over several Python-ecosystem quirks that don't show up in the dev venv: `transformers` opening `.py` sources at runtime, `scipy.stats._distn_infrastructure` hitting a frozen-importer `NameError`, and `chatterbox-multilingual` failing to find its Chinese segmenter dictionary. This release patches all of those in one sweep.
### Frozen-Binary Reliability ([#438](https://github.com/jamiepine/voicebox/pull/438))
- **Kokoro** now bundles `.py` sources alongside `.pyc` via `--collect-all kokoro` so `transformers`' `_can_set_attn_implementation` regex scan can read them — previously `FileNotFoundError: kokoro/modules.py` killed Kokoro loading in production builds
- **Chatterbox Multilingual** now bundles `spacy_pkuseg/dicts/default.pkl` and the package's native `.so` extensions via `--collect-all spacy_pkuseg` — previously the Chinese word segmenter crashed with `FileNotFoundError` on first load
- **scipy.stats._distn_infrastructure** — new runtime hook source-patches the trailing `del obj` (which raises `NameError` under PyInstaller's frozen importer because the preceding list comprehension evaluates empty) to `globals().pop('obj', None)`, unblocking `librosa` → `scipy.signal` → `scipy.stats` for every TTS engine that depends on librosa
- **transformers.masking_utils** — same runtime hook forces `_is_torch_greater_or_equal_than_2_6 = False` so the older `sdpa_mask_older_torch` path is selected; the 2.6+ path uses `TransformGetItemToIndex()`, a real `torch._dynamo` graph transform our permissive stub can't reproduce
- **torch._dynamo** — no-op stub replaces the real module before `transformers` imports it, preventing the `torch._numpy._ufuncs` import crash (`NameError: name 'name' is not defined`) that blocked Kokoro and every engine pulling in `flex_attention`
- `.spec` paths are now repo-relative instead of absolute, so the generated spec is portable across machines and CI
### Generation
- **Cancel queued or running generations** ([#444](https://github.com/jamiepine/voicebox/pull/444)) — new `/generate/{id}/cancel` endpoint and a Stop button on the history row while generating. The serial queue now tracks per-ID state (queued / running / cancelled) so queued jobs are skipped before the worker picks them up and running jobs are `.cancel()`-ed mid-flight; `run_generation` catches `CancelledError` and marks the row `failed` with a "cancelled" error.
- **Legacy `data/` path prefix resolution** ([#440](https://github.com/jamiepine/voicebox/pull/440)) — generations stored with the old `data/` prefix under pre-0.4 installs now resolve correctly after the storage root moved, fixing 404s for historical audio.
### Model Migration
- Migration dialog no longer hangs when the cache is empty ([#439](https://github.com/jamiepine/voicebox/pull/439)) — the backend now emits a completion SSE event even when zero models are moved.
- Storage-change flow surfaces a toast when there's nothing to migrate ([#433](https://github.com/jamiepine/voicebox/pull/433)) instead of proceeding with a no-op move and restarting the server.
- Deleting all generations from a voice profile now deletes the associated version files and DB rows too ([#447](https://github.com/jamiepine/voicebox/pull/447)) — previously orphaned versions accumulated in storage.
### Platform
- **Linux system audio capture** ([#457](https://github.com/jamiepine/voicebox/pull/457)) — `cpal`'s ALSA backend doesn't expose PulseAudio/PipeWire monitor sources by name, so the previous device-name search never matched and silently fell back to the microphone. Detection now uses `pactl get-default-sink` + `pactl list short sources` and routes via `PULSE_SOURCE`, with the name-based search retained as a fallback when `pactl` is absent.
### Frontend CI
- First PR-time quality gate ([#418](https://github.com/jamiepine/voicebox/pull/418)) — new `.github/workflows/ci.yml` runs `bun run typecheck` + `bun run build:web` on every PR. Fixed pre-existing type issues that were being suppressed with `@ts-expect-error`, cleaned up a dep-array typo (`[platform.metadata.isTauricheckOnMountcheckForUpdates]`) in `useAutoUpdater`, and removed 100+ lines of dead `ModelItem` code from `ModelManagement.tsx`.
- Follow-up: widened `apiClient.migrateModels()` return type to include `moved` and `errors` so the storage-change handler typechecks against the real backend response ([#470](https://github.com/jamiepine/voicebox/pull/470)).
### Docs
- Clarified in the Quick Start + README that paralinguistic tags (`[laugh]`, `[sigh]`) only work with Chatterbox Turbo; other engines read them as literal text ([#450](https://github.com/jamiepine/voicebox/pull/450)).
### New Contributors
- [@Bortlesboat](https://github.com/Bortlesboat) — generation cancellation (#444)
- [@gaojulong](https://github.com/gaojulong) — migration dialog hang fix (#439)
- [@fuleinist](https://github.com/fuleinist) — migration no-op toast (#433)
- [@erionjuniordeandrade-a11y](https://github.com/erionjuniordeandrade-a11y) — frontend CI + type hardening (#418)
- [@estefrac](https://github.com/estefrac) — Linux pactl system-audio capture (#457)
## [0.4.0] - 2026-04-16
The biggest Voicebox release yet. Three new TTS engines bring the lineup to **seven** — HumeAI TADA, Kokoro 82M, and Qwen CustomVoice join Qwen3-TTS, LuxTTS, Chatterbox Multilingual, and Chatterbox Turbo. GPU support broadens to Intel Arc (XPU) and NVIDIA Blackwell (RTX 50-series), with runtime diagnostics that warn when your PyTorch build doesn't match your GPU. The CUDA backend is now split into independently versioned server and library archives, so upgrading no longer redownloads 4 GB of PyTorch/CUDA DLLs.
This release also marks a big community moment: **13 new contributors** shipped fixes and features in 0.4.0. Thirty-plus bug fixes target the most-reported issues in the tracker — numpy 2.x TTS crashes, Windows background-server reliability, macOS 11 launch failures, audio playback silence, Stories clip-splitting races, history status staleness, and more.
### New TTS Engines
#### HumeAI TADA — Expressive English & Multilingual ([#296](https://github.com/jamiepine/voicebox/pull/296))
- Added `tada-1b` (English) and `tada-3b-ml` (multilingual) backends
- Replaced `descript-audio-codec` with a lightweight DAC shim to cut dependencies
- Switched audio decoding to `soundfile` to sidestep `torchcodec` bundling issues
- Redirected gated Llama tokenizer lookups to an ungated mirror so model loading works out of the box
- Fixed tokenizer patch that was corrupting `AutoTokenizer` for other engines
- Fixed TorchScript error in frozen builds
#### Kokoro 82M — Fast Lightweight TTS ([#325](https://github.com/jamiepine/voicebox/pull/325))
- Added Kokoro 82M engine with a new voice profile type system that distinguishes preset voices from cloned profiles
- Profile grid now handles engine compatibility directly — removed redundant dropdown filtering
- Tightened Kokoro profile handling so preset voices can't be edited like cloned profiles
#### Qwen CustomVoice ([#328](https://github.com/jamiepine/voicebox/pull/328))
- Added `qwen-custom-voice` preset engine backed by Qwen3-TTS
- Enforced preset/profile engine compatibility across the generation flow
- Floating generator now shows all engines instead of silently filtering
### Voice Profile UX
Until 0.4, every engine in Voicebox was a cloning model, so every voice profile was usable with every engine and the profile grid just showed them all. Introducing Kokoro and Qwen CustomVoice — which work from preset voices rather than cloned samples — broke that assumption for the first time. An early cut on `main` filtered the grid by the selected engine, which left users running pre-release builds thinking their cloned voices had vanished whenever they switched to a preset-only engine.
This release ships the resolution before it ever reaches a tagged version:
- **Grey-out instead of filter** — all profiles are always visible; unsupported ones render dimmed with a compatibility hint at the bottom of the grid
- **Auto-switch on selection** — clicking a greyed-out profile selects it AND switches the engine to a compatible one, instead of silently doing nothing
- **Instruct toggle restored for Qwen CustomVoice** — the floating generate box now reveals a delivery-instructions input (tone, emotion, pace) when CustomVoice is selected. Hidden across the board while the new multi-engine lineup was stabilizing because most engines don't honor the kwarg; now conditionally exposed only for the one engine that was actually trained for instruction-based style control
- Supported profiles sort first; the grid scrolls the selected profile into view after engine/sort changes
- Fixed engine desync on tab navigation — the form now initializes its engine from the store
- Fixed the disabled-and-selected card click edge case by bouncing selection to re-trigger the auto-switch
- Cleaned up scroll effect timers (requestAnimationFrame + setTimeout) to prevent stale DOM writes on unmount or rapid selection changes
### GPU & Platform
#### Intel Arc (XPU) Support ([#320](https://github.com/jamiepine/voicebox/pull/320))
- First-class Intel Arc support across all PyTorch-based backends
- Device-aware seeding, XPU detection in the GPU status panel, and setup flow detection
- Reports correct device name and VRAM in settings
#### Blackwell / RTX 50-series Support ([#316](https://github.com/jamiepine/voicebox/pull/316), [#401](https://github.com/jamiepine/voicebox/pull/401))
- Upgraded the CUDA backend from cu126 → cu128 for RTX 50-series support
- Added `sm_120+PTX` to the CUDA build via `TORCH_CUDA_ARCH_LIST` for forward-compatibility with Blackwell architectures (closes 5 open reports: #386, #395, #396, #399, #400)
- GPU settings UI fixes around install/uninstall state
#### GPU Compatibility Diagnostics ([#367](https://github.com/jamiepine/voicebox/pull/367), adapted)
- New `check_cuda_compatibility()` compares the current device's compute capability against the bundled PyTorch's architecture list
- Health endpoint exposes a `gpu_compatibility_warning` field so the UI can surface mismatches
- Startup logs a `WARN` when the installed PyTorch build doesn't support the detected GPU
- GPU status label shows `[UNSUPPORTED - see logs]` — no more silent "no kernel image" failures
#### Split CUDA Backend ([#298](https://github.com/jamiepine/voicebox/pull/298))
- CUDA backend now ships as two independently versioned archives: a small server binary and a large libs archive (the ~4 GB of PyTorch/CUDA DLLs)
- Upgrading Voicebox no longer redownloads the libs archive when only the server binary changed
- Added `asyncio.Lock` around `download_cuda_binary()` so auto-update and manual download can't race on the same temp file ([#428](https://github.com/jamiepine/voicebox/pull/428))
- Updated `package_cuda.py` for PyInstaller 6.18 onedir layout
- Temp archives are always cleaned up on failure, even when the install aborts mid-extract
### Bug Fixes
#### Critical: TTS Generation
- **numpy 2.x `torch.from_numpy` crash** ([#361](https://github.com/jamiepine/voicebox/pull/361)) — torch compiled against numpy 1.x ABI fails silently when paired with numpy 2.x, causing `RuntimeError: Numpy is not available` / `Unable to create tensor` on every TTS request in bundled macOS Intel / Rosetta builds. Pinned `numpy<2.0` in requirements and added a PyInstaller runtime hook with a `ctypes.memmove` fallback as belt-and-suspenders. Hardened afterward to raise on unknown dtypes instead of silently reinterpreting bytes as float32.
#### Platform Reliability
- **Windows background server** ([#402](https://github.com/jamiepine/voicebox/pull/402)) — "keep server running after close" now actually keeps the server running. The HTTP `/watchdog/disable` request could lose the race against process exit on Windows; added a `.keep-running` sentinel file as a synchronous fallback, with stale-sentinel cleanup on startup to avoid orphan server processes
- **macOS 11 launch crash** ([#424](https://github.com/jamiepine/voicebox/pull/424)) — weak-linked ScreenCaptureKit so the app can launch on macOS < 12.3 instead of crashing at dyld resolution. Gated system audio capture behind a real `sw_vers` version check so unsupported systems cleanly advertise "not available" rather than crashing at runtime
- **macOS Intel (x86_64) setup** ([#416](https://github.com/jamiepine/voicebox/pull/416)) — relaxed `torch>=2.7.0` → `torch>=2.2.0`. PyTorch dropped pre-built x86_64 wheels after 2.2.2, so Intel Mac devs could no longer `pip install`. Now resolves to the latest compatible torch per platform
- **Offline model loading** ([#318](https://github.com/jamiepine/voicebox/pull/318)) — Qwen TTS and Whisper force offline mode when loading cached models, so startup works without network access
- **GUI startup with external server** ([#319](https://github.com/jamiepine/voicebox/pull/319)) — fixed GUI launch when pointed at a remote/external server, and added data refresh on server switch; hardened health validation and error handling
- **Qwen3-TTS cache split on Windows** (adapted from [#218](https://github.com/jamiepine/voicebox/pull/218)) — route `Qwen3TTSModel.from_pretrained` through `hf_constants.HF_HUB_CACHE` so the speech tokenizer and `preprocessor_config.json` resolve from a single cache root
- **Qwen3-TTS bundling** ([#305](https://github.com/jamiepine/voicebox/pull/305)) — bundle `qwen_tts` source files in the PyInstaller build to fix `inspect.getsource` errors in frozen builds
- **Backend import paths** ([#345](https://github.com/jamiepine/voicebox/pull/345)) — moved lazy imports to top-level with absolute paths to resolve the "Failed to Save" preset error caused by `ModuleNotFoundError` in production builds
- **Effects service import** ([#384](https://github.com/jamiepine/voicebox/pull/384)) — fixed `ModuleNotFoundError` on preset create/update by switching to relative imports (#349)
#### Audio & Playback
- **cpal stream silent playback** ([#405](https://github.com/jamiepine/voicebox/pull/405)) — `cpal::Stream` was dropped on function return immediately after `play()`, causing every playback to fall silent. Now holds the stream until either the buffer drains or the stop flag fires (#404)
#### Stories & History
- **Clip-splitting race** ([#403](https://github.com/jamiepine/voicebox/pull/403)) — rapid double-clicks on split could race through `split_story_item` with inconsistent state. Added `with_for_update()` row locking on the backend and an `isPending` guard on the frontend (#366)
- **History `status` staleness** ([#394](https://github.com/jamiepine/voicebox/pull/394)) — `GET /history/{id}` was hardcoding `status="completed"` regardless of the DB row, breaking any client polling for job completion. Now returns `status`, `error`, `engine`, `model_size`, and `is_favorited` from the actual row
- **"Clear failed" bulk button** ([#412](https://github.com/jamiepine/voicebox/pull/412)) — new `DELETE /history/failed` endpoint and a header strip showing `"N failed generations"` with a Clear button, complementing the per-row trash icon added in #321 (#410)
- **Delete failed generations** ([#321](https://github.com/jamiepine/voicebox/pull/321)) — added a trash icon next to the retry button so failed entries can be cleaned up without having to retry first
#### Security & Safety
- **Voice prompt cache hardening** ([#429](https://github.com/jamiepine/voicebox/pull/429)) — `torch.load(weights_only=True)` on cached voice prompts per PyTorch 2.6 recommendation; replaced string-based SPA path guard with `Path.is_relative_to()` for more robust path-traversal protection
#### Infrastructure & Docker
- **Docker web build** ([#344](https://github.com/jamiepine/voicebox/pull/344)) — include `CHANGELOG.md` in the Docker web build so the in-app changelog page works in Docker deployments
- **Docker numba cache** ([#425](https://github.com/jamiepine/voicebox/pull/425)) — set `NUMBA_CACHE_DIR` in docker-compose so numba can write its JIT cache in container runtime (#308)
- **Relative media paths** ([#332](https://github.com/jamiepine/voicebox/pull/332)) — media paths now stored relative to the configured data dir rather than resolved against CWD, so the data directory is portable between installs
### Developer Tooling
- New `triage-prs` agent skill — encodes the end-to-end PR-speedrun workflow (classification → triage doc → rebase → squash-merge → follow-ups) so future release cycles can reproduce it
- Rewrote the TTS engine guide with the patterns learned from adding TADA and Kokoro
- Added the API refactor plan and CUDA libs addon design doc
- Fixed broken links in the Get Started section ([#332](https://github.com/jamiepine/voicebox/pull/332))
### New Contributors
Huge thank you to everyone who contributed their first PR to Voicebox in this release:
[@liorshahverdi](https://github.com/liorshahverdi), [@nicoschtein](https://github.com/nicoschtein), [@ArfianID](https://github.com/ArfianID), [@aimaaaimaa](https://github.com/aimaaaimaa), [@maxmcoding](https://github.com/maxmcoding), [@Khalodddd](https://github.com/Khalodddd), [@LuisSambrano](https://github.com/LuisSambrano), [@shaun0927](https://github.com/shaun0927), [@malletfils](https://github.com/malletfils), [@mvanhorn](https://github.com/mvanhorn), [@kuishou68](https://github.com/kuishou68), [@txhno](https://github.com/txhno), [@MukundaKatta](https://github.com/MukundaKatta)
## [0.3.0] - 2026-03-17
This release rewrites the backend into a modular architecture, overhauls the settings UI into routed sub-pages, fixes audio player freezing, migrates documentation to Fumadocs, and ships a batch of bug fixes targeting the most-reported issues from the tracker.
@@ -444,7 +595,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.2.3...HEAD
[Unreleased]: https://github.com/jamiepine/voicebox/compare/v0.4.1...HEAD
[0.4.1]: https://github.com/jamiepine/voicebox/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/jamiepine/voicebox/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/jamiepine/voicebox/compare/v0.2.3...v0.3.0
[0.2.3]: https://github.com/jamiepine/voicebox/compare/v0.2.2...v0.2.3
[0.2.2]: https://github.com/jamiepine/voicebox/compare/v0.2.1...v0.2.2
[0.2.1]: https://github.com/jamiepine/voicebox/compare/v0.1.13...v0.2.1
+4 -3
View File
@@ -260,7 +260,7 @@ voicebox/
### ✨ New Features
- Check the roadmap in README.md
- Check the roadmap in README.md and the engineering status in [`docs/PROJECT_STATUS.md`](docs/PROJECT_STATUS.md) before proposing work — it lists prioritized tasks (Tier 1 → 3), known architectural bottlenecks, and candidate TTS engines already under evaluation (including why some have been backlogged)
- Discuss major features in an issue first
- Keep features focused and well-scoped
@@ -359,7 +359,7 @@ Releases are managed by maintainers:
## Troubleshooting
See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues and solutions.
See [docs/content/docs/overview/troubleshooting.mdx](docs/content/docs/overview/troubleshooting.mdx) for common issues and solutions.
**Quick fixes:**
@@ -372,12 +372,13 @@ See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues and sol
- Open an issue for bugs or feature requests
- Check existing issues and discussions
- Review the codebase to understand patterns
- See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues
- See [docs/content/docs/overview/troubleshooting.mdx](docs/content/docs/overview/troubleshooting.mdx) for common issues
## Additional Resources
- [README.md](README.md) - Project overview
- [backend/README.md](backend/README.md) - API documentation
- [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) - Living engineering roadmap: architecture, shipped vs in-flight work, prioritized open issues, candidate TTS engines under evaluation, architectural bottlenecks. Keep this updated when you ship significant features, close or backlog a model integration, or identify new bottlenecks.
- [docs/AUTOUPDATER_QUICKSTART.md](docs/AUTOUPDATER_QUICKSTART.md) - Auto-updater setup
- [SECURITY.md](SECURITY.md) - Security policy
- [CHANGELOG.md](CHANGELOG.md) - Version history
+1 -1
View File
@@ -9,7 +9,7 @@ FROM oven/bun:1 AS frontend
WORKDIR /build
# Copy workspace config and frontend source
COPY package.json bun.lock ./
COPY package.json bun.lock CHANGELOG.md ./
COPY app/ ./app/
COPY web/ ./web/
+23 -7
View File
@@ -23,6 +23,9 @@
<a href="https://github.com/jamiepine/voicebox/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/jamiepine/voicebox?style=flat" alt="License" />
</a>
<a href="https://deepwiki.com/jamiepine/voicebox">
<img src="https://img.shields.io/static/v1?label=Ask&message=DeepWiki&color=5B6EF7" alt="Ask DeepWiki" />
</a>
</p>
<p align="center">
@@ -30,7 +33,8 @@
<a href="https://docs.voicebox.sh">Docs</a> •
<a href="#download">Download</a> •
<a href="#features">Features</a> •
<a href="#api">API</a>
<a href="#api">API</a> •
<a href="docs/content/docs/overview/troubleshooting.mdx">Troubleshooting</a>
</p>
<br/>
@@ -59,13 +63,14 @@
## What is Voicebox?
Voicebox is a **local-first voice cloning studio** — a free and open-source alternative to ElevenLabs. Clone voices from a few seconds of audio, generate speech in 23 languages across 5 TTS engines, apply post-processing effects, and compose multi-voice projects with a timeline editor.
Voicebox is a **local-first voice cloning studio** — a free and open-source alternative to ElevenLabs. Clone voices from a few seconds of audio or pick from 50+ preset voices, generate speech in 23 languages across 7 TTS engines, apply post-processing effects, and compose multi-voice projects with a timeline editor.
- **Complete privacy** — models and voice data stay on your machine
- **5 TTS engines** — Qwen3-TTS, LuxTTS, Chatterbox Multilingual, Chatterbox Turbo, and HumeAI TADA
- **7 TTS engines** — Qwen3-TTS, Qwen CustomVoice, LuxTTS, Chatterbox Multilingual, Chatterbox Turbo, HumeAI TADA, and Kokoro
- **Cloning and preset voices** — zero-shot cloning from a reference sample, or curated preset voices via Kokoro (50 voices) and Qwen CustomVoice (9 voices)
- **23 languages** — from English to Arabic, Japanese, Hindi, Swahili, and more
- **Post-processing effects** — pitch shift, reverb, delay, chorus, compression, and filters
- **Expressive speech** — paralinguistic tags like `[laugh]`, `[sigh]`, `[gasp]` via Chatterbox Turbo
- **Expressive speech** — paralinguistic tags like `[laugh]`, `[sigh]`, `[gasp]` via Chatterbox Turbo; natural-language delivery control via Qwen CustomVoice
- **Unlimited length** — auto-chunking with crossfade for scripts, articles, and chapters
- **Stories editor** — multi-track timeline for conversations, podcasts, and narratives
- **API-first** — REST API for integrating voice synthesis into your own projects
@@ -87,25 +92,34 @@ Voicebox is a **local-first voice cloning studio** — a free and open-source al
> **Linux** — Pre-built binaries are not yet available. See [voicebox.sh/linux-install](https://voicebox.sh/linux-install) for build-from-source instructions.
> **Having trouble?** See the [Troubleshooting Guide](docs/content/docs/overview/troubleshooting.mdx) for common install, generation, model-download, and GPU issues.
---
## Features
### Multi-Engine Voice Cloning
Five TTS engines with different strengths, switchable per-generation:
Seven TTS engines with different strengths, switchable per-generation:
| Engine | Languages | Strengths |
| --------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| **Qwen3-TTS** (0.6B / 1.7B) | 10 | High-quality multilingual cloning, delivery instructions ("speak slowly", "whisper") |
| **Qwen CustomVoice** | 10 | 9 curated preset voices with natural-language delivery control — no reference audio required |
| **LuxTTS** | English | Lightweight (~1GB VRAM), 48kHz output, 150x realtime on CPU |
| **Chatterbox Multilingual** | 23 | Broadest language coverage — Arabic, Danish, Finnish, Greek, Hebrew, Hindi, Malay, Norwegian, Polish, Swahili, Swedish, Turkish and more |
| **Chatterbox Turbo** | English | Fast 350M model with paralinguistic emotion/sound tags |
| **TADA** (1B / 3B) | 10 | HumeAI speech-language model — 700s+ coherent audio, text-acoustic dual alignment |
| **Kokoro** | 8 | 50 curated preset voices, tiny 82M model, fast CPU inference |
### Emotions & Paralinguistic Tags
Type `/` in the text input to insert expressive tags that the model synthesizes inline with speech (Chatterbox Turbo):
Only **Chatterbox Turbo** interprets paralinguistic tags like `[laugh]` and
`[sigh]`. Qwen3-TTS, LuxTTS, Chatterbox Multilingual, and HumeAI TADA read them
literally as text.
With **Chatterbox Turbo** selected, type `/` in the text input to open the tag
inserter and add expressive tags inline with speech:
`[laugh]` `[chuckle]` `[gasp]` `[cough]` `[sigh]` `[groan]` `[sniff]` `[shush]` `[clear throat]`
@@ -231,7 +245,7 @@ Full API documentation available at `http://localhost:17493/docs`.
| Frontend | React, TypeScript, Tailwind CSS |
| State | Zustand, React Query |
| Backend | FastAPI (Python) |
| TTS Engines | Qwen3-TTS, LuxTTS, Chatterbox, Chatterbox Turbo, TADA |
| TTS Engines | Qwen3-TTS, Qwen CustomVoice, LuxTTS, Chatterbox, Chatterbox Turbo, TADA, Kokoro |
| Effects | Pedalboard (Spotify) |
| Transcription | Whisper / Whisper Turbo (PyTorch or MLX) |
| Inference | MLX (Apple Silicon) / PyTorch (CUDA/ROCm/XPU/CPU) |
@@ -250,6 +264,8 @@ Full API documentation available at `http://localhost:17493/docs`.
| **Plugin Architecture** | Extend with custom models and effects |
| **Mobile Companion** | Control Voicebox from your phone |
For the **full engineering status, open-issue triage, and prioritized work queue**, see [`docs/PROJECT_STATUS.md`](docs/PROJECT_STATUS.md) — a living document that tracks what's shipped, what's in-flight, candidate TTS engines under evaluation, and why we've accepted or backlogged specific integrations.
---
## Development
+3 -3
View File
@@ -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
+2 -1
View File
@@ -1,11 +1,12 @@
{
"name": "@voicebox/app",
"version": "0.3.1",
"version": "0.4.1",
"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",
+98 -12
View File
@@ -4,6 +4,8 @@ import voiceboxLogo from '@/assets/voicebox-logo.png';
import ShinyText from '@/components/ShinyText';
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
import { apiClient } from '@/lib/api/client';
import type { HealthResponse } from '@/lib/api/types';
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext';
@@ -11,6 +13,33 @@ import { router } from '@/router';
import { useLogStore } from '@/stores/logStore';
import { useServerStore } from '@/stores/serverStore';
/**
* Validate that a health response has the expected Voicebox-specific shape.
* Prevents misidentifying an unrelated service on the same port.
*/
function isVoiceboxHealthResponse(health: HealthResponse): boolean {
return (
health?.status === 'healthy' &&
typeof health.model_loaded === 'boolean' &&
typeof health.gpu_available === 'boolean'
);
}
/**
* Check whether a startup error indicates the port is occupied by an external
* server (which we should try to reuse via health-check polling) vs. a real
* failure (missing sidecar, signing issue, etc.) that should surface immediately.
*/
function isPortInUseError(error: unknown): boolean {
const msg = error instanceof Error ? error.message : String(error);
return (
msg.includes('already in use') ||
msg.includes('port') ||
msg.includes('EADDRINUSE') ||
msg.includes('address already in use')
);
}
const LOADING_MESSAGES = [
'Warming up tensors...',
'Calibrating synthesizer engine...',
@@ -37,6 +66,7 @@ const LOADING_MESSAGES = [
function App() {
const platform = usePlatform();
const [serverReady, setServerReady] = useState(false);
const [startupError, setStartupError] = useState<string | null>(null);
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
const serverStartingRef = useRef(false);
@@ -91,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;
}
@@ -114,14 +143,52 @@ function App() {
useServerStore.getState().setServerUrl(serverUrl);
setServerReady(true);
// Mark that we started the server (so we know to stop it on close)
// @ts-expect-error - adding property to window
window.__voiceboxServerStartedByApp = true;
})
.catch((error) => {
console.error('Failed to auto-start server:', error);
serverStartingRef.current = false;
// @ts-expect-error - adding property to window
window.__voiceboxServerStartedByApp = false;
// Only fall back to health-check polling when the error indicates the
// port is occupied (likely an external server). For real failures
// (missing sidecar, signing issues, etc.) surface the error immediately.
if (!isPortInUseError(error)) {
const msg = error instanceof Error ? error.message : String(error);
console.error('Real startup failure — not polling:', msg);
setStartupError(msg);
return;
}
// Fall back to polling: the server may already be running externally
// (e.g. started via python/uvicorn/Docker). Poll the health endpoint
// until it responds with a valid Voicebox payload, then transition to
// the main UI.
console.log('Falling back to health-check polling...');
const pollInterval = setInterval(async () => {
try {
const health = await apiClient.getHealth();
if (!isVoiceboxHealthResponse(health)) {
console.log('Health response is not from a Voicebox server, keep polling...');
return;
}
console.log('External Voicebox server detected via health check');
clearInterval(pollInterval);
setServerReady(true);
} catch {
// Server not ready yet, keep polling
}
}, 2000);
// Stop polling after 2 minutes and surface the failure
setTimeout(() => {
clearInterval(pollInterval);
serverStartingRef.current = false;
setStartupError(
'Could not connect to a Voicebox server within 2 minutes. ' +
'Please check that the server is running and try again.',
);
}, 120_000);
});
// Cleanup: stop server on actual unmount (not StrictMode remount)
@@ -168,15 +235,34 @@ function App() {
className="w-48 h-48 object-contain animate-fade-in-scale relative z-10"
/>
</div>
<div className="animate-fade-in-delayed">
<ShinyText
text={LOADING_MESSAGES[loadingMessageIndex]}
className="text-lg font-medium text-muted-foreground"
speed={2}
color="hsl(var(--muted-foreground))"
shineColor="hsl(var(--foreground))"
/>
</div>
{startupError ? (
<div className="animate-fade-in-delayed max-w-md mx-auto space-y-3">
<p className="text-lg font-medium text-destructive">Server startup failed</p>
<p className="text-sm text-muted-foreground">{startupError}</p>
<button
type="button"
className="mt-2 px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
onClick={() => {
setStartupError(null);
serverStartingRef.current = false;
// Trigger a re-mount of the effect by toggling state
window.location.reload();
}}
>
Retry
</button>
</div>
) : (
<div className="animate-fade-in-delayed">
<ShinyText
text={LOADING_MESSAGES[loadingMessageIndex]}
className="text-lg font-medium text-muted-foreground"
speed={2}
color="hsl(var(--muted-foreground))"
shineColor="hsl(var(--foreground))"
/>
</div>
)}
</div>
</div>
);
+7 -3
View File
@@ -1,5 +1,6 @@
import { useRouterState } from '@tanstack/react-router';
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
import { AudioKeepAlive } from '@/components/AudioPlayer/AudioKeepAlive';
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
import { StoryTrackEditor } from '@/components/StoriesTab/StoryTrackEditor';
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
@@ -14,16 +15,19 @@ interface AppFrameProps {
export function AppFrame({ children }: AppFrameProps) {
const routerState = useRouterState();
const isStoriesRoute = routerState.location.pathname === '/stories';
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
const { data: story } = useStory(selectedStoryId);
// Show track editor when on stories route with a selected story that has items
const showTrackEditor = isStoriesRoute && selectedStoryId && story && story.items.length > 0;
return (
<div className={cn('h-screen bg-background flex flex-col overflow-hidden', TOP_SAFE_AREA_PADDING)}>
<div
className={cn('h-screen bg-background flex flex-col overflow-hidden', TOP_SAFE_AREA_PADDING)}
>
<TitleBarDragRegion />
<AudioKeepAlive />
{children}
{showTrackEditor ? (
<StoryTrackEditor storyId={story.id} items={story.items} />
@@ -0,0 +1,85 @@
import { useEffect, useRef } from 'react';
import { debug } from '@/lib/utils/debug';
// WKWebView tears down the app's CoreAudio output when idle for long enough,
// and a JS-level reload (cmd+R) does NOT restore it — only relaunching the
// Tauri app does. Keeping a silent <audio> element looping forever prevents
// the OS audio session from ever going dormant.
//
// Real silence (zero PCM samples) at full volume is preferred over a muted
// element: browsers/WebKit can optimize muted media away, which defeats the
// purpose of holding the session open.
function buildSilentWavUrl(seconds = 1, sampleRate = 8000): string {
const numSamples = seconds * sampleRate;
const bytes = 44 + numSamples * 2;
const buffer = new ArrayBuffer(bytes);
const view = new DataView(buffer);
const write = (offset: number, str: string) => {
for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
};
write(0, 'RIFF');
view.setUint32(4, bytes - 8, true);
write(8, 'WAVE');
write(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
write(36, 'data');
view.setUint32(40, numSamples * 2, true);
return URL.createObjectURL(new Blob([buffer], { type: 'audio/wav' }));
}
export function AudioKeepAlive() {
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
const url = buildSilentWavUrl(1, 8000);
const el = new Audio(url);
el.loop = true;
el.volume = 1;
el.preload = 'auto';
audioRef.current = el;
const tryPlay = () => {
if (!audioRef.current) return;
if (!audioRef.current.paused) return;
audioRef.current.play().catch((err) => {
debug.log('[AudioKeepAlive] play blocked (will retry on next gesture):', err);
});
};
tryPlay();
// Autoplay may be blocked until first user interaction — re-attempt then.
const onGesture = () => tryPlay();
window.addEventListener('pointerdown', onGesture, { once: false });
window.addEventListener('keydown', onGesture, { once: false });
// If the webview ever pauses the element on background, resume on return.
const onWake = () => {
if (!document.hidden) tryPlay();
};
document.addEventListener('visibilitychange', onWake);
window.addEventListener('focus', onWake);
window.addEventListener('pageshow', onWake);
return () => {
window.removeEventListener('pointerdown', onGesture);
window.removeEventListener('keydown', onGesture);
document.removeEventListener('visibilitychange', onWake);
window.removeEventListener('focus', onWake);
window.removeEventListener('pageshow', onWake);
el.pause();
el.src = '';
URL.revokeObjectURL(url);
audioRef.current = null;
};
}, []);
return null;
}
+1 -1
View File
@@ -124,7 +124,7 @@ export function AudioTab() {
);
}
const handleChannelDelete = async (e, channelId) => {
const handleChannelDelete = async (e: React.MouseEvent, channelId: string) => {
e.stopPropagation();
if (await confirm('Delete this channel?')) {
deleteChannel.mutate(channelId);
+16 -16
View File
@@ -1,20 +1,20 @@
import {EffectsDetail} from "./EffectsDetail";
import {EffectsList} from "./EffectsList";
import { EffectsDetail } from './EffectsDetail';
import { EffectsList } from './EffectsList';
export function EffectsTab() {
return (
<div className="flex flex-col h-full min-h-0 overflow-hidden">
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden">
{/* Left - Presets list */}
<div className="w-full max-w-[360px] shrink-0 flex flex-col min-h-0">
<EffectsList />
</div>
return (
<div className="flex flex-col h-full min-h-0 overflow-hidden">
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden">
{/* Left - Presets list */}
<div className="w-full max-w-[360px] shrink-0 flex flex-col min-h-0">
<EffectsList />
</div>
{/* Right - Detail / editor */}
<div className="flex-1 min-h-0 flex flex-col">
<EffectsDetail />
</div>
</div>
</div>
);
{/* Right - Detail / editor */}
<div className="flex-1 min-h-0 flex flex-col">
<EffectsDetail />
</div>
</div>
</div>
);
}
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import type { UseFormReturn } from 'react-hook-form';
import { FormControl } from '@/components/ui/form';
import {
@@ -7,6 +8,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { VoiceProfileResponse } from '@/lib/api/types';
import { getLanguageOptionsForEngine } from '@/lib/constants/languages';
import type { GenerationFormValues } from '@/lib/hooks/useGenerationForm';
@@ -15,34 +17,57 @@ import type { GenerationFormValues } from '@/lib/hooks/useGenerationForm';
* Adding a new engine means adding one entry here.
*/
const ENGINE_OPTIONS = [
{ value: 'qwen:1.7B', label: 'Qwen3-TTS 1.7B' },
{ value: 'qwen:0.6B', label: 'Qwen3-TTS 0.6B' },
{ value: 'luxtts', label: 'LuxTTS' },
{ value: 'chatterbox', label: 'Chatterbox' },
{ value: 'chatterbox_turbo', label: 'Chatterbox Turbo' },
{ value: 'tada:1B', label: 'TADA 1B' },
{ value: 'tada:3B', label: 'TADA 3B Multilingual' },
{ value: 'qwen:1.7B', label: 'Qwen3-TTS 1.7B', engine: 'qwen' },
{ value: 'qwen:0.6B', label: 'Qwen3-TTS 0.6B', engine: 'qwen' },
{ value: 'qwen_custom_voice:1.7B', label: 'Qwen CustomVoice 1.7B', engine: 'qwen_custom_voice' },
{ value: 'qwen_custom_voice:0.6B', label: 'Qwen CustomVoice 0.6B', engine: 'qwen_custom_voice' },
{ value: 'luxtts', label: 'LuxTTS', engine: 'luxtts' },
{ value: 'chatterbox', label: 'Chatterbox', engine: 'chatterbox' },
{ value: 'chatterbox_turbo', label: 'Chatterbox Turbo', engine: 'chatterbox_turbo' },
{ value: 'tada:1B', label: 'TADA 1B', engine: 'tada' },
{ value: 'tada:3B', label: 'TADA 3B Multilingual', engine: 'tada' },
{ value: 'kokoro', label: 'Kokoro 82M', engine: 'kokoro' },
] as const;
const ENGINE_DESCRIPTIONS: Record<string, string> = {
qwen: 'Multi-language, two sizes',
qwen_custom_voice: '9 preset voices, instruct control',
luxtts: 'Fast, English-focused',
chatterbox: '23 languages, incl. Hebrew',
chatterbox_turbo: 'English, [laugh] [cough] tags',
tada: 'HumeAI, 700s+ coherent audio',
kokoro: '82M params, CPU realtime, 8 langs',
};
/** Engines that only support English and should force language to 'en' on select. */
const ENGLISH_ONLY_ENGINES = new Set(['luxtts', 'chatterbox_turbo']);
/** Engines that support cloned (reference audio) profiles. */
const CLONING_ENGINES = new Set(['qwen', 'luxtts', 'chatterbox', 'chatterbox_turbo', 'tada']);
function getAvailableOptions(selectedProfile?: VoiceProfileResponse | null) {
if (!selectedProfile) return ENGINE_OPTIONS;
return ENGINE_OPTIONS.filter((opt) => isProfileCompatibleWithEngine(selectedProfile, opt.engine));
}
function getSelectValue(engine: string, modelSize?: string): string {
if (engine === 'qwen') return `qwen:${modelSize || '1.7B'}`;
if (engine === 'qwen_custom_voice') return `qwen_custom_voice:${modelSize || '1.7B'}`;
if (engine === 'tada') return `tada:${modelSize || '1B'}`;
return engine;
}
function handleEngineChange(form: UseFormReturn<GenerationFormValues>, value: string) {
if (value.startsWith('qwen:')) {
export function applyEngineSelection(form: UseFormReturn<GenerationFormValues>, value: string) {
if (value.startsWith('qwen_custom_voice:')) {
const [, modelSize] = value.split(':');
form.setValue('engine', 'qwen_custom_voice');
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
const currentLang = form.getValues('language');
const available = getLanguageOptionsForEngine('qwen_custom_voice');
if (!available.some((l) => l.value === currentLang)) {
form.setValue('language', available[0]?.value ?? 'en');
}
} else if (value.startsWith('qwen:')) {
const [, modelSize] = value.split(':');
form.setValue('engine', 'qwen');
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
@@ -85,12 +110,22 @@ function handleEngineChange(form: UseFormReturn<GenerationFormValues>, value: st
interface EngineModelSelectorProps {
form: UseFormReturn<GenerationFormValues>;
compact?: boolean;
selectedProfile?: VoiceProfileResponse | null;
}
export function EngineModelSelector({ form, compact }: EngineModelSelectorProps) {
export function EngineModelSelector({ form, compact, selectedProfile }: EngineModelSelectorProps) {
const engine = form.watch('engine') || 'qwen';
const modelSize = form.watch('modelSize');
const selectValue = getSelectValue(engine, modelSize);
const availableOptions = getAvailableOptions(selectedProfile);
const currentEngineAvailable = availableOptions.some((opt) => opt.value === selectValue);
useEffect(() => {
if (!currentEngineAvailable && availableOptions.length > 0) {
applyEngineSelection(form, availableOptions[0].value);
}
}, [availableOptions, currentEngineAvailable, form]);
const itemClass = compact ? 'text-xs text-muted-foreground' : undefined;
const triggerClass = compact
@@ -98,14 +133,14 @@ export function EngineModelSelector({ form, compact }: EngineModelSelectorProps)
: undefined;
return (
<Select value={selectValue} onValueChange={(v) => handleEngineChange(form, v)}>
<Select value={selectValue} onValueChange={(v) => applyEngineSelection(form, v)}>
<FormControl>
<SelectTrigger className={triggerClass}>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{ENGINE_OPTIONS.map((opt) => (
{availableOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className={itemClass}>
{opt.label}
</SelectItem>
@@ -119,3 +154,17 @@ export function EngineModelSelector({ form, compact }: EngineModelSelectorProps)
export function getEngineDescription(engine: string): string {
return ENGINE_DESCRIPTIONS[engine] ?? '';
}
/**
* Check if a profile is compatible with the currently selected engine.
* Useful for UI hints.
*/
export function isProfileCompatibleWithEngine(
profile: VoiceProfileResponse,
engine: string,
): boolean {
const voiceType = profile.voice_type || 'cloned';
if (voiceType === 'preset') return profile.preset_engine === engine;
if (voiceType === 'cloned') return CLONING_ENGINES.has(engine);
return true; // designed — future
}
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { useMatchRoute } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
import { Loader2, Sparkles } from 'lucide-react';
import { Loader2, SlidersHorizontal, Sparkles } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
@@ -36,9 +36,11 @@ export function FloatingGenerateBox({
}: FloatingGenerateBoxProps) {
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
const setSelectedEngine = useUIStore((state) => state.setSelectedEngine);
const { data: selectedProfile } = useProfile(selectedProfileId || '');
const { data: profiles } = useProfiles();
const [isExpanded, setIsExpanded] = useState(false);
const [isInstructExpanded, setIsInstructExpanded] = useState(false);
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -67,7 +69,12 @@ export function FloatingGenerateBox({
}
},
getEffectsChain: () => {
if (!selectedPresetId || !effectPresets) return undefined;
if (!selectedPresetId) return undefined;
// Profile's own effects chain (no matching preset)
if (selectedPresetId === '_profile') {
return selectedProfile?.effects_chain ?? undefined;
}
if (!effectPresets) return undefined;
const preset = effectPresets.find((p) => p.id === selectedPresetId);
return preset?.effects_chain;
},
@@ -110,12 +117,63 @@ export function FloatingGenerateBox({
}
}, [selectedProfileId, profiles, setSelectedProfileId]);
// Sync generation form language with selected profile's language
// Sync engine selection to global store so ProfileList can filter
const watchedEngine = form.watch('engine');
useEffect(() => {
if (watchedEngine) {
setSelectedEngine(watchedEngine);
}
}, [watchedEngine, setSelectedEngine]);
// Sync generation form language, engine, and effects with selected profile
type EngineValue =
| 'qwen'
| 'luxtts'
| 'chatterbox'
| 'chatterbox_turbo'
| 'tada'
| 'kokoro'
| 'qwen_custom_voice';
useEffect(() => {
if (selectedProfile?.language) {
form.setValue('language', selectedProfile.language as LanguageCode);
}
}, [selectedProfile, form]);
// Auto-switch engine to match the profile
const engine = selectedProfile?.default_engine ?? selectedProfile?.preset_engine;
if (engine) {
form.setValue('engine', engine as EngineValue);
} else if (selectedProfile && selectedProfile.voice_type !== 'preset') {
// Cloned/designed profile with no default — ensure a compatible (non-preset) engine
const currentEngine = form.getValues('engine');
const presetEngines = new Set(['kokoro', 'qwen_custom_voice']);
if (currentEngine && presetEngines.has(currentEngine)) {
form.setValue('engine', 'qwen');
}
}
// Pre-fill effects from profile defaults
if (
selectedProfile?.effects_chain &&
selectedProfile.effects_chain.length > 0 &&
effectPresets
) {
// Try to match against a known preset
const profileChainJson = JSON.stringify(selectedProfile.effects_chain);
const matchingPreset = effectPresets.find(
(p) => JSON.stringify(p.effects_chain) === profileChainJson,
);
if (matchingPreset) {
setSelectedPresetId(matchingPreset.id);
} else {
// No matching preset — use special value to pass profile chain directly
setSelectedPresetId('_profile');
}
} else if (
selectedProfile &&
(!selectedProfile.effects_chain || selectedProfile.effects_chain.length === 0)
) {
setSelectedPresetId(null);
}
}, [selectedProfile, effectPresets, form]);
// Auto-resize textarea based on content (only when expanded)
useEffect(() => {
@@ -296,9 +354,80 @@ export function FloatingGenerateBox({
: 'Generate speech'}
</span>
</div>
{/* Instruct toggle — only for Qwen CustomVoice, which actually honors the kwarg */}
<AnimatePresence>
{isExpanded && form.watch('engine') === 'qwen_custom_voice' && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
className="absolute top-0 right-[calc(100%+0.5rem)]"
>
<div className="group relative">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setIsInstructExpanded((prev) => !prev)}
className={cn(
'h-10 w-10 rounded-full transition-all duration-200',
isInstructExpanded
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
: 'bg-card border border-border hover:bg-background/50',
)}
aria-label={
isInstructExpanded
? 'Hide delivery instructions'
: 'Show delivery instructions'
}
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)
</span>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Additive instruct textarea — shown below main text when toggle is on and engine supports it */}
<AnimatePresence>
{isInstructExpanded && form.watch('engine') === 'qwen_custom_voice' && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="overflow-hidden"
>
<FormField
control={form.control}
name="instruct"
render={({ field }) => (
<FormItem className="mt-2">
<FormControl>
<Textarea
{...field}
placeholder="Delivery instructions — e.g. Speak slowly with warmth, Authoritative and clear..."
className="resize-none bg-transparent border border-accent/20 focus-visible:ring-1 focus-visible:ring-accent/40 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full px-3 py-2"
style={{ minHeight: '60px', maxHeight: '160px' }}
maxLength={500}
/>
</FormControl>
<FormMessage className="text-xs" />
</FormItem>
)}
/>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
<motion.div
initial={{ height: 0, opacity: 0 }}
@@ -375,6 +504,12 @@ export function FloatingGenerateBox({
<SelectItem value="none" className="text-xs">
No effects
</SelectItem>
{selectedProfile?.effects_chain &&
selectedProfile.effects_chain.length > 0 && (
<SelectItem value="_profile" className="text-xs">
Profile default
</SelectItem>
)}
{effectPresets?.map((preset) => (
<SelectItem key={preset.id} value={preset.id} className="text-xs">
{preset.name}
@@ -1,4 +1,5 @@
import { Loader2, Mic } from 'lucide-react';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
@@ -19,19 +20,45 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { getLanguageOptionsForEngine } from '@/lib/constants/languages';
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
import { useProfile } from '@/lib/hooks/useProfiles';
import { useUIStore } from '@/stores/uiStore';
import { EngineModelSelector, getEngineDescription } from './EngineModelSelector';
import {
applyEngineSelection,
EngineModelSelector,
getEngineDescription,
} from './EngineModelSelector';
import { ParalinguisticInput } from './ParalinguisticInput';
function getEngineSelectValue(engine: string): string {
if (engine === 'qwen') return 'qwen:1.7B';
if (engine === 'qwen_custom_voice') return 'qwen_custom_voice:1.7B';
if (engine === 'tada') return 'tada:1B';
return engine;
}
export function GenerationForm() {
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const { data: selectedProfile } = useProfile(selectedProfileId || '');
const { form, handleSubmit, isPending } = useGenerationForm();
useEffect(() => {
if (!selectedProfile) {
return;
}
if (selectedProfile.language) {
form.setValue('language', selectedProfile.language as LanguageCode);
}
const preferredEngine = selectedProfile.default_engine || selectedProfile.preset_engine;
if (preferredEngine) {
applyEngineSelection(form, getEngineSelectValue(preferredEngine));
}
}, [form, selectedProfile]);
async function onSubmit(data: Parameters<typeof handleSubmit>[0]) {
await handleSubmit(data, selectedProfileId);
}
@@ -91,7 +118,7 @@ export function GenerationForm() {
)}
/>
{form.watch('engine') === 'qwen' && (
{form.watch('engine') === 'qwen_custom_voice' && (
<FormField
control={form.control}
name="instruct"
@@ -118,7 +145,7 @@ export function GenerationForm() {
<div className="grid gap-4 md:grid-cols-3">
<FormItem>
<FormLabel>Model</FormLabel>
<EngineModelSelector form={form} />
<EngineModelSelector form={form} selectedProfile={selectedProfile} />
<FormDescription>
{getEngineDescription(form.watch('engine') || 'qwen')}
</FormDescription>
+168 -61
View File
@@ -1,15 +1,14 @@
import { useQueryClient } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import {
AlignCenter,
AudioLines,
AudioWaveform,
Download,
FileArchive,
Loader2,
MoreHorizontal,
Play,
RotateCcw,
Square,
Star,
Trash2,
Wand2,
@@ -45,6 +44,7 @@ import { apiClient } from '@/lib/api/client';
import type { EffectConfig, GenerationVersionResponse, HistoryResponse } from '@/lib/api/types';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import {
useClearFailedGenerations,
useDeleteGeneration,
useExportGeneration,
useExportGenerationAudio,
@@ -124,9 +124,28 @@ export function HistoryTable() {
});
const deleteGeneration = useDeleteGeneration();
const clearFailed = useClearFailedGenerations();
const [clearFailedDialogOpen, setClearFailedDialogOpen] = useState(false);
const exportGeneration = useExportGeneration();
const exportGenerationAudio = useExportGenerationAudio();
const importGeneration = useImportGeneration();
const cancelGeneration = useMutation({
mutationFn: (generationId: string) => apiClient.cancelGeneration(generationId),
onSuccess: async (data) => {
await queryClient.invalidateQueries({ queryKey: ['history'] });
toast({
title: 'Cancelling generation',
description: data.message,
});
},
onError: (error) => {
toast({
title: 'Cancel failed',
description: error instanceof Error ? error.message : 'Could not cancel generation',
variant: 'destructive',
});
},
});
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
@@ -157,11 +176,11 @@ export function HistoryTable() {
const pendingCount = useGenerationStore((state) => state.pendingGenerationIds.size);
const prevPendingCountRef = useRef(pendingCount);
useEffect(() => {
if (deleteGeneration.isSuccess || importGeneration.isSuccess) {
if (deleteGeneration.isSuccess || importGeneration.isSuccess || clearFailed.isSuccess) {
setPage(0);
setAllHistory([]);
}
}, [deleteGeneration.isSuccess, importGeneration.isSuccess]);
}, [deleteGeneration.isSuccess, importGeneration.isSuccess, clearFailed.isSuccess]);
useEffect(() => {
// A generation finished (pending count decreased) — scroll back to show it
@@ -415,6 +434,27 @@ export function HistoryTable() {
const history = allHistory;
const hasMore = allHistory.length < total;
const failedCount = history.filter((g) => g.status === 'failed').length;
const handleClearFailedConfirm = () => {
clearFailed.mutate(undefined, {
onSuccess: (data) => {
setClearFailedDialogOpen(false);
toast({
title: 'Cleared failed generations',
description: `${data.deleted} failed ${data.deleted === 1 ? 'generation' : 'generations'} removed.`,
});
},
onError: (error) => {
setClearFailedDialogOpen(false);
toast({
title: 'Failed to clear',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
},
});
};
return (
<div className="flex flex-col h-full min-h-0 relative">
@@ -424,6 +464,23 @@ export function HistoryTable() {
</div>
) : (
<>
{failedCount > 0 && (
<div className="flex items-center justify-between px-1 pb-2">
<span className="text-xs text-muted-foreground">
{failedCount} failed {failedCount === 1 ? 'generation' : 'generations'}
</span>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground hover:text-destructive"
onClick={() => setClearFailedDialogOpen(true)}
disabled={clearFailed.isPending}
>
<Trash2 className="h-3 w-3 mr-1.5" />
{clearFailed.isPending ? 'Clearing...' : 'Clear failed'}
</Button>
</div>
)}
{isScrolled && (
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
)}
@@ -442,6 +499,8 @@ export function HistoryTable() {
const isPlayable = !isGenerating && !isFailed;
const hasVersions = gen.versions && gen.versions.length > 1;
const isVersionsExpanded = expandedVersionsId === gen.id;
const isCancelling =
cancelGeneration.isPending && cancelGeneration.variables === gen.id;
return (
<div
key={gen.id}
@@ -569,69 +628,92 @@ export function HistoryTable() {
)}
{isFailed ? (
<>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
aria-label="Retry generation"
onClick={() => handleRetry(gen.id)}
>
<RotateCcw className="h-2 w-2" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
aria-label="Delete generation"
disabled={deleteGeneration.isPending}
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
>
<Trash2 className="h-2 w-2" />
</Button>
</>
) : isGenerating ? (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
aria-label="Retry generation"
onClick={() => handleRetry(gen.id)}
aria-label="Cancel generation"
disabled={isCancelling}
onClick={() => cancelGeneration.mutate(gen.id)}
>
<RotateCcw className="h-2 w-2" />
{isCancelling ? (
<Loader2 className="h-2 w-2 animate-spin" />
) : (
<Square className="h-2 w-2" />
)}
</Button>
) : (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
aria-label="Actions"
disabled={isGenerating}
>
<MoreHorizontal className="h-2 w-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
>
<Play className="mr-2 h-4 w-4" />
Play
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDownloadAudio(gen.id, gen.text)}
disabled={exportGenerationAudio.isPending}
>
<Download className="mr-2 h-4 w-4" />
Export Audio
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExportPackage(gen.id, gen.text)}
disabled={exportGeneration.isPending}
>
<FileArchive className="mr-2 h-4 w-4" />
Export Package
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleApplyEffects(gen.id)}>
<Wand2 className="mr-2 h-4 w-4" />
Apply Effects
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleRegenerate(gen.id)}>
<RotateCcw className="mr-2 h-4 w-4" />
Regenerate
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
disabled={deleteGeneration.isPending}
// className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
aria-label="Actions"
disabled={isGenerating}
>
<MoreHorizontal className="h-2 w-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}>
<Play className="mr-2 h-4 w-4" />
Play
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDownloadAudio(gen.id, gen.text)}
disabled={exportGenerationAudio.isPending}
>
<Download className="mr-2 h-4 w-4" />
Export Audio
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExportPackage(gen.id, gen.text)}
disabled={exportGeneration.isPending}
>
<FileArchive className="mr-2 h-4 w-4" />
Export Package
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleApplyEffects(gen.id)}>
<Wand2 className="mr-2 h-4 w-4" />
Apply Effects
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleRegenerate(gen.id)}>
<RotateCcw className="mr-2 h-4 w-4" />
Regenerate
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
disabled={deleteGeneration.isPending}
// className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
@@ -747,6 +829,31 @@ export function HistoryTable() {
</DialogContent>
</Dialog>
<Dialog open={clearFailedDialogOpen} onOpenChange={setClearFailedDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Clear failed generations</DialogTitle>
<DialogDescription>
This will permanently delete {failedCount} failed{' '}
{failedCount === 1 ? 'generation' : 'generations'} from your history. This cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setClearFailedDialogOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleClearFailedConfirm}
disabled={clearFailed.isPending}
>
{clearFailed.isPending ? 'Clearing...' : 'Clear all'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent>
<DialogHeader>
@@ -66,6 +66,12 @@ const MODEL_DESCRIPTIONS: Record<string, string> = {
'HumeAI TADA 1B — English speech-language model built on Llama 3.2 1B. Generates 700s+ of coherent audio with synchronized text-acoustic alignment.',
'tada-3b-ml':
'HumeAI TADA 3B Multilingual — built on Llama 3.2 3B. Supports 10 languages with high-fidelity voice cloning via text-acoustic dual alignment.',
kokoro:
'Kokoro 82M by hexgrad. Tiny 82M-parameter TTS that runs at CPU realtime. Supports 8 languages with pre-built voice styles. Apache 2.0 licensed.',
'qwen-custom-voice-1.7B':
'Qwen3-TTS CustomVoice 1.7B by Alibaba. 9 premium preset voices with instruct-based style control for tone, emotion, and prosody. Supports 10 languages.',
'qwen-custom-voice-0.6B':
'Qwen3-TTS CustomVoice 0.6B by Alibaba. Lightweight version with the same 9 preset voices and instruct control. Faster inference for lower-end hardware.',
'whisper-base':
'Smallest Whisper model (74M parameters). Fast transcription with moderate accuracy.',
'whisper-small':
@@ -394,9 +400,11 @@ export function ModelManagement() {
modelStatus?.models.filter(
(m) =>
m.model_name.startsWith('qwen-tts') ||
m.model_name.startsWith('qwen-custom-voice') ||
m.model_name.startsWith('luxtts') ||
m.model_name.startsWith('chatterbox') ||
m.model_name.startsWith('tada'),
m.model_name.startsWith('tada') ||
m.model_name.startsWith('kokoro'),
) ?? [];
const whisperModels = modelStatus?.models.filter((m) => m.model_name.startsWith('whisper')) ?? [];
@@ -969,7 +977,19 @@ export function ModelManagement() {
});
try {
// Start the migration (background task)
await apiClient.migrateModels(newDir);
const migrationResult = await apiClient.migrateModels(newDir);
// If no models to migrate, warn user and skip the change
if (migrationResult.moved === 0) {
setMigrating(false);
setMigrationProgress(null);
toast({
title: 'No models to migrate',
description: 'Download at least one model before changing the storage location.',
});
setPendingMigrateDir(null);
return;
}
// Connect to SSE for progress
await new Promise<void>((resolve, reject) => {
@@ -1056,105 +1076,3 @@ export function ModelManagement() {
);
}
interface ModelItemProps {
model: {
model_name: string;
display_name: string;
downloaded: boolean;
downloading?: boolean; // From server - true if download in progress
size_mb?: number;
loaded: boolean;
};
onDownload: () => void;
onDelete: () => void;
isDownloading: boolean; // Local state - true if user just clicked download
formatSize: (sizeMb?: number) => string;
}
function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: ModelItemProps) {
// Use server's downloading state OR local state (for immediate feedback before server updates)
const showDownloading = model.downloading || isDownloading;
const statusText = model.loaded
? 'Loaded'
: showDownloading
? 'Downloading'
: model.downloaded
? 'Downloaded'
: 'Not downloaded';
const sizeText =
model.downloaded && model.size_mb && !showDownloading ? `, ${formatSize(model.size_mb)}` : '';
const rowLabel = `${model.display_name}, ${statusText}${sizeText}. Use Tab to reach Download or Delete.`;
return (
<div
className="flex items-center justify-between p-3 border rounded-lg"
role="group"
tabIndex={0}
aria-label={rowLabel}
>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{model.display_name}</span>
{model.loaded && (
<Badge variant="default" className="text-xs">
Loaded
</Badge>
)}
{/* Only show Downloaded if actually downloaded AND not downloading */}
{model.downloaded && !model.loaded && !showDownloading && (
<Badge variant="secondary" className="text-xs">
Downloaded
</Badge>
)}
</div>
{model.downloaded && model.size_mb && !showDownloading && (
<div className="text-xs text-muted-foreground mt-1">
Size: {formatSize(model.size_mb)}
</div>
)}
</div>
<div className="flex items-center gap-2">
{model.downloaded && !showDownloading ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<span>Ready</span>
</div>
<Button
size="sm"
onClick={onDelete}
variant="outline"
disabled={model.loaded}
title={model.loaded ? 'Unload model before deleting' : 'Delete model'}
aria-label={
model.loaded ? 'Unload model before deleting' : `Delete ${model.display_name}`
}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : showDownloading ? (
<Button
size="sm"
variant="outline"
disabled
aria-label={`${model.display_name} downloading`}
>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Downloading...
</Button>
) : (
<Button
size="sm"
onClick={onDownload}
variant="outline"
aria-label={`Download ${model.display_name}`}
>
<Download className="h-4 w-4 mr-2" />
Download
</Button>
)}
</div>
</div>
);
}
@@ -12,7 +12,11 @@ interface ModelProgressProps {
isDownloading?: boolean;
}
export function ModelProgress({ modelName, displayName, isDownloading = false }: ModelProgressProps) {
export function ModelProgress({
modelName,
displayName,
isDownloading = false,
}: ModelProgressProps) {
const [progress, setProgress] = useState<ModelProgressType | null>(null);
const serverUrl = useServerStore((state) => state.serverUrl);
@@ -182,8 +182,8 @@ function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
return (
<div className="border-b border-border/50 pb-6">
<div className="flex items-baseline gap-3 mb-1">
<h3 className="text-sm font-medium">{entry.version}</h3>
<div className="flex items-baseline gap-3 mb-3">
<h3 className="text-xl font-semibold tracking-tight">{entry.version}</h3>
{entry.date && <span className="text-xs text-muted-foreground">{entry.date}</span>}
{entry.version === 'Unreleased' && <Badge variant="outline">dev</Badge>}
</div>
+12 -16
View File
@@ -87,7 +87,7 @@ export function StoryChatItem({
alt={`${item.profile_name} avatar`}
className={cn(
'h-full w-full object-cover transition-all duration-200',
!isCurrentlyPlaying && 'grayscale'
!isCurrentlyPlaying && 'grayscale',
)}
onError={() => setAvatarError(true)}
/>
@@ -127,7 +127,10 @@ export function StoryChatItem({
<Play className="mr-2 h-4 w-4" />
Play from here
</DropdownMenuItem>
<DropdownMenuItem onClick={onRemove} className="text-destructive focus:text-destructive">
<DropdownMenuItem
onClick={onRemove}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Remove from Story
</DropdownMenuItem>
@@ -139,15 +142,12 @@ export function StoryChatItem({
}
// Sortable wrapper component
export function SortableStoryChatItem(props: Omit<StoryChatItemProps, 'dragHandleProps' | 'isDragging'>) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: props.item.generation_id });
export function SortableStoryChatItem(
props: Omit<StoryChatItemProps, 'dragHandleProps' | 'isDragging'>,
) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.item.generation_id,
});
const style = {
transform: CSS.Transform.toString(transform),
@@ -156,11 +156,7 @@ export function SortableStoryChatItem(props: Omit<StoryChatItemProps, 'dragHandl
return (
<div ref={setNodeRef} style={style} {...attributes}>
<StoryChatItem
{...props}
dragHandleProps={listeners}
isDragging={isDragging}
/>
<StoryChatItem {...props} dragHandleProps={listeners} isDragging={isDragging} />
</div>
);
}
@@ -371,7 +371,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
}
}, [isResizing, handleResizeMove, handleResizeEnd]);
const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => {
const handleTimelineClick = (e: React.MouseEvent<HTMLElement>) => {
if (!tracksRef.current || draggingItem || trimmingItem) return;
const rect = tracksRef.current.getBoundingClientRect();
const x = e.clientX - rect.left + tracksRef.current.scrollLeft;
@@ -500,7 +500,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
}, [trimmingItem, trimSide, tempTrimValues, storyId, trimItem, toast]);
const handleSplit = useCallback(() => {
if (!selectedClipId) return;
if (!selectedClipId || splitItem.isPending) return;
const item = items.find((i) => i.id === selectedClipId);
if (!item) return;
@@ -14,12 +14,7 @@ const MemoizedWaveform = memo(function MemoizedWaveform({
<div className="absolute inset-0 pointer-events-none flex items-center justify-center opacity-30">
<Visualizer audio={audioStream} autoStart strokeColor="#b39a3d">
{({ canvasRef }) => (
<canvas
ref={canvasRef}
width={500}
height={150}
className="w-full h-full"
/>
<canvas ref={canvasRef} width={500} height={150} className="w-full h-full" />
)}
</Visualizer>
</div>
@@ -87,9 +82,7 @@ export function AudioSampleRecording({
<div className="space-y-4">
{!isRecording && !file && (
<div className="relative flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px] overflow-hidden">
{showWaveform && audioStream && (
<MemoizedWaveform audioStream={audioStream} />
)}
{showWaveform && audioStream && <MemoizedWaveform audioStream={audioStream} />}
<Button
type="button"
onClick={onStart}
@@ -107,9 +100,7 @@ export function AudioSampleRecording({
{isRecording && (
<div className="relative flex flex-col items-center justify-center gap-4 p-4 border-2 border-accent rounded-lg bg-accent/5 min-h-[180px] overflow-hidden">
{showWaveform && audioStream && (
<MemoizedWaveform audioStream={audioStream} />
)}
{showWaveform && audioStream && <MemoizedWaveform audioStream={audioStream} />}
<div className="relative z-10 flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-accent animate-pulse" />
@@ -17,11 +17,18 @@ import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn';
import { useUIStore } from '@/stores/uiStore';
/** Human-readable display names for preset engine badges. */
const ENGINE_DISPLAY_NAMES: Record<string, string> = {
kokoro: 'Kokoro',
qwen_custom_voice: 'CustomVoice',
};
interface ProfileCardProps {
profile: VoiceProfileResponse;
disabled?: boolean;
}
export function ProfileCard({ profile }: ProfileCardProps) {
export function ProfileCard({ profile, disabled }: ProfileCardProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const deleteProfile = useDeleteProfile();
@@ -34,6 +41,12 @@ export function ProfileCard({ profile }: 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);
return;
}
setSelectedProfileId(isSelected ? null : profile.id);
};
@@ -74,8 +87,9 @@ export function ProfileCard({ profile }: ProfileCardProps) {
<>
<Card
className={cn(
'cursor-pointer hover:shadow-md transition-all flex flex-col h-[162px]',
isSelected && 'ring-2 ring-accent shadow-md',
'cursor-pointer transition-all flex flex-col h-[162px]',
disabled ? 'opacity-40 hover:opacity-60' : 'hover:shadow-md',
isSelected && !disabled && 'ring-2 ring-accent shadow-md',
)}
onClick={handleSelect}
tabIndex={0}
@@ -97,6 +111,16 @@ export function ProfileCard({ profile }: ProfileCardProps) {
<Badge variant="outline" className="text-xs h-5 px-1.5 text-muted-foreground">
{profile.language}
</Badge>
{profile.voice_type === 'preset' && (
<Badge variant="secondary" className="text-xs h-5 px-1.5">
{ENGINE_DISPLAY_NAMES[profile.preset_engine ?? ''] ?? profile.preset_engine}
</Badge>
)}
{profile.voice_type === 'designed' && (
<Badge variant="secondary" className="text-xs h-5 px-1.5">
designed
</Badge>
)}
{profile.effects_chain && profile.effects_chain.length > 0 && (
<Sparkles className="h-3.5 w-3.5 text-accent fill-accent" />
)}
+416 -131
View File
@@ -1,9 +1,11 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Edit2, Mic, Monitor, Upload, X } from 'lucide-react';
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 * as z from 'zod';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -15,6 +17,7 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -32,7 +35,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type { EffectConfig } from '@/lib/api/types';
import type { EffectConfig, PresetVoice, VoiceType } from '@/lib/api/types';
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
@@ -40,6 +43,7 @@ import {
useAddSample,
useCreateProfile,
useDeleteAvatar,
useDeleteProfile,
useProfile,
useUpdateProfile,
useUploadAvatar,
@@ -56,6 +60,16 @@ import { AudioSampleUpload } from './AudioSampleUpload';
import { SampleList } from './SampleList';
const MAX_AUDIO_DURATION_SECONDS = 30;
const PRESET_ONLY_ENGINES = new Set(['kokoro', 'qwen_custom_voice']);
const DEFAULT_ENGINE_OPTIONS = [
{ value: 'qwen', label: 'Qwen3-TTS' },
{ value: 'qwen_custom_voice', label: 'Qwen CustomVoice' },
{ value: 'luxtts', label: 'LuxTTS' },
{ value: 'chatterbox', label: 'Chatterbox' },
{ value: 'chatterbox_turbo', label: 'Chatterbox Turbo' },
{ value: 'tada', label: 'TADA' },
{ value: 'kokoro', label: 'Kokoro 82M' },
] as const;
const baseProfileSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
@@ -116,20 +130,25 @@ export function ProfileForm() {
const createProfile = useCreateProfile();
const updateProfile = useUpdateProfile();
const addSample = useAddSample();
const deleteProfile = useDeleteProfile();
const uploadAvatar = useUploadAvatar();
const deleteAvatar = useDeleteAvatar();
const transcribe = useTranscription();
const { toast } = useToast();
const [voiceSource, setVoiceSource] = useState<'clone' | 'builtin'>('clone');
const [sampleMode, setSampleMode] = useState<'upload' | 'record' | 'system'>('record');
const [audioDuration, setAudioDuration] = useState<number | null>(null);
const [isValidatingAudio, setIsValidatingAudio] = useState(false);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [selectedPresetEngine, setSelectedPresetEngine] = useState<string>('kokoro');
const [selectedPresetVoiceId, setSelectedPresetVoiceId] = useState<string>('');
const avatarInputRef = useRef<HTMLInputElement>(null);
const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer();
const isCreating = !editingProfileId;
const serverUrl = useServerStore((state) => state.serverUrl);
const [profileEffectsChain, setProfileEffectsChain] = useState<EffectConfig[]>([]);
const [effectsDirty, setEffectsDirty] = useState(false);
const [defaultEngine, setDefaultEngine] = useState<string>('');
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
@@ -239,6 +258,26 @@ export function ProfileForm() {
},
});
// Fetch available preset voices for the selected engine
const presetEngineToQuery = isCreating
? selectedPresetEngine
: (editingProfile?.preset_engine ?? '');
const { data: presetVoicesData } = useQuery({
queryKey: ['presetVoices', presetEngineToQuery],
queryFn: () => apiClient.listPresetVoices(presetEngineToQuery),
enabled:
!!presetEngineToQuery &&
((voiceSource === 'builtin' && isCreating) ||
(!isCreating && editingProfile?.voice_type === 'preset')),
});
const presetVoices = presetVoicesData?.voices ?? [];
const isSampleBasedProfile = isCreating
? voiceSource === 'clone'
: editingProfile?.voice_type !== 'preset';
const availableDefaultEngines = DEFAULT_ENGINE_OPTIONS.filter(
(option) => !isSampleBasedProfile || !PRESET_ONLY_ENGINES.has(option.value),
);
// Show recording errors
useEffect(() => {
if (recordingError) {
@@ -287,6 +326,7 @@ export function ProfileForm() {
});
setProfileEffectsChain(editingProfile.effects_chain ?? []);
setEffectsDirty(false);
setDefaultEngine(editingProfile.default_engine ?? '');
} else if (profileFormDraft && open) {
// Restore from draft when opening in create mode
form.reset({
@@ -326,6 +366,24 @@ export function ProfileForm() {
}
}, [editingProfile, profileFormDraft, open, form]);
useEffect(() => {
if (
defaultEngine &&
!availableDefaultEngines.some((option) => option.value === defaultEngine)
) {
setDefaultEngine('');
}
}, [availableDefaultEngines, defaultEngine]);
useEffect(() => {
if (!selectedPresetVoiceId) {
return;
}
if (!presetVoices.some((voice: PresetVoice) => voice.voice_id === selectedPresetVoiceId)) {
setSelectedPresetVoiceId('');
}
}, [presetVoices, selectedPresetVoiceId]);
async function handleTranscribe() {
const file = form.getValues('sampleFile');
if (!file) {
@@ -415,13 +473,14 @@ export function ProfileForm() {
async function onSubmit(data: ProfileFormValues) {
try {
if (editingProfileId) {
// Editing: just update profile
// Editing: update profile
await updateProfile.mutateAsync({
profileId: editingProfileId,
data: {
name: data.name,
description: data.description,
language: data.language,
default_engine: defaultEngine || undefined,
},
});
@@ -464,8 +523,50 @@ export function ProfileForm() {
title: 'Voice updated',
description: `"${data.name}" has been updated successfully.`,
});
} 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.',
variant: 'destructive',
});
return;
}
const profile = await createProfile.mutateAsync({
name: data.name,
description: data.description,
language: data.language,
voice_type: 'preset' as VoiceType,
preset_engine: selectedPresetEngine,
preset_voice_id: selectedPresetVoiceId,
default_engine: selectedPresetEngine,
});
// Handle avatar upload if provided
if (data.avatarFile) {
try {
await uploadAvatar.mutateAsync({
profileId: profile.id,
file: data.avatarFile,
});
} catch (avatarError) {
toast({
title: 'Avatar upload failed',
description:
avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
variant: 'destructive',
});
}
}
toast({
title: 'Profile created',
description: `"${data.name}" has been created with a built-in voice.`,
});
} else {
// Creating: require sample file and reference text
// Creating cloned profile: require sample file and reference text
const sampleFile = form.getValues('sampleFile');
const referenceText = form.getValues('referenceText');
@@ -528,6 +629,7 @@ export function ProfileForm() {
name: data.name,
description: data.description,
language: data.language,
default_engine: defaultEngine || undefined,
});
// Convert non-WAV uploads to WAV so the backend can always use soundfile.
@@ -572,12 +674,32 @@ export function ProfileForm() {
description: `"${data.name}" has been created with a sample.`,
});
} catch (sampleError) {
// Profile was created but sample failed - still show error
let rollbackSucceeded = false;
try {
await deleteProfile.mutateAsync(profile.id);
rollbackSucceeded = true;
} catch (rollbackError) {
toast({
title: 'Rollback failed',
description:
rollbackError instanceof Error
? rollbackError.message
: 'Created profile could not be removed after sample upload failure.',
variant: 'destructive',
});
}
toast({
title: 'Failed to add sample',
description: `Profile "${data.name}" was created, but failed to add sample: ${sampleError instanceof Error ? sampleError.message : 'Unknown error'}`,
description:
sampleError instanceof Error
? `${sampleError.message}${rollbackSucceeded ? ' The profile was rolled back.' : ''}`
: rollbackSucceeded
? 'Failed to add sample. The profile was rolled back.'
: 'Failed to add sample.',
variant: 'destructive',
});
return;
}
}
@@ -642,16 +764,16 @@ export function ProfileForm() {
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-none w-screen h-screen left-0 top-0 translate-x-0 translate-y-0 rounded-none p-6 overflow-y-auto">
<div className="max-w-5xl max-h-[85vh] mx-auto my-auto w-full flex flex-col">
<DialogContent className="max-w-none w-screen h-screen left-0 top-0 translate-x-0 translate-y-0 rounded-none p-6 overflow-hidden">
<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' : 'Clone voice'}
{editingProfileId ? 'Edit Voice' : 'Create Voice'}
</DialogTitle>
<DialogDescription>
{editingProfileId
? 'Update your voice profile details and manage samples.'
: 'Create a new voice profile with an audio sample to clone the voice.'}
: 'Create a new voice profile from an audio sample or a built-in voice.'}
</DialogDescription>
{isCreating && profileFormDraft && (
<div className="flex items-center gap-2 pt-2">
@@ -682,143 +804,276 @@ export function ProfileForm() {
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 min-h-0 flex flex-col">
<div className="grid gap-6 grid-cols-2 flex-1 overflow-y-auto min-h-0">
<div className="grid gap-6 grid-cols-2 flex-1 min-h-0 overflow-hidden">
{/* Left column: Sample management */}
<div className="space-y-4 border-r pr-6">
<div className="space-y-4 border-r pr-6 overflow-y-auto min-h-0">
{isCreating ? (
<>
<Tabs
className="pt-4"
value={sampleMode}
onValueChange={(v) => {
const newMode = v as 'upload' | 'record' | 'system';
// Cancel any active recordings when switching modes
if (isRecording && newMode !== 'record') {
cancelRecording();
}
if (isSystemRecording && newMode !== 'system') {
cancelSystemRecording();
}
setSampleMode(newMode);
}}
>
<TabsList
className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
>
<TabsTrigger value="upload" className="flex items-center gap-2">
<Upload className="h-4 w-4 shrink-0" />
Upload
</TabsTrigger>
<TabsTrigger value="record" className="flex items-center gap-2">
<Mic className="h-4 w-4 shrink-0" />
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
</TabsTrigger>
)}
</TabsList>
{/* Voice source selector */}
<div className="flex pt-4 pb-2">
<div className="inline-flex rounded-lg border border-border p-0.5 bg-muted/50">
<button
type="button"
onClick={() => setVoiceSource('clone')}
className={`inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${
voiceSource === 'clone'
? 'bg-accent text-accent-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Mic className="h-3.5 w-3.5" />
Clone from audio
</button>
<button
type="button"
onClick={() => setVoiceSource('builtin')}
className={`inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${
voiceSource === 'builtin'
? 'bg-accent text-accent-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Music className="h-3.5 w-3.5" />
Built-in voice
</button>
</div>
</div>
<TabsContent value="upload" className="space-y-4">
<FormField
control={form.control}
name="sampleFile"
render={({ field: { onChange, name } }) => (
<AudioSampleUpload
file={selectedFile}
onFileChange={onChange}
onTranscribe={handleTranscribe}
onPlayPause={handlePlayPause}
isPlaying={isPlaying}
isValidating={isValidatingAudio}
isTranscribing={transcribe.isPending}
isDisabled={
audioDuration !== null &&
audioDuration > MAX_AUDIO_DURATION_SECONDS
}
fieldName={name}
/>
)}
/>
</TabsContent>
{voiceSource === 'builtin' ? (
<div className="space-y-4">
<FormDescription>
Choose a pre-built voice. These don't require an audio sample.
</FormDescription>
<TabsContent value="record" className="space-y-4">
<FormField
control={form.control}
name="sampleFile"
render={() => (
<AudioSampleRecording
file={selectedFile}
isRecording={isRecording}
duration={duration}
onStart={startRecording}
onStop={stopRecording}
onCancel={handleCancelRecording}
onTranscribe={handleTranscribe}
onPlayPause={handlePlayPause}
isPlaying={isPlaying}
isTranscribing={transcribe.isPending}
/>
)}
/>
</TabsContent>
{platform.metadata.isTauri && isSystemAudioSupported && (
<TabsContent value="system" className="space-y-4">
<FormField
control={form.control}
name="sampleFile"
render={() => (
<AudioSampleSystem
file={selectedFile}
isRecording={isSystemRecording}
duration={systemDuration}
onStart={startSystemRecording}
onStop={stopSystemRecording}
onCancel={handleCancelRecording}
onTranscribe={handleTranscribe}
onPlayPause={handlePlayPause}
isPlaying={isPlaying}
isTranscribing={transcribe.isPending}
/>
)}
/>
</TabsContent>
)}
</Tabs>
<FormField
control={form.control}
name="referenceText"
render={({ field }) => (
{/* Engine selector */}
<FormItem>
<FormLabel>Reference Text</FormLabel>
<FormControl>
<Textarea
placeholder="Enter the exact text spoken in the audio..."
className="min-h-[100px]"
{...field}
/>
</FormControl>
<FormMessage />
<FormLabel>Engine</FormLabel>
<Select
value={selectedPresetEngine}
onValueChange={setSelectedPresetEngine}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="kokoro">Kokoro 82M</SelectItem>
<SelectItem value="qwen_custom_voice">Qwen CustomVoice</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
{/* Voice picker */}
<FormItem>
<FormLabel>Voice</FormLabel>
<div className="grid grid-cols-2 gap-1.5 max-h-[340px] overflow-y-auto pr-1">
{presetVoices.map((voice: PresetVoice) => (
<button
key={voice.voice_id}
type="button"
onClick={() => {
setSelectedPresetVoiceId(voice.voice_id);
// Auto-set language from voice
if (voice.language) {
form.setValue('language', voice.language as LanguageCode);
}
}}
className={`text-left px-3 py-2 rounded-md border text-sm transition-colors ${
selectedPresetVoiceId === voice.voice_id
? 'border-accent bg-accent/10 text-accent-foreground'
: 'border-border hover:bg-muted'
}`}
>
<div className="font-medium">{voice.name}</div>
<div className="flex gap-1.5 mt-0.5">
<Badge variant="outline" className="text-[10px] h-4 px-1">
{voice.gender}
</Badge>
<Badge variant="outline" className="text-[10px] h-4 px-1">
{voice.language}
</Badge>
</div>
</button>
))}
</div>
</FormItem>
</div>
) : (
<>
<Tabs
className="pt-0"
value={sampleMode}
onValueChange={(v) => {
const newMode = v as 'upload' | 'record' | 'system';
// Cancel any active recordings when switching modes
if (isRecording && newMode !== 'record') {
cancelRecording();
}
if (isSystemRecording && newMode !== 'system') {
cancelSystemRecording();
}
setSampleMode(newMode);
}}
>
<TabsList
className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
>
<TabsTrigger value="upload" className="flex items-center gap-2">
<Upload className="h-4 w-4 shrink-0" />
Upload
</TabsTrigger>
<TabsTrigger value="record" className="flex items-center gap-2">
<Mic className="h-4 w-4 shrink-0" />
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
</TabsTrigger>
)}
</TabsList>
<TabsContent value="upload" className="space-y-4">
<FormField
control={form.control}
name="sampleFile"
render={({ field: { onChange, name } }) => (
<AudioSampleUpload
file={selectedFile}
onFileChange={onChange}
onTranscribe={handleTranscribe}
onPlayPause={handlePlayPause}
isPlaying={isPlaying}
isValidating={isValidatingAudio}
isTranscribing={transcribe.isPending}
isDisabled={
audioDuration !== null &&
audioDuration > MAX_AUDIO_DURATION_SECONDS
}
fieldName={name}
/>
)}
/>
</TabsContent>
<TabsContent value="record" className="space-y-4">
<FormField
control={form.control}
name="sampleFile"
render={() => (
<AudioSampleRecording
file={selectedFile}
isRecording={isRecording}
duration={duration}
onStart={startRecording}
onStop={stopRecording}
onCancel={handleCancelRecording}
onTranscribe={handleTranscribe}
onPlayPause={handlePlayPause}
isPlaying={isPlaying}
isTranscribing={transcribe.isPending}
/>
)}
/>
</TabsContent>
{platform.metadata.isTauri && isSystemAudioSupported && (
<TabsContent value="system" className="space-y-4">
<FormField
control={form.control}
name="sampleFile"
render={() => (
<AudioSampleSystem
file={selectedFile}
isRecording={isSystemRecording}
duration={systemDuration}
onStart={startSystemRecording}
onStop={stopSystemRecording}
onCancel={handleCancelRecording}
onTranscribe={handleTranscribe}
onPlayPause={handlePlayPause}
isPlaying={isPlaying}
isTranscribing={transcribe.isPending}
/>
)}
/>
</TabsContent>
)}
</Tabs>
<FormField
control={form.control}
name="referenceText"
render={({ field }) => (
<FormItem>
<FormLabel>Reference Text</FormLabel>
<FormControl>
<Textarea
placeholder="Enter the exact text spoken in the audio..."
className="min-h-[100px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</>
) : (
// Show sample list when editing
editingProfileId && (
// Editing mode
editingProfileId &&
editingProfile &&
(editingProfile.voice_type === 'preset' ? (
<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
</div>
<div className="flex items-center gap-3">
<div className="text-lg font-semibold">
{presetVoices.find(
(v: PresetVoice) => v.voice_id === editingProfile.preset_voice_id,
)?.name ?? editingProfile.preset_voice_id}
</div>
<Badge variant="secondary" className="text-xs">
{editingProfile.preset_engine}
</Badge>
</div>
{(() => {
const voice = presetVoices.find(
(v: PresetVoice) => v.voice_id === editingProfile.preset_voice_id,
);
return voice ? (
<div className="flex gap-1.5">
<Badge variant="outline" className="text-xs">
{voice.gender}
</Badge>
<Badge variant="outline" className="text-xs">
{voice.language}
</Badge>
</div>
) : null;
})()}
</div>
<p className="text-xs text-muted-foreground">
This profile uses a built-in voice. The voice cannot be changed after
creation.
</p>
</div>
) : (
<div>
<SampleList profileId={editingProfileId} />
</div>
)
))
)}
</div>
{/* Right column: Profile info */}
<div className="space-y-4">
<div className="space-y-4 overflow-y-auto min-h-0">
{/* Avatar Upload */}
<FormField
control={form.control}
@@ -924,6 +1179,36 @@ export function ProfileForm() {
)}
/>
<FormItem>
<FormLabel>Default Engine</FormLabel>
<Select
value={defaultEngine || '_none'}
onValueChange={(v) => {
setDefaultEngine(v === '_none' ? '' : v);
}}
disabled={
voiceSource === 'builtin' || editingProfile?.voice_type === 'preset'
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="No preference" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="_none">No preference</SelectItem>
{availableDefaultEngines.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Auto-selects this engine when the profile is chosen.
</p>
</FormItem>
{editingProfileId && (
<div className="space-y-2">
<FormLabel>Default Effects</FormLabel>
@@ -1,4 +1,5 @@
import { Mic, Sparkles } from 'lucide-react';
import { Info, Mic, Sparkles } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useProfiles } from '@/lib/hooks/useProfiles';
@@ -6,9 +7,36 @@ import { useUIStore } from '@/stores/uiStore';
import { ProfileCard } from './ProfileCard';
import { ProfileForm } from './ProfileForm';
/** Engines that use preset (built-in) voices instead of cloned profiles. */
const PRESET_ENGINES = new Set(['kokoro', 'qwen_custom_voice']);
export function ProfileList() {
const { data: profiles, isLoading, error } = useProfiles();
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const selectedEngine = useUIStore((state) => state.selectedEngine);
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Scroll to the selected profile after engine/sort changes
useEffect(() => {
if (!selectedProfileId) return;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const rafId = requestAnimationFrame(() => {
const el = cardRefs.current.get(selectedProfileId);
if (!el) return;
// Temporarily apply scroll-margin so it doesn't land flush at the top
el.style.scrollMarginTop = '180px';
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
timeoutId = setTimeout(() => {
el.style.scrollMarginTop = '';
}, 500);
});
return () => {
cancelAnimationFrame(rafId);
if (timeoutId) clearTimeout(timeoutId);
};
}, [selectedProfileId, selectedEngine]);
if (isLoading) {
return null;
@@ -23,6 +51,20 @@ export function ProfileList() {
}
const allProfiles = profiles || [];
const isPresetEngine = PRESET_ENGINES.has(selectedEngine);
/** Whether a profile is supported by the currently selected engine. */
const isSupported = (p: (typeof allProfiles)[number]) =>
isPresetEngine
? p.voice_type === 'preset' && p.preset_engine === selectedEngine
: p.voice_type !== 'preset';
// Sort so supported profiles come first
const sortedProfiles = [...allProfiles].sort(
(a, b) => (isSupported(a) ? 0 : 1) - (isSupported(b) ? 0 : 1),
);
const hasUnsupported = sortedProfiles.some((p) => !isSupported(p));
return (
<div className="flex flex-col">
@@ -42,11 +84,24 @@ export function ProfileList() {
</Card>
) : (
<div className="flex gap-4 overflow-x-auto p-1 pb-1 lg:grid lg:grid-cols-3 lg:auto-rows-auto lg:overflow-x-visible lg:pb-[150px]">
{allProfiles.map((profile) => (
<div key={profile.id} className="shrink-0 w-[200px] lg:w-auto lg:shrink">
<ProfileCard profile={profile} />
{sortedProfiles.map((profile) => (
<div
key={profile.id}
className="shrink-0 w-[200px] lg:w-auto lg:shrink"
ref={(el) => {
if (el) cardRefs.current.set(profile.id, el);
else cardRefs.current.delete(profile.id);
}}
>
<ProfileCard profile={profile} disabled={!isSupported(profile)} />
</div>
))}
{hasUnsupported && (
<div className="col-span-full flex items-center gap-2 text-xs text-muted-foreground py-2">
<Info className="h-3.5 w-3.5 shrink-0" />
<span>Only supported voice profiles can be selected for the current model.</span>
</div>
)}
</div>
)}
</div>
+1 -1
View File
@@ -111,4 +111,4 @@ export {
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
};
+13 -4
View File
@@ -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,
+1 -1
View File
@@ -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(() => {
+20 -1
View File
@@ -17,6 +17,7 @@ import type {
HistoryResponse,
ModelDownloadRequest,
ModelStatusListResponse,
PresetVoice,
ProfileSampleResponse,
StoryCreate,
StoryDetailResponse,
@@ -97,6 +98,10 @@ class ApiClient {
return this.request<VoiceProfileResponse>(`/profiles/${profileId}`);
}
async listPresetVoices(engine: string): Promise<{ engine: string; voices: PresetVoice[] }> {
return this.request<{ engine: string; voices: PresetVoice[] }>(`/profiles/presets/${engine}`);
}
async updateProfile(profileId: string, data: VoiceProfileCreate): Promise<VoiceProfileResponse> {
return this.request<VoiceProfileResponse>(`/profiles/${profileId}`, {
method: 'PUT',
@@ -229,6 +234,12 @@ class ApiClient {
});
}
async cancelGeneration(generationId: string): Promise<{ message: string }> {
return this.request<{ message: string }>(`/generate/${generationId}/cancel`, {
method: 'POST',
});
}
async regenerateGeneration(generationId: string): Promise<GenerationResponse> {
return this.request<GenerationResponse>(`/generate/${generationId}/regenerate`, {
method: 'POST',
@@ -265,6 +276,12 @@ class ApiClient {
});
}
async clearFailedGenerations(): Promise<{ deleted: number }> {
return this.request<{ deleted: number }>(`/history/failed`, {
method: 'DELETE',
});
}
async exportGeneration(generationId: string): Promise<Blob> {
const url = `${this.getBaseUrl()}/history/${generationId}/export`;
const response = await fetch(url);
@@ -373,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 }),
+27 -1
View File
@@ -1,10 +1,17 @@
// API Types matching backend Pydantic models
import type { LanguageCode } from '@/lib/constants/languages';
export type VoiceType = 'cloned' | 'preset' | 'designed';
export interface VoiceProfileCreate {
name: string;
description?: string;
language: LanguageCode;
voice_type?: VoiceType;
preset_engine?: string;
preset_voice_id?: string;
design_prompt?: string;
default_engine?: string;
}
export interface VoiceProfileResponse {
@@ -14,12 +21,24 @@ export interface VoiceProfileResponse {
language: string;
avatar_path?: string;
effects_chain?: EffectConfig[];
voice_type: VoiceType;
preset_engine?: string;
preset_voice_id?: string;
design_prompt?: string;
default_engine?: string;
generation_count: number;
sample_count: number;
created_at: string;
updated_at: string;
}
export interface PresetVoice {
voice_id: string;
name: string;
gender: 'male' | 'female';
language: string;
}
export interface ProfileSampleCreate {
reference_text: string;
}
@@ -43,7 +62,14 @@ export interface GenerationRequest {
language: LanguageCode;
seed?: number;
model_size?: '1.7B' | '0.6B' | '1B' | '3B';
engine?: 'qwen' | 'luxtts' | 'chatterbox' | 'chatterbox_turbo' | 'tada';
engine?:
| 'qwen'
| 'qwen_custom_voice'
| 'luxtts'
| 'chatterbox'
| 'chatterbox_turbo'
| 'tada'
| 'kokoro';
instruct?: string;
max_chunk_chars?: number;
crossfade_ms?: number;
+3
View File
@@ -5,6 +5,7 @@
* LuxTTS is English-only.
* Chatterbox Multilingual supports 23 languages.
* Chatterbox Turbo is English-only.
* Kokoro supports 8 languages.
*/
/** All languages that any engine supports. */
@@ -67,6 +68,8 @@ export const ENGINE_LANGUAGES: Record<string, readonly LanguageCode[]> = {
],
chatterbox_turbo: ['en'],
tada: ['en', 'ar', 'zh', 'de', 'es', 'fr', 'it', 'ja', 'pl', 'pt'],
kokoro: ['en', 'es', 'fr', 'hi', 'it', 'pt', 'ja', 'zh'],
qwen_custom_voice: ['zh', 'en', 'ja', 'ko', 'de', 'fr', 'ru', 'pt', 'es', 'it'],
} as const;
/** Helper: get language options for a given engine. */
+34 -8
View File
@@ -10,6 +10,7 @@ import { useGeneration } from '@/lib/hooks/useGeneration';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { useGenerationStore } from '@/stores/generationStore';
import { useServerStore } from '@/stores/serverStore';
import { useUIStore } from '@/stores/uiStore';
const generationSchema = z.object({
text: z.string().min(1, '').max(50000),
@@ -17,7 +18,17 @@ const generationSchema = z.object({
seed: z.number().int().optional(),
modelSize: z.enum(['1.7B', '0.6B', '1B', '3B']).optional(),
instruct: z.string().max(500).optional(),
engine: z.enum(['qwen', 'luxtts', 'chatterbox', 'chatterbox_turbo', 'tada']).optional(),
engine: z
.enum([
'qwen',
'qwen_custom_voice',
'luxtts',
'chatterbox',
'chatterbox_turbo',
'tada',
'kokoro',
])
.optional(),
});
export type GenerationFormValues = z.infer<typeof generationSchema>;
@@ -35,6 +46,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
const selectedEngine = useUIStore((state) => state.selectedEngine);
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
@@ -52,7 +64,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
seed: undefined,
modelSize: '1.7B',
instruct: '',
engine: 'qwen',
engine: (selectedEngine as GenerationFormValues['engine']) || 'qwen',
...options.defaultValues,
},
});
@@ -83,7 +95,11 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
? data.modelSize === '3B'
? 'tada-3b-ml'
: 'tada-1b'
: `qwen-tts-${data.modelSize}`;
: engine === 'kokoro'
? 'kokoro'
: engine === 'qwen_custom_voice'
? `qwen-custom-voice-${data.modelSize}`
: `qwen-tts-${data.modelSize}`;
const displayName =
engine === 'luxtts'
? 'LuxTTS'
@@ -95,9 +111,15 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
? data.modelSize === '3B'
? 'TADA 3B Multilingual'
: 'TADA 1B'
: data.modelSize === '1.7B'
? 'Qwen TTS 1.7B'
: 'Qwen TTS 0.6B';
: engine === 'kokoro'
? 'Kokoro 82M'
: engine === 'qwen_custom_voice'
? data.modelSize === '1.7B'
? 'Qwen CustomVoice 1.7B'
: 'Qwen CustomVoice 0.6B'
: data.modelSize === '1.7B'
? 'Qwen TTS 1.7B'
: 'Qwen TTS 0.6B';
// Check if model needs downloading
try {
@@ -112,7 +134,11 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
console.error('Failed to check model status:', error);
}
const hasModelSizes = engine === 'qwen' || engine === 'tada';
const hasModelSizes =
engine === 'qwen' || engine === 'qwen_custom_voice' || engine === 'tada';
// Only Qwen CustomVoice actually honors the instruct kwarg at model level.
// Base Qwen3-TTS accepts the kwarg but ignores it.
const supportsInstruct = engine === 'qwen_custom_voice';
const effectsChain = options.getEffectsChain?.();
// This now returns immediately with status="generating"
const result = await generation.mutateAsync({
@@ -122,7 +148,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
seed: data.seed,
model_size: hasModelSizes ? data.modelSize : undefined,
engine,
instruct: engine === 'qwen' ? data.instruct || undefined : undefined,
instruct: supportsInstruct ? data.instruct || undefined : undefined,
max_chunk_chars: maxChunkChars,
crossfade_ms: crossfadeMs,
normalize: normalizeAudio,
+11
View File
@@ -29,6 +29,17 @@ export function useDeleteGeneration() {
});
}
export function useClearFailedGenerations() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => apiClient.clearFailedGenerations(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['history'] });
},
});
}
export function useExportGeneration() {
const platform = usePlatform();
+2 -1
View File
@@ -131,7 +131,8 @@ export function useModelDownloadToast({
)}
</div>
),
duration: progress.status === 'complete' || progress.status === 'error' ? 5000 : Infinity,
duration:
progress.status === 'complete' || progress.status === 'error' ? 5000 : Infinity,
});
// Close connection and dismiss toast on completion or error
+18 -2
View File
@@ -26,8 +26,24 @@ export function useSystemAudioCapture({
// Check if system audio capture is supported
useEffect(() => {
const supported = platform.audio.isSystemAudioSupported();
setIsSupported(supported);
let isActive = true;
void platform.audio
.isSystemAudioSupported()
.then((supported) => {
if (isActive) {
setIsSupported(supported);
}
})
.catch(() => {
if (isActive) {
setIsSupported(false);
}
});
return () => {
isActive = false;
};
}, [platform]);
const startRecording = useCallback(async () => {
+19
View File
@@ -0,0 +1,19 @@
import { QueryClient } from '@tanstack/react-query';
/**
* Shared QueryClient instance used across the app.
*
* Extracted into its own side-effect-free module so it can be imported from
* both the React bootstrap (main.tsx) and non-React code (stores, utilities)
* without pulling in ReactDOM or other bootstrap side effects.
*/
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
retry: 1,
refetchOnWindowFocus: false,
},
},
});
+2 -12
View File
@@ -1,20 +1,10 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { QueryClientProvider } from '@tanstack/react-query';
// import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
retry: 1,
refetchOnWindowFocus: false,
},
},
});
import { queryClient } from './lib/queryClient';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
+1 -5
View File
@@ -9,11 +9,7 @@ export interface PlatformProviderProps {
}
export function PlatformProvider({ platform, children }: PlatformProviderProps) {
return (
<PlatformContext.Provider value={platform}>
{children}
</PlatformContext.Provider>
);
return <PlatformContext.Provider value={platform}>{children}</PlatformContext.Provider>;
}
export function usePlatform(): Platform {
+1 -1
View File
@@ -42,7 +42,7 @@ export interface AudioDevice {
}
export interface PlatformAudio {
isSystemAudioSupported(): boolean;
isSystemAudioSupported(): Promise<boolean>;
startSystemAudioCapture(maxDurationSecs: number): Promise<void>;
stopSystemAudioCapture(): Promise<Blob>;
listOutputDevices(): Promise<AudioDevice[]>;
+17 -2
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { queryClient } from '@/lib/queryClient';
interface ServerStore {
serverUrl: string;
@@ -30,11 +31,25 @@ interface ServerStore {
setCustomModelsDir: (dir: string | null) => void;
}
/**
* Invalidate all React Query caches so stale data from the previous
* server is not shown. Called when the server URL changes.
*/
function invalidateAllServerData() {
queryClient.invalidateQueries();
}
export const useServerStore = create<ServerStore>()(
persist(
(set) => ({
(set, get) => ({
serverUrl: 'http://127.0.0.1:17493',
setServerUrl: (url) => set({ serverUrl: url }),
setServerUrl: (url) => {
const prev = get().serverUrl;
set({ serverUrl: url });
if (url !== prev) {
invalidateAllServerData();
}
},
isConnected: false,
setIsConnected: (connected) => set({ isConnected: connected }),
+7
View File
@@ -31,6 +31,10 @@ interface UIStore {
selectedProfileId: string | null;
setSelectedProfileId: (id: string | null) => void;
// Currently selected engine (synced from generation form)
selectedEngine: string;
setSelectedEngine: (engine: string) => void;
// Selected voice in Voices tab inspector
selectedVoiceId: string | null;
setSelectedVoiceId: (id: string | null) => void;
@@ -59,6 +63,9 @@ export const useUIStore = create<UIStore>((set) => ({
selectedProfileId: null,
setSelectedProfileId: (id) => set({ selectedProfileId: id }),
selectedEngine: 'qwen',
setSelectedEngine: (engine) => set({ selectedEngine: engine }),
selectedVoiceId: null,
setSelectedVoiceId: (id) => set({ selectedVoiceId: id }),
+1 -1
View File
@@ -1,3 +1,3 @@
# Backend package
__version__ = "0.3.1"
__version__ = "0.4.1"
+31 -3
View File
@@ -135,7 +135,7 @@ def _mount_frontend(application: FastAPI) -> None:
async def serve_spa(full_path: str):
file_path = (frontend_dir / full_path).resolve()
# Guard against path traversal — only serve files inside frontend_dir
if full_path and file_path.is_file() and str(file_path).startswith(str(frontend_dir)):
if full_path and file_path.is_file() and file_path.is_relative_to(frontend_dir):
return FileResponse(file_path)
return FileResponse(frontend_dir / "index.html", media_type="text/html")
@@ -146,15 +146,36 @@ def _get_gpu_status() -> str:
"""Return a human-readable string describing GPU availability."""
backend_type = get_backend_type()
if torch.cuda.is_available():
from .backends.base import check_cuda_compatibility
device_name = torch.cuda.get_device_name(0)
compatible, _warning = check_cuda_compatibility()
is_rocm = hasattr(torch.version, "hip") and torch.version.hip is not None
if is_rocm:
return f"ROCm ({device_name})"
return f"CUDA ({device_name})"
label = f"ROCm ({device_name})"
else:
label = f"CUDA ({device_name})"
if not compatible:
label += " [UNSUPPORTED - see logs]"
return label
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "MPS (Apple Silicon)"
elif backend_type == "mlx":
return "Metal (Apple Silicon via MLX)"
# Intel XPU (Arc / Data Center) via IPEX
try:
import intel_extension_for_pytorch # noqa: F401
if hasattr(torch, "xpu") and torch.xpu.is_available():
try:
xpu_name = torch.xpu.get_device_name(0)
except Exception:
xpu_name = "Intel GPU"
return f"XPU ({xpu_name})"
except ImportError:
pass
return "None (CPU only)"
@@ -216,6 +237,13 @@ def _register_lifecycle(application: FastAPI) -> None:
logger.info("Backend: %s", backend_type.upper())
logger.info("GPU: %s", _get_gpu_status())
# Warn if GPU architecture is not supported by this PyTorch build
from .backends.base import check_cuda_compatibility
_compatible, _cuda_warning = check_cuda_compatibility()
if not _compatible:
logger.warning("GPU COMPATIBILITY: %s", _cuda_warning)
from .services.cuda import check_and_update_cuda_binary
create_background_task(check_and_update_cuda_binary())
+64 -4
View File
@@ -163,10 +163,12 @@ _stt_backend: Optional[STTBackend] = None
# The factory function uses this for the if/elif chain; the model configs live on the backend classes.
TTS_ENGINES = {
"qwen": "Qwen TTS",
"qwen_custom_voice": "Qwen CustomVoice",
"luxtts": "LuxTTS",
"chatterbox": "Chatterbox TTS",
"chatterbox_turbo": "Chatterbox Turbo",
"tada": "TADA",
"kokoro": "Kokoro",
}
@@ -204,6 +206,32 @@ def _get_qwen_model_configs() -> list[ModelConfig]:
]
def _get_qwen_custom_voice_configs() -> list[ModelConfig]:
"""Return Qwen CustomVoice model configs."""
return [
ModelConfig(
model_name="qwen-custom-voice-1.7B",
display_name="Qwen CustomVoice 1.7B",
engine="qwen_custom_voice",
hf_repo_id="Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
model_size="1.7B",
size_mb=3500,
supports_instruct=True,
languages=["zh", "en", "ja", "ko", "de", "fr", "ru", "pt", "es", "it"],
),
ModelConfig(
model_name="qwen-custom-voice-0.6B",
display_name="Qwen CustomVoice 0.6B",
engine="qwen_custom_voice",
hf_repo_id="Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice",
model_size="0.6B",
size_mb=1200,
supports_instruct=True,
languages=["zh", "en", "ja", "ko", "de", "fr", "ru", "pt", "es", "it"],
),
]
def _get_non_qwen_tts_configs() -> list[ModelConfig]:
"""Return model configs for non-Qwen TTS engines.
@@ -278,6 +306,14 @@ def _get_non_qwen_tts_configs() -> list[ModelConfig]:
size_mb=8000,
languages=["en", "ar", "zh", "de", "es", "fr", "it", "ja", "pl", "pt"],
),
ModelConfig(
model_name="kokoro",
display_name="Kokoro 82M",
engine="kokoro",
hf_repo_id="hexgrad/Kokoro-82M",
size_mb=350,
languages=["en", "es", "fr", "hi", "it", "pt", "ja", "zh"],
),
]
@@ -324,12 +360,12 @@ def _get_whisper_configs() -> list[ModelConfig]:
def get_all_model_configs() -> list[ModelConfig]:
"""Return the full list of model configs (TTS + STT)."""
return _get_qwen_model_configs() + _get_non_qwen_tts_configs() + _get_whisper_configs()
return _get_qwen_model_configs() + _get_qwen_custom_voice_configs() + _get_non_qwen_tts_configs() + _get_whisper_configs()
def get_tts_model_configs() -> list[ModelConfig]:
"""Return only TTS model configs."""
return _get_qwen_model_configs() + _get_non_qwen_tts_configs()
return _get_qwen_model_configs() + _get_qwen_custom_voice_configs() + _get_non_qwen_tts_configs()
# Lookup helpers — these replace the if/elif chains in main.py
@@ -360,7 +396,7 @@ def engine_has_model_sizes(engine: str) -> bool:
async def load_engine_model(engine: str, model_size: str = "default") -> None:
"""Load a model for the given engine, handling engines with multiple model sizes."""
backend = get_tts_backend_for_engine(engine)
if engine == "qwen":
if engine in ("qwen", "qwen_custom_voice"):
await backend.load_model_async(model_size)
elif engine == "tada":
await backend.load_model(model_size)
@@ -379,7 +415,7 @@ async def ensure_model_cached_or_raise(engine: str, model_size: str = "default")
cfg = c
break
if engine in ("qwen", "tada"):
if engine in ("qwen", "qwen_custom_voice", "tada"):
if not backend._is_model_cached(model_size):
raise HTTPException(
status_code=400,
@@ -414,6 +450,14 @@ def unload_model_by_config(config: ModelConfig) -> bool:
return True
return False
if config.engine == "qwen_custom_voice":
backend = get_tts_backend_for_engine(config.engine)
loaded_size = getattr(backend, "_current_model_size", None) or getattr(backend, "model_size", None)
if backend.is_loaded() and loaded_size == config.model_size:
backend.unload_model()
return True
return False
# All other TTS engines
backend = get_tts_backend_for_engine(config.engine)
if backend.is_loaded():
@@ -437,6 +481,11 @@ def check_model_loaded(config: ModelConfig) -> bool:
loaded_size = getattr(tts_model, "_current_model_size", None) or getattr(tts_model, "model_size", None)
return tts_model.is_loaded() and loaded_size == config.model_size
if config.engine == "qwen_custom_voice":
backend = get_tts_backend_for_engine(config.engine)
loaded_size = getattr(backend, "_current_model_size", None) or getattr(backend, "model_size", None)
return backend.is_loaded() and loaded_size == config.model_size
backend = get_tts_backend_for_engine(config.engine)
return backend.is_loaded()
except Exception:
@@ -454,6 +503,9 @@ def get_model_load_func(config: ModelConfig):
if config.engine == "qwen":
return lambda: tts.get_tts_model().load_model(config.model_size)
if config.engine == "qwen_custom_voice":
return lambda: get_tts_backend_for_engine(config.engine).load_model(config.model_size)
return lambda: get_tts_backend_for_engine(config.engine).load_model()
@@ -515,6 +567,14 @@ def get_tts_backend_for_engine(engine: str) -> TTSBackend:
from .hume_backend import HumeTadaBackend
backend = HumeTadaBackend()
elif engine == "kokoro":
from .kokoro_backend import KokoroTTSBackend
backend = KokoroTTSBackend()
elif engine == "qwen_custom_voice":
from .qwen_custom_voice_backend import QwenCustomVoiceBackend
backend = QwenCustomVoiceBackend()
else:
raise ValueError(f"Unknown TTS engine: {engine}. Supported: {list(TTS_ENGINES.keys())}")
+69
View File
@@ -126,6 +126,75 @@ def get_torch_device(
return "cpu"
def check_cuda_compatibility() -> tuple[bool, str | None]:
"""Check if the installed PyTorch supports the current GPU's compute capability.
Returns:
(compatible, warning_message) — compatible is True if OK or no CUDA GPU,
warning_message is a human-readable string if there's a problem.
"""
import torch
if not torch.cuda.is_available():
return True, None
major, minor = torch.cuda.get_device_capability(0)
capability = f"{major}.{minor}"
device_name = torch.cuda.get_device_name(0)
sm_tag = f"sm_{major}{minor}"
# torch.cuda._get_arch_list() returns the SM architectures this build
# was compiled for (e.g. ["sm_50", "sm_60", ..., "sm_90"]).
try:
arch_list = torch.cuda._get_arch_list()
if arch_list:
# Check for both sm_XX and compute_XX (JIT-compiled) entries
compute_tag = f"compute_{major}{minor}"
if sm_tag not in arch_list and compute_tag not in arch_list:
return False, (
f"{device_name} (compute capability {capability} / {sm_tag}) "
f"is not supported by this PyTorch build. "
f"Supported architectures: {', '.join(arch_list)}. "
f"Install PyTorch nightly (cu128) for newer GPU support: "
f"pip install torch --index-url https://download.pytorch.org/whl/nightly/cu128"
)
except AttributeError:
pass
return True, None
def empty_device_cache(device: str) -> None:
"""
Free cached memory on the given device (CUDA or XPU).
Backends should call this after unloading models so VRAM is returned
to the OS.
"""
import torch
if device == "cuda" and torch.cuda.is_available():
torch.cuda.empty_cache()
elif device == "xpu" and hasattr(torch, "xpu"):
torch.xpu.empty_cache()
def manual_seed(seed: int, device: str) -> None:
"""
Set the random seed on both CPU and the active accelerator.
Covers CUDA and Intel XPU so that generation is reproducible
regardless of which GPU backend is in use.
"""
import torch
torch.manual_seed(seed)
if device == "cuda" and torch.cuda.is_available():
torch.cuda.manual_seed(seed)
elif device == "xpu" and hasattr(torch, "xpu"):
torch.xpu.manual_seed(seed)
async def combine_voice_prompts(
audio_paths: List[str],
reference_texts: List[str],
+6 -10
View File
@@ -18,6 +18,8 @@ from . import TTSBackend
from .base import (
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
combine_voice_prompts as _combine_voice_prompts,
model_load_progress,
patch_chatterbox_f32,
@@ -48,7 +50,7 @@ class ChatterboxTTSBackend:
self._model_load_lock = asyncio.Lock()
def _get_device(self) -> str:
return get_torch_device(force_cpu_on_mac=True)
return get_torch_device(force_cpu_on_mac=True, allow_xpu=True)
def is_loaded(self) -> bool:
return self.model is not None
@@ -117,10 +119,7 @@ class ChatterboxTTSBackend:
del self.model
self.model = None
self._device = None
if device == "cuda":
import torch
torch.cuda.empty_cache()
empty_device_cache(device)
logger.info("Chatterbox unloaded")
async def create_voice_prompt(
@@ -200,7 +199,7 @@ class ChatterboxTTSBackend:
import torch
if seed is not None:
torch.manual_seed(seed)
manual_seed(seed, self._device)
logger.info(f"[Chatterbox] Generating: lang={language}")
@@ -220,10 +219,7 @@ class ChatterboxTTSBackend:
else:
audio = np.asarray(wav, dtype=np.float32)
sample_rate = (
getattr(self.model, "sr", None)
or getattr(self.model, "sample_rate", 24000)
)
sample_rate = getattr(self.model, "sr", None) or getattr(self.model, "sample_rate", 24000)
return audio, sample_rate
+6 -10
View File
@@ -18,6 +18,8 @@ from . import TTSBackend
from .base import (
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
combine_voice_prompts as _combine_voice_prompts,
model_load_progress,
patch_chatterbox_f32,
@@ -48,7 +50,7 @@ class ChatterboxTurboTTSBackend:
self._model_load_lock = asyncio.Lock()
def _get_device(self) -> str:
return get_torch_device(force_cpu_on_mac=True)
return get_torch_device(force_cpu_on_mac=True, allow_xpu=True)
def is_loaded(self) -> bool:
return self.model is not None
@@ -116,10 +118,7 @@ class ChatterboxTurboTTSBackend:
del self.model
self.model = None
self._device = None
if device == "cuda":
import torch
torch.cuda.empty_cache()
empty_device_cache(device)
logger.info("Chatterbox Turbo unloaded")
async def create_voice_prompt(
@@ -181,7 +180,7 @@ class ChatterboxTurboTTSBackend:
import torch
if seed is not None:
torch.manual_seed(seed)
manual_seed(seed, self._device)
logger.info("[Chatterbox Turbo] Generating (English)")
@@ -200,10 +199,7 @@ class ChatterboxTurboTTSBackend:
else:
audio = np.asarray(wav, dtype=np.float32)
sample_rate = (
getattr(self.model, "sr", None)
or getattr(self.model, "sample_rate", 24000)
)
sample_rate = getattr(self.model, "sr", None) or getattr(self.model, "sample_rate", 24000)
return audio, sample_rate
+19 -20
View File
@@ -24,6 +24,8 @@ from . import TTSBackend
from .base import (
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
combine_voice_prompts as _combine_voice_prompts,
model_load_progress,
)
@@ -66,7 +68,7 @@ class HumeTadaBackend:
def _get_device(self) -> str:
# Force CPU on macOS — MPS has issues with flow matching
# and large vocab lm_head (>65536 output channels)
return get_torch_device(force_cpu_on_mac=True)
return get_torch_device(force_cpu_on_mac=True, allow_xpu=True)
def is_loaded(self) -> bool:
return self.model is not None
@@ -105,6 +107,7 @@ class HumeTadaBackend:
# package. The real package pulls in onnx/tensorboard/matplotlib via
# descript-audiotools, so we use a lightweight shim instead.
from ..utils.dac_shim import install_dac_shim
install_dac_shim()
import torch
@@ -142,9 +145,12 @@ class HumeTadaBackend:
allow_patterns=["tokenizer*", "special_tokens*"],
)
# Determine dtype — use bf16 on CUDA for ~50% memory savings
# Determine dtype — use bf16 on CUDA/XPU for ~50% memory savings
if device == "cuda" and torch.cuda.is_bf16_supported():
model_dtype = torch.bfloat16
elif device == "xpu":
# Intel Arc (Alchemist+) supports bf16 natively
model_dtype = torch.bfloat16
else:
model_dtype = torch.float32
@@ -153,14 +159,14 @@ class HumeTadaBackend:
# This avoids monkey-patching AutoTokenizer.from_pretrained
# which corrupts the classmethod descriptor for other engines.
from tada.modules.aligner import AlignerConfig
AlignerConfig.tokenizer_name = tokenizer_path
# Load encoder (only needed for voice prompt encoding)
from tada.modules.encoder import Encoder
logger.info("Loading TADA encoder...")
self.encoder = Encoder.from_pretrained(
TADA_CODEC_REPO, subfolder="encoder"
).to(device)
self.encoder = Encoder.from_pretrained(TADA_CODEC_REPO, subfolder="encoder").to(device)
self.encoder.eval()
# Load the causal LM (includes decoder for wav generation).
@@ -169,12 +175,11 @@ class HumeTadaBackend:
# which hits the gated repo. Pre-load the config from HF,
# inject the local tokenizer path, then pass it in.
from tada.modules.tada import TadaForCausalLM, TadaConfig
logger.info(f"Loading TADA {model_size} model...")
config = TadaConfig.from_pretrained(repo)
config.tokenizer_name = tokenizer_path
self.model = TadaForCausalLM.from_pretrained(
repo, config=config, torch_dtype=model_dtype
).to(device)
self.model = TadaForCausalLM.from_pretrained(repo, config=config, torch_dtype=model_dtype).to(device)
self.model.eval()
logger.info(f"HumeAI TADA {model_size} loaded successfully on {device}")
@@ -188,11 +193,11 @@ class HumeTadaBackend:
del self.encoder
self.encoder = None
device = self._device
self._device = None
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
if device:
empty_device_cache(device)
logger.info("HumeAI TADA unloaded")
@@ -213,9 +218,7 @@ class HumeTadaBackend:
"""
await self.load_model(self.model_size)
cache_key = (
"tada_" + get_cache_key(audio_path, reference_text)
) if use_cache else None
cache_key = ("tada_" + get_cache_key(audio_path, reference_text)) if use_cache else None
if cache_key:
cached = get_cached_voice_prompt(cache_key)
@@ -239,9 +242,7 @@ class HumeTadaBackend:
# Encode with forced alignment
text_arg = [reference_text] if reference_text else None
prompt = self.encoder(
audio, text=text_arg, sample_rate=sr
)
prompt = self.encoder(audio, text=text_arg, sample_rate=sr)
# Serialize EncoderOutput to a dict of CPU tensors for caching
prompt_dict = {}
@@ -299,9 +300,7 @@ class HumeTadaBackend:
from tada.modules.encoder import EncoderOutput
if seed is not None:
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed(seed)
manual_seed(seed, self._device)
device = self._device
+288
View File
@@ -0,0 +1,288 @@
"""
Kokoro TTS backend implementation.
Wraps the Kokoro-82M model for fast, lightweight text-to-speech.
82M parameters, CPU realtime, 24kHz output, Apache 2.0 license.
Kokoro uses pre-built voice style vectors (not traditional zero-shot cloning
from arbitrary audio). Voice prompts are stored as deferred references to
HF-hosted voice .pt files.
Languages supported (via misaki G2P):
- American English (a), British English (b)
- Spanish (e), French (f), Hindi (h), Italian (i), Portuguese (p)
- Japanese (j) — requires misaki[ja]
- Chinese (z) — requires misaki[zh]
"""
import asyncio
import logging
import os
from typing import Optional
import numpy as np
from . import TTSBackend
from .base import (
get_torch_device,
combine_voice_prompts as _combine_voice_prompts,
model_load_progress,
)
logger = logging.getLogger(__name__)
# HuggingFace repo for model + voice detection
KOKORO_HF_REPO = "hexgrad/Kokoro-82M"
KOKORO_SAMPLE_RATE = 24000
# Default voice if none specified
KOKORO_DEFAULT_VOICE = "af_heart"
# All available Kokoro voices: (voice_id, display_name, gender, lang_code)
KOKORO_VOICES = [
# American English female
("af_alloy", "Alloy", "female", "en"),
("af_aoede", "Aoede", "female", "en"),
("af_bella", "Bella", "female", "en"),
("af_heart", "Heart", "female", "en"),
("af_jessica", "Jessica", "female", "en"),
("af_kore", "Kore", "female", "en"),
("af_nicole", "Nicole", "female", "en"),
("af_nova", "Nova", "female", "en"),
("af_river", "River", "female", "en"),
("af_sarah", "Sarah", "female", "en"),
("af_sky", "Sky", "female", "en"),
# American English male
("am_adam", "Adam", "male", "en"),
("am_echo", "Echo", "male", "en"),
("am_eric", "Eric", "male", "en"),
("am_fenrir", "Fenrir", "male", "en"),
("am_liam", "Liam", "male", "en"),
("am_michael", "Michael", "male", "en"),
("am_onyx", "Onyx", "male", "en"),
("am_puck", "Puck", "male", "en"),
("am_santa", "Santa", "male", "en"),
# British English female
("bf_alice", "Alice", "female", "en"),
("bf_emma", "Emma", "female", "en"),
("bf_isabella", "Isabella", "female", "en"),
("bf_lily", "Lily", "female", "en"),
# British English male
("bm_daniel", "Daniel", "male", "en"),
("bm_fable", "Fable", "male", "en"),
("bm_george", "George", "male", "en"),
("bm_lewis", "Lewis", "male", "en"),
# Spanish
("ef_dora", "Dora", "female", "es"),
("em_alex", "Alex", "male", "es"),
("em_santa", "Santa", "male", "es"),
# French
("ff_siwis", "Siwis", "female", "fr"),
# Hindi
("hf_alpha", "Alpha", "female", "hi"),
("hf_beta", "Beta", "female", "hi"),
("hm_omega", "Omega", "male", "hi"),
("hm_psi", "Psi", "male", "hi"),
# Italian
("if_sara", "Sara", "female", "it"),
("im_nicola", "Nicola", "male", "it"),
# Japanese
("jf_alpha", "Alpha", "female", "ja"),
("jf_gongitsune", "Gongitsune", "female", "ja"),
("jf_nezumi", "Nezumi", "female", "ja"),
("jf_tebukuro", "Tebukuro", "female", "ja"),
("jm_kumo", "Kumo", "male", "ja"),
# Portuguese
("pf_dora", "Dora", "female", "pt"),
("pm_alex", "Alex", "male", "pt"),
("pm_santa", "Santa", "male", "pt"),
# Chinese
("zf_xiaobei", "Xiaobei", "female", "zh"),
("zf_xiaoni", "Xiaoni", "female", "zh"),
("zf_xiaoxiao", "Xiaoxiao", "female", "zh"),
("zf_xiaoyi", "Xiaoyi", "female", "zh"),
]
# Map our ISO language codes to Kokoro lang_code characters
LANG_CODE_MAP = {
"en": "a", # American English
"es": "e",
"fr": "f",
"hi": "h",
"it": "i",
"pt": "p",
"ja": "j",
"zh": "z",
}
class KokoroTTSBackend:
"""Kokoro-82M TTS backend — tiny, fast, CPU-friendly."""
def __init__(self):
self._model = None
self._pipelines: dict = {} # lang_code -> KPipeline
self._device: Optional[str] = None
self.model_size = "default"
def _get_device(self) -> str:
"""Select device. Kokoro supports CUDA and CPU. MPS needs fallback env var."""
device = get_torch_device(allow_mps=False)
# Kokoro can use MPS but requires PYTORCH_ENABLE_MPS_FALLBACK=1
# For now, skip MPS to avoid user confusion — CPU is already realtime
return device
@property
def device(self) -> str:
if self._device is None:
self._device = self._get_device()
return self._device
def is_loaded(self) -> bool:
return self._model is not None
def _get_model_path(self, model_size: str) -> str:
return KOKORO_HF_REPO
def _is_model_cached(self, model_size: str = "default") -> bool:
"""Check if Kokoro model files are cached locally."""
from .base import is_model_cached
return is_model_cached(
KOKORO_HF_REPO,
required_files=["config.json", "kokoro-v1_0.pth"],
)
async def load_model(self, model_size: str = "default") -> None:
"""Load the Kokoro model."""
if self._model is not None:
return
await asyncio.to_thread(self._load_model_sync)
def _load_model_sync(self):
"""Synchronous model loading."""
model_name = "kokoro"
is_cached = self._is_model_cached()
with model_load_progress(model_name, is_cached):
from kokoro import KModel
device = self.device
logger.info(f"Loading Kokoro-82M on {device}...")
self._model = KModel(repo_id=KOKORO_HF_REPO).to(device).eval()
logger.info("Kokoro-82M loaded successfully")
def _get_pipeline(self, lang_code: str):
"""Get or create a KPipeline for the given language code."""
kokoro_lang = LANG_CODE_MAP.get(lang_code, "a")
if kokoro_lang not in self._pipelines:
from kokoro import KPipeline
# Create pipeline with our existing model (no redundant model loading)
self._pipelines[kokoro_lang] = KPipeline(
lang_code=kokoro_lang,
repo_id=KOKORO_HF_REPO,
model=self._model,
)
return self._pipelines[kokoro_lang]
def unload_model(self) -> None:
"""Unload model to free memory."""
if self._model is not None:
del self._model
self._model = None
self._pipelines.clear()
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
logger.info("Kokoro unloaded")
async def create_voice_prompt(
self,
audio_path: str,
reference_text: str,
use_cache: bool = True,
) -> tuple[dict, bool]:
"""
Create voice prompt for Kokoro.
Kokoro doesn't do traditional voice cloning from arbitrary audio.
When called for a cloned profile (fallback), uses the default voice.
For preset profiles, the voice_prompt dict is built by the profile
service and bypasses this method entirely.
"""
return {
"voice_type": "preset",
"preset_engine": "kokoro",
"preset_voice_id": KOKORO_DEFAULT_VOICE,
}, False
async def combine_voice_prompts(
self,
audio_paths: list[str],
reference_texts: list[str],
) -> tuple[np.ndarray, str]:
"""Combine voice prompts — uses base implementation for audio concatenation."""
return await _combine_voice_prompts(
audio_paths, reference_texts, sample_rate=KOKORO_SAMPLE_RATE
)
async def generate(
self,
text: str,
voice_prompt: dict,
language: str = "en",
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> tuple[np.ndarray, int]:
"""
Generate audio from text using Kokoro.
Args:
text: Text to synthesize
voice_prompt: Dict with kokoro_voice key
language: Language code
seed: Random seed for reproducibility
instruct: Not supported by Kokoro (ignored)
Returns:
Tuple of (audio_array, sample_rate)
"""
await self.load_model()
voice_name = voice_prompt.get("preset_voice_id") or voice_prompt.get("kokoro_voice") or KOKORO_DEFAULT_VOICE
def _generate_sync():
import torch
if seed is not None:
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed(seed)
pipeline = self._get_pipeline(language)
# Generate all chunks and concatenate
audio_chunks = []
for result in pipeline(text, voice=voice_name, speed=1.0):
if result.audio is not None:
chunk = result.audio
if isinstance(chunk, torch.Tensor):
chunk = chunk.detach().cpu().numpy()
audio_chunks.append(chunk.squeeze())
if not audio_chunks:
# Return 1 second of silence as fallback
return np.zeros(KOKORO_SAMPLE_RATE, dtype=np.float32), KOKORO_SAMPLE_RATE
audio = np.concatenate(audio_chunks)
return audio.astype(np.float32), KOKORO_SAMPLE_RATE
return await asyncio.to_thread(_generate_sync)
+17 -11
View File
@@ -12,7 +12,14 @@ from typing import Optional, Tuple
import numpy as np
from . import TTSBackend
from .base import is_model_cached, get_torch_device, combine_voice_prompts as _combine_voice_prompts, model_load_progress
from .base import (
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
combine_voice_prompts as _combine_voice_prompts,
model_load_progress,
)
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
logger = logging.getLogger(__name__)
@@ -30,7 +37,7 @@ class LuxTTSBackend:
self._device = None
def _get_device(self) -> str:
return get_torch_device(allow_mps=True)
return get_torch_device(allow_mps=True, allow_xpu=True)
def is_loaded(self) -> bool:
return self.model is not None
@@ -69,9 +76,12 @@ class LuxTTSBackend:
if device == "cpu":
import os
threads = os.cpu_count() or 4
self.model = LuxTTS(
model_path=LUXTTS_HF_REPO, device="cpu", threads=min(threads, 8),
model_path=LUXTTS_HF_REPO,
device="cpu",
threads=min(threads, 8),
)
else:
self.model = LuxTTS(model_path=LUXTTS_HF_REPO, device=device)
@@ -81,12 +91,12 @@ class LuxTTSBackend:
def unload_model(self) -> None:
"""Unload model to free memory."""
if self.model is not None:
device = self.device
del self.model
self.model = None
self._device = None
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
empty_device_cache(device)
logger.info("LuxTTS unloaded")
@@ -154,12 +164,8 @@ class LuxTTSBackend:
await self.load_model()
def _generate_sync():
import torch
if seed is not None:
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed(seed)
manual_seed(seed, self.device)
wav = self.model.generate_speech(
text=text,
+14 -7
View File
@@ -14,6 +14,8 @@ from . import TTSBackend, STTBackend, LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS
from .base import (
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
combine_voice_prompts as _combine_voice_prompts,
model_load_progress,
)
@@ -97,16 +99,25 @@ class PyTorchTTSBackend:
model_path = self._get_model_path(model_size)
logger.info("Loading TTS model %s on %s...", model_size, self.device)
# Route both HF Hub and Transformers through a single cache root.
# On Windows local setups, model assets can otherwise split between
# .hf-cache/hub and .hf-cache/transformers, causing speech_tokenizer
# and preprocessor_config.json to fail to resolve during load.
from huggingface_hub import constants as hf_constants
tts_cache_dir = hf_constants.HF_HUB_CACHE
with force_offline_if_cached(is_cached, model_name):
if self.device == "cpu":
self.model = Qwen3TTSModel.from_pretrained(
model_path,
cache_dir=tts_cache_dir,
torch_dtype=torch.float32,
low_cpu_mem_usage=False,
)
else:
self.model = Qwen3TTSModel.from_pretrained(
model_path,
cache_dir=tts_cache_dir,
device_map=self.device,
torch_dtype=torch.bfloat16,
)
@@ -122,8 +133,7 @@ class PyTorchTTSBackend:
self.model = None
self._current_model_size = None
if torch.cuda.is_available():
torch.cuda.empty_cache()
empty_device_cache(self.device)
logger.info("TTS model unloaded")
@@ -215,9 +225,7 @@ class PyTorchTTSBackend:
"""Run synchronous generation in thread pool."""
# Set seed if provided
if seed is not None:
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed(seed)
manual_seed(seed, self.device)
# Generate audio - this is the blocking operation
wavs, sample_rate = self.model.generate_voice_clone(
@@ -300,8 +308,7 @@ class PyTorchSTTBackend:
self.model = None
self.processor = None
if torch.cuda.is_available():
torch.cuda.empty_cache()
empty_device_cache(self.device)
logger.info("Whisper model unloaded")
@@ -0,0 +1,210 @@
"""
Qwen3-TTS CustomVoice backend implementation.
Wraps the Qwen3-TTS-12Hz CustomVoice model for preset-speaker TTS with
instruction-based style control. Uses the same qwen_tts library as the
Base model (pytorch_backend.py) but loads a different checkpoint and
calls generate_custom_voice() instead of generate_voice_clone().
Key differences from the Base engine:
- Uses preset speakers (9 built-in voices) instead of zero-shot cloning
- Supports instruct parameter for tone/emotion/prosody control
- Two model sizes: 1.7B and 0.6B
Languages supported: zh, en, ja, ko, de, fr, ru, pt, es, it
"""
import asyncio
import logging
from typing import Optional
import numpy as np
import torch
from . import TTSBackend, LANGUAGE_CODE_TO_NAME
from .base import (
is_model_cached,
get_torch_device,
combine_voice_prompts as _combine_voice_prompts,
model_load_progress,
)
logger = logging.getLogger(__name__)
# ── Preset speakers ──────────────────────────────────────────────────
# (speaker_id, display_name, gender, native_language_code, description)
QWEN_CUSTOM_VOICES = [
("Vivian", "Vivian", "female", "zh", "Bright, slightly edgy young female voice"),
("Serena", "Serena", "female", "zh", "Warm, gentle young female voice"),
("Uncle_Fu", "Uncle Fu", "male", "zh", "Seasoned male voice with a low, mellow timbre"),
("Dylan", "Dylan", "male", "zh", "Youthful Beijing male voice with a clear, natural timbre"),
("Eric", "Eric", "male", "zh", "Lively Chengdu male voice with a slightly husky brightness"),
("Ryan", "Ryan", "male", "en", "Dynamic male voice with strong rhythmic drive"),
("Aiden", "Aiden", "male", "en", "Sunny American male voice with a clear midrange"),
("Ono_Anna", "Ono Anna", "female", "ja", "Playful Japanese female voice with a light, nimble timbre"),
("Sohee", "Sohee", "female", "ko", "Warm Korean female voice with rich emotion"),
]
QWEN_CV_DEFAULT_SPEAKER = "Ryan"
# HuggingFace repo IDs per model size
QWEN_CV_HF_REPOS = {
"1.7B": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
"0.6B": "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice",
}
class QwenCustomVoiceBackend:
"""Qwen3-TTS CustomVoice backend — preset speakers with instruct control."""
def __init__(self, model_size: str = "1.7B"):
self.model = None
self.model_size = model_size
self.device = self._get_device()
self._current_model_size: Optional[str] = None
def _get_device(self) -> str:
return get_torch_device(allow_xpu=True, allow_directml=True)
def is_loaded(self) -> bool:
return self.model is not None
def _get_model_path(self, model_size: str) -> str:
if model_size not in QWEN_CV_HF_REPOS:
raise ValueError(f"Unknown model size: {model_size}")
return QWEN_CV_HF_REPOS[model_size]
def _is_model_cached(self, model_size: Optional[str] = None) -> bool:
size = model_size or self.model_size
return is_model_cached(self._get_model_path(size))
async def load_model_async(self, model_size: Optional[str] = None) -> None:
if model_size is None:
model_size = self.model_size
if self.model is not None and self._current_model_size == model_size:
return
if self.model is not None and self._current_model_size != model_size:
self.unload_model()
await asyncio.to_thread(self._load_model_sync, model_size)
# Alias for compatibility with the TTSBackend protocol
load_model = load_model_async
def _load_model_sync(self, model_size: str) -> None:
model_name = f"qwen-custom-voice-{model_size}"
is_cached = self._is_model_cached(model_size)
with model_load_progress(model_name, is_cached):
from qwen_tts import Qwen3TTSModel
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,
)
self._current_model_size = model_size
self.model_size = model_size
logger.info("Qwen CustomVoice %s loaded successfully", model_size)
def unload_model(self) -> None:
if self.model is not None:
del self.model
self.model = None
self._current_model_size = None
if torch.cuda.is_available():
torch.cuda.empty_cache()
logger.info("Qwen CustomVoice unloaded")
async def create_voice_prompt(
self,
audio_path: str,
reference_text: str,
use_cache: bool = True,
) -> tuple[dict, bool]:
"""
Create voice prompt for CustomVoice.
CustomVoice doesn't use reference audio — it uses preset speakers.
When called for a cloned profile (fallback), uses the default speaker.
For preset profiles, the voice_prompt dict is built by the profile
service and bypasses this method entirely.
"""
return {
"voice_type": "preset",
"preset_engine": "qwen_custom_voice",
"preset_voice_id": QWEN_CV_DEFAULT_SPEAKER,
}, False
async def combine_voice_prompts(
self,
audio_paths: list[str],
reference_texts: list[str],
) -> tuple[np.ndarray, str]:
return await _combine_voice_prompts(audio_paths, reference_texts)
async def generate(
self,
text: str,
voice_prompt: dict,
language: str = "en",
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> tuple[np.ndarray, int]:
"""
Generate audio using Qwen CustomVoice.
Args:
text: Text to synthesize
voice_prompt: Dict with preset_voice_id (speaker name)
language: Language code (zh, en, ja, ko, etc.)
seed: Random seed for reproducibility
instruct: Natural language instruction for style control
(e.g. "Speak in an angry tone", "Very happy")
Returns:
Tuple of (audio_array, sample_rate)
"""
await self.load_model_async(None)
speaker = voice_prompt.get("preset_voice_id") or QWEN_CV_DEFAULT_SPEAKER
def _generate_sync():
if seed is not None:
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed(seed)
lang_name = LANGUAGE_CODE_TO_NAME.get(language, "auto")
kwargs = {
"text": text,
"language": lang_name.capitalize() if lang_name != "auto" else "Auto",
"speaker": speaker,
}
# Only pass instruct if non-empty
if instruct:
kwargs["instruct"] = instruct
wavs, sample_rate = self.model.generate_custom_voice(**kwargs)
return wavs[0], sample_rate
audio, sample_rate = await asyncio.to_thread(_generate_sync)
return audio, sample_rate
+61
View File
@@ -52,6 +52,29 @@ def build_server(cuda=False):
if platform.system() == "Windows":
args.append("--noconsole")
# numpy 2.x / torch ABI mismatch fix: install memmove fallback for
# torch.from_numpy() before the app starts. Runtime hooks run after
# FrozenImporter is registered so frozen torch/numpy are importable.
# Paths are passed relative to backend_dir because os.chdir(backend_dir)
# runs before PyInstaller. Absolute paths would get baked into the
# generated .spec, breaking reproducible builds on other machines / CI.
args.extend(
[
"--runtime-hook",
"pyi_rth_numpy_compat.py",
# Stub torch.compiler.disable before transformers imports
# flex_attention, which otherwise triggers torch._dynamo →
# torch._numpy._ufuncs and crashes at module load under
# PyInstaller. See pyi_rth_torch_compiler_disable.py.
"--runtime-hook",
"pyi_rth_torch_compiler_disable.py",
# Per-module collection overrides (e.g. forcing scipy.stats._distn_infrastructure
# to bundle .py source alongside .pyc so the runtime hook can source-patch it).
"--additional-hooks-dir",
"pyi_hooks",
]
)
# Add local qwen_tts path if specified (for editable installs)
qwen_tts_path = os.getenv("QWEN_TTS_PATH")
if qwen_tts_path and Path(qwen_tts_path).exists():
@@ -86,6 +109,8 @@ def build_server(cuda=False):
"--hidden-import",
"backend.backends.pytorch_backend",
"--hidden-import",
"backend.backends.qwen_custom_voice_backend",
"--hidden-import",
"backend.utils.audio",
"--hidden-import",
"backend.utils.cache",
@@ -113,6 +138,11 @@ def build_server(cuda=False):
"backend.backends.chatterbox_backend",
"--hidden-import",
"backend.backends.chatterbox_turbo_backend",
# chatterbox multilingual uses spacy_pkuseg for Chinese word
# segmentation, which ships pickled dict files (dicts/default.pkl)
# and native .so extensions that --hidden-import alone won't bundle.
"--collect-all",
"spacy_pkuseg",
"--hidden-import",
"backend.backends.luxtts_backend",
"--hidden-import",
@@ -228,6 +258,37 @@ def build_server(cuda=False):
"torchaudio",
"--collect-submodules",
"tada",
# Kokoro 82M — lightweight TTS engine using misaki G2P
# collect-all is required because transformers introspects .py source
# files at runtime (e.g. _can_set_attn_implementation opens the class
# file); hidden-import alone only bundles bytecode.
"--hidden-import",
"backend.backends.kokoro_backend",
"--collect-all",
"kokoro",
# misaki ships G2P data files (dictionaries, phoneme tables)
# that must be bundled for espeak/en/ja/zh G2P to work
"--collect-all",
"misaki",
# language_tags ships JSON data files (index.json etc.) loaded at
# runtime via: misaki → phonemizer → segments → csvw → language_tags
"--collect-all",
"language_tags",
# espeakng_loader ships the entire espeak-ng-data directory (369 files)
# loaded at import time by misaki.espeak via get_data_path()
"--collect-all",
"espeakng_loader",
# spacy en_core_web_sm model — misaki.en tries to spacy.cli.download()
# at runtime if not found, which calls pip as a subprocess and crashes
# the frozen binary. Bundle the model so spacy.util.is_package() passes.
"--collect-all",
"en_core_web_sm",
"--copy-metadata",
"en_core_web_sm",
"--hidden-import",
"en_core_web_sm",
"--hidden-import",
"loguru",
]
)
+58 -3
View File
@@ -19,7 +19,22 @@ if _custom_models_dir:
logger.info("Model download path set to: %s", _custom_models_dir)
# Default data directory (used in development)
_data_dir = Path("data")
_data_dir = Path("data").resolve()
def _path_relative_to_any_data_dir(path: Path) -> Path | None:
"""Extract the path within a data dir from an absolute or relative path."""
parts = path.parts
for idx, part in enumerate(parts):
if part != "data":
continue
tail = parts[idx + 1 :]
if tail:
return Path(*tail)
return Path()
return None
def set_data_dir(path: str | Path):
@@ -30,9 +45,9 @@ def set_data_dir(path: str | Path):
path: Path to the data directory
"""
global _data_dir
_data_dir = Path(path)
_data_dir = Path(path).resolve()
_data_dir.mkdir(parents=True, exist_ok=True)
logger.info("Data directory set to: %s", _data_dir.absolute())
logger.info("Data directory set to: %s", _data_dir)
def get_data_dir() -> Path:
@@ -45,6 +60,46 @@ def get_data_dir() -> Path:
return _data_dir
def to_storage_path(path: str | Path) -> str:
"""Convert a filesystem path to a DB-safe path relative to the data dir."""
resolved_path = Path(path).resolve()
relative_to_any_data_dir = _path_relative_to_any_data_dir(resolved_path)
if relative_to_any_data_dir is not None:
return str(relative_to_any_data_dir)
try:
return str(resolved_path.relative_to(_data_dir))
except ValueError:
return str(resolved_path)
def resolve_storage_path(path: str | Path | None) -> Path | None:
"""Resolve a DB-stored path against the configured data dir."""
if path is None:
return None
stored_path = Path(path)
if stored_path.is_absolute():
rebased_path = _path_relative_to_any_data_dir(stored_path)
if rebased_path is not None:
candidate = (_data_dir / rebased_path).resolve()
if candidate.exists() or not stored_path.exists():
return candidate
return stored_path
# 0.3.0 records sometimes stored relative paths with the data-dir name
# baked in (e.g. "data/profiles/..."). Joining those directly with
# _data_dir produces a spurious "<data_dir>/data/profiles/..." nest.
if stored_path.parts and stored_path.parts[0] == "data":
stored_path = (
Path(*stored_path.parts[1:]) if len(stored_path.parts) > 1 else Path()
)
return (_data_dir / stored_path).resolve()
def get_db_path() -> Path:
"""Get database file path."""
return _data_dir / "voicebox.db"
+56
View File
@@ -34,6 +34,7 @@ def run_migrations(engine) -> None:
_migrate_generations(engine, inspector, tables)
_migrate_effect_presets(engine, inspector, tables)
_migrate_generation_versions(engine, inspector, tables)
_normalize_storage_paths(engine, tables)
# -- helpers ---------------------------------------------------------------
@@ -134,6 +135,17 @@ def _migrate_profiles(engine, inspector, tables: set[str]) -> None:
_add_column(engine, "profiles", "avatar_path VARCHAR", "avatar_path")
if "effects_chain" not in columns:
_add_column(engine, "profiles", "effects_chain TEXT", "effects_chain")
# Voice type system — v0.3.x
if "voice_type" not in columns:
_add_column(engine, "profiles", "voice_type VARCHAR DEFAULT 'cloned'", "voice_type")
if "preset_engine" not in columns:
_add_column(engine, "profiles", "preset_engine VARCHAR", "preset_engine")
if "preset_voice_id" not in columns:
_add_column(engine, "profiles", "preset_voice_id VARCHAR", "preset_voice_id")
if "design_prompt" not in columns:
_add_column(engine, "profiles", "design_prompt TEXT", "design_prompt")
if "default_engine" not in columns:
_add_column(engine, "profiles", "default_engine VARCHAR", "default_engine")
def _migrate_generations(engine, inspector, tables: set[str]) -> None:
@@ -168,3 +180,47 @@ def _migrate_generation_versions(engine, inspector, tables: set[str]) -> None:
columns = _get_columns(inspector, "generation_versions")
if "source_version_id" not in columns:
_add_column(engine, "generation_versions", "source_version_id VARCHAR", "source_version_id")
def _normalize_storage_paths(engine, tables: set[str]) -> None:
"""Normalize stored file paths to be relative to the configured data dir."""
from pathlib import Path
from ..config import get_data_dir, to_storage_path, resolve_storage_path
data_dir = get_data_dir()
path_columns = [
("generations", "audio_path"),
("generation_versions", "audio_path"),
("profile_samples", "audio_path"),
("profiles", "avatar_path"),
]
total_fixed = 0
with engine.connect() as conn:
for table, column in path_columns:
if table not in tables:
continue
rows = conn.execute(
text(f"SELECT id, {column} FROM {table} WHERE {column} IS NOT NULL")
).fetchall()
for row_id, path_val in rows:
if not path_val:
continue
p = Path(path_val)
resolved = resolve_storage_path(p)
if resolved is None:
continue
normalized = to_storage_path(resolved)
if normalized != path_val:
conn.execute(
text(f"UPDATE {table} SET {column} = :path WHERE id = :id"),
{"path": normalized, "id": row_id},
)
total_fixed += 1
if total_fixed > 0:
conn.commit()
logger.info("Normalized %d stored file paths", total_fixed)
+15 -1
View File
@@ -10,7 +10,13 @@ Base = declarative_base()
class VoiceProfile(Base):
"""Voice profile."""
"""Voice profile.
voice_type discriminates three flavours:
- "cloned" — traditional reference-audio profiles (all cloning engines)
- "preset" — engine-specific pre-built voice (e.g. Kokoro voices)
- "designed" — text-described voice (e.g. Qwen CustomVoice, future)
"""
__tablename__ = "profiles"
@@ -20,6 +26,14 @@ class VoiceProfile(Base):
language = Column(String, default="en")
avatar_path = Column(String, nullable=True)
effects_chain = Column(Text, nullable=True)
# Voice type system — added v0.3.x
voice_type = Column(String, default="cloned") # "cloned" | "preset" | "designed"
preset_engine = Column(String, nullable=True) # e.g. "kokoro" — only for preset
preset_voice_id = Column(String, nullable=True) # e.g. "am_adam" — only for preset
design_prompt = Column(Text, nullable=True) # text description — only for designed
default_engine = Column(String, nullable=True) # auto-selected engine, locked for preset
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+4 -2
View File
@@ -3,7 +3,8 @@
import json
import logging
import uuid
from pathlib import Path
from .. import config
logger = logging.getLogger(__name__)
@@ -25,7 +26,8 @@ def backfill_generation_versions(SessionLocal, Generation, GenerationVersion) ->
for gen in generations:
if gen.id in existing_version_gen_ids:
continue
if not Path(gen.audio_path).exists():
resolved_audio_path = config.resolve_storage_path(gen.audio_path)
if resolved_audio_path is None or not resolved_audio_path.exists():
continue
version = GenerationVersion(
id=str(uuid.uuid4()),
+12 -1
View File
@@ -15,6 +15,11 @@ class VoiceProfileCreate(BaseModel):
language: str = Field(
default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he|ar|da|el|fi|hi|ms|nl|no|pl|sv|sw|tr)$"
)
voice_type: Optional[str] = Field(default="cloned", pattern="^(cloned|preset|designed)$")
preset_engine: Optional[str] = Field(None, max_length=50)
preset_voice_id: Optional[str] = Field(None, max_length=100)
design_prompt: Optional[str] = Field(None, max_length=2000)
default_engine: Optional[str] = Field(None, max_length=50)
class VoiceProfileResponse(BaseModel):
@@ -26,6 +31,11 @@ class VoiceProfileResponse(BaseModel):
language: str
avatar_path: Optional[str] = None
effects_chain: Optional[List["EffectConfig"]] = None
voice_type: str = "cloned"
preset_engine: Optional[str] = None
preset_voice_id: Optional[str] = None
design_prompt: Optional[str] = None
default_engine: Optional[str] = None
generation_count: int = 0
sample_count: int = 0
created_at: datetime
@@ -68,7 +78,7 @@ class GenerationRequest(BaseModel):
seed: Optional[int] = Field(None, ge=0)
model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B|1B|3B)$")
instruct: Optional[str] = Field(None, max_length=500)
engine: Optional[str] = Field(default="qwen", pattern="^(qwen|luxtts|chatterbox|chatterbox_turbo|tada)$")
engine: Optional[str] = Field(default="qwen", pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$")
max_chunk_chars: int = Field(
default=800, ge=100, le=5000, description="Max characters per chunk for long text splitting"
)
@@ -172,6 +182,7 @@ class HealthResponse(BaseModel):
vram_used_mb: Optional[float] = None
backend_type: Optional[str] = None # Backend type (mlx or pytorch)
backend_variant: Optional[str] = None # Binary variant (cpu or cuda)
gpu_compatibility_warning: Optional[str] = None # Warning if GPU arch unsupported
class DirectoryCheck(BaseModel):
@@ -0,0 +1,12 @@
"""
Force scipy.stats._distn_infrastructure to be bundled with its .py source file
alongside the .pyc bytecode.
The runtime hook in backend/pyi_rth_torch_compiler_disable.py patches this
module's source at load time (the module has a `del obj` at line 369 that
raises NameError under PyInstaller's frozen importer). That patch reads the
source via loader.get_source(), which only works if the .py file was
actually collected into the bundle.
"""
module_collection_mode = "pyz+py"
@@ -0,0 +1,11 @@
"""
Force transformers.masking_utils to be bundled with its .py source alongside
the .pyc bytecode so the runtime hook in
backend/pyi_rth_torch_compiler_disable.py can source-patch it.
The patch forces the torch<2.6 code path, bypassing `with TransformGetItemToIndex()`
which our torch._dynamo no-op stub can't implement for real — the real context
manager uses dynamo graph transforms to avoid `.item()` calls inside vmap.
"""
module_collection_mode = "pyz+py"
+95
View File
@@ -0,0 +1,95 @@
"""
PyInstaller runtime hook: numpy 2.x / torch ABI mismatch fix.
Problem
-------
torch is compiled against numpy 1.x headers. numpy 2.x changed the version
number returned by PyArray_GetNDArrayCVersion() (0x01000009 → 0x02000000),
so torch's is_numpy_available() returns False and every torch.from_numpy()
call raises:
RuntimeError: Numpy is not available
This surfaces as:
ValueError: Unable to create tensor, you should probably activate
padding with 'padding=True'
during TTS generation (EncodecFeatureExtractor → BatchFeature.convert_to_tensors).
Fix
---
Runtime hooks execute after PyInstaller's FrozenImporter is registered, so
frozen torch/numpy are importable here. We start a background thread that
waits for torch to finish loading then wraps torch.from_numpy with a ctypes
memmove fallback that bypasses the C-level numpy ABI check entirely.
This approach works with any numpy version and is safer than binary-patching
libtorch_python.dylib (which risks PyArray_Descr struct layout mismatches).
"""
import sys
import threading
def _patch_torch_from_numpy():
import time
for _ in range(7200): # poll up to 360 s at 50 ms intervals
time.sleep(0.05)
torch = sys.modules.get("torch")
if torch is None or not hasattr(torch, "from_numpy"):
continue
if getattr(torch, "_vb_from_numpy_patched", False):
return
try:
import ctypes
import numpy as np
_orig = torch.from_numpy
# Explicit numpy → torch dtype map. Silent fallback to float32 on
# unknown dtypes would reinterpret the memcpy'd bytes as fp32 and
# silently corrupt data (e.g. fp16 tensors from some TTS engines),
# so we raise instead.
dtype_map = {
"float16": _t.float16,
"float32": _t.float32,
"float64": _t.float64,
"int8": _t.int8,
"int16": _t.int16,
"int32": _t.int32,
"int64": _t.int64,
"uint8": _t.uint8,
"bool": _t.bool,
"complex64": _t.complex64,
"complex128": _t.complex128,
}
def _safe_from_numpy(
arr, _orig=_orig, _c=ctypes, _np=np, _t=torch, _map=dtype_map
):
try:
return _orig(arr)
except RuntimeError:
a = _np.ascontiguousarray(arr)
key = str(a.dtype)
if key not in _map:
raise TypeError(
f"pyi_rth_numpy_compat: unsupported numpy dtype "
f"{key!r} in torch.from_numpy fallback; add an "
f"explicit mapping rather than silently copying "
f"bytes into the wrong dtype."
)
out = _t.empty(list(a.shape), dtype=_map[key])
_c.memmove(out.data_ptr(), a.ctypes.data, a.nbytes)
return out
torch.from_numpy = _safe_from_numpy
torch._vb_from_numpy_patched = True
except Exception:
pass
return
threading.Thread(target=_patch_torch_from_numpy, daemon=True).start()
+540
View File
@@ -0,0 +1,540 @@
"""
PyInstaller runtime hook: stub torch._dynamo to a no-op module.
Problem
-------
transformers triggers torch._dynamo import at module-load time (not just
when torch.compile is called) via class-body decorators:
transformers/modeling_utils.py:1984
@torch._dynamo.allow_in_graph
class PreTrainedModel(...)
transformers/integrations/flex_attention.py:61
@torch.compiler.disable(recursive=False)
class WrappedFlexAttention...
The attribute access triggers torch.__getattr__ -> importlib.import_module
-> torch._dynamo -> torch._dynamo.utils imports torch._numpy ->
torch._numpy._ndarray imports torch._numpy._ufuncs, which crashes under
PyInstaller with:
File "torch/_numpy/_ufuncs.py", line 235, in <module>
vars()[name] = deco_binary_ufunc(ufunc)
NameError: name 'name' is not defined
(The module-level `for name in _binary: vars()[name] = ...` pattern works
in a regular venv but fails in the PyInstaller bundle. Root cause is in
PyInstaller's importer / bytecode pipeline and not easily fixed upstream.)
Surfaces as Kokoro failing to load when `from transformers import AlbertModel`
trips the decorator chain.
Fix
---
voicebox never uses torch.compile / torch._dynamo for inference, so we
replace torch._dynamo with a no-op stub module before transformers is
imported. Any attribute access on the stub returns a pass-through callable,
so `@torch._dynamo.allow_in_graph`, `torch._dynamo.is_compiling()`,
`torch._dynamo.mark_static_address(...)`, etc. all work.
This hook is pure sys.modules manipulation — we deliberately do NOT import
torch here. Runtime hooks run before the app starts and before
pyi_rth_numpy_compat has had a chance to patch torch.from_numpy (it runs
in a background thread, waiting for torch to appear in sys.modules).
Eager-importing torch at hook time would trip the numpy ABI issue and
kill the server process at startup.
torch.compiler.disable does not need a separate stub: its implementation
is effectively `import torch._dynamo; return torch._dynamo.disable(...)`,
and since our stub is in sys.modules, that call resolves to our no-op
_NoopDecorator pass-through.
"""
import os
import sys
import tempfile
import types
# Diagnostics — log hook activity to a file alongside the bundle so we can
# see what's happening when the server is run as a sidecar (no stdout for
# runtime hook prints). Safe no-op if the file can't be written.
_DIAG_PATH = os.path.join(tempfile.gettempdir(), "voicebox_rt_hook.log")
def _diag(msg: str) -> None:
try:
with open(_DIAG_PATH, "a", encoding="utf-8") as f:
f.write(msg + "\n")
except Exception:
pass
_HOOK_VERSION = "v6-masking-utils-finder"
_diag(f"=== runtime hook load @ pid={os.getpid()} version={_HOOK_VERSION} ===")
class _NoopDecorator:
"""Multi-role no-op: decorator, falsey predicate, and context manager.
Returned from calls like `torch._dynamo.disable()` (decorator),
`torch._dynamo.is_compiling()` (predicate used in `if not ...`), and
`with torch._dynamo._trace_wrapped_higher_order_op.TransformGetItemToIndex():`
(context manager used to scope an fx graph transformation).
By implementing __call__, __bool__, __enter__, __exit__, and __iter__ we
cover every use pattern we've seen transformers/torch use on a stubbed
object. Anything we haven't covered will raise a clearer error than a
silent wrong-result.
"""
__slots__ = ()
def __call__(self, fn=None, *args, **kwargs):
return fn
def __bool__(self) -> bool:
return False
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
return False # don't suppress exceptions
def __iter__(self):
return iter(())
_noop_decorator_singleton = _NoopDecorator()
def _noop_callable(*args, **kwargs):
# Direct-decorator use: @torch._dynamo.foo (no parens) — fn is positional
if len(args) == 1 and callable(args[0]) and not kwargs:
return args[0]
# Side-effect call with non-callable arg(s), e.g. mark_static_address(tensor)
return _noop_decorator_singleton
class _NoopDynamoModule(types.ModuleType):
"""Permissive stub: every attribute is a pass-through callable.
Covers attributes transformers hits at import time (allow_in_graph) and
runtime (is_compiling, mark_static_address, reset, disable, ...).
Dunder attributes (__file__, __spec__, __loader__, ...) raise
AttributeError so probes like inspect.getmodule() — which does
`hasattr(m, '__file__')` then `os.path.normpath(m.__file__)` — see the
module as having no source file and fall through to its normal
handling, instead of receiving a function and blowing up.
"""
def __getattr__(self, name: str):
if name.startswith("__") and name.endswith("__"):
raise AttributeError(name)
return _noop_callable
class _DynamoLoader:
"""Loader used by _DynamoMetaPathFinder to materialise stub submodules."""
def create_module(self, spec):
return _NoopDynamoModule(spec.name)
def exec_module(self, module):
# Mark every stub submodule as a package so deeper submodule imports
# (`from torch._dynamo.X.Y import Z`) keep working.
module.__path__ = []
class _DynamoMetaPathFinder:
"""Resolve any `torch._dynamo.X[.Y...]` import to a no-op stub module.
Without this, `from torch._dynamo._trace_wrapped_higher_order_op import X`
fails even with torch._dynamo pre-populated in sys.modules — Python's
import machinery checks the parent's __path__ and then looks up the
child, and we need to provide both.
"""
def find_spec(self, fullname, path=None, target=None):
if fullname == "torch._dynamo":
return None # handled by the pre-populated sys.modules entry
if not fullname.startswith("torch._dynamo."):
return None
from importlib.machinery import ModuleSpec
return ModuleSpec(fullname, _DynamoLoader(), is_package=True)
class _TransformersStubFinder:
"""Replace specific transformers submodules with no-op stubs.
Two modules are targeted:
1. transformers.utils.auto_docstring
The real @auto_docstring decorator loads
transformers.models.auto.modeling_auto just to build example docstrings,
which drags in GenerationMixin -> candidate_generator -> sklearn.metrics
-> scipy.stats._distn_infrastructure and trips (2) below. Docstrings
aren't functional for inference, so a pass-through decorator is safe.
2. transformers.generation.candidate_generator
Imported at module scope by transformers.generation.utils. It does
`from sklearn.metrics import roc_curve` at module load, which triggers:
File "scipy/stats/_distn_infrastructure.py", line 369, in <module>
NameError: name 'obj' is not defined
This is a PyInstaller-specific module-load bug (same class as the
torch._numpy._ufuncs crash) where a module-level `for obj in [s for s
in dir() if ...]` loop evaluates to empty in the bundle, leaving `obj`
unbound before `del obj`.
The exports (AssistedCandidateGenerator, EarlyExitCandidateGenerator,
etc.) are speculative-decoding helpers voicebox's TTS engines do not
use; a no-op stub module satisfies the imports.
"""
_STUBBED_MODULES = frozenset(
{
"transformers.utils.auto_docstring",
"transformers.generation.candidate_generator",
}
)
def find_spec(self, fullname, path=None, target=None):
if fullname not in self._STUBBED_MODULES:
return None
from importlib.machinery import ModuleSpec
return ModuleSpec(fullname, _NoopStubLoader(), is_package=False)
class _NoopStubLoader:
def create_module(self, spec):
return _NoopDynamoModule(spec.name)
def exec_module(self, module):
# _NoopDynamoModule.__getattr__ already answers every non-dunder
# attribute with a pass-through callable, which satisfies
# `from stubbed_module import X` for any X.
pass
def _patch_scipy_distn_source(source: str) -> str:
"""Replace the unsafe `del obj` with a no-op that survives when obj is unbound.
Returns the input unchanged if the target line isn't found (e.g. scipy
version has changed).
"""
target = "\ndel obj\n"
replacement = "\nglobals().pop('obj', None)\n"
if target in source:
return source.replace(target, replacement, 1)
return source
def _patch_masking_utils_source(source: str) -> str:
"""Force torch<2.6 code path in transformers.masking_utils.
The torch>=2.6 path uses `with TransformGetItemToIndex():` to allow
`.item()` calls inside vmap. That context manager is implemented via
torch._dynamo graph transforms, which our stub doesn't reproduce — it's
a no-op. The inner `_vmap_for_bhqkv` then crashes with:
RuntimeError: vmap: It looks like you're calling .item() on a Tensor.
Forcing the torch<2.6 flag off selects sdpa_mask_older_torch which uses
a different vmap pattern that does not hit .item() and does not need
TransformGetItemToIndex.
"""
target = 'is_torch_greater_or_equal("2.6", accept_dev=True)'
# Find the specific line that assigns _is_torch_greater_or_equal_than_2_6
if "_is_torch_greater_or_equal_than_2_6 = " + target in source:
return source.replace(
"_is_torch_greater_or_equal_than_2_6 = " + target,
"_is_torch_greater_or_equal_than_2_6 = False",
1,
)
return source
class _SourcePatchingFinder:
"""Generic delegate-and-wrap meta-path finder that patches a module's
source before exec'ing.
Subclasses declare `target` (module fullname) and `patch` (str->str).
Requires the target module's .py source to be bundled (use a PyInstaller
hook setting module_collection_mode = "pyz+py").
"""
target: str
patch_fn: callable = None
def find_spec(self, fullname, path=None, target=None):
if fullname != self.target:
return None
for finder in sys.meta_path:
if finder is self:
continue
find = getattr(finder, "find_spec", None)
if find is None:
continue
try:
real_spec = find(fullname, path, target)
except Exception:
continue
if real_spec is None or real_spec.loader is None:
continue
real_spec.loader = _SourcePatchLoader(real_spec.loader, self.patch_fn)
return real_spec
return None
class _SourcePatchLoader:
"""Delegate loader that reads source via get_source, applies a patch, and
compile/exec's the patched text into module.__dict__.
"""
def __init__(self, inner, patch_fn):
self._inner = inner
self._patch_fn = patch_fn
def __getattr__(self, name):
return getattr(self._inner, name)
def create_module(self, spec):
return self._inner.create_module(spec)
def exec_module(self, module):
source = None
try:
source = self._inner.get_source(module.__name__)
except Exception as e:
_diag(f"[source-patch] get_source({module.__name__}) failed: {e!r}")
if not source:
_diag(
f"[source-patch] no source for {module.__name__}; "
"falling back to inner exec_module (patch NOT applied)"
)
self._inner.exec_module(module)
return
patched = self._patch_fn(source)
_diag(
f"[source-patch] {module.__name__}: "
f"patched={patched is not source}, len={len(patched)}"
)
spec = module.__spec__
if spec is not None and spec.submodule_search_locations is not None:
module.__path__ = spec.submodule_search_locations
filename = getattr(self._inner, "path", module.__name__)
exec(compile(patched, filename, "exec"), module.__dict__)
_diag(f"[source-patch] {module.__name__} OK")
class _MaskingUtilsFinder(_SourcePatchingFinder):
target = "transformers.masking_utils"
patch_fn = staticmethod(_patch_masking_utils_source)
class _ScipyDistnPatchingFinder:
"""Delegate-and-wrap finder for scipy.stats._distn_infrastructure.
That module ends with:
for obj in [s for s in dir() if s.startswith('_doc_')]:
exec('del ' + obj)
del obj
In the PyInstaller bundle the list comprehension evaluates to empty
(module-level dir() under the frozen importer returns a different scope
than CPython's normal module-exec path — same class of bug as the
torch._numpy._ufuncs crash). The for loop body doesn't run, `obj` is
never bound, and the trailing `del obj` raises NameError at module load.
This kills every downstream module: librosa (needed by nearly every TTS
engine for mel filters) -> scipy.signal -> scipy.stats -> here.
Workaround: delegate to the real loader, but pre-bind `obj = None` in the
module namespace before its bytecode runs. If the for loop executes, each
iteration overwrites the sentinel via STORE_NAME (normal behaviour). If it
doesn't, `del obj` removes the sentinel and module load succeeds. The
`_doc_*` cleanup this line was meant to do is purely cosmetic — those vars
stay in the module namespace but nothing references them after this point.
"""
_TARGET = "scipy.stats._distn_infrastructure"
def find_spec(self, fullname, path=None, target=None):
if fullname != self._TARGET:
return None
_diag(f"[scipy-finder] match: {fullname}, path={path!r}")
# Delegate to the other finders to locate the real spec
for finder in sys.meta_path:
if finder is self:
continue
find = getattr(finder, "find_spec", None)
if find is None:
continue
try:
real_spec = find(fullname, path, target)
except Exception as e:
_diag(f"[scipy-finder] inner finder {type(finder).__name__} raised: {e}")
continue
if real_spec is None:
continue
if real_spec.loader is None:
_diag(f"[scipy-finder] {type(finder).__name__} returned spec with loader=None")
continue
_diag(
f"[scipy-finder] wrapped loader from "
f"{type(finder).__name__} -> {type(real_spec.loader).__name__}"
)
real_spec.loader = _ScipyDistnPrebindLoader(real_spec.loader)
return real_spec
_diag("[scipy-finder] NO inner finder returned a spec")
return None
class _ScipyDistnPrebindLoader:
"""Thin wrapper that pre-binds `obj = None` before delegating to the
real PyInstaller loader.
Every other attribute/method delegates to the inner loader — PyiFrozenLoader
is a rich FileLoader/ExecutionLoader with get_code/get_source/get_filename/
is_package/get_resource_reader/etc., any of which Python's import machinery
or 3rd-party code may call on spec.loader. Forwarding via __getattr__
avoids breaking any of those paths (and preserves @_check_name contracts
because the decorated methods run on the inner instance where self.name
matches spec.name).
"""
def __init__(self, inner):
self._inner = inner
def __getattr__(self, name):
# __getattr__ fires only for attrs not already on self, so delegate
# everything that isn't create_module/exec_module (or __getattr__/init).
return getattr(self._inner, name)
def create_module(self, spec):
return self._inner.create_module(spec)
def exec_module(self, module):
# Compile scipy's module source with the problematic line patched.
#
# The real module ends with:
# for obj in [s for s in dir() if s.startswith('_doc_')]:
# exec('del ' + obj)
# del obj
#
# Under PyInstaller's frozen importer, `del obj` raises NameError
# even when we pre-populate module.__dict__['obj'] — the pre-compiled
# .pyc bytecode interacts with the frame setup differently than a
# fresh compile() from source. Easiest robust fix: read the source
# and replace `del obj` with a safe variant before compiling.
#
# Requires the .py source to be bundled alongside the .pyc — see
# backend/pyi_hooks/hook-scipy.stats._distn_infrastructure.py.
source = None
try:
source = self._inner.get_source(module.__name__)
except Exception as e:
_diag(f"[scipy-loader] get_source failed: {e!r}")
if source:
patched = _patch_scipy_distn_source(source)
_diag(
f"[scipy-loader] source-patch path: patched={patched is not source}, "
f"len={len(patched)}"
)
spec = module.__spec__
if spec is not None and spec.submodule_search_locations is not None:
module.__path__ = spec.submodule_search_locations
filename = getattr(self._inner, "path", module.__name__)
bytecode = compile(patched, filename, "exec")
try:
exec(bytecode, module.__dict__)
except Exception as e:
_diag(f"[scipy-loader] patched exec raised {type(e).__name__}: {e!r}")
raise
_diag(f"[scipy-loader] exec_module {module.__name__} OK (source-patched)")
return
# No source available — fall back to the pre-bind approach. This is
# best-effort; if the frozen .pyc really does see a different `obj`
# slot, this will still crash, but we've done all we can without
# source.
_diag("[scipy-loader] no source available; falling back to pre-bind")
module.__dict__["obj"] = None
self._inner.exec_module(module)
def _install_dynamo_stub() -> None:
stub = _NoopDynamoModule("torch._dynamo")
# Mark as a package so `from torch._dynamo.X import Y` imports work
# (Python's import machinery checks parent.__path__ before looking up
# the child).
stub.__path__ = []
# torch._dynamo.config is accessed as a nested attribute namespace
# (e.g. `torch._dynamo.config.capture_scalar_outputs = True`), so use
# a permissive module so any attr read returns a no-op and sets succeed.
stub.config = _NoopDynamoModule("torch._dynamo.config")
stub.config.__path__ = []
sys.modules["torch._dynamo"] = stub
sys.modules["torch._dynamo.config"] = stub.config
# Finders:
# - torch._dynamo.* submodules -> no-op stubs
# - transformers.utils.auto_docstring and
# transformers.generation.candidate_generator -> no-op stubs (both
# paths reach sklearn -> scipy.stats which trips a separate crash)
# - scipy.stats._distn_infrastructure -> real load with `obj` pre-bound,
# so librosa -> scipy.signal -> scipy.stats loads cleanly
for _FinderCls in (
_DynamoMetaPathFinder,
_TransformersStubFinder,
_ScipyDistnPatchingFinder,
_MaskingUtilsFinder,
):
try:
sys.meta_path.insert(0, _FinderCls())
_diag(f"installed finder: {_FinderCls.__name__}")
except Exception as e:
_diag(f"FAILED to install {_FinderCls.__name__}: {e!r}")
_diag(
"final sys.meta_path head: "
+ ", ".join(type(f).__name__ for f in sys.meta_path[:6])
)
# If torch is already imported, also set the attribute on the package so
# `torch._dynamo` resolves to our stub without triggering torch.__getattr__
# (which would lazy-import the real module and crash).
torch_mod = sys.modules.get("torch")
if torch_mod is not None:
torch_mod._dynamo = stub
try:
_install_dynamo_stub()
except Exception as _e:
# Best effort. If this fails the original NameError will surface when
# transformers imports — no worse than not patching at all.
_diag(f"_install_dynamo_stub FAILED: {_e!r}")
# NOTE: we deliberately do NOT import torch or torch.compiler here.
# Runtime hooks run before the app starts and before pyi_rth_numpy_compat
# has had a chance to patch torch.from_numpy (it runs in a background
# thread, waiting for torch to appear in sys.modules). Importing torch
# eagerly at hook time would trip the numpy ABI issue and kill the
# server process at startup.
#
# torch.compiler.disable does not need an explicit stub: its
# implementation is effectively `import torch._dynamo; return
# torch._dynamo.disable(fn, recursive, reason=reason)`, and since our
# stub is installed in sys.modules, that call resolves to our no-op
# _NoopDecorator pass-through.
+11 -1
View File
@@ -2,4 +2,14 @@
# These should only be installed on aarch64-apple-darwin platforms
mlx>=0.30.0
mlx-audio>=0.3.1
# 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). All other mlx-audio runtime deps
# (huggingface_hub, librosa, miniaudio, mlx-lm, numba, numpy, protobuf,
# pyloudnorm, sounddevice, tqdm) are already in requirements.txt.
+9 -2
View File
@@ -8,7 +8,7 @@ sqlalchemy>=2.0.0
alembic>=1.13.0
# ML models
torch>=2.7.0
torch>=2.2.0
transformers>=4.36.0,<=4.57.6
accelerate>=0.26.0
huggingface_hub>=0.20.0
@@ -40,10 +40,17 @@ pyloudnorm
# provides the only class TADA uses: Snake1d.)
torchaudio
# Kokoro TTS (lightweight 82M-param engine)
kokoro>=0.9.4
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
# Audio processing
librosa>=0.10.0
soundfile>=0.12.0
numpy>=1.24.0
numpy>=1.24.0,<2.0
numba>=0.60.0,<0.61.0
pedalboard>=0.9.0
+7 -9
View File
@@ -1,12 +1,10 @@
"""Audio file serving endpoints."""
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from .. import models
from .. import config, models
from ..services import history
from ..database import get_db
@@ -22,8 +20,8 @@ async def get_version_audio(version_id: str, db: Session = Depends(get_db)):
if not version:
raise HTTPException(status_code=404, detail="Version not found")
audio_path = Path(version.audio_path)
if not audio_path.exists():
audio_path = config.resolve_storage_path(version.audio_path)
if audio_path is None or not audio_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(
@@ -40,8 +38,8 @@ async def get_audio(generation_id: str, db: Session = Depends(get_db)):
if not generation:
raise HTTPException(status_code=404, detail="Generation not found")
audio_path = Path(generation.audio_path)
if not audio_path.exists():
audio_path = config.resolve_storage_path(generation.audio_path)
if audio_path is None or not audio_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(
@@ -60,8 +58,8 @@ async def get_sample_audio(sample_id: str, db: Session = Depends(get_db)):
if not sample:
raise HTTPException(status_code=404, detail="Sample not found")
audio_path = Path(sample.audio_path)
if not audio_path.exists():
audio_path = config.resolve_storage_path(sample.audio_path)
if audio_path is None or not audio_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(
+7 -6
View File
@@ -3,7 +3,6 @@
import asyncio
import io
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
@@ -41,10 +40,11 @@ async def preview_effects(
all_versions = versions_mod.list_versions(generation_id, db)
clean_version = next((v for v in all_versions if v.effects_chain is None), None)
source_path = clean_version.audio_path if clean_version else gen.audio_path
if not source_path or not Path(source_path).exists():
resolved_source_path = config.resolve_storage_path(source_path)
if resolved_source_path is None or not resolved_source_path.exists():
raise HTTPException(status_code=404, detail="Source audio file not found")
audio, sample_rate = await asyncio.to_thread(load_audio, source_path)
audio, sample_rate = await asyncio.to_thread(load_audio, str(resolved_source_path))
processed = await asyncio.to_thread(apply_effects, audio, sample_rate, chain_dicts)
import soundfile as sf
@@ -193,10 +193,11 @@ async def apply_effects_to_generation(
source_path = clean_version.audio_path
source_version_id = clean_version.id
if not source_path or not Path(source_path).exists():
resolved_source_path = config.resolve_storage_path(source_path)
if resolved_source_path is None or not resolved_source_path.exists():
raise HTTPException(status_code=404, detail="Source audio file not found")
audio, sample_rate = await asyncio.to_thread(load_audio, source_path)
audio, sample_rate = await asyncio.to_thread(load_audio, str(resolved_source_path))
processed_audio = await asyncio.to_thread(apply_effects, audio, sample_rate, chain_dicts)
version_id = str(uuid.uuid4())
@@ -208,7 +209,7 @@ async def apply_effects_to_generation(
version = versions_mod.create_version(
generation_id=generation_id,
label=label,
audio_path=str(processed_path),
audio_path=config.to_storage_path(processed_path),
db=db,
effects_chain=chain_dicts,
is_default=data.set_as_default,
+63 -3
View File
@@ -14,12 +14,16 @@ from .. import models
from ..services import history, profiles, tts
from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db
from ..services.generation import run_generation
from ..services.task_queue import enqueue_generation
from ..services.task_queue import cancel_generation as cancel_generation_job, enqueue_generation
from ..utils.tasks import get_task_manager
router = APIRouter()
def _resolve_generation_engine(data: models.GenerationRequest, profile) -> str:
return data.engine or getattr(profile, "default_engine", None) or getattr(profile, "preset_engine", None) or "qwen"
@router.post("/generate", response_model=models.GenerationResponse)
async def generate_speech(
data: models.GenerationRequest,
@@ -35,7 +39,12 @@ async def generate_speech(
from ..backends import engine_has_model_sizes
engine = data.engine or "qwen"
engine = _resolve_generation_engine(data, profile)
try:
profiles.validate_profile_engine(profile, engine)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
model_size = (data.model_size or "1.7B") if engine_has_model_sizes(engine) else None
generation = await history.create_generation(
@@ -73,6 +82,7 @@ async def generate_speech(
pass
enqueue_generation(
generation_id,
run_generation(
generation_id=generation_id,
profile_id=data.profile_id,
@@ -118,6 +128,7 @@ async def retry_generation(generation_id: str, db: Session = Depends(get_db)):
)
enqueue_generation(
generation_id,
run_generation(
generation_id=generation_id,
profile_id=gen.profile_id,
@@ -161,6 +172,7 @@ async def regenerate_generation(generation_id: str, db: Session = Depends(get_db
version_id = str(uuid.uuid4())
enqueue_generation(
generation_id,
run_generation(
generation_id=generation_id,
profile_id=gen.profile_id,
@@ -178,6 +190,34 @@ async def regenerate_generation(generation_id: str, db: Session = Depends(get_db
return models.GenerationResponse.model_validate(gen)
@router.post("/generate/{generation_id}/cancel")
async def cancel_generation(generation_id: str, db: Session = Depends(get_db)):
"""Cancel a queued or running generation."""
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
if not gen:
raise HTTPException(status_code=404, detail="Generation not found")
if (gen.status or "completed") not in ("loading_model", "generating"):
raise HTTPException(status_code=400, detail="Only active generations can be cancelled")
cancellation_state = cancel_generation_job(generation_id)
if cancellation_state is None:
raise HTTPException(status_code=409, detail="Generation is no longer cancellable")
if cancellation_state == "queued":
task_manager = get_task_manager()
task_manager.complete_generation(generation_id)
await history.update_generation_status(
generation_id=generation_id,
status="failed",
db=db,
error="Generation cancelled",
)
return {"message": "Queued generation cancelled"}
return {"message": "Generation cancellation requested"}
@router.get("/generate/{generation_id}/status")
async def get_generation_status(generation_id: str, db: Session = Depends(get_db)):
"""SSE endpoint that streams generation status updates."""
@@ -230,7 +270,11 @@ async def stream_speech(
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
engine = data.engine or "qwen"
engine = _resolve_generation_engine(data, profile)
try:
profiles.validate_profile_engine(profile, engine)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
tts_model = get_tts_backend_for_engine(engine)
model_size = data.model_size or "1.7B"
@@ -263,6 +307,22 @@ async def stream_speech(
trim_fn=trim_fn,
)
effects_chain_config = None
if data.effects_chain is not None:
effects_chain_config = [e.model_dump() for e in data.effects_chain]
elif profile.effects_chain:
import json as _json
try:
effects_chain_config = _json.loads(profile.effects_chain)
except Exception:
effects_chain_config = None
if effects_chain_config:
from ..utils.effects import apply_effects
audio = apply_effects(audio, sample_rate, effects_chain_config)
if data.normalize:
from ..utils.audio import normalize_audio
+16 -1
View File
@@ -93,6 +93,12 @@ async def health():
except ImportError:
pass
gpu_compat_warning = None
if has_cuda:
from ..backends.base import check_cuda_compatibility
_compatible, gpu_compat_warning = check_cuda_compatibility()
gpu_available = has_cuda or has_mps or has_xpu or has_directml or backend_type == "mlx"
gpu_type = None
@@ -110,6 +116,11 @@ async def health():
vram_used = None
if has_cuda:
vram_used = torch.cuda.memory_allocated() / 1024 / 1024
elif has_xpu:
try:
vram_used = torch.xpu.memory_allocated() / 1024 / 1024
except Exception:
pass # memory_allocated() may not be available on all IPEX versions
model_loaded = False
model_size = None
@@ -162,7 +173,11 @@ async def health():
gpu_type=gpu_type,
vram_used_mb=vram_used,
backend_type=backend_type,
backend_variant=os.environ.get("VOICEBOX_BACKEND_VARIANT", "cuda" if torch.cuda.is_available() else "cpu"),
backend_variant=os.environ.get(
"VOICEBOX_BACKEND_VARIANT",
"cuda" if torch.cuda.is_available() else ("xpu" if has_xpu else "cpu"),
),
gpu_compatibility_warning=gpu_compat_warning,
)
+15 -4
View File
@@ -1,13 +1,12 @@
"""Generation history endpoints."""
import io
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.orm import Session
from .. import models
from .. import config, models
from ..services import export_import, history
from ..app import safe_content_disposition
from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db
@@ -63,6 +62,13 @@ async def import_generation(
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/history/failed")
async def clear_failed_generations(db: Session = Depends(get_db)):
"""Delete every generation with status='failed'. Used by the UI's 'Clear failed' button (#410)."""
count = await history.delete_failed_generations(db)
return {"deleted": count}
@router.get("/history/{generation_id}", response_model=models.HistoryResponse)
async def get_generation(
generation_id: str,
@@ -90,6 +96,11 @@ async def get_generation(
duration=gen.duration,
seed=gen.seed,
instruct=gen.instruct,
engine=gen.engine or "qwen",
model_size=gen.model_size,
status=gen.status or "completed",
error=gen.error,
is_favorited=bool(gen.is_favorited),
created_at=gen.created_at,
)
@@ -162,8 +173,8 @@ async def export_generation_audio(
if not generation.audio_path:
raise HTTPException(status_code=404, detail="Generation has no audio file")
audio_path = Path(generation.audio_path)
if not audio_path.is_file():
audio_path = config.resolve_storage_path(generation.audio_path)
if audio_path is None or not audio_path.is_file():
raise HTTPException(status_code=404, detail="Audio file not found")
safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (" ", "-", "_")).strip()
+3 -2
View File
@@ -135,14 +135,15 @@ async def migrate_models(request: models.ModelMigrateRequest):
if destination.resolve().is_relative_to(source.resolve()):
raise HTTPException(status_code=400, detail="Destination cannot be inside the current cache directory")
progress_manager = get_progress_manager()
model_dirs = [d for d in source.iterdir() if d.name.startswith("models--") and d.is_dir()]
if not model_dirs:
progress_manager.update_progress("migration", 1, 1, status="complete")
progress_manager.mark_complete("migration")
return {"moved": 0, "errors": [], "source": str(source), "destination": str(destination)}
destination.mkdir(parents=True, exist_ok=True)
progress_manager = get_progress_manager()
same_fs = False
try:
same_fs = source.stat().st_dev == destination.stat().st_dev
+46 -4
View File
@@ -1,6 +1,8 @@
"""Voice profile endpoints."""
import io
import json as _json
import logging
import tempfile
from datetime import datetime
from pathlib import Path
@@ -15,6 +17,8 @@ from ..database import VoiceProfile as DBVoiceProfile, get_db
from ..services import channels, export_import, profiles
from ..services.profiles import _profile_to_response
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -62,6 +66,46 @@ async def import_profile(
raise HTTPException(status_code=500, detail=str(e))
# ── Preset Voice Endpoints ───────────────────────────────────────────
# These MUST be declared before /profiles/{profile_id} to avoid the
# wildcard swallowing "presets" as a profile_id.
@router.get("/profiles/presets/{engine}")
async def list_preset_voices(engine: str):
"""List available preset voices for an engine."""
if engine == "kokoro":
from ..backends.kokoro_backend import KOKORO_VOICES
return {
"engine": engine,
"voices": [
{
"voice_id": vid,
"name": name,
"gender": gender,
"language": lang,
}
for vid, name, gender, lang in KOKORO_VOICES
],
}
if engine == "qwen_custom_voice":
from ..backends.qwen_custom_voice_backend import QWEN_CUSTOM_VOICES
return {
"engine": engine,
"voices": [
{
"voice_id": speaker_id,
"name": display_name,
"gender": gender,
"language": lang,
}
for speaker_id, display_name, gender, lang, _desc in QWEN_CUSTOM_VOICES
],
}
return {"engine": engine, "voices": []}
@router.get("/profiles/{profile_id}", response_model=models.VoiceProfileResponse)
async def get_profile(
profile_id: str,
@@ -215,8 +259,8 @@ async def get_profile_avatar(
if not profile.avatar_path:
raise HTTPException(status_code=404, detail="No avatar found for this profile")
avatar_path = Path(profile.avatar_path)
if not avatar_path.exists():
avatar_path = config.resolve_storage_path(profile.avatar_path)
if avatar_path is None or not avatar_path.exists():
raise HTTPException(status_code=404, detail="Avatar file not found")
return FileResponse(avatar_path)
@@ -297,8 +341,6 @@ async def update_profile_effects(
db: Session = Depends(get_db),
):
"""Set or clear the default effects chain for a voice profile."""
import json as _json
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
+30
View File
@@ -105,6 +105,11 @@ def _start_parent_watchdog(parent_pid, data_dir=None):
This is the clean shutdown mechanism: instead of the Tauri app trying to
forcefully kill the server (which spawns console windows on Windows),
the server monitors its parent and shuts itself down gracefully.
The Tauri app writes a .keep-running sentinel file to data_dir before
exiting when "remain running after close" is enabled. This is a reliable
fallback for the HTTP /watchdog/disable request, which can race with
process exit on Windows.
"""
import os
import signal
@@ -164,6 +169,19 @@ def _start_parent_watchdog(parent_pid, data_dir=None):
if not alive:
watchdog_logger.warning(f"Parent PID {parent_pid} not found on first check — disabling watchdog")
return
# Clear any stale .keep-running sentinel from a previous session. The
# sentinel is only removed by the watchdog when it's consumed during a
# grace period; if the HTTP /watchdog/disable path wins the race on a
# "keep running" exit, the sentinel is left on disk. Wipe it here so a
# future session can't inherit that stale signal.
if data_dir:
stale = os.path.join(data_dir, ".keep-running")
if os.path.exists(stale):
try:
os.remove(stale)
watchdog_logger.info("Removed stale .keep-running sentinel from previous session")
except OSError as e:
watchdog_logger.warning(f"Failed to remove stale sentinel: {e}")
while True:
if _watchdog_disabled:
watchdog_logger.info("Watchdog disabled (keep server running), stopping monitor")
@@ -178,6 +196,18 @@ def _start_parent_watchdog(parent_pid, data_dir=None):
if _watchdog_disabled:
watchdog_logger.info("Watchdog was disabled during grace period, keeping server alive")
return
# Check for sentinel file written by Tauri before exit.
# This catches the case where the HTTP disable request
# didn't arrive before the parent process died (common
# on Windows where process teardown is fast).
sentinel = os.path.join(data_dir, ".keep-running") if data_dir else None
if sentinel and os.path.exists(sentinel):
watchdog_logger.info("Found .keep-running sentinel file, keeping server alive")
try:
os.remove(sentinel)
except OSError:
pass
return
watchdog_logger.info("Watchdog still enabled after grace period, shutting down server...")
if sys.platform == "win32":
# sys.exit triggers SystemExit, allowing uvicorn to run
+16
View File
@@ -11,6 +11,7 @@ Both archives are extracted into {data_dir}/backends/cuda/ which forms the
complete PyInstaller --onedir directory structure that torch expects.
"""
import asyncio
import hashlib
import json
import logging
@@ -34,6 +35,12 @@ PROGRESS_KEY = "cuda-backend"
# CUDA toolkit version or torch's CUDA dependency changes (e.g. cu126 -> cu128).
CUDA_LIBS_VERSION = "cu128-v1"
# Prevents concurrent download_cuda_binary() calls from racing on the same
# temp file. The auto-update background task and the manual HTTP endpoint
# can both invoke download_cuda_binary(); without this lock the progress-
# manager status check is a TOCTOU race.
_download_lock = asyncio.Lock()
def get_backends_dir() -> Path:
"""Directory where downloaded backend binaries are stored."""
@@ -241,6 +248,15 @@ async def download_cuda_binary(version: Optional[str] = None):
Args:
version: Version tag (e.g. "v0.3.0"). Defaults to current app version.
"""
if _download_lock.locked():
logger.info("CUDA download already in progress, skipping duplicate request")
return
async with _download_lock:
await _download_cuda_binary_locked(version)
async def _download_cuda_binary_locked(version: Optional[str] = None):
"""Inner implementation of download_cuda_binary, called under _download_lock."""
import httpx
if version is None:
+2 -2
View File
@@ -11,6 +11,8 @@ from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from ..utils.effects import validate_effects_chain
from ..database import EffectPreset as DBEffectPreset
from ..models import EffectPresetResponse, EffectPresetCreate, EffectPresetUpdate, EffectConfig
@@ -52,7 +54,6 @@ def get_preset_by_name(name: str, db: Session) -> Optional[EffectPresetResponse]
def create_preset(data: EffectPresetCreate, db: Session) -> EffectPresetResponse:
"""Create a new user effect preset."""
from .utils.effects import validate_effects_chain
chain_dicts = [e.model_dump() for e in data.effects_chain]
error = validate_effects_chain(chain_dicts)
@@ -94,7 +95,6 @@ def update_preset(preset_id: str, data: EffectPresetUpdate, db: Session) -> Opti
if data.description is not None:
preset.description = data.description
if data.effects_chain is not None:
from .utils.effects import validate_effects_chain
chain_dicts = [e.model_dump() for e in data.effects_chain]
error = validate_effects_chain(chain_dicts)
+11 -9
View File
@@ -73,8 +73,8 @@ def export_profile_to_zip(profile_id: str, db: Session) -> bytes:
# Check if profile has avatar
has_avatar = False
if profile.avatar_path:
avatar_path = Path(profile.avatar_path)
if avatar_path.exists():
avatar_path = config.resolve_storage_path(profile.avatar_path)
if avatar_path is not None and avatar_path.exists():
has_avatar = True
# Add avatar to ZIP root with original extension
avatar_ext = avatar_path.suffix
@@ -98,7 +98,9 @@ def export_profile_to_zip(profile_id: str, db: Session) -> bytes:
for sample in samples:
# Get filename from audio_path (should be {sample_id}.wav)
audio_path = Path(sample.audio_path)
audio_path = config.resolve_storage_path(sample.audio_path)
if audio_path is None:
raise ValueError(f"Audio file not found: {sample.audio_path}")
filename = audio_path.name
# Read audio file
@@ -279,7 +281,7 @@ def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
# Build version manifest entries
version_entries = []
for v in versions:
v_path = Path(v.audio_path)
v_path = config.resolve_storage_path(v.audio_path)
effects_chain = None
if v.effects_chain:
effects_chain = json.loads(v.effects_chain)
@@ -314,14 +316,14 @@ def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
# Add all version audio files
for v in versions:
v_path = Path(v.audio_path)
if v_path.exists():
v_path = config.resolve_storage_path(v.audio_path)
if v_path is not None and v_path.exists():
zip_file.write(v_path, f"audio/{v_path.name}")
# Fallback: if no versions exist, include the generation's main audio
if not versions:
audio_path = Path(generation.audio_path)
if audio_path.exists():
audio_path = config.resolve_storage_path(generation.audio_path)
if audio_path is not None and audio_path.exists():
zip_file.write(audio_path, f"audio/{audio_path.name}")
zip_buffer.seek(0)
@@ -426,7 +428,7 @@ async def import_generation_from_zip(file_bytes: bytes, db: Session) -> dict:
profile_id=profile_id,
text=generation_data["text"],
language=generation_data["language"],
audio_path=str(audio_dest),
audio_path=config.to_storage_path(audio_dest),
duration=generation_data["duration"],
seed=generation_data.get("seed"),
instruct=generation_data.get("instruct"),
+16 -6
View File
@@ -16,6 +16,7 @@ Mode differences:
from __future__ import annotations
import asyncio
import traceback
from typing import Literal, Optional
@@ -126,6 +127,13 @@ async def run_generation(
duration=duration,
)
except asyncio.CancelledError:
await history.update_generation_status(
generation_id=generation_id,
status="failed",
db=bg_db,
error="Generation cancelled",
)
except Exception as e:
traceback.print_exc()
await history.update_generation_status(
@@ -163,7 +171,7 @@ def _save_generate(
versions_mod.create_version(
generation_id=generation_id,
label="original",
audio_path=str(clean_audio_path),
audio_path=config.to_storage_path(clean_audio_path),
db=db,
effects_chain=None,
is_default=not has_effects,
@@ -174,6 +182,8 @@ def _save_generate(
if has_effects:
from ..utils.effects import apply_effects, validate_effects_chain
assert effects_chain is not None
error_msg = validate_effects_chain(effects_chain)
if error_msg:
import logging
@@ -189,13 +199,13 @@ def _save_generate(
versions_mod.create_version(
generation_id=generation_id,
label="version-2",
audio_path=str(processed_path),
audio_path=config.to_storage_path(processed_path),
db=db,
effects_chain=effects_chain,
is_default=True,
)
return final_audio_path
return config.to_storage_path(final_audio_path)
def _save_retry(
@@ -211,7 +221,7 @@ def _save_retry(
"""
audio_path = config.get_generations_dir() / f"{generation_id}.wav"
save_audio(audio, str(audio_path), sample_rate)
return str(audio_path)
return config.to_storage_path(audio_path)
def _save_regenerate(
@@ -244,10 +254,10 @@ def _save_regenerate(
versions_mod.create_version(
generation_id=generation_id,
label=label,
audio_path=str(audio_path),
audio_path=config.to_storage_path(audio_path),
db=db,
effects_chain=None,
is_default=True,
)
return str(audio_path)
return config.to_storage_path(audio_path)
+45 -4
View File
@@ -253,8 +253,8 @@ async def delete_generation(
# Delete main audio file (if not already removed by version cleanup)
if generation.audio_path:
audio_path = Path(generation.audio_path)
if audio_path.exists():
audio_path = config.resolve_storage_path(generation.audio_path)
if audio_path is not None and audio_path.exists():
audio_path.unlink()
# Delete from database
@@ -264,6 +264,43 @@ async def delete_generation(
return True
async def delete_failed_generations(db: Session) -> int:
"""
Delete every generation whose status is 'failed'.
Used by the "Clear failed" action in the UI so users can tidy up
history after the model wasn't loaded, the app was closed mid-run,
or a generation otherwise errored out (see issue #410).
Returns:
Number of generations deleted.
"""
from . import versions as versions_mod
failed = db.query(DBGeneration).filter(DBGeneration.status == "failed").all()
count = 0
for generation in failed:
# Clean up version files/rows first.
versions_mod.delete_versions_for_generation(generation.id, db)
# Remove the main audio file if it somehow made it to disk.
if generation.audio_path:
audio_path = config.resolve_storage_path(generation.audio_path)
if audio_path is not None and audio_path.exists():
try:
audio_path.unlink()
except OSError:
# Best-effort cleanup — don't abort the whole sweep
# if a single file can't be removed.
pass
db.delete(generation)
count += 1
db.commit()
return count
async def delete_generations_by_profile(
profile_id: str,
db: Session,
@@ -282,9 +319,13 @@ async def delete_generations_by_profile(
count = 0
for generation in generations:
# Delete associated version files and rows first
from . import versions as versions_mod
versions_mod.delete_versions_for_generation(generation.id, db)
# Delete audio file
audio_path = Path(generation.audio_path)
if audio_path.exists():
audio_path = config.resolve_storage_path(generation.audio_path)
if audio_path is not None and audio_path.exists():
audio_path.unlink()
# Delete from database
+219 -59
View File
@@ -1,33 +1,30 @@
"""
Voice profile management module.
"""
"""Voice profile management module."""
from typing import List, Optional
from datetime import datetime
import uuid
import json as _json
import logging
import shutil
import uuid
from datetime import datetime
from pathlib import Path
from sqlalchemy.orm import Session
from sqlalchemy import func, select
from sqlalchemy import func
from sqlalchemy.orm import Session
from .. import config
from ..database import Generation as DBGeneration, ProfileSample as DBProfileSample, VoiceProfile as DBVoiceProfile
from ..models import (
EffectConfig,
ProfileSampleResponse,
VoiceProfileCreate,
VoiceProfileResponse,
ProfileSampleCreate,
ProfileSampleResponse,
)
from ..database import (
VoiceProfile as DBVoiceProfile,
ProfileSample as DBProfileSample,
Generation as DBGeneration,
)
from ..models import EffectConfig
from ..utils.audio import validate_reference_audio, validate_and_load_reference_audio, load_audio, save_audio
from ..utils.images import validate_image, process_avatar
from ..utils.audio import save_audio, validate_and_load_reference_audio
from ..utils.cache import _get_cache_dir, clear_profile_cache
from .tts import get_tts_model
from .. import config
import json as _json
from ..utils.images import process_avatar, validate_image
logger = logging.getLogger(__name__)
CLONING_ENGINES = {"qwen", "luxtts", "chatterbox", "chatterbox_turbo", "tada"}
def _profile_to_response(
@@ -52,6 +49,11 @@ def _profile_to_response(
language=profile.language,
avatar_path=profile.avatar_path,
effects_chain=effects_chain,
voice_type=getattr(profile, "voice_type", None) or "cloned",
preset_engine=getattr(profile, "preset_engine", None),
preset_voice_id=getattr(profile, "preset_voice_id", None),
design_prompt=getattr(profile, "design_prompt", None),
default_engine=getattr(profile, "default_engine", None),
generation_count=generation_count,
sample_count=sample_count,
created_at=profile.created_at,
@@ -59,6 +61,79 @@ def _profile_to_response(
)
def _get_preset_voice_ids(engine: str) -> set[str]:
if engine == "kokoro":
from ..backends.kokoro_backend import KOKORO_VOICES
return {voice_id for voice_id, _name, _gender, _lang in KOKORO_VOICES}
if engine == "qwen_custom_voice":
from ..backends.qwen_custom_voice_backend import QWEN_CUSTOM_VOICES
return {voice_id for voice_id, _name, _gender, _lang, _desc in QWEN_CUSTOM_VOICES}
return set()
def _validate_profile_fields(
*,
voice_type: str,
preset_engine: str | None,
preset_voice_id: str | None,
design_prompt: str | None,
default_engine: str | None,
) -> str | None:
if voice_type == "preset":
if not preset_engine or not preset_voice_id:
return "Preset profiles require both preset_engine and preset_voice_id"
if default_engine and default_engine != preset_engine:
return "Preset profiles must use their preset_engine as default_engine"
available_voice_ids = _get_preset_voice_ids(preset_engine)
if available_voice_ids and preset_voice_id not in available_voice_ids:
return f"Preset voice '{preset_voice_id}' is not valid for engine '{preset_engine}'"
return None
if voice_type == "designed":
if not design_prompt or not design_prompt.strip():
return "Designed profiles require a design_prompt"
if preset_engine or preset_voice_id:
return "Designed profiles cannot set preset_engine or preset_voice_id"
return None
if preset_engine or preset_voice_id:
return "Cloned profiles cannot set preset_engine or preset_voice_id"
if design_prompt:
return "Cloned profiles cannot set design_prompt"
if default_engine and default_engine not in CLONING_ENGINES:
return f"Cloned profiles cannot use default engine '{default_engine}'"
return None
def validate_profile_engine(profile, engine: str) -> None:
voice_type = getattr(profile, "voice_type", None) or "cloned"
if voice_type == "preset":
preset_engine = getattr(profile, "preset_engine", None)
preset_voice_id = getattr(profile, "preset_voice_id", None)
if not preset_engine or not preset_voice_id:
raise ValueError(f"Preset profile {profile.id} is missing preset engine metadata")
if preset_engine != engine:
raise ValueError(
f"Preset profile {profile.id} only supports engine '{preset_engine}', not '{engine}'"
)
return
if voice_type == "designed":
design_prompt = getattr(profile, "design_prompt", None)
if not design_prompt or not design_prompt.strip():
raise ValueError(f"Designed profile {profile.id} is missing design_prompt")
return
if engine not in CLONING_ENGINES:
raise ValueError(f"Engine '{engine}' does not support cloned voice profiles")
async def create_profile(
data: VoiceProfileCreate,
db: Session,
@@ -80,11 +155,32 @@ async def create_profile(
if existing_profile:
raise ValueError(f"A profile with the name '{data.name}' already exists. Please choose a different name.")
# Auto-set default_engine for preset profiles
default_engine = data.default_engine
voice_type = data.voice_type or "cloned"
if voice_type == "preset" and data.preset_engine and not default_engine:
default_engine = data.preset_engine
validation_error = _validate_profile_fields(
voice_type=voice_type,
preset_engine=data.preset_engine,
preset_voice_id=data.preset_voice_id,
design_prompt=data.design_prompt,
default_engine=default_engine,
)
if validation_error:
raise ValueError(validation_error)
db_profile = DBVoiceProfile(
id=str(uuid.uuid4()),
name=data.name,
description=data.description,
language=data.language,
voice_type=voice_type,
preset_engine=data.preset_engine,
preset_voice_id=data.preset_voice_id,
design_prompt=data.design_prompt,
default_engine=default_engine,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
@@ -140,7 +236,7 @@ async def add_profile_sample(
db_sample = DBProfileSample(
id=sample_id,
profile_id=profile_id,
audio_path=str(dest_path),
audio_path=config.to_storage_path(dest_path),
reference_text=reference_text,
)
@@ -161,7 +257,7 @@ async def add_profile_sample(
async def get_profile(
profile_id: str,
db: Session,
) -> Optional[VoiceProfileResponse]:
) -> VoiceProfileResponse | None:
"""
Get a voice profile by ID.
@@ -182,7 +278,7 @@ async def get_profile(
async def get_profile_samples(
profile_id: str,
db: Session,
) -> List[ProfileSampleResponse]:
) -> list[ProfileSampleResponse]:
"""
Get all samples for a profile.
@@ -197,7 +293,7 @@ async def get_profile_samples(
return [ProfileSampleResponse.model_validate(s) for s in samples]
async def list_profiles(db: Session) -> List[VoiceProfileResponse]:
async def list_profiles(db: Session) -> list[VoiceProfileResponse]:
"""
List all voice profiles with generation and sample counts.
@@ -238,7 +334,7 @@ async def update_profile(
profile_id: str,
data: VoiceProfileCreate,
db: Session,
) -> Optional[VoiceProfileResponse]:
) -> VoiceProfileResponse | None:
"""
Update a voice profile.
@@ -262,9 +358,27 @@ async def update_profile(
if existing_profile:
raise ValueError(f"A profile with the name '{data.name}' already exists. Please choose a different name.")
voice_type = getattr(profile, "voice_type", None) or "cloned"
preset_engine = getattr(profile, "preset_engine", None)
preset_voice_id = getattr(profile, "preset_voice_id", None)
design_prompt = getattr(profile, "design_prompt", None)
default_engine = data.default_engine if data.default_engine is not None else getattr(profile, "default_engine", None)
validation_error = _validate_profile_fields(
voice_type=voice_type,
preset_engine=preset_engine,
preset_voice_id=preset_voice_id,
design_prompt=design_prompt,
default_engine=default_engine,
)
if validation_error:
raise ValueError(validation_error)
profile.name = data.name
profile.description = data.description
profile.language = data.language
if data.default_engine is not None:
profile.default_engine = data.default_engine or None # empty string → NULL
profile.updated_at = datetime.utcnow()
db.commit()
@@ -327,8 +441,8 @@ async def delete_profile_sample(
# Store profile_id before deleting
profile_id = sample.profile_id
audio_path = Path(sample.audio_path)
if audio_path.exists():
audio_path = config.resolve_storage_path(sample.audio_path)
if audio_path is not None and audio_path.exists():
audio_path.unlink()
db.delete(sample)
@@ -345,7 +459,7 @@ async def update_profile_sample(
sample_id: str,
reference_text: str,
db: Session,
) -> Optional[ProfileSampleResponse]:
) -> ProfileSampleResponse | None:
"""
Update a profile sample's reference text.
@@ -382,19 +496,57 @@ async def create_voice_prompt_for_profile(
engine: str = "qwen",
) -> dict:
"""
Create a combined voice prompt from all samples in a profile.
Create a voice prompt from a profile.
For cloned profiles: combines all audio samples into a voice prompt.
For preset profiles: returns the engine-specific preset voice reference.
For designed profiles: returns the text design prompt (future).
Args:
profile_id: Profile ID
db: Database session
use_cache: Whether to use cached prompts
engine: TTS engine to create prompt for ("qwen" or "luxtts")
engine: TTS engine to create prompt for
Returns:
Voice prompt dictionary
"""
from ..backends import get_tts_backend_for_engine
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile:
raise ValueError(f"Profile not found: {profile_id}")
voice_type = getattr(profile, "voice_type", None) or "cloned"
validate_profile_engine(profile, engine)
# ── Preset profiles: return engine-specific voice reference ──
if voice_type == "preset":
if not profile.preset_engine or not profile.preset_voice_id:
raise ValueError(f"Preset profile {profile_id} is missing preset engine metadata")
if profile.preset_engine != engine:
raise ValueError(
f"Preset profile {profile_id} only supports engine '{profile.preset_engine}', not '{engine}'"
)
return {
"voice_type": "preset",
"preset_engine": profile.preset_engine,
"preset_voice_id": profile.preset_voice_id,
}
# ── Designed profiles: return text description (future) ──
if voice_type == "designed":
if not profile.design_prompt or not profile.design_prompt.strip():
raise ValueError(f"Designed profile {profile_id} is missing design_prompt")
return {
"voice_type": "designed",
"design_prompt": profile.design_prompt,
}
if engine not in CLONING_ENGINES:
raise ValueError(f"Engine '{engine}' does not support cloned voice profiles")
# ── Cloned profiles: create from audio samples ──
samples = db.query(DBProfileSample).filter_by(profile_id=profile_id).all()
if not samples:
@@ -404,40 +556,48 @@ async def create_voice_prompt_for_profile(
if len(samples) == 1:
sample = samples[0]
sample_audio_path = config.resolve_storage_path(sample.audio_path)
if sample_audio_path is None:
raise ValueError(f"Sample audio not found for profile {profile_id}")
voice_prompt, _ = await tts_model.create_voice_prompt(
sample.audio_path,
str(sample_audio_path),
sample.reference_text,
use_cache=use_cache,
)
return voice_prompt
else:
audio_paths = [s.audio_path for s in samples]
reference_texts = [s.reference_text for s in samples]
combined_audio, combined_text = await tts_model.combine_voice_prompts(
audio_paths,
reference_texts,
)
audio_paths = []
for sample in samples:
sample_audio_path = config.resolve_storage_path(sample.audio_path)
if sample_audio_path is None:
raise ValueError(f"Sample audio not found for profile {profile_id}")
audio_paths.append(str(sample_audio_path))
reference_texts = [s.reference_text for s in samples]
# Save combined audio to cache directory (persistent)
# Create a hash of sample IDs to identify this specific combination
import hashlib
combined_audio, combined_text = await tts_model.combine_voice_prompts(
audio_paths,
reference_texts,
)
sample_ids_str = "-".join(sorted([s.id for s in samples]))
combination_hash = hashlib.md5(sample_ids_str.encode()).hexdigest()[:12]
# Save combined audio to cache directory (persistent)
# Create a hash of sample IDs to identify this specific combination
import hashlib
cache_dir = _get_cache_dir()
cache_dir.mkdir(parents=True, exist_ok=True)
combined_path = cache_dir / f"combined_{profile_id}_{combination_hash}.wav"
sample_ids_str = "-".join(sorted([s.id for s in samples]))
combination_hash = hashlib.md5(sample_ids_str.encode()).hexdigest()[:12]
save_audio(combined_audio, str(combined_path), 24000)
cache_dir = _get_cache_dir()
cache_dir.mkdir(parents=True, exist_ok=True)
combined_path = cache_dir / f"combined_{profile_id}_{combination_hash}.wav"
voice_prompt, _ = await tts_model.create_voice_prompt(
str(combined_path),
combined_text,
use_cache=use_cache,
)
return voice_prompt
save_audio(combined_audio, str(combined_path), 24000)
voice_prompt, _ = await tts_model.create_voice_prompt(
str(combined_path),
combined_text,
use_cache=use_cache,
)
return voice_prompt
async def upload_avatar(
@@ -465,8 +625,8 @@ async def upload_avatar(
raise ValueError(error_msg)
if profile.avatar_path:
old_avatar = Path(profile.avatar_path)
if old_avatar.exists():
old_avatar = config.resolve_storage_path(profile.avatar_path)
if old_avatar is not None and old_avatar.exists():
old_avatar.unlink()
# Determine file extension from uploaded file
@@ -487,7 +647,7 @@ async def upload_avatar(
process_avatar(image_path, str(output_path))
profile.avatar_path = str(output_path)
profile.avatar_path = config.to_storage_path(output_path)
profile.updated_at = datetime.utcnow()
db.commit()
@@ -514,8 +674,8 @@ async def delete_avatar(
if not profile or not profile.avatar_path:
return False
avatar_path = Path(profile.avatar_path)
if avatar_path.exists():
avatar_path = config.resolve_storage_path(profile.avatar_path)
if avatar_path is not None and avatar_path.exists():
avatar_path.unlink()
profile.avatar_path = None
+6 -3
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from sqlalchemy.orm import Session
from sqlalchemy import func
from .. import config
from ..models import (
StoryCreate,
StoryResponse,
@@ -483,13 +484,15 @@ async def split_story_item(
Returns:
List of two updated item details (original and new) or None if not found/invalid
"""
# Get the item
# Get the item with a row lock to prevent concurrent splits on the
# same clip (e.g. from rapid double-clicks racing each other).
item = (
db.query(DBStoryItem)
.filter_by(
id=item_id,
story_id=story_id,
)
.with_for_update()
.first()
)
if not item:
@@ -826,8 +829,8 @@ async def export_story_audio(
if version:
resolved_audio_path = version.audio_path
audio_path = Path(resolved_audio_path)
if not audio_path.exists():
audio_path = config.resolve_storage_path(resolved_audio_path)
if audio_path is None or not audio_path.exists():
continue
try:
+67 -7
View File
@@ -5,12 +5,27 @@ to avoid GPU contention.
import asyncio
import traceback
from dataclasses import dataclass
from typing import Coroutine, Literal
# Keep references to fire-and-forget background tasks to prevent GC
_background_tasks: set = set()
@dataclass
class GenerationJob:
"""Queued generation work plus the generation ID it belongs to."""
generation_id: str
coro: Coroutine
# Generation queue — serializes TTS inference to avoid GPU contention
_generation_queue: asyncio.Queue = None # type: ignore # initialized at startup
_generation_worker_task: asyncio.Task | None = None
_queued_generation_ids: set[str] = set()
_running_generation_tasks: dict[str, asyncio.Task] = {}
_cancelled_generation_ids: set[str] = set()
def create_background_task(coro) -> asyncio.Task:
@@ -24,25 +39,70 @@ def create_background_task(coro) -> asyncio.Task:
async def _generation_worker():
"""Worker that processes generation tasks one at a time."""
while True:
coro = await _generation_queue.get()
job = await _generation_queue.get()
try:
await coro
if job.generation_id in _cancelled_generation_ids:
_cancelled_generation_ids.discard(job.generation_id)
job.coro.close()
continue
task = asyncio.create_task(job.coro)
_running_generation_tasks[job.generation_id] = task
_queued_generation_ids.discard(job.generation_id)
try:
await task
except asyncio.CancelledError:
if not task.cancelled():
raise
except Exception:
traceback.print_exc()
finally:
_running_generation_tasks.pop(job.generation_id, None)
_queued_generation_ids.discard(job.generation_id)
_generation_queue.task_done()
def enqueue_generation(coro):
def enqueue_generation(generation_id: str, coro):
"""Add a generation coroutine to the serial queue."""
_generation_queue.put_nowait(coro)
if _generation_queue is None:
raise RuntimeError("Generation queue has not been initialized")
_queued_generation_ids.add(generation_id)
_generation_queue.put_nowait(GenerationJob(generation_id=generation_id, coro=coro))
def init_queue():
def cancel_generation(generation_id: str) -> Literal["queued", "running"] | None:
"""Cancel a queued or running generation if it is still active."""
running_task = _running_generation_tasks.get(generation_id)
if running_task is not None:
running_task.cancel()
return "running"
if generation_id in _queued_generation_ids:
_queued_generation_ids.discard(generation_id)
_cancelled_generation_ids.add(generation_id)
return "queued"
return None
def init_queue(force: bool = False):
"""Initialize the generation queue and start the worker.
Must be called once during application startup (inside a running event loop).
"""
global _generation_queue
global _generation_queue, _generation_worker_task
global _queued_generation_ids, _running_generation_tasks, _cancelled_generation_ids
if _generation_worker_task is not None and not _generation_worker_task.done():
if not force:
return
_generation_worker_task.cancel()
for task in list(_running_generation_tasks.values()):
task.cancel()
_generation_queue = asyncio.Queue()
create_background_task(_generation_worker())
_queued_generation_ids = set()
_running_generation_tasks = {}
_cancelled_generation_ids = set()
_generation_worker_task = create_background_task(_generation_worker())
+4 -4
View File
@@ -158,8 +158,8 @@ def delete_version(version_id: str, db: Session) -> bool:
gen_id = version.generation_id
# Delete audio file
audio_path = Path(version.audio_path)
if audio_path.exists():
audio_path = config.resolve_storage_path(version.audio_path)
if audio_path is not None and audio_path.exists():
audio_path.unlink()
db.delete(version)
@@ -193,8 +193,8 @@ def delete_versions_for_generation(generation_id: str, db: Session) -> int:
)
count = 0
for v in versions:
audio_path = Path(v.audio_path)
if audio_path.exists():
audio_path = config.resolve_storage_path(v.audio_path)
if audio_path is not None and audio_path.exists():
audio_path.unlink()
db.delete(v)
count += 1
+220
View File
@@ -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.
+16
View File
@@ -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"
```
+630
View File
@@ -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())
+112
View File
@@ -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,54 @@
import asyncio
import pytest
from backend.services import task_queue
@pytest.mark.asyncio
async def test_cancel_queued_generation_skips_execution():
task_queue.init_queue(force=True)
running_started = asyncio.Event()
release_running = asyncio.Event()
queued_ran = asyncio.Event()
async def running_job():
running_started.set()
await release_running.wait()
async def queued_job():
queued_ran.set()
task_queue.enqueue_generation("gen-running", running_job())
await asyncio.wait_for(running_started.wait(), timeout=1)
task_queue.enqueue_generation("gen-queued", queued_job())
assert task_queue.cancel_generation("gen-queued") == "queued"
release_running.set()
await asyncio.sleep(0.1)
assert not queued_ran.is_set()
@pytest.mark.asyncio
async def test_cancel_running_generation_cancels_task():
task_queue.init_queue(force=True)
running_started = asyncio.Event()
running_cancelled = asyncio.Event()
async def running_job():
running_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
running_cancelled.set()
raise
task_queue.enqueue_generation("gen-running", running_job())
await asyncio.wait_for(running_started.wait(), timeout=1)
assert task_queue.cancel_generation("gen-running") == "running"
await asyncio.wait_for(running_cancelled.wait(), timeout=1)
+71 -9
View File
@@ -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
+1 -1
View File
@@ -64,7 +64,7 @@ def get_cached_voice_prompt(
cache_file = _get_cache_dir() / f"{cache_key}.prompt"
if cache_file.exists():
try:
prompt = torch.load(cache_file)
prompt = torch.load(cache_file, weights_only=True)
_memory_cache[cache_key] = prompt
return prompt
except Exception:
+19 -6
View File
@@ -1,13 +1,11 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_data_files
from PyInstaller.utils.hooks import collect_submodules
from PyInstaller.utils.hooks import collect_all
from PyInstaller.utils.hooks import copy_metadata
datas = []
binaries = []
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.services.profiles', 'backend.services.history', 'backend.services.tts', 'backend.services.transcribe', 'backend.utils.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.services.cuda', 'backend.services.effects', 'backend.utils.effects', 'backend.services.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
datas += collect_data_files('qwen_tts')
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.services.profiles', 'backend.services.history', 'backend.services.tts', 'backend.services.transcribe', 'backend.utils.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.backends.qwen_custom_voice_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.services.cuda', 'backend.services.effects', 'backend.utils.effects', 'backend.services.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.hume_backend', 'tada', 'tada.modules', 'tada.modules.tada', 'tada.modules.encoder', 'tada.modules.decoder', 'tada.modules.aligner', 'tada.modules.acoustic_spkr_verf', 'tada.nn', 'tada.nn.vibevoice', 'tada.utils', 'tada.utils.gray_code', 'tada.utils.text', 'backend.utils.dac_shim', 'torchaudio', 'backend.backends.kokoro_backend', 'en_core_web_sm', 'loguru', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
datas += copy_metadata('qwen-tts')
datas += copy_metadata('requests')
datas += copy_metadata('transformers')
@@ -15,10 +13,13 @@ datas += copy_metadata('huggingface-hub')
datas += copy_metadata('tokenizers')
datas += copy_metadata('safetensors')
datas += copy_metadata('tqdm')
hiddenimports += collect_submodules('qwen_tts')
datas += copy_metadata('en_core_web_sm')
hiddenimports += collect_submodules('jaraco')
hiddenimports += collect_submodules('tada')
hiddenimports += collect_submodules('mlx')
hiddenimports += collect_submodules('mlx_audio')
tmp_ret = collect_all('spacy_pkuseg')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('zipvoice')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('linacodec')
@@ -27,12 +28,24 @@ tmp_ret = collect_all('lazy_loader')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('librosa')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('qwen_tts')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('inflect')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('perth')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('piper_phonemize')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('kokoro')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('misaki')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('language_tags')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('espeakng_loader')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('en_core_web_sm')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('mlx')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('mlx_audio')
@@ -45,9 +58,9 @@ a = Analysis(
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hookspath=['pyi_hooks'],
hooksconfig={},
runtime_hooks=[],
runtime_hooks=['pyi_rth_numpy_compat.py', 'pyi_rth_torch_compiler_disable.py'],
excludes=['nvidia', 'nvidia.cublas', 'nvidia.cuda_cupti', 'nvidia.cuda_nvrtc', 'nvidia.cuda_runtime', 'nvidia.cudnn', 'nvidia.cufft', 'nvidia.curand', 'nvidia.cusolver', 'nvidia.cusparse', 'nvidia.nccl', 'nvidia.nvjitlink', 'nvidia.nvtx'],
noarchive=False,
optimize=0,
+7 -4
View File
@@ -17,7 +17,7 @@
},
"app": {
"name": "@voicebox/app",
"version": "0.2.0",
"version": "0.4.1",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -72,9 +72,10 @@
},
"landing": {
"name": "@voicebox/landing",
"version": "0.2.0",
"version": "0.4.1",
"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 +101,7 @@
},
"tauri": {
"name": "@voicebox/tauri",
"version": "0.2.0",
"version": "0.4.1",
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
@@ -123,7 +124,7 @@
},
"web": {
"name": "@voicebox/web",
"version": "0.2.0",
"version": "0.4.1",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"react": "^18.3.0",
@@ -287,6 +288,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=="],
+1
View File
@@ -22,6 +22,7 @@ services:
environment:
- LOG_LEVEL=info
- NUMBA_CACHE_DIR=/tmp/numba_cache
networks:
- voicebox-net
+638
View File
@@ -0,0 +1,638 @@
# 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**. Platform-dependent:
- **On MLX (Apple Silicon) — not a bug.** `mlx-community` only publishes 1.7B-Base-bf16 weights, so the 0.6B Base option intentionally resolves to the same repo (`backend/backends/__init__.py:180` — `# 0.6B not available in MLX, falls back`). UX gap: the selector offers a size that doesn't exist on the active backend. Fix: (a) hide the 0.6B option on MLX, or (b) label it "0.6B (uses 1.7B on Apple Silicon)".
- **On PyTorch (Windows/Linux/CUDA/ROCm/XPU/CPU) — real bug if reported.** Both 0.6B and 1.7B have distinct repos (`Qwen/Qwen3-TTS-12Hz-0.6B-Base` vs `-1.7B-Base`). Triage each report by platform before merging into the MLX cluster.
- **Qwen CustomVoice (either platform)** — no fallback, 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** | Platform-dependent. On MLX: not a bug (0.6B weights don't exist upstream, fallback is intentional — fix UX). On PyTorch: real bug if reproducible. Classify each by reporter's platform before deduping. |
| **#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>
+4 -4
View File
@@ -1,7 +1,7 @@
@import 'tailwindcss';
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';
@import 'fumadocs-openapi/css/preset.css';
@import "tailwindcss";
@import "fumadocs-ui/css/neutral.css";
@import "fumadocs-ui/css/preset.css";
@import "fumadocs-openapi/css/preset.css";
:root {
--color-fd-primary: hsl(43, 50%, 50%);
+2 -11
View File
@@ -5,22 +5,13 @@ import { generate as DefaultImage } from 'fumadocs-ui/og';
export const revalidate = false;
export async function GET(
_req: Request,
{ params }: RouteContext<'/og/docs/[...slug]'>,
) {
export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...slug]'>) {
const { slug } = await params;
const page = source.getPage(slug.slice(0, -1));
if (!page) notFound();
return new ImageResponse(
(
<DefaultImage
title={page.data.title}
description={page.data.description}
site="My App"
/>
),
<DefaultImage title={page.data.title} description={page.data.description} site="My App" />,
{
width: 1200,
height: 630,
+13 -42
View File
@@ -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`.

Some files were not shown because too many files have changed in this diff Show More