Compare commits

...
Author SHA1 Message Date
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
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
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 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
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 2e95b7c5d8 fix: force offline mode when loading cached models (Qwen TTS & Whisper)
Qwen TTS and Whisper Base make network calls to HuggingFace even when
model weights are fully cached locally, because from_pretrained()
defaults to local_files_only=False. This causes failures for offline
users.

Add a reusable force_offline_if_cached() context manager that sets
HF_HUB_OFFLINE=1 during model loading when is_model_cached() is True.
Applied to all four affected load paths:

- PyTorchTTSBackend (Qwen TTS)
- PyTorchSTTBackend (Whisper)
- MLXTTSBackend (refactored from inline implementation)
- MLXSTTBackend (previously unprotected)

Closes #82
2026-03-18 10:31:30 -07:00
69 changed files with 1302 additions and 291 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.0
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
+6
View File
@@ -203,6 +203,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
+114 -1
View File
@@ -7,6 +7,117 @@
## [Unreleased]
## [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 +555,9 @@ 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.0...HEAD
[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
+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/
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@voicebox/app",
"version": "0.3.1",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
+5 -3
View File
@@ -14,15 +14,17 @@ 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 />
{children}
{showTrackEditor ? (
+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,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';
@@ -40,6 +40,7 @@ export function FloatingGenerateBox({
const { data: selectedProfile } = useProfile(selectedProfileId || '');
const { data: profiles } = useProfiles();
const [isExpanded, setIsExpanded] = useState(false);
const [isInstructExpanded, setIsInstructExpanded] = useState(false);
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -125,22 +126,29 @@ export function FloatingGenerateBox({
}, [watchedEngine, setSelectedEngine]);
// Sync generation form language, engine, and effects with selected profile
type EngineValue =
| 'qwen'
| 'luxtts'
| 'chatterbox'
| 'chatterbox_turbo'
| 'tada'
| 'kokoro'
| 'qwen_custom_voice';
useEffect(() => {
if (selectedProfile?.language) {
form.setValue('language', selectedProfile.language as LanguageCode);
}
// Auto-switch engine if profile has a default
if (selectedProfile?.default_engine) {
form.setValue(
'engine',
selectedProfile.default_engine as
| 'qwen'
| 'luxtts'
| 'chatterbox'
| 'chatterbox_turbo'
| 'tada'
| 'kokoro',
);
// Auto-switch engine to match the profile
const engine = selectedProfile?.default_engine ?? selectedProfile?.preset_engine;
if (engine) {
form.setValue('engine', engine as EngineValue);
} else if (selectedProfile && selectedProfile.voice_type !== 'preset') {
// Cloned/designed profile with no default — ensure a compatible (non-preset) engine
const currentEngine = form.getValues('engine');
const presetEngines = new Set(['kokoro', 'qwen_custom_voice']);
if (presetEngines.has(currentEngine)) {
form.setValue('engine', 'qwen');
}
}
// Pre-fill effects from profile defaults
if (
@@ -346,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 }}
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { Loader2, Mic } from 'lucide-react';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
@@ -24,7 +24,11 @@ import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
import { useProfile } from '@/lib/hooks/useProfiles';
import { useUIStore } from '@/stores/uiStore';
import { EngineModelSelector, applyEngineSelection, getEngineDescription } from './EngineModelSelector';
import {
applyEngineSelection,
EngineModelSelector,
getEngineDescription,
} from './EngineModelSelector';
import { ParalinguisticInput } from './ParalinguisticInput';
function getEngineSelectValue(engine: string): string {
@@ -114,7 +118,7 @@ export function GenerationForm() {
)}
/>
{(form.watch('engine') === 'qwen' || form.watch('engine') === 'qwen_custom_voice') && (
{form.watch('engine') === 'qwen_custom_voice' && (
<FormField
control={form.control}
name="instruct"
+68 -2
View File
@@ -45,6 +45,7 @@ import { apiClient } from '@/lib/api/client';
import type { EffectConfig, GenerationVersionResponse, HistoryResponse } from '@/lib/api/types';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import {
useClearFailedGenerations,
useDeleteGeneration,
useExportGeneration,
useExportGenerationAudio,
@@ -124,6 +125,8 @@ export function HistoryTable() {
});
const deleteGeneration = useDeleteGeneration();
const clearFailed = useClearFailedGenerations();
const [clearFailedDialogOpen, setClearFailedDialogOpen] = useState(false);
const exportGeneration = useExportGeneration();
const exportGenerationAudio = useExportGenerationAudio();
const importGeneration = useImportGeneration();
@@ -157,11 +160,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 +418,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 +448,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" />
)}
@@ -759,6 +800,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>
@@ -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>
);
}
@@ -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" />
@@ -25,9 +25,10 @@ const ENGINE_DISPLAY_NAMES: Record<string, string> = {
interface ProfileCardProps {
profile: VoiceProfileResponse;
disabled?: boolean;
}
export function ProfileCard({ profile }: ProfileCardProps) {
export function ProfileCard({ profile, disabled }: ProfileCardProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const deleteProfile = useDeleteProfile();
@@ -40,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);
};
@@ -80,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}
@@ -1,4 +1,5 @@
import { Mic, Music, 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';
@@ -9,16 +10,33 @@ import { ProfileForm } from './ProfileForm';
/** Engines that use preset (built-in) voices instead of cloned profiles. */
const PRESET_ENGINES = new Set(['kokoro', 'qwen_custom_voice']);
/** Human-readable engine names for empty state messages. */
const ENGINE_NAMES: Record<string, string> = {
kokoro: 'Kokoro',
qwen_custom_voice: 'Qwen CustomVoice',
};
export function ProfileList() {
const { 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;
@@ -35,10 +53,18 @@ export function ProfileList() {
const allProfiles = profiles || [];
const isPresetEngine = PRESET_ENGINES.has(selectedEngine);
// Filter profiles based on selected engine
const filteredProfiles = isPresetEngine
? allProfiles.filter((p) => p.voice_type === 'preset' && p.preset_engine === selectedEngine)
: allProfiles.filter((p) => p.voice_type !== 'preset');
/** Whether a profile is supported by the currently selected engine. */
const isSupported = (p: (typeof allProfiles)[number]) =>
isPresetEngine
? p.voice_type === 'preset' && p.preset_engine === selectedEngine
: p.voice_type !== 'preset';
// Sort so supported profiles come first
const sortedProfiles = [...allProfiles].sort(
(a, b) => (isSupported(a) ? 0 : 1) - (isSupported(b) ? 0 : 1),
);
const hasUnsupported = sortedProfiles.some((p) => !isSupported(p));
return (
<div className="flex flex-col">
@@ -56,29 +82,26 @@ export function ProfileList() {
</Button>
</CardContent>
</Card>
) : filteredProfiles.length === 0 && isPresetEngine ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Music className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-2">
No {ENGINE_NAMES[selectedEngine] ?? selectedEngine} voices created yet.
</p>
<p className="text-sm text-muted-foreground mb-4">
Create a profile to choose a specific voice before generating.
</p>
<Button onClick={() => setDialogOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
Create {ENGINE_NAMES[selectedEngine] ?? selectedEngine} Voice
</Button>
</CardContent>
</Card>
) : (
<div className="flex gap-4 overflow-x-auto p-1 pb-1 lg:grid lg:grid-cols-3 lg:auto-rows-auto lg:overflow-x-visible lg:pb-[150px]">
{filteredProfiles.map((profile) => (
<div key={profile.id} className="shrink-0 w-[200px] lg:w-auto lg:shrink">
<ProfileCard profile={profile} />
{sortedProfiles.map((profile) => (
<div
key={profile.id}
className="shrink-0 w-[200px] lg:w-auto lg:shrink"
ref={(el) => {
if (el) cardRefs.current.set(profile.id, el);
else cardRefs.current.delete(profile.id);
}}
>
<ProfileCard profile={profile} disabled={!isSupported(profile)} />
</div>
))}
{hasUnsupported && (
<div className="col-span-full flex items-center gap-2 text-xs text-muted-foreground py-2">
<Info className="h-3.5 w-3.5 shrink-0" />
<span>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,
};
};
+6
View File
@@ -270,6 +270,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);
+6 -2
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),
@@ -45,6 +46,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
const selectedEngine = useUIStore((state) => state.selectedEngine);
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
@@ -62,7 +64,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
seed: undefined,
modelSize: '1.7B',
instruct: '',
engine: 'qwen',
engine: (selectedEngine as GenerationFormValues['engine']) || 'qwen',
...options.defaultValues,
},
});
@@ -134,7 +136,9 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
const hasModelSizes =
engine === 'qwen' || engine === 'qwen_custom_voice' || engine === 'tada';
const supportsInstruct = engine === 'qwen' || engine === 'qwen_custom_voice';
// Only Qwen CustomVoice actually honors the instruct kwarg at model level.
// Base Qwen3-TTS accepts the kwarg but ignores it.
const supportsInstruct = engine === 'qwen_custom_voice';
const effectsChain = options.getEffectsChain?.();
// This now returns immediately with status="generating"
const result = await generation.mutateAsync({
+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 () => {
+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[]>;
+1 -1
View File
@@ -1,3 +1,3 @@
# Backend package
__version__ = "0.3.1"
__version__ = "0.4.0"
+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())
+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
+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,
+9 -26
View File
@@ -6,7 +6,6 @@ from typing import Optional, List, Tuple
import asyncio
import logging
import numpy as np
import os
from pathlib import Path
logger = logging.getLogger(__name__)
@@ -21,6 +20,7 @@ ensure_original_qwen_config_cached()
from . import TTSBackend, STTBackend, LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS
from .base import is_model_cached, combine_voice_prompts as _combine_voice_prompts, model_load_progress
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
from ..utils.hf_offline_patch import force_offline_if_cached
class MLXTTSBackend:
@@ -96,32 +96,13 @@ class MLXTTSBackend:
model_name = f"qwen-tts-{model_size}"
is_cached = self._is_model_cached(model_size)
# Force offline mode when cached to avoid network requests
original_hf_hub_offline = os.environ.get("HF_HUB_OFFLINE")
if is_cached:
os.environ["HF_HUB_OFFLINE"] = "1"
logger.info("[PATCH] Model %s is cached, forcing HF_HUB_OFFLINE=1 to avoid network requests", model_size)
with model_load_progress(model_name, is_cached):
from mlx_audio.tts import load
try:
with model_load_progress(model_name, is_cached):
from mlx_audio.tts import load
logger.info("Loading MLX TTS model %s...", model_size)
logger.info("Loading MLX TTS model %s...", model_size)
try:
self.model = load(model_path)
except Exception as load_error:
if is_cached and "offline" in str(load_error).lower():
logger.warning("[PATCH] Offline load failed, trying with network: %s", load_error)
os.environ.pop("HF_HUB_OFFLINE", None)
self.model = load(model_path)
else:
raise
finally:
if original_hf_hub_offline is not None:
os.environ["HF_HUB_OFFLINE"] = original_hf_hub_offline
else:
os.environ.pop("HF_HUB_OFFLINE", None)
with force_offline_if_cached(is_cached, model_name):
self.model = load(model_path)
self._current_model_size = model_size
self.model_size = model_size
@@ -329,7 +310,9 @@ class MLXSTTBackend:
model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
logger.info("Loading MLX Whisper model %s...", model_size)
self.model = load(model_name)
with force_offline_if_cached(is_cached, progress_model_name):
self.model = load(model_name)
self.model_size = model_size
logger.info("MLX Whisper model %s loaded successfully", model_size)
+31 -21
View File
@@ -14,11 +14,14 @@ 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,
)
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
from ..utils.audio import load_audio
from ..utils.hf_offline_patch import force_offline_if_cached
class PyTorchTTSBackend:
@@ -96,18 +99,28 @@ class PyTorchTTSBackend:
model_path = self._get_model_path(model_size)
logger.info("Loading TTS model %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,
)
# 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,
)
self._current_model_size = model_size
self.model_size = model_size
@@ -120,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")
@@ -213,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(
@@ -282,8 +292,9 @@ class PyTorchSTTBackend:
model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
logger.info("Loading Whisper model %s on %s...", model_size, self.device)
self.processor = WhisperProcessor.from_pretrained(model_name)
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
with force_offline_if_cached(is_cached, progress_model_name):
self.processor = WhisperProcessor.from_pretrained(model_name)
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
self.model.to(self.device)
self.model_size = model_size
@@ -297,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")
+10
View File
@@ -52,6 +52,16 @@ 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.
args.extend(
[
"--runtime-hook",
str(backend_dir / "pyi_rth_numpy_compat.py"),
]
)
# 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():
+1
View File
@@ -182,6 +182,7 @@ class HealthResponse(BaseModel):
vram_used_mb: Optional[float] = None
backend_type: Optional[str] = None # Backend type (mlx or pytorch)
backend_variant: Optional[str] = None # Binary variant (cpu or cuda)
gpu_compatibility_warning: Optional[str] = None # Warning if GPU arch unsupported
class DirectoryCheck(BaseModel):
+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()
+2 -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
@@ -50,7 +50,7 @@ en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_
# 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
+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,
)
+12
View File
@@ -62,6 +62,13 @@ async def import_generation(
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/history/failed")
async def clear_failed_generations(db: Session = Depends(get_db)):
"""Delete every generation with status='failed'. Used by the UI's 'Clear failed' button (#410)."""
count = await history.delete_failed_generations(db)
return {"deleted": count}
@router.get("/history/{generation_id}", response_model=models.HistoryResponse)
async def get_generation(
generation_id: str,
@@ -89,6 +96,11 @@ async def get_generation(
duration=gen.duration,
seed=gen.seed,
instruct=gen.instruct,
engine=gen.engine or "qwen",
model_size=gen.model_size,
status=gen.status or "completed",
error=gen.error,
is_favorited=bool(gen.is_favorited),
created_at=gen.created_at,
)
+1
View File
@@ -4,6 +4,7 @@ import io
import json as _json
import logging
import tempfile
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
+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)
+37
View File
@@ -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,
+3 -1
View File
@@ -484,13 +484,15 @@ async def split_story_item(
Returns:
List of two updated item details (original and new) or None if not found/invalid
"""
# Get the item
# Get the item with a row lock to prevent concurrent splits on the
# same clip (e.g. from rapid double-clicks racing each other).
item = (
db.query(DBStoryItem)
.filter_by(
id=item_id,
story_id=story_id,
)
.with_for_update()
.first()
)
if not item:
+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:
+49 -2
View File
@@ -1,17 +1,64 @@
"""Monkey-patch huggingface_hub to force offline mode with cached models.
Prevents mlx_audio from making network requests when models are already
downloaded. Must be imported BEFORE mlx_audio.
Prevents mlx_audio / transformers from making network requests when models
are already downloaded. Must be imported BEFORE mlx_audio.
"""
import logging
import os
from contextlib import contextmanager
from pathlib import Path
from typing import Optional, Union
logger = logging.getLogger(__name__)
@contextmanager
def force_offline_if_cached(is_cached: bool, model_label: str = ""):
"""Context manager that sets ``HF_HUB_OFFLINE=1`` while loading a cached model.
If *is_cached* is ``False`` the block runs normally (network allowed).
If the offline load raises an error containing "offline" we automatically
retry with network access so a partially-cached model still works.
Args:
is_cached: Whether the model weights are already on disk.
model_label: Human-readable name used in log messages.
"""
if not is_cached:
yield
return
original_value = os.environ.get("HF_HUB_OFFLINE")
os.environ["HF_HUB_OFFLINE"] = "1"
logger.info(
"[offline-guard] %s is cached — forcing HF_HUB_OFFLINE=1",
model_label or "model",
)
try:
yield
except Exception as exc:
if "offline" in str(exc).lower():
logger.warning(
"[offline-guard] Offline load failed for %s, retrying with network: %s",
model_label or "model",
exc,
)
# Restore original env and retry — caller must wrap the load
# inside force_offline_if_cached so retrying here isn't possible.
# Instead, propagate a flag via the exception so the caller can
# decide. For simplicity we just let it fall through to the
# finally block and re-raise.
raise
raise
finally:
if original_value is not None:
os.environ["HF_HUB_OFFLINE"] = original_value
else:
os.environ.pop("HF_HUB_OFFLINE", None)
def patch_huggingface_hub_offline():
"""Monkey-patch huggingface_hub to force offline mode."""
try:
+1
View File
@@ -22,6 +22,7 @@ services:
environment:
- LOG_LEVEL=info
- NUMBA_CACHE_DIR=/tmp/numba_cache
networks:
- voicebox-net
+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,
+3 -3
View File
@@ -31,6 +31,6 @@ Voicebox is a **local-first voice cloning studio** -- a free and open-source alt
## Get Started
- [Installation](/docs/overview/installation) -- download and install Voicebox
- [Quick Start](/docs/overview/quick-start) -- get up and running in 5 minutes
- [API Reference](/docs/api-reference) -- integrate voice synthesis into your apps
- [Installation](/overview/installation) -- download and install Voicebox
- [Quick Start](/overview/quick-start) -- get up and running in 5 minutes
- [API Reference](/api-reference) -- integrate voice synthesis into your apps
+1 -6
View File
@@ -1,9 +1,4 @@
import {
defineConfig,
defineDocs,
frontmatterSchema,
metaSchema,
} from 'fumadocs-mdx/config';
import { defineConfig, defineDocs, frontmatterSchema, metaSchema } from 'fumadocs-mdx/config';
// You can customise Zod schemas for frontmatter and `meta.json` here
// see https://fumadocs.dev/docs/mdx/collections
+4 -14
View File
@@ -2,11 +2,7 @@
"compilerOptions": {
"baseUrl": ".",
"target": "ESNext",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -20,12 +16,8 @@
"jsx": "react-jsx",
"incremental": true,
"paths": {
"@/*": [
"./*"
],
"@/.source": [
".source"
]
"@/*": ["./*"],
"@/.source": [".source"]
},
"plugins": [
{
@@ -40,7 +32,5 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
"exclude": ["node_modules"]
}
+13 -1
View File
@@ -69,10 +69,22 @@ setup-python:
}
Write-Host "Installing Python dependencies..."
& "{{ python }}" -m pip install --upgrade pip -q
$hasNvidia = $null -ne (Get-WmiObject Win32_VideoController | Where-Object { $_.Name -match 'NVIDIA' })
$gpus = Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name
Write-Host "Detected GPUs: $($gpus -join ', ')"
$hasNvidia = ($gpus | Where-Object { $_ -match 'NVIDIA' }).Count -gt 0
$hasIntelArc = ($gpus | Where-Object { $_ -match 'Arc' }).Count -gt 0
if ($hasNvidia) { \
Write-Host "NVIDIA GPU detected — installing PyTorch with CUDA support..."; \
& "{{ pip }}" install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128; \
} elseif ($hasIntelArc) { \
Write-Host "Intel Arc GPU detected — installing PyTorch with XPU support..."; \
& "{{ pip }}" install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/xpu; \
& "{{ pip }}" install intel-extension-for-pytorch --index-url https://download.pytorch.org/whl/xpu; \
} else { \
Write-Host "No NVIDIA or Intel Arc GPU detected — using CPU-only PyTorch."; \
Write-Host "If you have an Intel Arc GPU, install XPU support manually:"; \
Write-Host " pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/xpu"; \
Write-Host " pip install intel-extension-for-pytorch --index-url https://download.pytorch.org/whl/xpu"; \
}
& "{{ pip }}" install -r {{ backend_dir }}/requirements.txt
& "{{ pip }}" install --no-deps chatterbox-tts
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@voicebox/landing",
"version": "0.3.1",
"version": "0.4.0",
"description": "Landing page for voicebox.sh",
"scripts": {
"dev": "bun --bun next dev --turbo",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "voicebox",
"version": "0.3.1",
"version": "0.4.0",
"private": true,
"workspaces": [
"app",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@voicebox/tauri",
"private": true,
"version": "0.3.1",
"version": "0.4.0",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "voicebox"
version = "0.3.1"
version = "0.4.0"
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
authors = ["you"]
license = ""
+4
View File
@@ -5,6 +5,10 @@ fn main() {
// Link Swift runtime libraries for screencapturekit crate
#[cfg(target_os = "macos")]
{
// ScreenCaptureKit does not exist on macOS 11, so weak-link it to
// allow the app to launch and gate usage at runtime instead.
println!("cargo:rustc-link-arg=-Wl,-weak_framework,ScreenCaptureKit");
// Add Swift runtime library paths to RPATH
println!("cargo:rustc-link-arg=-Wl,-rpath,/usr/lib/swift");
println!("cargo:rustc-link-arg=-L/usr/lib/swift");
+21 -11
View File
@@ -13,6 +13,7 @@ use screencapturekit::{
},
};
use std::io::Cursor;
use std::process::Command;
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
@@ -20,6 +21,10 @@ pub async fn start_capture(
state: &AudioCaptureState,
max_duration_secs: u32,
) -> Result<(), String> {
if !is_supported() {
return Err("System audio capture requires macOS 12.3 or newer.".to_string());
}
// Reset previous samples
state.reset();
@@ -144,17 +149,22 @@ pub async fn stop_capture(state: &AudioCaptureState) -> Result<String, String> {
}
pub fn is_supported() -> bool {
// ScreenCaptureKit requires macOS 12.3+
// Check if we're on a supported version
#[cfg(target_os = "macos")]
{
// Basic check - ScreenCaptureKit should be available on macOS 12.3+
true
}
#[cfg(not(target_os = "macos"))]
{
false
}
macos_version_at_least(12, 3)
}
fn macos_version_at_least(required_major: u64, required_minor: u64) -> bool {
let output = match Command::new("sw_vers").arg("-productVersion").output() {
Ok(output) if output.status.success() => output,
_ => return false,
};
let version = String::from_utf8_lossy(&output.stdout);
let mut parts = version.trim().split('.');
let major = parts.next().and_then(|part| part.parse::<u64>().ok()).unwrap_or(0);
let minor = parts.next().and_then(|part| part.parse::<u64>().ok()).unwrap_or(0);
major > required_major || (major == required_major && minor >= required_minor)
}
fn extract_audio_samples(sample_buffer: CMSampleBuffer) -> Result<Vec<f32>, String> {
+17 -1
View File
@@ -401,9 +401,25 @@ impl AudioOutputState {
eprintln!("play_to_device: Failed to play stream: {}", e);
format!("Failed to play stream: {}", e)
})?;
eprintln!("play_to_device: Stream started successfully");
// Keep the stream alive until playback finishes.
// Previously the stream was dropped immediately on function return,
// causing silent playback (cpal stops output when its Stream is dropped).
let total_samples = {
buffer.lock().unwrap().len()
};
loop {
let pos = position.load(std::sync::atomic::Ordering::Relaxed);
if pos >= total_samples || stop_flag.load(std::sync::atomic::Ordering::Relaxed) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
// stream is dropped here, after audio has finished playing
drop(stream);
eprintln!("play_to_device: Function completed successfully");
Ok(())
}
+16
View File
@@ -860,6 +860,22 @@ pub fn run() {
// Tell the server to disable its watchdog so it survives
// after this process exits.
println!("Keep server running: disabling watchdog...");
// Write a sentinel file as a reliable fallback. On Windows
// the HTTP request below can race with process exit, leaving
// the watchdog unaware it should stay alive. The sentinel
// file is checked during the watchdog grace period.
let data_dir = app
.path()
.app_data_dir()
.unwrap_or_default();
let sentinel = data_dir.join(".keep-running");
if let Err(e) = std::fs::write(&sentinel, b"1") {
eprintln!("Failed to write keep-running sentinel: {}", e);
} else {
println!("Wrote keep-running sentinel to {:?}", sentinel);
}
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Voicebox",
"version": "0.3.1",
"version": "0.4.0",
"identifier": "sh.voicebox.app",
"build": {
"beforeDevCommand": "bun run dev",
+2 -3
View File
@@ -2,9 +2,8 @@ import { invoke } from '@tauri-apps/api/core';
import type { PlatformAudio, AudioDevice } from '@/platform/types';
export const tauriAudio: PlatformAudio = {
isSystemAudioSupported(): boolean {
// This will be checked dynamically via invoke
return true; // Tauri supports it, but actual support depends on platform
async isSystemAudioSupported(): Promise<boolean> {
return await invoke<boolean>('is_system_audio_supported');
},
async startSystemAudioCapture(maxDurationSecs: number): Promise<void> {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@voicebox/web",
"private": true,
"version": "0.3.1",
"version": "0.4.0",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,7 +1,7 @@
import type { PlatformAudio, AudioDevice } from '@/platform/types';
export const webAudio: PlatformAudio = {
isSystemAudioSupported(): boolean {
async isSystemAudioSupported(): Promise<boolean> {
return false; // System audio capture not supported in web
},