The create flow wrote raw audio (and a transcoded .wav for non-wav
sources) to data/captures before the DB row was committed, so any
failure between the write and the commit — a webm that decoded to a
0-length array, a whisper model that errored mid-transcribe, a SQLite
contention on the commit — left the audio on disk with nothing pointing
at it. Over enough flaky uploads the directory grows without bound.
Now every path written before the commit is tracked in a list, and the
whole stretch from the first write to db.commit() runs inside a
try/except that unlinks each tracked file on raise and re-raises. The
transcode branch removes the raw file from the cleanup list only when
the unlink actually succeeds, so an OSError on the raw-path delete
still hands cleanup the original blob to retry.
Routes were calling model_dump(exclude_none=True), which drops every
client-sent null before it reaches the service. The service then layered
on its own `if value is not None` guard. Net effect: setting a nullable
column back to null was a no-op — the MCPPage default-voice picker sends
null when the user picks "no default" and the row was silently keeping
whatever was there before.
Switched the routes to exclude_unset=True so absent fields stay absent
but explicit nulls survive the dump, and centralised the per-field
nullability check in the service. The check inspects the SQLAlchemy
column metadata so non-nullable columns (stt_model, llm_model, the chord
key lists) still drop nulls instead of crashing the request, while
default_playback_voice_id can finally be cleared.
- 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.
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]>
- 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
});
```
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]>
When the model wasn't loaded, the app was closed mid-run, or a
generation otherwise errored out, the resulting "Failed" rows
accumulate in history and there was no way to remove them in bulk —
individual delete was the only option.
Adds a header row above the history list (only rendered when at
least one failed generation is present) with a "Clear failed" button
that opens a confirmation dialog, then calls a new
DELETE /history/failed endpoint which sweeps all status='failed'
rows (plus their version files / audio files on disk).
Closesjamiepine/voicebox#410
Co-authored-by: Claude Opus 4.6 <[email protected]>
* fix: add asyncio.Lock to prevent concurrent CUDA downloads
The startup auto-update task and the manual download endpoint can both
invoke download_cuda_binary() concurrently. Without mutual exclusion,
both coroutines write to the same temp file path, corrupting the
download. The progress-manager status check is a TOCTOU race because
the status is not set until after several synchronous checks complete.
Add a module-level asyncio.Lock acquired at the top of
download_cuda_binary() so only one download can proceed at a time.
* fix: fast-reject duplicate CUDA download when lock is held
Address CodeRabbit review feedback: check _download_lock.locked()
before awaiting the lock so concurrent callers return immediately
instead of queueing behind the first download. This prevents the
route handler from returning "started" to multiple callers when only
one download actually proceeds.
Two changes to address the race condition causing "Failed to split clip":
Backend (stories.py): Added with_for_update() to the item query in
split_story_item so concurrent requests for the same clip are
serialized via a row lock instead of racing.
Frontend (StoryTrackEditor.tsx): Guard handleSplit with
splitItem.isPending to prevent rapid double-clicks from firing
multiple mutations before the first completes.
Fixes#366
Co-authored-by: Matt Van Horn <[email protected]>
Add Kokoro-82M as a new TTS engine — 82M params, CPU realtime, 8 languages,
Apache 2.0. Unlike cloning engines, Kokoro uses pre-built voice styles, which
required a new profile type system to support non-cloning engines cleanly.
Kokoro engine:
- New kokoro_backend.py implementing TTSBackend protocol
- 50 built-in voices across en/es/fr/hi/it/pt/ja/zh
- KPipeline API with language-aware G2P routing via misaki
- PyInstaller bundling for misaki, language_tags, espeakng_loader, en_core_web_sm
Voice profile type system:
- New voice_type column: 'cloned' | 'preset' | 'designed' (future)
- Preset profiles store engine + voice ID instead of audio samples
- default_engine field on profiles — auto-selects engine on profile pick
- Create Voice dialog: toggle between 'Clone from audio' and 'Built-in voice'
- Edit dialog shows preset voice info instead of sample list for preset profiles
- Engine selector locks to preset engine when preset profile is selected
- Profile grid filters by engine — shows Kokoro voices when Kokoro selected
- Custom empty state when no preset profiles exist for selected engine
Bug fixes:
- Fix relative audio paths in DB causing 404s in production builds
- config.set_data_dir() now resolves to absolute paths
- Startup migration converts existing relative paths to absolute
Also updates PROJECT_STATUS.md and tts-engines.mdx developer guide.
Upgrade CUDA toolkit from 12.6 (cu126) to 12.8 (cu128) for proper
RTX 50-series (Blackwell) GPU support. Users with RTX 5070/5080/5090
were reporting CUDA detection failures with cu126.
Also fix the GPU Acceleration settings panel where the 'Switch to CPU
Backend' button was unreachable — it was inside a conditional block
that required !isCurrentlyCuda, making it impossible to switch back
to CPU once running on CUDA.
Closes#315
- Wrap download/verify/extract in try/finally so .download-*.tmp is
always deleted, even on mid-download or extraction failures
- Fix justfile build-server-cuda to use sh.voicebox.app (production path)
Switch CUDA builds from PyInstaller --onefile to --onedir and split the
output into two separately versioned archives:
1. Server core (~200-400MB) — versioned with the app, redownloaded on
every app update
2. CUDA libs (~2GB) — versioned independently (cu126-v1), only
redownloaded when the CUDA toolkit or torch version changes
This eliminates the ~2.4GB full redownload on every version bump.
After initial setup, most app updates only need ~200-400MB.
Closes#297
Move audio validation and saving to thread pool so librosa/ffmpeg decoding
doesn't block the async event loop. Combine validate + load into a single
pass to avoid decoding the file twice. Add 50 MB upload limit and chunked
reads to prevent unbounded memory allocation.
Closes#278
- Use DB COUNT query instead of list length for take-N label to avoid
TOCTOU race between list_versions and create_version
- Add focus:bg-muted to SelectTrigger for keyboard focus visibility
Critical:
- Remove dead backend.utils.validation PyInstaller hidden import
- Fix story_items table rebuild to preserve track/trim/version columns
- Guard cache migration against same source/destination path
- Fix regeneration audio overwrite (use random uuid suffix per take)
Major:
- Engine selector: validate language on Qwen switch, clear stale modelSize
- Sync language validation regex between profile create and generate (22 langs)
- Guard CUDA download against duplicate concurrent requests
- Only set model_size for engines that support multiple sizes
- Fix 404 swallowed by generic except in history export
- Validate audio_path before FileResponse in export-audio
- Transcription: stream uploads in 1MB chunks, use robust cache check,
call complete_download() on Whisper download success
- Set clean version as default when effects chain validation fails
- Return explicit error when Windows port occupied by non-voicebox process
Backend:
- Generation service reports 'loading_model' status only when model
is not yet in memory, then 'generating' once inference starts
- Migrate hf_offline_patch.py from print() to logging module
- Update ADDING_TTS_ENGINES.md for post-refactor file paths
Frontend:
- HistoryTable shows 'Loading model...' vs 'Generating...' based on step
- FloatingGenerateBox: replace instruct toggle + inline effects editor
with an effects preset dropdown (third dropdown after language and engine)
- Instruct UI removed for now (form field preserved for future models)
- Remove focus ring from Select component globally
Split the 2,578-line main.py (90 routes) into 12 domain-specific router
modules under routes/. main.py is now a 45-line entry point.
New structure:
- app.py: FastAPI instance, CORS, startup/shutdown, safe_content_disposition
- routes/: health, profiles, channels, generations, history, transcription,
stories, effects, audio, models, tasks, cuda
- services/cuda.py: moved from cuda_download.py
Also includes Phase 5 database/ package (from parallel agent):
- database/__init__.py re-exports all symbols for backward compat
- database/models.py, session.py, migrations.py, seed.py
All 90 routes verified registered and app imports cleanly.