Files
voicebox/CHANGELOG.md
7df366d0c8 feat: 0.5.0 Capture release — dictation, MCP, personalities (#544)
* feat(capture): dictation, personalities, 0.5.0

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]>

* feat(mcp): local MCP server exposes voicebox.* tools to AI agents

Mounts FastMCP at /mcp (Streamable HTTP) so Claude Code, Cursor,
Windsurf, and the VS Code MCP extensions can call voicebox.speak,
voicebox.transcribe, voicebox.list_captures, and voicebox.list_profiles
against the running Voicebox server.

Backend
- new backend/mcp_server package (tools, middleware, profile resolve,
  pub/sub events); named mcp_server to avoid shadowing the installed mcp
  PyPI package FastMCP imports internally
- app.py migrated from @app.on_event to lifespan= so FastMCP's session
  manager cohabits with Voicebox's startup/shutdown
- new MCPClientBinding table + /mcp/bindings CRUD; ClientIdMiddleware
  reads X-Voicebox-Client-Id into a ContextVar and stamps last_seen_at
- profile resolution precedence: explicit -> per-client binding ->
  capture_settings.default_playback_voice_id
- POST /speak REST wrapper for non-MCP callers (shell, ACP, A2A)
- GET /events/speak SSE broadcasts speak-start / speak-end so the pill
  surfaces agent-initiated speech
- backend/mcp_shim proxy (plain httpx) for stdio-only MCP clients
- PyInstaller spec updates + new --shim build target (~18 MB)

Frontend
- Settings -> MCP page with HTTP / stdio / claude-mcp-add copy snippets,
  default voice picker, per-client bindings table, connection status
- useMCPBindings, useSpeakEvents hooks
- CapturePill gains 'speaking' state; DictateWindow subscribes to SSE
  and emits dictate:show so the Rust side surfaces the pill window

Native
- tauri.conf.json externalBin now includes voicebox-mcp
- show_dictate_window helper + dictate:show listener in main.rs
- (also in this commit: InputMonitoringGate UX, hotkey_monitor tweaks,
  landing footer/navbar updates, new overview docs for captures /
  dictation / mcp-server / voice-personalities)

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

* feat(mcp): Rust-owned speaking pill with self-contained audio playback

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]>

* readme and dev script

* feat(capture): gate global hotkey on dictation readiness checklist

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]>

* color

* model download status

* progress

* personality: bool API, i18n across the app

- 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
});
```

* i18n: GenerationPage sidebar copy

* fix: BOOL import for windows crate 0.62

BOOL moved from Win32::Foundation to windows::core in 0.62.

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

* fix(capture): layout-aware V keycode for synthetic paste on macOS

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]>

* fix(capture): cooperative app activation for synthetic paste on macOS 14+

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]>

* fix(capture): conditional clipboard restore + always-attempt on paste failure

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]>

* chore(deps): pin rdev to jamiepine/rdev fork

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]>

* fix(mcp): idle timeout + escalating backoff for speak-SSE monitor

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]>

* fix(refinement): character-level loop collapse + pytest coverage

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]>

* fix(db): graceful fallback when SQLite < 3.35 on MCP bindings migration

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]>

* fix(mcp): correct lifespan shutdown order — drain MCP before unloading models

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]>

* fix(mcp): stamp last_seen_at on /speak too + tighten path predicate

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]>

* feat(captures): scrubbable WaveSurfer player for capture detail view

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]>

* feat(ui): persist selectedProfileId across sessions

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]>

* feat(captures): mirror readiness checklist into the settings sidebar

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]>

* feat(captures): move sidebar checklist below differences + hide when all green

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]>

* fix(captures): refetch readiness immediately after STT/LLM model swap

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]>

* fix(captures): hide macOS-only copy when running on Windows / Linux

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]>

* feat(capture): swap the rdev fork for keytap 0.2, delete local chord state machine

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]>

* chore(deps): bump keytap 0.2 → 0.4 for macOS modifier-events fix

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.

* perf(captures): stop polling readiness once both models are green

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.

* feat(ui): theme settings, stories polish, track editor restructure

- add dark/light/system theme with persisted choice + OS change listener
- restyle stories sidebar (search, item layout, border) to match captures
- move floating generate box to right column of stories, add top fade mask
- story track editor: sticky track labels aligned via flex rows, custom scrollbar with left/right zoom handles
- capture pill light mode pass, fix inline waveform progress color
- pull mcp_server hidden imports into the pyinstaller spec
- notarization doc draft

* fix mlx llm bundling

* feat(ui): shared ListPane primitive + misc polish

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

* fix(captures): Play As autoplay + default voice + orphan recovery

- 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.

* fix(captures+chord): Stop button stops, ChordPicker accepts shorter chords

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.

* fix(settings): honor explicit null on nullable fields, ignore on the rest

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.

* fix(captures): clean up audio files when create_capture fails

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.

* fix(mcp): restrict voicebox.transcribe(audio_path=...) to loopback

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.

* fix: PR review nits — response shape, landing copy, form reset

- /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.

* perf(mcp): move last_seen_at stamp off the request path

ClientIdMiddleware was running the SQLAlchemy SELECT/INSERT/UPDATE/COMMIT
inline on the event loop after every /mcp/* and /speak request. SQLite
serialises writes, so concurrent MCP traffic queued behind the stamp
write — the response sat waiting on a side-effect that the client never
needs in band, and SSE streams would stall briefly per request.

The middleware now hands the stamp to asyncio.to_thread via a fire-and-
forget create_task so the response returns immediately and the write
runs on the default executor. A module-level set keeps strong refs to
in-flight tasks (per asyncio docs) so the GC can't collect them mid-
write. The fallback path runs the stamp inline if no loop is available
(tests/oddball callers) rather than silently dropping it.

* fix(dictate): force-dismiss the speaking pill when SSE never comes back

The pill subscribed to /generation/{id}/status to know when to start
playback, but EventSource.onerror was a no-op — auto-reconnect was the
intended recovery for transient drops. The gap: if the backend deletes
the gen row mid-flight or the connection silently dies in a way the
browser keeps retrying without ever getting a status event, the pill
sits in 'speaking' forever and the user has no way to clear it.

Added a 60-second hard cap that arms when the SSE opens and clears the
moment any real status event lands. If it fires while the pill is still
on the same id and audio never started, it force-dismisses. Same idea
as the existing post-speak-end 15s grace, but covers the case where the
backend never says anything at all.

* fix: i18n cleanup + readiness checklist effect cadence + ChordPicker shadow

- DictationReadinessChecklist was constructing downloadByModel as a fresh Map every render and listing it in the cleanup effect's deps. With the 1 s polling cadence and arbitrary parent rerenders the effect ran more often than it needed to. Memoised the Map on activeTasks; the effect now keys off the memo's identity.
- zh-CN persona tooltipActive/ariaLabelActive matched their inactive twins byte-for-byte ("以人物设定朗读"). The other locales differentiate the active state with a -ing / -中 suffix; zh-CN now reads "正以人物设定朗读" when active.
- personalityPlaceholder was a ~290-character paragraph that doubled as both the example text and the explanation, repeating most of what personalityHint already said. Trimmed to the example only and folded the explanation + leave-blank consequence into the hint, across all four locales.
- Refinement model size keys were size06 / size17 / size4. Renamed the 4B variant to size40 so the decimal padding is consistent.
- ChordPicker's open-effect bound a window.setTimeout id to a local `t`, shadowing the i18n `t` from useTranslation. Renamed to timeoutId.

* perf(settings): persist generation sliders on release, not per pointer-move

Both sliders on the generation settings page were calling update() —
which is a React Query mutation that PATCHes /settings/generation —
inside onValueChange. Dragging the chunk-limit slider from 800 to 3000
fired a request per pointer-move pixel, and a mid-drag failure plus
optimistic rollback would leave persisted state visibly out of sync
with the thumb position.

Local state now mirrors each slider during a drag and the persist
happens once on Radix's onValueCommit (pointer-up / keyboard-release).
useEffects keep the local state in sync if the persisted value changes
out-of-band — another window editing the same setting still updates the
slider position cleanly.

* chore(backend): Ruff lint pass — deprecated APIs, exception leaks, dead patterns

Mechanical sweep of items called out in the PR review:

- qwen_llm_backend: AutoModelForCausalLM.from_pretrained(torch_dtype=…) is deprecated in transformers ≥4.41 in favor of dtype=. Renamed.
- routes/llm: try/except around backend.generate() raised HTTPException(500, detail=str(e)) which leaks stack traces / paths to clients and trips Ruff B904. Now logs the original exception server-side and hands the client a generic message; chained via `from e` to preserve traceback context.
- mcp_bindings + mcp_server/context: datetime.utcnow() is deprecated since 3.12. Switched the two assignment sites to datetime.now(timezone.utc). The schema-level `default=datetime.utcnow` defaults in database/models.py are left for a later schema-aware pass.
- routes/generations: `logger = …` sat between two import blocks (Ruff E402). Moved below imports.
- mcp_server/server + tests/test_refinement_samples: typing.Callable / typing.Iterable have been preferred-via collections.abc since 3.9 (Ruff UP035).
- routes/events: `except asyncio.TimeoutError` aliases plain `TimeoutError` since 3.11 (UP041).
- services/captures: hoisted WHISPER_NATIVE_FORMATS to module scope (was a function-local UPPER_SNAKE that tripped N806) and replaced the raw_path.unlink try/except OSError-pass with contextlib.suppress (SIM105). Semantic equivalence preserved — written_files.remove(raw_path) still only runs when unlink succeeds because it sits inside the suppressed block after the unlink call.
- database/migrations: hoisted the duplicate `import sqlite3` from inside two helper bodies to a single module-level import.

* feat(stories): regenerate action on clips and the chat list dropdown

The track editor's clip toolbar now has a regenerate icon next to Delete; clicking it kicks a fresh take of the selected clip's underlying generation through the same /generate/{id}/regenerate path the History table uses, and pushes the id into the global pending set so the SSE watcher picks it up. The chat list's per-item dropdown gets the same action between Play-from-here and Remove. Translation keys added under storyContent.itemActions / storyContent.toast across all four locales.

* feat(stories): import external audio into the timeline (drag-drop + picker)

You can now drop a music file onto the story content area or pick one through the new "Import audio" button in the add-clip popover. Both call POST /generate/import which writes the file to data/generations/<id>.<ext>, probes duration via librosa, and inserts a Generation row pointing at a singleton "Imported Audio" profile (created lazily on first import). The existing addStoryItem flow takes over from there — the timeline doesn't care that the row didn't come out of TTS.

Engine field on the row is "import"; it's surfaced on StoryItemDetail so the chat list shows a music icon instead of the (missing) profile avatar and both the dropdown and the track-editor toolbar hide the Regenerate action — there's nothing to regenerate. Accepted formats: wav/mp3/flac/ogg/m4a/aac/webm, capped at 200 MB. Translation keys added across en/ja/zh-CN/zh-TW.

* fix(audio): serve real Content-Type so imports decode in WaveSurfer

/audio/{id} and /audio/version/{id} hardcoded media_type="audio/wav" on
the FileResponse. That was a no-op when every generation came out of
TTS (everything on disk was a .wav anyway), but imported audio keeps
its source format — .mp3 / .m4a / .ogg — and the WaveSurfer MediaElement
backend uses an <audio> tag that checks Content-Type before letting the
clip play, so an MP3 announced as audio/wav silently failed to load.

Both endpoints now derive the type via mimetypes.guess_type and fall
back to audio/wav for unknown suffixes. Download filenames also keep
the real extension instead of always saying ".wav".

* feat(stories): zoom bar bounds tracked to project length, default 60s scope

The track editor's zoom was clamped to a hardcoded [10, 200] pixels-per-second range, which had no relationship to the project — on a 4-minute story a "max zoom out" of 200 px/s still required scrolling, and on a 5-second story you could zoom all the way in to where every clip was a tiny sliver. Reframed the bounds in the unit the user actually thinks in: how many seconds of timeline are visible at once. Min scope is 10 s (most zoomed in), max scope is the entire project, and the default lands on a 60 s scope (or the full project, whichever is shorter) once the editor measures its visible track width on first mount.

The pixels-per-second value still lives in component state (because every downstream calculation already uses it) but minPps/maxPps are computed from `containerWidth − LABEL_COL_WIDTH` and the project's effective duration, so the +/- buttons and the edge-drag handles on the scrollbar all clamp to bounds that move with the project. Re-clamping fires whenever those bounds shift — adding a long clip or resizing the window pulls the current zoom inside the new range instead of leaving the user parked outside it.

* fix(stories): show the source filename on imported clips

Imports were rendering as "Imported Audio" everywhere because every
import points at the singleton voice profile. The filename was already
being stored on the generation row (in the `text` field), so the chat
item title and the timeline clip label now read from `text` when
`engine === 'import'` and fall back to the profile name otherwise. The
chat item also drops the language pill (always "en" on imports — not
informative) and skips the transcript textarea since imports have no
spoken text to show.

* fix(stories): round split_time_ms before posting

handleSplit was sending currentTimeMs - item.start_time_ms straight to the backend, which rejects it because StoryItemSplit.split_time_ms is typed as int and the playhead's currentTimeMs is a float (it's driven from HTMLAudioElement.currentTime, which carries sub-millisecond precision). Pydantic surfaced the mismatch as "Input should be a valid integer, got a number with a fractional part" and the toast read "Failed to split clip". Math.round at the call site, matching what the trim and move handlers already do.

* feat(stories): per-clip volume control on the timeline

Each story item now carries a volume column (linear gain, default 1.0,
clamped 0.0–2.0 server-side). New PUT /stories/{}/items/{}/volume route
+ useUpdateStoryItemVolume hook + a Volume2 icon in the clip-edit
toolbar that opens a popover with a 0–200% slider. Local slider state
drives the visual during a drag; the persist fires once on
onValueCommit, mirroring the generation-page slider pattern.

Web Audio playback inserts a per-clip GainNode between source and
master so volume changes apply live without re-decoding the buffer
(source -> clipGain -> masterGain -> destination). Server-side
mixdown in export multiplies the trimmed clip by its volume before
summing into the timeline. Split + duplicate carry the volume forward
to the new clips so trimming a faded section keeps the level you set.

Migration adds the volume column with default 1.0 so existing rows
read as full volume.

* fix(stories): mute the clip waveform's media element so it can't bleed audio

The clip waveforms drawn inside each timeline track use WaveSurfer with the default MediaElement backend, which creates an internal <audio> element to drive playback timing. Web Audio in useStoryPlayback is what actually produces sound, but WaveSurfer's element was happily preloading and — after the first user gesture unlocked browser autoplay — playing the source URL through the page output too.

For TTS clips it was masked: they're short, both sources start at the same time, and stopping the BufferSourceNode at pause coincides with the natural end of the audio element. For long imports (a four-minute MP3) the BufferSourceNode stops on pause but WaveSurfer's element keeps going on its own track — which is exactly the "music keeps playing when I pause" symptom.

Hand WaveSurfer a muted <audio> element via the `media` option so the visual still loads peaks but the element itself can never produce sound. preload="metadata" keeps the load lightweight.

* fix(stories): hard-cut the audio graph on stop so long imports actually halt

source.stop() was the only thing happening when a clip was halted, and on long imported buffers (multi-minute MP3s scheduled via source.start with a duration argument) it was silently failing to halt the buffer in some browsers — pause left the music playing and seek stacked another source on top of the original. The mute-the-WaveSurfer-element fix was a different bug along the same path; this is the one that actually addresses the duplicated audio.

ActiveSource now carries the per-clip GainNode alongside the source, and stopSource detaches the onended handler before calling stop() (so the natural-end callback can't race with explicit teardown and re-delete a freshly rescheduled entry at the same id), then disconnects both nodes inside their own try/catch blocks. Even when stop() doesn't actually halt the buffer the graph is severed — no path from source to destination, no audio.

* feat(stories): add empty tracks above/below the timeline

Tiny + strips sit at the top of the topmost label cell and the bottom of the bottommost one, sticky-positioned in the label column so they follow horizontal scroll. Clicking either extends the visible track stack in that direction by one — above adds max(existing)+1, below adds min(existing)-1. Both compute against the full set (defaults + item-derived + previously-added) so successive clicks keep extending instead of fighting over the same number.

Empty extras live in component state because a track only earns its keep once a clip lands on it. Once one does, item.track carries the number forward and the row keeps deriving from items naturally; if nothing lands there before reload, the empty row simply isn't there next time, which matches what the user expects of an unused affordance.

* fix(mcp): bundle stdio shim sidecar

* fix(captures): allow dictation without paste permission

* fix(mcp): preserve speak engine defaults

* fix(captures): use platform hotkey defaults

* fix(mcp): preload speak pill window

* fix(captures): hide unwired storage settings

* feat(sponsors): add /sponsors page, homepage promo, and in-app strip

* style(landing): drop pill chrome from /download maintainer kicker

* changelog

* better naming for sponsors

* windows keybind note

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-25 15:46:35 -07:00

61 KiB
Raw Permalink Blame History

Changelog

0.5.0 - 2026-04-22

The Capture release. Voicebox stops being just a voice-cloning studio and becomes a full AI voice studio. Hold a key anywhere on your machine, speak, release — the transcript lands in the focused text field. Flip the primitive around and any MCP-aware agent — Claude Code, Cursor, Spacebot — speaks back through an on-screen pill in one of your cloned voices. A local LLM sits between the two, so transcripts come out clean and voice profiles can carry a personality that reshapes what the agent says before it gets spoken.

Dictation — speak anywhere, paste anywhere

  • Global hotkey capture. Hold a customizable chord anywhere on your machine (defaults: right-Cmd + right-Option on macOS, right-Ctrl + right-Shift on Windows), speak, release. A floating on-screen pill walks through recording → transcribing → refining → done with a live elapsed timer. The transcript lands as clean text.
  • Push-to-talk and toggle modes, each with its own chord. The default toggle chord adds Space to the push-to-talk chord. Holding PTT and tapping Space mid-hold upgrades a hold into a hands-free session without a gap in the recording.
  • Auto-paste into the focused app. Once transcription finishes, Voicebox synthesizes a paste into whatever text field had focus when you started the chord — not wherever focus drifted while you were talking. Works across Dvorak / AZERTY layouts. Your clipboard is saved before and restored after.
  • Chord picker UI. Customize either chord from Settings → Captures by holding the keys you want. Left/right modifier badges show whether a key is the left or right variant.
  • Defaults stay out of your way. macOS defaults avoid left-hand Cmd+Option chords so the system shortcuts they collide with stay yours. Windows defaults route around AltGr collisions on German / French / Spanish layouts.
  • Accessibility permission is scoped. If macOS Accessibility isn't granted, dictation still runs and transcripts still land in the Captures tab — only synthetic paste is disabled. The permission prompt lives inline next to the auto-paste toggle, not as a global banner.

Personality — voice profiles that speak for themselves

Voice profiles now carry an optional personality — a free-form description of who this voice is, up to 2000 characters. When set, two new controls appear next to the generate button, each powered by a new Qwen3 LLM running entirely locally:

  • Compose — the shuffle button drops a fresh in-character line into the textarea. Click again for variety, edit before speaking.
  • Speak in character — the wand toggle runs your input through the personality LLM before TTS, preserving every idea but delivering it in the character's voice.

The same LLM doubles as the refinement model, so there's one local LLM in the app, not two.

API surface. POST /generate, POST /speak, and the MCP voicebox.speak tool accept personality: bool. POST /profiles/{id}/compose powers the shuffle button. MCP client bindings carry a default_personality: bool that applies when personality isn't passed explicitly.

Agents — any MCP-aware agent gets a voice

Voicebox ships a built-in Model Context Protocol server at http://127.0.0.1:17493/mcp so Claude Code, Cursor, Windsurf, Cline, VS Code MCP extensions — any MCP-aware agent — can call into your local Voicebox install. Four tools ship with dotted names:

  • voicebox.speak — speak text in any voice profile, with optional personality: true to run through the profile's personality LLM first

  • voicebox.transcribe — Whisper transcription of a base64 blob or an absolute local path. Path mode is restricted to loopback callers so a Voicebox bound on 0.0.0.0 doesn't double as an unauthenticated arbitrary-local-file read primitive.

  • voicebox.list_captures — recent captures with their transcripts

  • voicebox.list_profiles — available voice profiles (cloned + preset)

  • Streamable HTTP as primary transport. Cursor / Windsurf / VS Code / Claude Code all support it out of the box — drop a mcpServers block with the URL and an X-Voicebox-Client-Id header.

  • Stdio shim for clients that don't speak HTTP MCP. A voicebox-mcp binary ships inside the app bundle as a Tauri sidecar. The Settings page renders the install snippet with the right absolute path pre-filled.

  • Per-client voice binding. Pin Claude Code to Morgan, Cursor to Scarlett, Cline to its own voice — the X-Voicebox-Client-Id header resolves to a bound voice whenever speak is called without an explicit profile. Managed in Settings → MCP.

  • Profile resolution precedence. Explicit profile arg (name or id, case-insensitive) → per-client binding → global default from capture_settings.default_playback_voice_id → error with a pointer to Settings.

  • Speaking pill. Agent-initiated speech surfaces the same on-screen pill as dictation, in a speaking state with the profile name and an elapsed timer. Silent background TTS is a trust hazard — the pill always shows what's coming out of your machine.

  • POST /speak REST wrapper. Same code path and voice resolution for shell scripts, ACP, A2A, GitHub Actions, or anything else that isn't MCP-native.

Claude Code one-liner:

claude mcp add voicebox --transport http --url http://127.0.0.1:17493/mcp --header "X-Voicebox-Client-Id: claude-code"

Refinement

A clean transcript needs more than Whisper. Each capture flows through a small Qwen3 LLM that strips fillers, fixes punctuation, and optionally rewrites self-corrections — all on-device.

  • Loop-stripping before the LLM sees the transcript. Whisper's "thanks for watching thanks for watching thanks for watching…" hallucination loops are collapsed at a six-identical-tokens threshold (case-insensitive) so a small refinement model can't echo them back. Coverage spans single-word runs, multi-word phrases, CJK character runs, and Japanese emphasis patterns; legitimate repetition ("no, no, no, no, no") doesn't cross the threshold.
  • Per-capture flag snapshot. smart_cleanup, self_correction, and preserve_technical are stored on each capture, so refinement can be re-run later with different flags without losing the raw transcript.
  • Model picker — Qwen3 0.6B (400 MB, very fast), 1.7B (1.1 GB, fast), 4B (2.5 GB, full quality). 0.6B is the default; 1.7B is the sweet spot for transcripts with code identifiers.

Captures tab + settings

Settings → Captures is now the home for the whole dictation flow:

  • Dictation: global shortcut toggle, push-to-talk chord picker, toggle chord picker, live pill preview, auto-paste into focused field (with inline accessibility prompt).
  • Transcription: model picker (Whisper Base / Small / Medium / Large / Turbo), language lock.
  • Refinement: auto-refine toggle, model picker, smart cleanup, remove self-corrections, preserve technical terms.
  • Playback: default voice for the Captures tab's "Play as" action — picking a voice from the split-button persists the choice across tab switches and restarts.
  • Storage: captures folder quick-open.

Stories — timeline editor

The Stories tab graduates from a TTS sequencer into a real timeline editor. Same generation-row backing, but clips now compose with imported audio, per-clip levels, and a flexible track stack.

  • Import external audio. Drag a music file onto the story content area or pick one from the new "Import audio" entry in the add-clip popover. Accepted formats: wav / mp3 / flac / ogg / m4a / aac / webm, capped at 200 MB. Imported clips show their filename instead of a profile name and skip the regenerate / version-picker controls — there's nothing to regenerate.
  • Per-clip volume. A Volume2 icon in the clip-edit toolbar opens a 0200% slider. Adjustments apply live and to exports. Split and duplicate carry the volume forward into the new clips.
  • Regenerate from both the clip's chat-list dropdown and the track-editor toolbar. Re-runs the underlying generation through the same path the History tab uses, with completion tracked in the global pending set.
  • Add empty tracks above or below the timeline via tiny + strips at the top of the topmost label cell and the bottom of the bottommost. Sticky in the label column so they follow horizontal scroll.
  • Zoom bar tracks the project. Min scope is 10 seconds visible (zoomed in cap), max is the entire project (zoomed out cap), default lands on 60 s. Both the +/ buttons and the scrollbar edge-drag handles clamp to those dynamic bounds.

Interface

  • Theme selector. Light / dark / system in Settings → General, persisted across sessions. System mode listens for OS-level appearance changes and flips live without a restart.
  • Scrubbable waveform player on captures. The capture detail card now embeds a WaveSurfer waveform with click-to-seek and a current / total timestamp pair, replacing the static duration label.
  • Capture pill light mode. The on-screen pill gets a dedicated light palette so it stays legible against bright windows.
  • Readiness checklist in the Captures settings sidebar. The same six-gate checklist the Captures empty state uses mirrors into Settings → Captures so a red gate can't hide behind a green toggle. Hidden once every gate is green. macOS-only rows (Input Monitoring, Accessibility) hide entirely on Windows and Linux.

Windows parity

Same dictation flow on Windows. Right-hand default chord (Ctrl+Shift) avoids AltGr collisions on layouts where Ctrl+Alt is the compose key. Focus is captured at chord-start so paste lands in the original field even if focus drifts during transcribe/refine.

0.4.5 - 2026-04-22

Second hotfix for the "offline mode is enabled" crash on model load. 0.4.4 reverted the inference-path offline guards but kept the same trap on the load path, so users who updated to 0.4.4 kept hitting the exact error the release was supposed to fix (#526). This release removes the load-path guards and patches the transformers tokenizer load to be robust to HuggingFace metadata failures at the source, so the class of bug can't recur.

Reliability

  • Load no longer fails with "offline mode is enabled" (#530, fixes #526). transformers 4.57.x added an unconditional huggingface_hub.model_info() call inside AutoTokenizer.from_pretrained (via _patch_mistral_regex) that runs for every non-local repo load, regardless of cache state or whether the target model is actually a Mistral variant. The load-time HF_HUB_OFFLINE guard from 0.4.2 turned that into a hard crash for cached online users the moment 0.4.4 removed the inference-path guard that had been masking the problem. Fix wraps _patch_mistral_regex so any exception from the HF metadata check is caught and the tokenizer is returned unchanged — matching the success-path behavior for non-Mistral repos. The wrapper installs at backend.backends import time so it covers Qwen Base, Qwen CustomVoice, TADA, and every other transformers-backed engine on Windows, Linux, and CUDA alike. The load-time force_offline_if_cached guards were removed — with the wrapper in place they provide zero value and only risk re-introducing the same failure mode.
  • No more 30s pause when generating without a network. The HuggingFace metadata timeout called out as a known caveat in 0.4.4 is covered by the same patch; offline users no longer wait for the check to time out before load completes.

0.4.4 - 2026-04-21

Hotfix for a regression in 0.4.3 where generation and transcription could fail outright with "offline mode is enabled" even when the user was online.

Reliability

  • Inference no longer fails with "offline mode is enabled" while online (#524, reverts the inference-path guards from #503). 0.4.3 wrapped every inference body (generate, transcribe, create_voice_clone_prompt) with a process-wide HF_HUB_OFFLINE flip to stop lazy HuggingFace lookups from hanging when the network drops mid-inference (#462). That flag also blocks legitimate metadata calls (e.g. HfApi().model_info for revision resolution) so online users started seeing generation fail outright. Inference now runs with the process's default HF state. Load-time offline guards — which weren't the source of the regression — stay in place.

Known caveat: users generating without an internet connection may see brief pauses during inference while HuggingFace metadata lookups time out (typically ~30s, after which the library recovers). A proper offline-mode toggle is planned for 0.4.5.

0.4.3 - 2026-04-20

A patch focused on two user-impacting reliability fixes: macOS DMG notarization (unblocks brew install voicebox on macOS 15 Sequoia and fixes spurious "app isn't signed" Gatekeeper dialogs on older Intel Macs) and Kokoro Japanese voice initialization on fresh installs.

macOS

  • DMGs are now notarized and stapled (#523). Tauri's bundler notarizes the .app inside the DMG but ships the DMG wrapper itself unnotarized. Gatekeeper rejects that on macOS 15 Sequoia (confirmed by Homebrew Cask CI failing on both arm and intel Sequoia runners) and causes the "the app is not signed" dialog on older Intel Macs when Apple's notarization servers are slow or unreachable (#509). The release workflow now submits each DMG to notarytool, staples the ticket, verifies with spctl, and overwrites the draft-release asset tauri-action uploaded. Adds ~5-10 min per macOS job.

Backend

  • Kokoro Japanese voices no longer crash on fresh installs (#521, fixes #514). misaki[ja] pulls in fugashi, which needs a MeCab dictionary on disk. The unidic package that was being installed ships no data and expects a ~526MB runtime download that just setup doesn't run (and which wouldn't survive PyInstaller anyway). Swapped to unidic-lite, which bundles a MeCab-compatible dict inside the wheel (~50MB). Collected in build_binary.py so frozen builds pick up unidic_lite/dicdir/.

0.4.2 - 2026-04-20

This release localizes the entire app. English, Simplified Chinese (zh-CN), Traditional Chinese (zh-TW), and Japanese (ja) are wired up end-to-end across every tab, modal, dialog, and toast — 559 translation keys per locale, parity verified. Plus a batch of reliability fixes: offline-mode now actually stays offline, Chatterbox accepts reference samples it used to reject, MLX Qwen 0.6B points at the right repo, and macOS system audio survives backgrounding.

Internationalization (#508)

  • i18next foundation with an in-app language switcher that re-renders the tree on change — lazy-loaded components were holding stale strings without an explicit key-bump on the React root.
  • Four locales at full coverage: English, Simplified Chinese, Traditional Chinese, Japanese. No partial/English-fallback surfaces.
  • Every user-visible surface translated: Stories (list, content editor, dialogs, toasts), Effects (list, detail, chain editor, built-in preset names), Voices (table, search, inspector, Create/Edit modal, audio sample panels), Audio Channels (list, dialogs, device picker), history + story dropdown menus, ProfileCard / ProfileList / HistoryTable, and the unsupported-model note.
  • Relative dates localize via date-fns locale objects (3 days ago3 天前 / 3 日前) — Intl.RelativeTimeFormat doesn't produce the phrasing we use in the history table.
  • Dev-build version suffix (v0.4.2 (dev) / (开发版) / (開發版) / (開発版)) is now locale-aware.
  • 559 translation keys across all four locales.

Reliability

  • HF_HUB_OFFLINE now guards every inference path (#503) — some engines were still attempting a HuggingFace metadata roundtrip on first load when offline mode was enabled, causing hangs on airgapped or flaky networks.
  • Chatterbox reference samples are preprocessed instead of rejected (#502) — samples outside the expected sample rate or channel layout are resampled to match, rather than failing with an opaque error.
  • MLX Qwen 0.6B repo path fixed (#501) — now points at the published mlx-community repo so the model actually downloads on Apple Silicon.
  • macOS system audio survives backgrounding (#486, closes #41) — WKWebView was tearing down the audio session when the app lost focus, silently killing system-audio capture.
  • MLX backend miniaudio dependency pinned (#506) — mlx_audio.stt needs it at runtime and nothing else transitively pulled it in, so --no-deps installs were breaking on first use.

Landing / Docs

  • New /download page (#487) — no more dumping first-time visitors onto the GitHub releases list. The API example snippet on the landing page also got an accuracy pass.
  • Download redirects work behind reverse proxies (#498) — uses the public origin instead of localhost when resolving platform-specific installer URLs.
  • MDX docs audited against the multi-engine backend (#484) — stale single-engine assumptions removed.
  • Three more tutorials + mobile navbar / hero CTA fixes (#483).

Linux

  • Still not shipping. The re-enable attempt (#488) landed on main but CI still hangs in the tauri-action bundler step on ubuntu-22.04 — no output for 25+ minutes after rpm bundling, even with createUpdaterArtifacts: false and --bundles deb,rpm. The matrix entry is disabled again for 0.4.2; the ubuntu-specific setup steps stay in the workflow so re-enabling is a one-line change once we identify the hang. Next release will take another pass.

New Contributors

0.4.1 - 2026-04-18

A fast follow-up to 0.4.0 focused on making the new engines actually load in the production binary — plus generation cancellation, Linux system-audio capture, and the repo's first PR-time type check. Five first-time contributors shipped in this release.

0.4.0 introduced three new TTS engines, but the frozen PyInstaller binary tripped over several Python-ecosystem quirks that don't show up in the dev venv: transformers opening .py sources at runtime, scipy.stats._distn_infrastructure hitting a frozen-importer NameError, and chatterbox-multilingual failing to find its Chinese segmenter dictionary. This release patches all of those in one sweep.

Frozen-Binary Reliability (#438)

  • Kokoro now bundles .py sources alongside .pyc via --collect-all kokoro so transformers' _can_set_attn_implementation regex scan can read them — previously FileNotFoundError: kokoro/modules.py killed Kokoro loading in production builds
  • Chatterbox Multilingual now bundles spacy_pkuseg/dicts/default.pkl and the package's native .so extensions via --collect-all spacy_pkuseg — previously the Chinese word segmenter crashed with FileNotFoundError on first load
  • scipy.stats._distn_infrastructure — new runtime hook source-patches the trailing del obj (which raises NameError under PyInstaller's frozen importer because the preceding list comprehension evaluates empty) to globals().pop('obj', None), unblocking librosascipy.signalscipy.stats for every TTS engine that depends on librosa
  • transformers.masking_utils — same runtime hook forces _is_torch_greater_or_equal_than_2_6 = False so the older sdpa_mask_older_torch path is selected; the 2.6+ path uses TransformGetItemToIndex(), a real torch._dynamo graph transform our permissive stub can't reproduce
  • torch._dynamo — no-op stub replaces the real module before transformers imports it, preventing the torch._numpy._ufuncs import crash (NameError: name 'name' is not defined) that blocked Kokoro and every engine pulling in flex_attention
  • .spec paths are now repo-relative instead of absolute, so the generated spec is portable across machines and CI

Generation

  • Cancel queued or running generations (#444) — new /generate/{id}/cancel endpoint and a Stop button on the history row while generating. The serial queue now tracks per-ID state (queued / running / cancelled) so queued jobs are skipped before the worker picks them up and running jobs are .cancel()-ed mid-flight; run_generation catches CancelledError and marks the row failed with a "cancelled" error.
  • Legacy data/ path prefix resolution (#440) — generations stored with the old data/ prefix under pre-0.4 installs now resolve correctly after the storage root moved, fixing 404s for historical audio.

Model Migration

  • Migration dialog no longer hangs when the cache is empty (#439) — the backend now emits a completion SSE event even when zero models are moved.
  • Storage-change flow surfaces a toast when there's nothing to migrate (#433) instead of proceeding with a no-op move and restarting the server.
  • Deleting all generations from a voice profile now deletes the associated version files and DB rows too (#447) — previously orphaned versions accumulated in storage.

Platform

  • Linux system audio capture (#457) — cpal's ALSA backend doesn't expose PulseAudio/PipeWire monitor sources by name, so the previous device-name search never matched and silently fell back to the microphone. Detection now uses pactl get-default-sink + pactl list short sources and routes via PULSE_SOURCE, with the name-based search retained as a fallback when pactl is absent.

Frontend CI

  • First PR-time quality gate (#418) — new .github/workflows/ci.yml runs bun run typecheck + bun run build:web on every PR. Fixed pre-existing type issues that were being suppressed with @ts-expect-error, cleaned up a dep-array typo ([platform.metadata.isTauricheckOnMountcheckForUpdates]) in useAutoUpdater, and removed 100+ lines of dead ModelItem code from ModelManagement.tsx.
  • Follow-up: widened apiClient.migrateModels() return type to include moved and errors so the storage-change handler typechecks against the real backend response (#470).

Docs

  • Clarified in the Quick Start + README that paralinguistic tags ([laugh], [sigh]) only work with Chatterbox Turbo; other engines read them as literal text (#450).

New Contributors

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)

  • 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)

  • 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)

  • 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)

  • 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, #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, 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)

  • 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)
  • 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) — 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) — "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) — 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) — relaxed torch>=2.7.0torch>=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) — Qwen TTS and Whisper force offline mode when loading cached models, so startup works without network access
  • GUI startup with external server (#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) — 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) — bundle qwen_tts source files in the PyInstaller build to fix inspect.getsource errors in frozen builds
  • Backend import paths (#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) — fixed ModuleNotFoundError on preset create/update by switching to relative imports (#349)

Audio & Playback

  • cpal stream silent playback (#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) — 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) — 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) — 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) — 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) — 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) — include CHANGELOG.md in the Docker web build so the in-app changelog page works in Docker deployments
  • Docker numba cache (#425) — set NUMBA_CACHE_DIR in docker-compose so numba can write its JIT cache in container runtime (#308)
  • Relative media paths (#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)

New Contributors

Huge thank you to everyone who contributed their first PR to Voicebox in this release:

@liorshahverdi, @nicoschtein, @ArfianID, @aimaaaimaa, @maxmcoding, @Khalodddd, @LuisSambrano, @shaun0927, @malletfils, @mvanhorn, @kuishou68, @txhno, @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.

The backend's 3,000-line monolith main.py has been decomposed into domain routers, a services layer, and a proper database package. A style guide and ruff configuration now enforce consistency. On the frontend, settings have been split into dedicated routed pages with server logs, a changelog viewer, and an about page. The audio player no longer freezes mid-playback, and model loading status is now visible in the UI. Seven user-reported bugs have been fixed, including server crashes during sample uploads, generation list staleness, cryptic error messages, and CUDA support for RTX 50-series GPUs.

Settings Overhaul (#294)

  • Split settings into routed sub-tabs: General, Generation, GPU, Logs, Changelog, About
  • Added live server log viewer with auto-scroll
  • Added in-app changelog page that parses CHANGELOG.md at build time
  • Added About page with version info, license, and generation folder quick-open
  • Extracted reusable SettingRow component for consistent setting layouts

Audio Player Fix (#293)

  • Fixed audio player freezing during playback
  • Improved playback UX with better state management and listener cleanup
  • Fixed restart race condition during regeneration
  • Added stable keys for audio element re-rendering
  • Improved accessibility across player controls

Backend Refactor (#285)

  • Extracted all routes from main.py into 13 domain routers under backend/routes/main.py dropped from ~3,100 lines to ~10
  • Moved CRUD and service modules into backend/services/, platform detection into backend/utils/
  • Split monolithic database.py into a database/ package with separate models, session, migrations, and seed modules
  • Added backend/STYLE_GUIDE.md and pyproject.toml with ruff linting config
  • Removed dead code: unused _get_cuda_dll_excludes, stale studio.py, example_usage.py, old Makefile
  • Deduplicated shared logic across TTS backends into backends/base.py
  • Improved startup logging with version, platform, data directory, and database stats
  • Fixed startup database session leak — sessions now rollback and close in finally block
  • Isolated shutdown unload calls so one backend failure doesn't block the others
  • Handled null duration in story_items migration
  • Reject model migration when target is a subdirectory of source cache

Documentation Rewrite (#288)

  • Migrated docs site from Mintlify to Fumadocs (Next.js-based)
  • Rewrote introduction and root page with content from README
  • Added "Edit on GitHub" links and last-updated timestamps on all pages
  • Generated OpenAPI spec and auto-generated API reference pages
  • Removed stale planning docs (CUDA_BACKEND_SWAP, EXTERNAL_PROVIDERS, MLX_AUDIO, TTS_PROVIDER_ARCHITECTURE, etc.)
  • Sidebar groups now expand by default; root redirects to /docs
  • Added OG image metadata and /og preview page

UI & Frontend

  • Added model loading status indicator and effects preset dropdown (3187344)
  • Fixed take-label race condition during regeneration
  • Added accessible focus styling to select component
  • Softened select focus indicator opacity
  • Addressed 4 critical and 12 major issues from CodeRabbit review

Bug Fixes (#295)

  • Fixed sample uploads crashing the server — audio decoding now runs in a thread pool instead of blocking the async event loop (#278)
  • Fixed generation list not updating when a generation completes — switched to refetchQueries for reliable cache busting, added SSE error fallback, and page reset on completion (#231)
  • Fixed error toasts showing [object Object] instead of the actual error message (#290)
  • Added Whisper model selection (base, small, medium, large, turbo) and expanded language support to the /transcribe endpoint (#233)
  • Upgraded CUDA backend build from cu121 to cu126 for RTX 50-series (Blackwell) GPU support (#289)
  • Handled client disconnects in SSE and streaming endpoints to suppress [Errno 32] Broken Pipe errors (#248)
  • Fixed Docker build failure from pip hash mismatch on Qwen3-TTS dependencies (#286)
  • Added 50 MB upload size limit with chunked reads to prevent unbounded memory allocation on sample uploads
  • Eliminated redundant double audio decode in sample processing pipeline

Platform Fixes

  • Replaced netstat with TcpStream + PowerShell for Windows port detection (#277)
  • Fixed Docker frontend build and cleaned up Docker docs
  • Fixed macOS download links to use .dmg instead of .app.tar.gz
  • Added dynamic download redirect routes to landing site

Release Tooling

  • Added draft-release-notes and release-bump agent skills
  • Wired CI release workflow to extract notes from CHANGELOG.md for GitHub Releases
  • Backfilled changelog with all historical releases

0.2.3 - 2026-03-15

The "it works in dev but not in prod" release. This version fixes a series of PyInstaller bundling issues that prevented model downloading, loading, generation, and progress tracking from working in production builds.

Model Downloads Now Actually Work

The v0.2.1/v0.2.2 builds could not download or load models that weren't already cached from a dev install. This release fixes the entire chain:

  • Chatterbox, Chatterbox Turbo, and LuxTTS all download, load, and generate correctly in bundled builds
  • Real-time download progress — byte-level progress bars now work in production. The root cause: huggingface_hub silently disables tqdm progress bars based on logger level, which prevented our progress tracker from receiving byte updates. We now force-enable the internal counter regardless.
  • Fixed Python 3.12.0 code.replace() bug — the macOS build was on Python 3.12.0, which has a known CPython bug that corrupts bytecode when PyInstaller rewrites code objects. This caused NameError: name 'obj' is not defined crashes during scipy/torch imports. Upgraded to Python 3.12.13.

PyInstaller Fixes

  • Collect all inflect files — typeguard's @typechecked decorator calls inspect.getsource() at import time, which needs .py source files, not just bytecode. Fixes LuxTTS "could not get source code" error.
  • Collect all perth files — bundles the pretrained watermark model (hparams.yaml, .pth.tar) needed by Chatterbox at runtime
  • Collect all piper_phonemize files — bundles espeak-ng-data/ (phoneme tables, language dicts) needed by LuxTTS for text-to-phoneme conversion
  • Set ESPEAK_DATA_PATH in frozen builds so the espeak-ng C library finds the bundled data instead of looking at /usr/share/espeak-ng-data/
  • Collect all linacodec files — fixes inspect.getsource error in Vocos codec
  • Collect all zipvoice files — fixes source code lookup in LuxTTS voice cloning
  • Copy metadata for requests, transformers, huggingface-hub, tokenizers, safetensors, tqdm — fixes importlib.metadata lookups in frozen binary
  • Add hidden imports for chatterbox, chatterbox_turbo, luxtts, zipvoice backends
  • Add multiprocessing.freeze_support() to fix resource_tracker subprocess crash in frozen binary
  • --noconsole now only applied on Windows — macOS/Linux need stdout/stderr for Tauri sidecar log capture
  • Hardened sys.stdout/sys.stderr devnull redirect to test writability, not just None check

Updater

  • Fixed updater artifact generation with v1Compatible for tauri-action signature files
  • Updated tauri-action to v0.6 to fix updater JSON and .sig generation

Other Fixes

  • Full traceback logging on all backend model loading errors (was just str(e) before)

0.2.2 - 2026-03-15

  • Fix Chatterbox model support in bundled builds
  • Fix LuxTTS/ZipVoice support in bundled builds
  • Auto-update CUDA binary when app version changes
  • CUDA download progress bar
  • Fix server process staying alive on macOS (SIGHUP handling, watchdog grace period)
  • Hide console window when running CUDA binary on Windows

0.2.1 - 2026-03-15

Voicebox v0.1.x was a single-engine voice cloning app built around Qwen3-TTS. v0.2.0 is a ground-up rethink: four TTS engines, 23 languages, paralinguistic emotion controls, a post-processing effects pipeline, unlimited generation length, an async generation queue, and support for every major GPU vendor. Plus Docker.

New TTS Engines

Multi-Engine Architecture

Voicebox now runs four independent TTS engines behind a thread-safe per-engine backend registry. Switch engines per-generation from a single dropdown — no restart required.

Engine Languages Size Key Strengths
Qwen3-TTS 1.7B 10 ~3.5 GB Highest quality, delivery instructions
Qwen3-TTS 0.6B 10 ~1.2 GB Lighter, faster variant
LuxTTS English ~300 MB CPU-friendly, 48 kHz output, 150x realtime
Chatterbox Multilingual 23 ~3.2 GB Broadest language coverage, zero-shot cloning
Chatterbox Turbo English ~1.5 GB 350M params, low latency, paralinguistic tags

Chatterbox Multilingual — 23 Languages (#257)

Zero-shot voice cloning in Arabic, Chinese, Danish, Dutch, English, Finnish, French, German, Greek, Hebrew, Hindi, Italian, Japanese, Korean, Malay, Norwegian, Polish, Portuguese, Russian, Spanish, Swahili, Swedish, and Turkish.

LuxTTS — Lightweight English TTS (#254)

A fast, CPU-friendly English engine. ~300 MB download, 48 kHz output, runs at 150x realtime on CPU.

Chatterbox Turbo — Expressive English (#258)

A fast 350M-parameter English model with inline paralinguistic tags.

Paralinguistic Tags Autocomplete (#265)

Type / in the text input with Chatterbox Turbo selected to open an autocomplete for 9 expressive tags: [laugh] [chuckle] [gasp] [cough] [sigh] [groan] [sniff] [shush] [clear throat]

Generation

Unlimited Generation Length — Auto-Chunking (#266)

Long text is now automatically split at sentence boundaries, generated per-chunk, and crossfaded back together. Engine-agnostic.

  • Auto-chunking limit slider — 1005,000 chars (default 800)
  • Crossfade slider — 0200ms (default 50ms)
  • Max text length raised to 50,000 characters
  • Smart splitting respects abbreviations, CJK punctuation, and [tags]

Asynchronous Generation Queue (#269)

Generation is now fully non-blocking. Serial execution queue prevents GPU contention. Real-time SSE status streaming.

Generation Versions

Every generation now supports multiple versions with provenance tracking — original, effects versions, takes, source tracking, version pinning in stories, and favorites.

Post-Processing Effects (#271)

A full audio effects system powered by Spotify's pedalboard library: Pitch Shift, Reverb, Delay, Chorus/Flanger, Compressor, Gain, High-Pass Filter, Low-Pass Filter. 4 built-in presets, custom presets, per-profile default effects, and live preview.

Platform Support

  • Windows Support (#272) — Full Windows support with CUDA GPU detection
  • Linux (#262) — AMD ROCm, NVIDIA GBM fix, WebKitGTK mic access (build from source)
  • NVIDIA CUDA Backend Swap (#252) — Download and swap in CUDA backend from within the app
  • Intel Arc (XPU) and DirectML — PyTorch backend supports Intel Arc and DirectML
  • Docker + Web Deployment (#161) — 3-stage build, non-root runtime, health checks
  • Whisper Turbo — Added openai/whisper-large-v3-turbo as a transcription model option

Model Management (#268)

Per-model unload, custom models directory, model folder migration, download cancel/clear UI (#238), restructured settings UI.

Security & Reliability

  • CORS hardening (#88)
  • Network access toggle (#133)
  • Offline crash fix (#152)
  • Atomic audio saves (#263)
  • Filesystem health endpoint
  • Chatterbox float64 dtype fix (#264)

Accessibility (#243)

Screen reader support, keyboard navigation, state-aware aria-label attributes on all interactive controls.

UI Polish

  • Redesigned landing page (#274)
  • Voices tab overhaul with inline inspector
  • Responsive layout improvements
  • Duplicate profile name validation (#175)

Community Contributors

@haosenwang1018, @Balneario-de-Cofrentes, @ageofalgo, @mikeswann, @rayl15, @mpecanha, @ways2read, @ieguiguren, @Vaibhavee89, @pandego, @luminest-llc

0.1.13 - 2026-02-23

Stability and reliability

  • #95 Fix: selecting 0.6B model still downloads and uses 1.7B
  • #93 fix(mlx): bundle native libs and broaden error handling for Apple Silicon
  • #79 fix: handle non-ASCII filenames in Content-Disposition headers
  • #78 fix: guard getUserMedia call against undefined mediaDevices in non-secure contexts
  • #77 fix: await for confirmation before deleting voices and channels
  • #128 fix: resolve multiple issues (#96, #119, #111, #108, #121, #125, #127)
  • #40 Fix: audio export path resolution

Build and packaging

  • #122 fix(web): add @tailwindcss/vite plugin to web config
  • #126 Create requirements.txt

UX and docs

  • #44 Enhances floating generate box UX
  • #57 chore: updates repo URL in README
  • #146 Add Spacebot banner to landing page
  • #1 Improvements

0.1.12 - 2026-01-31

Model Download UX Overhaul

  • Real-time download progress tracking with accurate percentage and speed info
  • No more downloading notifications during generation even when its not downloading
  • Better error handling and status reporting throughout the download process

Other Improvements

  • Enhanced health check endpoint with GPU type information
  • Improved model caching verification
  • More reliable SSE progress updates
  • Actual update notifications — no need to manually check in settings anymore

0.1.11 - 2026-01-30

  • Fixed transcriptions on MLX
  • Fixed model download progress (finally)

0.1.10 - 2026-01-30

Faster generation on Apple Silicon

Massive speed gains, from around 20s per generation to 2-3s. Added native MLX backend support for Apple Silicon, providing significantly faster TTS and STT generation on M-series macOS machines.

  • MLX Backend — New backend implementation optimized for Apple Silicon using MLX framework
  • Dynamic Backend Selection — Automatically detects platform and selects between MLX (macOS) and PyTorch (other platforms)
  • Refactored TTS and STT logic into modular backend implementations
  • Updated build process to include MLX-specific dependencies for macOS builds

0.1.9 - 2026-01-30

Improved voice profile creation flow

  • Voice create drafts: No longer lose work if you close the modal
  • Fixed whisper only transcribing English or Chinese, now has support for all languages

Improved Stories editor

  • Added spacebar for play/pause
  • Timeline now auto-scrolls to follow playhead during playback
  • Fixed misalignment of the items with mouse when picking up
  • Fixed hitbox for selecting an item
  • Fixed playhead jumping forward when pressing play

Generation box improvements

  • Instruct mode no longer wipes prompt text
  • Improved UI cleanliness

Misc

  • Fixed "Model downloading" toast during generation when model is already downloaded

0.1.8 - 2026-01-29

Model Download Timeout Issues

Fixed critical issue where model downloads would fail with "Failed to fetch" errors on Windows. Refactored download endpoints to return immediately and continue downloads in background.

Cross-Platform Cache Path Issues

Fixed hardcoded ~/.cache/huggingface/hub paths that don't work on Windows. All cache paths now use hf_constants.HF_HUB_CACHE for proper cross-platform support.

Windows Process Management

  • Added /shutdown endpoint for graceful server shutdown on Windows
  • Added gpu_type field to health check response

0.1.7 - 2026-01-29

  • Trim and split audio clips in Story Editor
  • Auto-activation of stories in Story Editor with visible playhead
  • Conditional auto-play support in AudioPlayer for better user control
  • Refactored audio loading across HistoryTable, SampleList, and generation forms
  • Audio now only auto-plays when explicitly intended, preventing unexpected playback

0.1.6 - 2026-01-29

Introducing Stories

A full voice editor for composing podcasts and generated conversations.

  • Stories Editor — Create multi-voice narratives, podcasts, or conversations with a timeline-based editor
  • Compose tracks with different voices
  • Edit and arrange audio segments inline
  • Build generated conversations with multiple participants
  • Improved Voice Generation UI — Auto-resizing input, default voice selection, better layout
  • Track Editor Integration — Inline track editing within story items

0.1.5 - 2026-01-28

Fixed recording length limit at 0:29 to auto stop instead of passing the limit and getting an error, which would cause users to lose their recording.

0.1.4 - 2026-01-28

  • Audio channel management system
  • Native audio playback handling in AudioPlayer component
  • Refactored ConnectionForm and Checkbox components
  • Improved layout consistency and responsiveness
  • Added safe area constants for better responsive design

0.1.3 - 2026-01-27

  • Improved the generate textbox
  • Maybe fixed Windows autoupdate restarting entire computer

0.1.2 - 2026-01-27

Audio Capture & Format Conversion

  • Added audio format conversion util
  • Enhanced system audio capture on macOS and Windows
  • Improved audio recording hooks
  • Added audio input entitlement for macOS
  • Added audio capture tests

Update System

  • Enhanced auto-updater functionality and update status display

0.1.1 - 2026-01-27

Platform Support

  • macOS Audio Capture — Native audio capture support for sample creation
  • Windows Audio Capture — WASAPI implementation with improved thread safety
  • Linux Support — Temporarily removed builds due to runner disk space constraints

Audio Features

  • Play/pause for audio samples across all components
  • Three new sample components: Recording, System capture, Upload with drag-and-drop
  • Audio validation, error handling, and consistent cleanup

Voice Profile Management

  • Profile import with file size validation (100MB limit)
  • Enhanced profile form with new audio sample components
  • Drag-and-drop support for audio file uploads

Server Management

  • Changed default URL from localhost:8000 to 127.0.0.1:17493
  • Server reuse logic, "keep server running" preference, orphaned process handling

Build & Release

  • Added .bumpversion.cfg for automated version management
  • Enhanced icon generation script for multi-size Windows icons

Bug Fixes

  • Fixed date formatting for timezone-less date strings
  • Fixed getLatestRelease file filtering
  • Improved audio duration metadata on Windows

0.1.0 - 2026-01-27

The first public release of Voicebox — an open-source voice synthesis studio powered by Qwen3-TTS.

Voice Cloning with Qwen3-TTS

  • Automatic model download from HuggingFace
  • Multiple model sizes (1.7B and 0.6B)
  • Voice prompt caching for instant regeneration
  • English and Chinese support

Voice Profile Management

  • Create profiles from audio files or record directly in the app
  • Multiple samples per profile for higher quality cloning
  • Import/Export profiles
  • Automatic transcription via Whisper

Speech Generation

  • Simple text-to-speech with profile selection
  • Seed control for reproducible generations
  • Long-form support up to 5,000 characters

Generation History

  • Full history with metadata
  • Search by text content
  • Inline playback and download

Flexible Deployment

  • Local mode with bundled backend
  • Remote mode for GPU servers on your network
  • One-click server setup

Desktop Experience

  • Built with Tauri v2 (Rust) — native performance, not Electron
  • Cross-platform: macOS and Windows
  • No Python installation required

Tech Stack

Tauri v2, React, TypeScript, Tailwind CSS, FastAPI, Qwen3-TTS, Whisper, SQLite