- /llm/generate's "model is downloading" branch was raising HTTPException(202, detail={...}), which wraps the payload in {"detail": ...} and forces clients to parse a success status as if it were an error. Switched to JSONResponse so the payload sits at the top level.
- The landing page's "Language Models" card advertised "Qwen 3.5" with sizes 4B/2B/0.8B; we ship Qwen3 at 0.6B/1.7B/4B. Aligned to what's actually in the binary.
- ProfileForm's discard-draft button reset the form without touching `personality` or `avatarFile`, so stale persona text and an attached avatar would survive the discard. The other three resets in the file already include both fields — this brings the discard path in line.
audio_path mode took any absolute filesystem path and returned its
decoded contents as transcribed text with no caller verification beyond
the existence/size checks. The X-Voicebox-Client-Id middleware records
the header but never rejects an absent or fake one, so a Voicebox bound
to 0.0.0.0 (the documented "remote access" mode) was effectively an
unauthenticated arbitrary-local-file read primitive.
The middleware now stashes the request's remote address in a ContextVar
alongside the existing client_id, and audio_path mode refuses anything
that doesn't parse as a loopback address (IPv4 127.0.0.0/8, IPv6 ::1).
audio_base64 mode is unchanged — that path was always bounded to bytes
the caller already has.
Loopback callers (the Tauri webview, local CLI scripts, MCP clients on
the same machine) keep working. Remote callers now have to send the
audio over the wire if they want it transcribed.
The create flow wrote raw audio (and a transcoded .wav for non-wav
sources) to data/captures before the DB row was committed, so any
failure between the write and the commit — a webm that decoded to a
0-length array, a whisper model that errored mid-transcribe, a SQLite
contention on the commit — left the audio on disk with nothing pointing
at it. Over enough flaky uploads the directory grows without bound.
Now every path written before the commit is tracked in a list, and the
whole stretch from the first write to db.commit() runs inside a
try/except that unlinks each tracked file on raise and re-raises. The
transcode branch removes the raw file from the cleanup list only when
the unlink actually succeeds, so an OSError on the raw-path delete
still hands cleanup the original blob to retry.
Routes were calling model_dump(exclude_none=True), which drops every
client-sent null before it reaches the service. The service then layered
on its own `if value is not None` guard. Net effect: setting a nullable
column back to null was a no-op — the MCPPage default-voice picker sends
null when the user picks "no default" and the row was silently keeping
whatever was there before.
Switched the routes to exclude_unset=True so absent fields stay absent
but explicit nulls survive the dump, and centralised the per-field
nullability check in the service. The check inspects the SQLAlchemy
column metadata so non-nullable columns (stt_model, llm_model, the chord
key lists) still drop nulls instead of crashing the request, while
default_playback_voice_id can finally be cleared.
Two unrelated correctness bugs caught in PR review:
- The Play As "Stop" button was wired to handlePlayAs() unconditionally, so clicking it during playback kicked a fresh generation instead of halting. Now pauses the player when the click came from the main button while playbackState is 'playing'. Picking a different voice from the dropdown still kicks a new generation as before.
- ChordPicker tracked the peak set of held keys but seeded the peak from initialKeys, so a user who opened the picker with a 3-key chord saved couldn't replace it with a 2-key chord — the candidate length never beat the seed. The peak now resets on the first press of a fresh sequence (when no keys were held immediately prior), then grows monotonically within that hold.
- Hand /generate ids to the global SSE watcher so playback fires on completion. The mutation onSuccess was checking audio_path on a queued row, which is always empty — autoplay never ran.
- Bind the Play As voice selection to capture_settings.default_playback_voice_id, kept in sync with the Settings → Captures and Settings → MCP pickers. Picking from the split-button dropdown writes back to settings.
- Extract AudioBars from HistoryTable into a shared component; use it for the Play As generating state in place of Loader2.
- Stop the active-state hover from flashing white text when the button is in its lighter accent/10 fill.
- Drop the gradient avatar swatches from the Settings → Captures voice dropdown.
- Backend: when the gen worker exits without writing a terminal status (e.g. SQLite lock racing the failed-status write inside its own exception handler), the cancel endpoint now flips the row to failed instead of 409-ing. Worker also force-fails on its way out as a belt-and-suspenders.
ListPane is a compound component (Header / TitleRow / Title / Actions /
Search / Scroll) that owns the relative wrapper, faded right divider
(50px top fade), top scroll mask, and absolute-positioned header used by
every list-detail tab. Wires up CapturesTab, StoryList, and EffectsList.
EffectsTab gets -mx-8 / pr-8 to match the edge-to-edge layout used
elsewhere.
Other changes:
- MCPPage: native <select> → shadcn <Select> for default voice and
per-binding voice pickers
- Button outline variant: add hover:border-accent
- Drop hover:text-destructive from trailing delete buttons
(HistoryTable, GpuAcceleration, GpuPage, EffectsChainEditor,
EffectsDetail)
- HistoryTable empty state moved behind t('history.empty')
- StoryContent scroll padding pt-14 → pt-16
- backend health reports the captures dir
- landing CapturesMockup: "Send to" → "Export" with Download icon
- CHANGELOG: drop [Unreleased] personality section
useQuery was firing GET /capture/readiness every 5s forever, and also
on every window focus. Once stt.ready and llm.ready are both true the
answer can only change when the user swaps a model in settings, and
useSettings already invalidates the query on that path — the polling
was pure noise.
Gate both refetchInterval and refetchOnWindowFocus on "not fully ready"
so we fall silent once the checklist is green.
0.2 read CGEventFlags via CGEventGetIntegerValueField(event, 0x81),
which is not a valid CGEventField id — macOS silently returned 0, so
FlagsChanged events produced no KeyDown / KeyUp for any modifier key
and the PTT / toggle chords never armed on macOS. 0.4 uses the
documented CGEventGetFlags(event) API.
0.3 (tracing / serde / Fn / IntlBackslash) is picked up as a free
consequence; no API surface we depend on changed.
Dep swap:
- Drop the git-pinned jamiepine/rdev fork we were carrying since the
upstream crate is abandoned.
- Depend on keytap 0.2 from crates.io — our own cross-platform global
keyboard tap crate. Clean shutdown via Drop, Sonoma-safe by design
(no TSMGetInputSourceProperty calls off the main thread, so
`set_is_main_thread(false)` is gone), and properly versioned.
Chord engine rewrite:
- Delete hotkey_monitor.rs's internal Chord state machine (Match enum,
KeyEvent enum, step()/classify() methods, associated unit tests).
keytap's ChordMatcher subsumes it: Momentary chord for PTT,
add_toggle() for Toggle-to-talk, longest-match resolution, sticky-end
for Toggle. Net: -80 LOC in hotkey_monitor.rs; the remaining module
is the dispatcher loop + Effect→Tauri translation.
- Preserve the PTT→Toggle "RestartRecording" upgrade signal. keytap
emits End(PTT)+Start(Toggle) atomically (same Instant) when the held
set upgrades from a shorter chord to a longer superset. The
dispatcher peeks at the matcher with a 5 ms recv_timeout after any
End and coalesces the pair into Effect::RestartRecording so the
frontend still gets the "discard the transition-moment audio" signal
instead of an unrelated Stop+Start pair.
- HotkeyMonitor::update_bindings now actually tears down the tap on
empty bindings instead of leaving an idle CGEventTap around. New
bindings rebuild the matcher and the dispatcher thread from scratch.
key_codes.rs:
- Rewrite the browser-code → Key table against keytap's cleaner Key
variant names (`A`..`Z` not `KeyA`..`KeyZ`, `Digit0`..`Digit9` not
`Num0`..`Num9`, `ArrowUp` not `UpArrow`, `AltLeft`/`AltRight` instead
of `Alt`/`AltGr`, `Period` not `Dot`, …). On-disk chord string
format (W3C `KeyboardEvent.code` identifiers) is unchanged, so
capture_settings rows written before the swap round-trip identically.
Legacy aliases (`Alt`, `AltGr`, `Num0`, `UpArrow`, `Dot`, …) kept for
forward-compat on old rows.
main.rs / input_monitoring.rs:
- Update the few doc comments that referenced `rdev::listen` to
describe keytap's Tap; no behavioural change.
- build_chord_bindings now imports from keytap::Key.
- enable_hotkey / disable_hotkey / update_chord_bindings reach into
HotkeyMonitor via &mut since apply()/update_bindings() now mutate.
Tests live in keytap now (22 chord-related tests in keytap 0.2,
including the PTT→Toggle upgrade scenario that used to be tested in
hotkey_monitor.rs). Voicebox's hotkey_monitor.rs is thin enough that
local testing would be trivia.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Two surfaces leaked macOS-specific copy onto other platforms:
1. The Input Monitoring + Accessibility rows in the readiness
checklist rendered everywhere. On Windows/Linux the Rust permission
stubs return true, so the rows showed as permanent green checkmarks
with copy like "macOS allows Voicebox to detect your global
shortcut." — nonsense when you're on Windows. Gate both rows on a
userAgent-based isMacOS check so they only render where the
underlying TCC permission actually exists.
2. The global-shortcut setting description ended with "macOS will ask
for Input Monitoring permission the first time you turn this on."
That sentence rendered on every platform. The readiness checklist
already surfaces the TCC requirement at the right moment on macOS,
so the description doesn't need the platform note — drop it from
en / ja / zh-CN / zh-TW.
Other macOS strings (AccessibilityNotice, InputMonitoringNotice, their
"stillMissing" hints) are already gated behind the Rust permission
booleans returning false, which never happens on Windows/Linux, so they
stay inert without further changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
useCaptureSettings updated its own cache optimistically but never
invalidated ['capture-readiness'], so for up to 5 s (the poll interval)
after switching stt_model or llm_model the checklist kept showing the
previous model's ready/missing state. The backend endpoint resolves
the model live on each call — it was just the frontend cache that
lagged. Invalidate in onSettled only when the patch touched a model
field, so unrelated updates (chord keys, toggles) don't pay for a
refetch.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Two small follow-ups to the sidebar checklist placement. Move it below
the What's different section so the sticky top of the sidebar stays the
page's narrative context (About → differences) and the checklist reads
as a status panel rather than preamble. Gate the whole block on
!readiness.allReady so once every gate is green the sidebar drops back
to just About + What's different — no value in real estate full of
checkmarks.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The six-gate checklist only rendered in the CapturesTab empty state, so
a user already on the settings page had no single surface showing which
gate was red — the inline InputMonitoringNotice covered one, the model
pickers covered another, and Accessibility was only hinted at by the
auto-paste toggle. Mirror the same component into the right sidebar of
the settings page so every gate (STT model, LLM model, Input Monitoring,
Accessibility, plus the hotkey toggle in the main column) is always
visible while the user configures dictation.
New compact prop on DictationReadinessChecklist drops the centered
header and empty-state max-width so it fits the 280 px sidebar next to
the existing About / Differences blocks. Callers in compact mode own
the heading — CapturesPage reuses the existing captures.readiness.title
key (present in en / ja / zh-CN / zh-TW already) as an h3 matching the
sibling sidebar sections.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Wrap useUIStore in zustand/middleware's persist under the key
voicebox-ui. partialize only selectedProfileId so volatile UI state
(dialog open flags, form drafts, engine/voice pickers, sidebar) stays
in-memory as before — but reopening the app no longer loses whichever
profile the user was last working with.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Replace the placeholder fake-waveform + play button in CapturesTab's
audio card with a real CaptureInlinePlayer (wavesurfer.js). The player
renders the actual waveform, lets users scrub through the clip, and
shows a proper current/total timestamp pair in place of the
duration-only label.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
POST /speak is a REST wrapper around voicebox.speak for agents that
don't talk MCP (shell scripts, ACP, A2A). It reads X-Voicebox-Client-Id
and uses it for the same per-client profile resolution + default
personality lookup the MCP tool does (speak.py:39-64), so its callers
are first-class clients — but the ClientIdMiddleware only stamped
last_seen_at on /mcp* paths. REST speak callers showed up as "never
seen" in Settings → MCP despite actively acting on their bindings.
Widen the stamp predicate to an explicit ("/mcp", "/speak") prefix
list, and require a path boundary on match so future routes named
/mcpfoo or /speakers don't silently inherit the stamp via the prefix.
New test_client_id_middleware.py pins the scope with 17 parametrised
cases (both the allowed set and the overlap cases that must not match).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The inline lifespan ran _run_shutdown inside the MCP context, so the
TTS / Whisper / LLM models were unloaded *before* FastMCP's __aexit__
got a chance to cancel its in-flight session tasks. Any MCP request
mid-generate at shutdown time would crash on "model unloaded" instead
of receiving a clean session-cancelled error.
Rewire via compose_lifespan (which was already defined in
mcp_server.server for exactly this purpose but never used):
AsyncExitStack enters factories in order and exits in LIFO, so
MCP teardown fires first — cancelling sessions — and _run_shutdown
runs after nothing is holding the models. Smoke test shows the
log order flipped as expected:
Ready
StreamableHTTP session manager started
... running ...
StreamableHTTP session manager shutting down ← was last, now first
Voicebox server shutting down... ← was first, now last
As a side benefit, _run_shutdown is now paired with _run_startup via
try/finally inside voicebox_lifespan, so a partial startup (models
half-loaded, MCP __aenter__ fails) still unloads whatever was loaded
instead of leaking it to process exit.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
SQLite gained ALTER TABLE … DROP COLUMN in 3.35 (Mar 2021). Production
PyInstaller builds bundle Python 3.12 which links to SQLite 3.40+ so
that path is always safe, but a dev running the backend directly on
Ubuntu 20.04 (3.31) or Debian 11 (3.34) would crash on first startup
trying to drop the legacy default_intent column.
Add _supports_drop_column(engine) — returns True on non-SQLite
dialects (Postgres / MySQL have supported DROP COLUMN for decades) and
gates on the runtime sqlite_version for SQLite. When unsupported, log a
warning and leave the unused column in place: SQLAlchemy only maps
declared columns, so a stray default_intent column does no reads or
writes and can't interfere with runtime behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The word-level pass catches single-word Whisper loops ("URL URL URL…")
but misses two common hallucination patterns the PR had to claim as
"edge cases":
1. Multi-word English loops — "thanks for watching thanks for watching…"
× 6 sails through because no two consecutive tokens are identical
after text.split().
2. CJK loops — "謝謝觀看" × 7 sails through because text.split() returns
a single unsplit token for the whole loop (no whitespace between
characters).
Add a character-level second pass: a non-greedy regex finds any 2–60
char substring that repeats min_run+ times immediately after itself and
strips the run. The 2-char floor keeps emphasised single-letter runs
("wooooooow") intact. The 60-char ceiling covers every observed
Whisper tail hallucination ("Please like and subscribe to my
channel.", "Subtitles by the Amara.org community") while staying short
enough that coincidental long-phrase repetition in legitimate speech
doesn't hit the threshold. Whitespace normalisation only runs when the
pass actually stripped something, so untouched transcripts keep their
original spacing.
New test_refinement_collapse.py gives the pre-processor its first
deterministic unit-test coverage: 17 tests pinning the word-level
legacy behaviour plus the new multi-word English / CJK / Japanese /
emphasis-preservation cases.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Two reliability gaps in the /events/speak subscriber:
1. resp.chunk().await had no idle timeout. A backend that accepts the
TCP connection but stops producing frames (deadlocked SSE endpoint,
zombie process) would block the task forever without reconnecting.
The pill window would never surface for agent-initiated speech and
there would be nothing to log. Backend emits a `:ping` heartbeat
every 15 s, so 45 s without any data is now treated as a dead
stream — the task errors out and the reconnect loop takes over.
2. Flat 2 s backoff escalates nowhere. Logs fill with reconnect lines
when the backend is down for minutes, and a backend that accepts +
immediately closes connections (no data) spins the loop tightly.
Backoff now escalates 500 ms → 30 s on unproductive rounds and
resets only when at least one frame arrives (the connection was
genuinely productive, not just accepted).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Upstream Narsil/rdev has shipped no release since 2023-06 (crates.io
still serves 0.5.3), so the Sonoma main-thread fix we depend on — PR
#147, applied at hotkey_monitor.rs:184 — is only reachable via a git
pin. A pin to a third-party repo breaks the build whenever the remote
force-pushes, renames, or is taken down, and Cargo does not durably
cache git-dep archives the way it does crates.io tarballs.
Forking to jamiepine/rdev at the same SHA removes that failure mode
without changing crate behavior and gives us a place to cherry-pick
future OS-compatibility fixes on our own timeline. The SHA was verified
to exist on the fork before re-pinning.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Two bugs in paste_final_text' clipboard handling:
1. Restore was unconditional. If the user ⌘C'd in the target app during
the 400 ms paste-consume window — or a clipboard history tool (Paste,
Pastebot, Maccy) or Universal Clipboard sync snapshotted our staged
text — the blind restore overwrote their newer content with the
pre-paste snapshot, silently losing user data.
2. send_paste' errors were propagated with ? before the restore, so a
CGEventPost / SendInput failure left the user's clipboard stuck on
the transcript.
Fix folds both into one pattern: capture the post-write change count,
re-read it after paste-consume, restore only when they match (plus treat
a change-count read failure as "unknown, don't overwrite"). Isolate
send_paste's error so the restore runs regardless of paste success, then
propagate the paste error after.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
macOS 14 deprecated NSRunningApplication.activateWithOptions: in favour
of a cooperative-activation pattern: the caller first yields activation
rights to the target, then the target activate()s against the tightened
Sonoma foreground rules. Without the yield, activate() on 14+ sometimes
silently fails or only bounces the dock icon — the exact "paste lands in
the wrong app" symptom we were previously one API break away from.
activate_pid now discovers the 14+ selector via respondsToSelector: and
branches: on 14+ it yieldActivationToApplication:'s from
NSRunningApplication.current then calls -activate on the target; on
11–13 it stays on -activateWithOptions: (still the only option). Both
branches propagate the BOOL return — if activation is refused we error
out before clobbering the clipboard instead of silently proceeding.
The respondsToSelector: result is cached in a OnceLock so the probe
isn't repeated on every paste.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
macOS apps match Cmd+V against the layout-translated character via NSMenu
key equivalents, so posting kVK_ANSI_V (= 9, the QWERTY V position) on
Dvorak produces Cmd+. and never triggers Paste. New keyboard_layout
module resolves the active layout's V keycode via
TISCopyCurrentKeyboardLayoutInputSource + UCKeyTranslate, caches it in an
AtomicU16, and refreshes on kTISNotifySelectedKeyboardInputSourceChanged.
All TIS calls run on the main thread (init from Tauri setup; observer
callback delivered to the main runloop); synthetic_keys::send_paste
reads the cached value once per paste. Falls back to kVK_ANSI_V when
resolution fails or the active input source carries no Unicode key
layout data.
Windows is intentionally left on hardcoded VK_V — SendInput delivers
WM_KEYDOWN with wParam = VK_V to the target regardless of the active
layout, which is why `Send "^v"` works for AutoHotkey on Dvorak Windows.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- Collapse intent tri-state (respond/rewrite/compose) to `personality: bool` on /generate, /speak, and voicebox.speak. Drop respond entirely; keep compose as a standalone button via /profiles/{id}/compose. Remove /rewrite, /respond, and /speak profile endpoints.
- FloatingGenerateBox: Wand2 persona toggle + Dices compose button appear when the selected profile has a personality. ProfileCard badges Wand2 alongside the effects Sparkles.
- MCP bindings: default_intent column → default_personality: bool. Migration drops the legacy column.
- i18n: en / ja / zh-CN / zh-TW translation files filled out and wired through the capture, server, and profile UI.
```ts
voicebox.speak({
text: "Deploy complete.",
profile: "Morgan",
personality: true, // rewrite through the profile's personality LLM
});
```
Stops the "stuck pill" failure where pressing the chord with missing
STT/LLM models triggers a recording that has nowhere to land. The
hotkey now stays disarmed until every gate (models downloaded, Input
Monitoring + Accessibility granted) is green; the empty-state checklist
in CapturesTab surfaces each unmet gate with a one-click action and
auto-arms the chord once everything turns green.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The pill window now surfaces for agent-initiated speech without main-window
involvement. Rust subscribes to /events/speak via a tokio task + reqwest
streaming body (speak_monitor.rs), shows the pill, and forwards events to
the dictate webview over Tauri's event bus. The pill plays audio via a
plain HTMLAudioElement and emits dictate:hide when playback ends. The
pill stays hidden through the ~1 s generation wait and only surfaces when
audio actually starts, with the counter armed at that moment.
Fixes a shared-dict mutation in mcp_server/events.publish() that caused
the second subscriber (Rust speak_monitor) to receive `event: message`
instead of named speak-start/speak-end frames. Also teaches the speak_monitor
parser to handle CRLF framing (sse-starlette default). Main-window
AudioPlayer now skips autoplay for source in {mcp, rest} to avoid
double-play when both windows are alive.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Ships the Capture release end to end. Global-hotkey dictation with
synthetic paste into the focused app on macOS and Windows, an on-screen
pill across recording / transcribing / refining, customizable push-to-
talk and toggle chords, and an accessibility-permission prompt scoped to
Settings → Captures with inline re-check feedback.
Voice profiles gain optional personalities that power compose / rewrite /
respond actions via a local Qwen3 LLM — shared with refinement, so there
is one local LLM in the app, not two.
Refinement hardened with deterministic Whisper-loop collapse before the
LLM sees the transcript, per-capture flag snapshots for re-runs, and a
ten-transcript evaluation harness across every bundled refinement size.
Version bump 0.4.5 → 0.5.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The 0.4.1 → 0.4.2 version bump (a756295) was done manually and missed
.bumpversion.cfg; catching it up so the next bumpversion run picks up
the right base. Also writes the 0.4.2 release story into CHANGELOG.md
so the release workflow can extract it as the GitHub Release body.
Bundler still hangs on linuxdeploy download mid-rpm even with
createUpdaterArtifacts: false. Drop the ubuntu-22.04 matrix entry
until we figure out a reliable path; the ubuntu-specific setup steps
stay so we can re-enable by adding the matrix row back.
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]>
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]>
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]>
- 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]>
- 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]>
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]>
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]>
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]>
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]>
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]>
- Rust: Replace fragile body.contains("status") with proper JSON
deserialization validating status=="healthy", model_loaded (bool),
and gpu_available (bool) to prevent misidentifying non-Voicebox services
- Frontend: Validate health response has Voicebox-specific fields before
marking server as ready during fallback polling
- Frontend: Discriminate port-in-use errors (poll for external server) from
real startup failures (missing sidecar, signing issues) — surface errors
immediately with a startupError state and Retry button in the UI
- Frontend: Set explicit startup-error state when 2-minute polling timeout
expires so the loading screen shows actionable feedback instead of hanging
- Architecture: Extract QueryClient to standalone side-effect-free module
(lib/queryClient.ts) to decouple serverStore from React bootstrap entrypoint
Add Kokoro-82M as a new TTS engine — 82M params, CPU realtime, 8 languages,
Apache 2.0. Unlike cloning engines, Kokoro uses pre-built voice styles, which
required a new profile type system to support non-cloning engines cleanly.
Kokoro engine:
- New kokoro_backend.py implementing TTSBackend protocol
- 50 built-in voices across en/es/fr/hi/it/pt/ja/zh
- KPipeline API with language-aware G2P routing via misaki
- PyInstaller bundling for misaki, language_tags, espeakng_loader, en_core_web_sm
Voice profile type system:
- New voice_type column: 'cloned' | 'preset' | 'designed' (future)
- Preset profiles store engine + voice ID instead of audio samples
- default_engine field on profiles — auto-selects engine on profile pick
- Create Voice dialog: toggle between 'Clone from audio' and 'Built-in voice'
- Edit dialog shows preset voice info instead of sample list for preset profiles
- Engine selector locks to preset engine when preset profile is selected
- Profile grid filters by engine — shows Kokoro voices when Kokoro selected
- Custom empty state when no preset profiles exist for selected engine
Bug fixes:
- Fix relative audio paths in DB causing 404s in production builds
- config.set_data_dir() now resolves to absolute paths
- Startup migration converts existing relative paths to absolute
Also updates PROJECT_STATUS.md and tts-engines.mdx developer guide.
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.
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.
Two fixes for issue #312:
1. GUI stuck on loading screen when backend is already running externally
(e.g. via python/uvicorn/Docker):
- Rust: add HTTP health check fallback when the process on the port
doesn't have 'voicebox' in its name. If /health responds with a
valid Voicebox response, reuse the server instead of erroring.
- Frontend: when startServer() fails, fall back to polling the
health endpoint every 2s instead of permanently blocking.
2. No data refresh when switching server URLs in settings:
- serverStore.setServerUrl() now invalidates all React Query caches
when the URL actually changes, so profiles/history/models/stories
are re-fetched from the new server.
- Export queryClient from main.tsx for store-level cache invalidation.
Fixes#312
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
Upgrade CUDA toolkit from 12.6 (cu126) to 12.8 (cu128) for proper
RTX 50-series (Blackwell) GPU support. Users with RTX 5070/5080/5090
were reporting CUDA detection failures with cu126.
Also fix the GPU Acceleration settings panel where the 'Switch to CPU
Backend' button was unreachable — it was inside a conditional block
that required !isCurrentlyCuda, making it impossible to switch back
to CPU once running on CUDA.
Closes#315
Replace --collect-submodules + --collect-data with --collect-all for
qwen_tts. The qwen_tts runtime expects physical .py source files
(e.g. modeling_qwen3_tts.py) under _MEIPASS, which only --collect-all
provides. This is the same pattern used for inflect/typeguard.
Fixes#212
- Wrap download/verify/extract in try/finally so .download-*.tmp is
always deleted, even on mid-download or extraction failures
- Fix justfile build-server-cuda to use sh.voicebox.app (production path)
- Fix is_nvidia_file() to match NVIDIA DLLs in _internal/torch/lib/
(PyInstaller 6.18 + torch 2.10 no longer uses nvidia/ subdirectories)
- Remove deprecated split_binary.py (both archives are under 2GB)
- Update torch_compat range to >=2.6.0,<2.11.0
- Update build docs for new dual-archive packaging flow
Switch CUDA builds from PyInstaller --onefile to --onedir and split the
output into two separately versioned archives:
1. Server core (~200-400MB) — versioned with the app, redownloaded on
every app update
2. CUDA libs (~2GB) — versioned independently (cu126-v1), only
redownloaded when the CUDA toolkit or torch version changes
This eliminates the ~2.4GB full redownload on every version bump.
After initial setup, most app updates only need ~200-400MB.
Closes#297
Enrich tts-engines.mdx with patterns discovered during TADA integration:
- Phase 0.2: new greps for @torch.jit.script, torchaudio.load, gated repos
- Phase 3.4: model naming inconsistency warning
- Phase 5.2: TADA shim failure added to lessons table
- Phase 6: four new workaround sections (gated repos, torchcodec,
torch.jit.script, toxic dependency shim pattern)
- Checklist: four new items matching the new scan patterns
- Remove TADA from upcoming engines (now shipped)
Add CUDA_LIBS_ADDON.md exploring --onedir split to avoid 2.4GB
redownloads on every version bump.
Remove @torch.jit.script from the DAC shim's snake() function —
TorchScript calls inspect.getsource() which fails in PyInstaller
binaries (no .py source files).
Update all user-facing docs: 4 → 5 TTS engines, add TADA row to
every engine comparison table, mark TADA as Shipped in the upcoming
engines list, update architecture diagrams and tech stack tables.
Replace the monkey-patch on AutoTokenizer.from_pretrained (which broke
the classmethod descriptor and caused 'Tokenizer not loaded' errors
when loading Qwen after TADA) with two targeted config patches:
- Set AlignerConfig.tokenizer_name to the local ungated tokenizer path
- Pre-load TadaConfig, inject tokenizer_name, pass config= to from_pretrained
No global state is modified; other engines are unaffected.
torchaudio 2.10+ switched its default audio loading backend to
torchcodec, which isn't installed. Replace torchaudio.load() with
soundfile.read() in create_voice_prompt(). TADA's internal use of
torchaudio.functional.resample() is unaffected (pure PyTorch math,
no torchcodec dependency).
TADA hardcodes 'meta-llama/Llama-3.2-1B' as its tokenizer source in
both the Aligner and TadaForCausalLM.from_pretrained(). That repo is
gated and requires accepting Meta's license on HuggingFace.
Monkey-patch AutoTokenizer.from_pretrained during model loading to
redirect Llama tokenizer requests to 'unsloth/Llama-3.2-1B', an
ungated mirror with identical tokenizer files. The patch is scoped
to model loading only and restored immediately after.
The real descript-audio-codec package pulls in descript-audiotools,
which transitively requires onnx, tensorboard, protobuf, matplotlib,
pystoi, and other heavy dependencies. onnx fails to build from source
on macOS due to CMake version incompatibility.
TADA only uses Snake1d (a 7-line PyTorch module) from DAC. This commit
adds a shim in backend/utils/dac_shim.py that registers fake dac.*
modules in sys.modules with just the Snake1d class, completely
eliminating the DAC/audiotools dependency chain.
Integrates HumeAI's TADA (Text-Acoustic Dual Alignment) speech-language
model as a new TTS engine. TADA uses a novel 1:1 token-audio alignment
that produces coherent speech over long sequences (700s+).
Two model variants:
- tada-1b: English-only, ~4GB, built on Llama 3.2 1B
- tada-3b-ml: 10 languages, ~8GB, built on Llama 3.2 3B
Backend uses the Encoder for voice prompt encoding with caching, and
TadaForCausalLM with flow-matching diffusion for generation. Supports
bf16 inference on CUDA, forces CPU on macOS (MPS compatibility).
Installed with --no-deps due to torch>=2.7 pin conflict; descript-audio-codec
and torchaudio added as explicit sub-dependencies.
build.rs now generates voicebox.icns via sips + iconutil alongside the
existing actool Assets.car compilation. On non-macOS, empty stub files
are created so Tauri's resource bundler doesn't fail on missing paths.
Move audio validation and saving to thread pool so librosa/ffmpeg decoding
doesn't block the async event loop. Combine validate + load into a single
pass to avoid decoding the file twice. Add 50 MB upload limit and chunked
reads to prevent unbounded memory allocation.
Closes#278
cu121 only ships kernels up to SM 9.0 (Ada Lovelace). RTX 50-series
(Blackwell, SM 12.0) and RTX 6000 Pro need cu126 which includes SM 12.0
support while remaining backward compatible with older GPUs.
Closes#289