Compare commits

..
Author SHA1 Message Date
Jamie Pine cd116bcfe7 test: App startup, ChordPicker, HistoryTable, and generate-flow suites
App: non-Tauri skips the startup gate, Tauri dev reaches the router
with close-handler/updater wiring, ?view=dictate renders the pill
without booting the main app (branches behind import.meta.env.PROD are
unreachable under vitest and stay uncovered). ChordPicker: chord
capture via raw KeyboardEvents with explicit codes, peak-set
semantics, save/cancel. HistoryTable: rows, empty state, player-store
autoplay intent, favorite/delete round trips, export through
platform.filesystem.saveFile. FloatingGenerateBox: mounted via the
real index route so submit → pending → SSE completion → history
refetch runs the actual RootLayout wiring.
2026-08-08 15:42:01 -07:00
Jamie Pine bfd6ec2f02 ci: unit, e2e, and backend-test jobs
quality keeps typecheck + web build and uploads the bundle. unit runs
vitest (node + Chromium browser projects) and blocks PRs. e2e boots
the CPU backend with fake TTS and runs Playwright against the preview
build — informational until it has a sustained green run, then flip
continue-on-error off. backend-tests runs the existing pytest suite
for the first time in CI, also informational.
2026-08-08 15:41:11 -07:00
Jamie Pine 37b7110e52 test: Playwright E2E with real backend + fake TTS engine
e2e/ drives the web build (vite preview) against a per-worker uvicorn:
each Playwright worker boots its own backend on its own port with a
temp cwd, so SQLite and audio files are fully isolated and parallel-
safe. The page fixture seeds the persisted voicebox-server store with
the worker's URL before any script runs.

VOICEBOX_FAKE_TTS=1 short-circuits get_tts_backend_for_engine to a
backend that synthesizes a sine tone sized to the text — the real task
queue, SSE progress, history rows, and audio serving all run, only
inference is fake. backend/requirements-ci.txt is the slim dependency
set validated to boot the app on a CPU-only runner.

Specs: startup, settings layout, seeded profile in /voices, and
generate-to-completed-audio through the full pipeline.
2026-08-08 15:40:36 -07:00
Jamie Pine c7a3ff5d65 test: harness — typed fixtures, domain MSW handlers, SSE helpers, route render
Fixture builders match the hand-written API types with deterministic
ids. Handlers are per-domain factories tests compose via worker.use;
unstubbed requests fail loudly. sseController streams real
text/event-stream bodies through MSW into EventSource, so generation
and download progress are testable without touching the transport.
renderRoute mounts the real route tree over memory history. Query
clients drain on teardown so in-flight fetches can't leak past handler
reset, and the browser project pre-bundles app deps so a cold dep-
optimizer cache can't reload mid-run.
2026-08-08 15:26:53 -07:00
Jamie Pine fddcd61f4d test: vitest foundation with browser mode
Two projects in one root config: 'unit' (happy-dom) for stores, hooks,
and utils, and 'browser' (real Chromium via the playwright provider)
for component tests — no jsdom polyfills for EventSource, AudioContext,
or canvas. Harness pieces: createMockPlatform (spy-able Platform with
an updater emit handle), renderWithProviders (fresh QueryClient with
polling disabled + PlatformProvider), MSW worker with baseline health
handler, and a store-reset teardown covering all eight zustand stores.
Seed tests cover uiStore theme, serverStore invalidation, and an
AboutPage render in Chromium.

Requires node >= 20 (.node-version added); run with bun run test.
2026-08-08 15:22:28 -07:00
Jamie Pine 6174cd64d6 chore: drop dead code, export routeTree
Removes the orphaned generated OpenAPI client (superseded by the
hand-written client.ts), the unrouted AudioTab, and app/'s unused
standalone entry (main.tsx, index.html, vite.config) — app/ is a
shared library consumed by web/ and tauri/. routeTree is exported so
tests can build routers over memory history.
2026-08-08 15:15:14 -07:00
Jamie Pine 696e927f6d fix: gate DictateWindow tauri IPC behind platform.metadata.isTauri 2026-08-08 15:15:14 -07:00
Jamie Pine 91cdaa9162 fix: use the shared queryClient in both entries, init i18n explicitly
Each entry built its own QueryClient while serverStore invalidates the
singleton from lib/queryClient — so switching server URL never
refreshed data. Both entries now render with the singleton. i18n init
was only reachable transitively through lib/utils/format.ts; import it
directly in both entries.
2026-08-08 15:15:14 -07:00
Jamie Pine 7d7c0b59e5 fix: remove stale useAutoUpdater duplicate
Both useAutoUpdater.ts and .tsx existed; Vite resolves .ts first,
which accepts showToast but never implements it — so the update toast
App.tsx asks for never rendered. The .tsx variant is the full
implementation and has the same signature.
2026-08-08 15:15:14 -07:00
Jamie Pine 46cf5803a3 fix: route CapturesTab exports through the platform layer
The wav/txt/md export handlers called plugin-dialog and plugin-fs
directly, which breaks in the web build. saveFile now returns the
saved path (null on cancel) so callers can toast accurately, and the
cross-window capture listeners only register under Tauri.
2026-08-08 15:15:14 -07:00
Aftaab SiddiquiandJamie Pine 901ffcc93b Fix voice sample validation on Python 3.13 (fixes #852) (#853)
* Fix voice sample validation on Python 3.13

Python 3.13 removed audioop from the standard library, which broke reference
audio validation when adding voice samples. Add the audioop-lts backport for
3.13+ installs and bundle audioop in PyInstaller builds on the same versions.

* style(tests): satisfy Ruff import ordering

---------

Co-authored-by: Jamie Pine <[email protected]>
2026-07-20 22:35:40 -07:00
a3fa9a2784 fix(backend): return 404 instead of 500 for audio of failed generations (#893)
* fix(backend): return 404 instead of 500 for audio of failed generations

A failed generation stores an empty audio_path. resolve_storage_path("")
resolved to the data directory itself, which exists, so the route's 404
guard passed and FileResponse raised RuntimeError ("File at path .../data
is not a file"), surfacing as a 500.

- resolve_storage_path now returns None for empty paths
- audio routes check is_file() instead of exists() so directories never
  reach FileResponse
- GET /audio/{generation_id} reports "Generation failed; no audio
  available" when the generation status is failed

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(backend): reject empty Path objects in resolve_storage_path

Path("") is truthy, so the previous `if not path` guard only caught
None and empty strings. Callers such as database/migrations.py pass
Path objects, so an empty Path could still resolve to the data dir.
Check None separately and reject paths with no parts.

Also add regression tests asserting the version and sample audio
endpoints 404 when a stored path resolves to an existing directory
(guards the is_file() checks against regressing to exists()).

Addresses CodeRabbit review on PR #893.

Co-Authored-By: Claude Fable 5 <[email protected]>

* style(tests): drop parentheses on pytest.fixture decorator (ruff PT001)

Co-Authored-By: Claude Fable 5 <[email protected]>

* style(tests): satisfy Ruff naming rule

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Jamie Pine <[email protected]>
2026-07-20 22:35:40 -07:00
cc9c905b55 fix(setup): install mlx-lm and mlx-audio in setup-python on Apple Silicon (#892)
* fix(setup): install mlx-lm and mlx-audio in setup-python on Apple Silicon

The dev setup installed requirements-mlx.txt but not mlx-audio/mlx-lm
themselves, so POST /transcribe failed on a fresh Apple Silicon setup
with "No module named 'mlx_audio'" (then "No module named 'mlx_lm'").
The release workflow already installs both with --no-deps (they declare
transformers>=5.x, conflicting with our <=4.57.x cap); mirror that in
the setup-python recipe with the same pins.

Co-Authored-By: Claude Fable 5 <[email protected]>

* test: add MLX smoke test for the --no-deps mlx-audio/mlx-lm install

mlx-audio and mlx-lm are installed --no-deps, so a missing transitive
dependency only surfaces at import time. Add a pytest-discoverable
smoke test (skipped off Apple Silicon) covering the exact entry points
the backend uses: mlx_audio.tts.load, mlx_audio.stt.load (which also
exercises the miniaudio dep from issue #505), mlx_lm.load/generate,
and a basic mlx.core op.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-20 22:27:01 -07:00
XariannandJamie Pine 7d4844fb10 fix(rocm): unset empty HSA_OVERRIDE_GFX_VERSION before torch loads (#864)
Docker compose sets HSA_OVERRIDE_GFX_VERSION=${HSA_OVERRIDE_GFX_VERSION:-}
which results in an empty string when not provided. An empty string is
not the same as unset - ROCm treats it as 'force-empty' and no GPU is
detected, even natively supported ones (e.g. gfx1201 / RX 9070 on ROCm 7.2).

Pop the env var when it is empty, before torch loads, so ROCm auto-detects
the GPU correctly.

Tested on RX 9070 (gfx1201) with ROCm 7.2 and PyTorch 2.12.1+rocm7.2.
2026-07-20 22:27:01 -07:00
309120ec89 fix(build): build voicebox-mcp shim sidecar on Windows (#794)
The Windows `build-server` just recipe only built and copied the
voicebox-server sidecar, omitting the voicebox-mcp stdio shim that the
Unix scripts/build-server.sh builds via `build_binary.py --shim`.

As a result `just build` on Windows produced only one sidecar and the
Tauri bundle step failed with:

    resource path `binaries\voicebox-mcp-<triple>.exe` doesn't exist

Build and copy the shim sidecar after the server, mirroring
build-server.sh. Hoist the triple/binaries-dir setup ahead of both
builds so the shim step reuses them.

Co-authored-by: namu.shin <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-20 22:27:01 -07:00
29dadb5543 fix: the justfile syntax errror, GPU information cannot be read (#669)
Co-authored-by: xor_s <[email protected]>
2026-07-20 22:27:01 -07:00
Shabeer VPKandJamie Pine 2a206cd09f Update .dockerignore (#861)
whitelist ROCm entrypoint
2026-07-20 22:27:01 -07:00
jitendra kumar sainiandJamie Pine dbd57dfb60 docs: fix incorrect app identifier in CONTRIBUTING.md (#863)
The CUDA backend path used com.voicebox.app, but the actual Tauri identifier is sh.voicebox.app (as in tauri.conf.json and all other docs).
2026-07-20 21:19:19 -07:00
neuron-tech-aiandJamie Pine 0f2032f058 Batch story item counts in list_stories to eliminate N+1 (#663)
list_stories() previously executed one COUNT(story_items) query per
story in a Python loop. With N stories that is N+1 round-trips to
SQLite regardless of list length. Replace with a single aggregated
GROUP BY query that fetches all counts at once, then populate each
StoryResponse from a dict lookup.
2026-07-20 21:15:00 -07:00
youtsuhoandJamie Pine 46fe1c3608 fix: gate macOS-only keyboard_layout symbols behind cfg (#831)
* fix: gate macOS-only keyboard_layout symbols behind cfg to suppress dead_code warnings

* chore: sync bun.lock with package.json
2026-07-20 21:07:46 -07:00
Jamie Pine 7346c9e652 chore(release): reconcile approved main promotions 2026-07-20 21:03:34 -07:00
cbca21ac77 fix(kokoro): add missing male Mandarin voices (#788)
Co-authored-by: Siddharth Chintawar <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-07-20 20:51:17 -07:00
30db291b01 fix(kokoro): add missing male Mandarin voices (#788)
Co-authored-by: Siddharth Chintawar <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-07-20 20:51:06 -07:00
Andrew BarnesandJamie Pine 12ce7a4c35 Fix CUDA downloads on unsupported platforms (#770)
* Fix CUDA downloads on unsupported platforms

* fix: align CUDA status nullability

* fix: require CUDA download support flag
2026-07-20 19:22:57 -07:00
Andrew BarnesandGitHub 71b51366bc Fix CUDA downloads on unsupported platforms (#770)
* Fix CUDA downloads on unsupported platforms

* fix: align CUDA status nullability

* fix: require CUDA download support flag
2026-07-20 19:22:18 -07:00
3f4631c865 fix(offline): remove process-global offline guard from Qwen3 LLM loads (#924)
force_offline_if_cached flips HF_HUB_OFFLINE (env + huggingface_hub
constant + transformers._is_offline_mode) process-wide for the duration
of a cached LLM load, silently switching every concurrent model
download/load on other threads to offline mode. With default capture
settings (whisper-turbo STT + Qwen3 refinement + auto_refine) a first
run downloads several models concurrently, and a poisoned fetch
surfaces as "Can't load feature extractor..." (whisper) or
"Unrecognized model ... model_type" (Qwen3) rather than anything
mentioning offline mode.

These are the last two call sites of the guard — the same pattern was
deliberately removed app-wide in #524/#530 after identical failures,
and the 0.5.0 LLM backend reintroduced it. LLM loads now run with the
process's default HF_HUB_OFFLINE state, matching every other backend
(issue #462 precedent).

Fixes #841

Claude-Session: https://claude.ai/code/session_011iwL9AyeAWgz2jpgcHxJpC

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-20 18:53:32 -07:00
258b92c9c0 fix(offline): remove process-global offline guard from Qwen3 LLM loads (#924)
force_offline_if_cached flips HF_HUB_OFFLINE (env + huggingface_hub
constant + transformers._is_offline_mode) process-wide for the duration
of a cached LLM load, silently switching every concurrent model
download/load on other threads to offline mode. With default capture
settings (whisper-turbo STT + Qwen3 refinement + auto_refine) a first
run downloads several models concurrently, and a poisoned fetch
surfaces as "Can't load feature extractor..." (whisper) or
"Unrecognized model ... model_type" (Qwen3) rather than anything
mentioning offline mode.

These are the last two call sites of the guard — the same pattern was
deliberately removed app-wide in #524/#530 after identical failures,
and the 0.5.0 LLM backend reintroduced it. LLM loads now run with the
process's default HF_HUB_OFFLINE state, matching every other backend
(issue #462 precedent).

Fixes #841


Claude-Session: https://claude.ai/code/session_011iwL9AyeAWgz2jpgcHxJpC

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-20 18:47:10 -07:00
Jamie Pine e001439c06 chore: remove public landing application 2026-07-20 15:29:48 -07:00
TedChang-LimandJamie Pine 47d9ce908f feat(i18n): add Korean (ko) locale with 559 translation keys (#814)
* feat(i18n): add Korean (ko) locale with 559 translation keys

* fix(i18n): complete Korean translations for current UI

---------

Co-authored-by: Jamie Pine <[email protected]>
2026-07-20 15:19:56 -07:00
e6cf50c7f7 feat(i18n): add Korean (ko) locale with 559 translation keys (#814)
* feat(i18n): add Korean (ko) locale with 559 translation keys

* fix(i18n): complete Korean translations for current UI

---------

Co-authored-by: Jamie Pine <[email protected]>
2026-07-20 15:19:39 -07:00
b57cfed3ef feat(i18n): add Spanish (es) locale (#798)
Adds Spanish as a UI display language, matching the existing
4-locale pattern (en, ja, zh-CN, zh-TW) with full key parity.

- app/src/i18n/locales/es/translation.json: 832 strings across 18
  namespaces, translated from the en master. Keys, {{interpolation}}
  placeholders, <code>/<path>/<link>/<strong> tags and _one/_other
  plurals preserved. Brand/model names (Whisper, Qwen3, CUDA, MCP…)
  left untranslated by design.
- app/src/i18n/index.ts: register `es` in SUPPORTED_LANGUAGES and
  resources; the language switcher and LanguageCode derive automatically.
- app/src/lib/utils/format.ts: wire the date-fns `es` locale for
  relative-date formatting.

Verified: key parity 832/832 (no missing/extra, placeholders & tags
intact), biome check clean, app+web typecheck pass, build:web succeeds.

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: Jamie Pine <[email protected]>
2026-07-20 13:16:13 -07:00
albanobattistellaandJamie Pine cf173ae837 Add Italian translation (#904)
* Add Italian translation

* Add Italian language support to i18n
2026-07-20 13:16:13 -07:00
2dc3b075d5 feat(i18n): add Spanish (es) locale (#798)
Adds Spanish as a UI display language, matching the existing
4-locale pattern (en, ja, zh-CN, zh-TW) with full key parity.

- app/src/i18n/locales/es/translation.json: 832 strings across 18
  namespaces, translated from the en master. Keys, {{interpolation}}
  placeholders, <code>/<path>/<link>/<strong> tags and _one/_other
  plurals preserved. Brand/model names (Whisper, Qwen3, CUDA, MCP…)
  left untranslated by design.
- app/src/i18n/index.ts: register `es` in SUPPORTED_LANGUAGES and
  resources; the language switcher and LanguageCode derive automatically.
- app/src/lib/utils/format.ts: wire the date-fns `es` locale for
  relative-date formatting.

Verified: key parity 832/832 (no missing/extra, placeholders & tags
intact), biome check clean, app+web typecheck pass, build:web succeeds.

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: Jamie Pine <[email protected]>
2026-07-20 13:15:57 -07:00
albanobattistellaandGitHub 05d90790f8 Add Italian translation (#904)
* Add Italian translation

* Add Italian language support to i18n
2026-07-20 13:14:31 -07:00
89d489f711 fix(linux): skip click-through toggle on dictate pill to prevent startup crash (#906)
The dictate pill window is built hidden at setup and the frontend emits
dictate:hide as soon as it mounts. The handler calls
set_ignore_cursor_events(true) on a window GTK has never realized, and
tao's CursorIgnoreEvents path unwraps the missing GdkWindow
(tao-0.34.5 event_loop.rs:449), panicking inside a glib dispatch that
cannot unwind — the process aborts within seconds of launch on Linux.

The click-through toggle exists as a macOS workaround for transparent
always-on-top NSWindows lingering as invisible click targets; it was
never needed on Linux. Gate all three call sites so Linux never toggles
it: the true/false pair stays balanced (never set, never unset), and
macOS/Windows builds are unchanged.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-20 12:41:24 -07:00
f7c08477a0 fix(models): stop reporting errored downloads as still downloading (#926)
TaskManager.error_download() intentionally keeps a failed task in the
active list (status="error") so /tasks/active can surface the error
and retry UI — but /models/status derived its "downloading" flag from
the same unfiltered list. One failed download therefore showed the
model as downloading:true / downloaded:false for the life of the
process, masking the model's real cache state (even a fully valid
on-disk cache) until an app restart. Likely behind endless-spinner
reports like #181 and the restart-fixes-it pattern in #883.

Add TaskManager.get_pending_downloads() (downloading/extracting only)
and use it in /models/status; /tasks/active behavior is unchanged.

Fixes #925


Claude-Session: https://claude.ai/code/session_011iwL9AyeAWgz2jpgcHxJpC

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-20 12:41:19 -07:00
Elem OghenekaroandJamie Pine 7d3f7b96d7 Fix: keep the uploaded file extension when transcribing (#903)
/transcribe wrote every upload to a temp file named .wav regardless of its
real format. librosa picks its decoder from the extension, so any non-wav
upload failed with "could not open/decode file" even though the format is
one the app handles elsewhere.

profiles.py already solves this for voice samples by keeping the uploaded
extension when it is one of the audio types it accepts, and falling back to
.wav otherwise. Same approach here, same set. The fallback means an unknown
or missing extension behaves exactly as it does today.
2026-07-20 12:41:19 -07:00
4a6b5da793 fix(linux): skip click-through toggle on dictate pill to prevent startup crash (#906)
The dictate pill window is built hidden at setup and the frontend emits
dictate:hide as soon as it mounts. The handler calls
set_ignore_cursor_events(true) on a window GTK has never realized, and
tao's CursorIgnoreEvents path unwraps the missing GdkWindow
(tao-0.34.5 event_loop.rs:449), panicking inside a glib dispatch that
cannot unwind — the process aborts within seconds of launch on Linux.

The click-through toggle exists as a macOS workaround for transparent
always-on-top NSWindows lingering as invisible click targets; it was
never needed on Linux. Gate all three call sites so Linux never toggles
it: the true/false pair stays balanced (never set, never unset), and
macOS/Windows builds are unchanged.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-20 12:40:10 -07:00
2c9d02af62 fix(models): stop reporting errored downloads as still downloading (#926)
TaskManager.error_download() intentionally keeps a failed task in the
active list (status="error") so /tasks/active can surface the error
and retry UI — but /models/status derived its "downloading" flag from
the same unfiltered list. One failed download therefore showed the
model as downloading:true / downloaded:false for the life of the
process, masking the model's real cache state (even a fully valid
on-disk cache) until an app restart. Likely behind endless-spinner
reports like #181 and the restart-fixes-it pattern in #883.

Add TaskManager.get_pending_downloads() (downloading/extracting only)
and use it in /models/status; /tasks/active behavior is unchanged.

Fixes #925


Claude-Session: https://claude.ai/code/session_011iwL9AyeAWgz2jpgcHxJpC

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-20 12:39:58 -07:00
Elem OghenekaroandGitHub b680097dfb Fix: keep the uploaded file extension when transcribing (#903)
/transcribe wrote every upload to a temp file named .wav regardless of its
real format. librosa picks its decoder from the extension, so any non-wav
upload failed with "could not open/decode file" even though the format is
one the app handles elsewhere.

profiles.py already solves this for voice samples by keeping the uploaded
extension when it is one of the audio types it accepts, and falling back to
.wav otherwise. Same approach here, same set. The fallback means an unknown
or missing extension behaves exactly as it does today.
2026-07-20 12:39:46 -07:00
Jamie Pine 7e424a20a3 fix(dictation): preserve native focus and fullscreen injection
Carry focus and auto-paste permission per capture, support dictation over macOS fullscreen Spaces, preserve native window behavior flags, and abort paste when the pill cannot be hidden safely.\n\nVerified: frontend CI; cargo check; diff and security scans. Packaged multi-Space validation remains a release gate.
2026-07-19 17:45:52 -07:00
Jamie Pine 9b1beba3e1 fix(dictation): make microphone lifecycle race-safe
Coalesce and invalidate microphone acquisition, preserve immediate stop/cancel events, block overlapping/finalising takes, and add the opt-in keep_mic_warm setting with privacy-preserving default off.\n\nVerified: frontend CI; keep_mic_warm migration default and idempotency; diff checks.
2026-07-19 17:45:52 -07:00
Jamie Pine 0070c04bcf fix(mlx): serialize accelerator lifecycle and inference
Route MLX load, inference, unload, reset, cache cleanup, and shutdown through a single worker. Add affinity and concurrent-unload regression coverage.\n\nVerified: 17 related backend tests; frontend CI; cargo check.
2026-07-19 17:45:52 -07:00
287 changed files with 8567 additions and 13447 deletions
-4
View File
@@ -37,7 +37,3 @@ replace = "version": "{new_version}"
[bumpversion:file:backend/__init__.py]
search = __version__ = "{current_version}"
replace = __version__ = "{new_version}"
[bumpversion:file:backend/pyproject.toml]
search = version = "{current_version}"
replace = version = "{new_version}"
+2 -1
View File
@@ -8,7 +8,8 @@ tauri/
landing/
docs/
mlx-test/
scripts/
scripts/*
!scripts/rocm-entrypoint.sh
# Dependencies & build artifacts (rebuilt in Docker)
node_modules/
-2
View File
@@ -1,2 +0,0 @@
package.json text eol=lf
scripts/*.sh text eol=lf
+93 -50
View File
@@ -6,14 +6,9 @@ on:
branches:
- main
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
frontend-quality:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -23,70 +18,118 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Lint (biome)
run: bun run lint
- name: Typecheck app + web
run: bun run typecheck
- name: Unit tests
run: bun run test
- name: Build web smoke test
- name: Build web
run: bun run build:web
backend-quality:
# macOS arm64 matches the primary user platform and lets the MLX-path
# tests run instead of being skipped.
runs-on: macos-14
- name: Upload web build
uses: actions/upload-artifact@v4
with:
name: web-dist
path: web/dist
retention-days: 1
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
- uses: actions/setup-node@v4
with:
python-version: "3.12"
node-version: 22
- name: Cache pip downloads
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/Library/Caches/pip
key: pip-${{ runner.os }}-${{ hashFiles('backend/requirements*.txt', 'justfile') }}
restore-keys: pip-${{ runner.os }}-
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
- name: Install just
run: brew install just
- name: Install Chromium
run: bunx playwright install chromium --with-deps
- name: Install backend dependencies
run: just setup-python
- name: Lint (ruff)
run: venv/bin/ruff check .
working-directory: backend
- name: Run tests
run: venv/bin/python -m pytest tests -q
working-directory: backend
rust-quality:
runs-on: macos-14
- name: Vitest (unit + browser)
run: bunx vitest run
e2e:
# Informational while the suite beds in; flip to blocking once it has
# a sustained green run.
continue-on-error: true
needs: quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Rust cache
uses: Swatinem/rust-cache@v2
- uses: actions/setup-node@v4
with:
workspaces: tauri/src-tauri
node-version: 22
- name: Stub sidecar binaries
# tauri-build validates externalBin paths; real sidecars are only
# produced by the release pipeline.
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --frozen-lockfile
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
cache-dependency-path: backend/requirements-ci.txt
- name: Install backend (CPU)
run: |
mkdir -p tauri/src-tauri/binaries tauri/dist
touch tauri/src-tauri/binaries/voicebox-server-aarch64-apple-darwin
touch tauri/src-tauri/binaries/voicebox-mcp-aarch64-apple-darwin
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install -r backend/requirements-ci.txt
- name: Cargo check
run: cargo check --manifest-path tauri/src-tauri/Cargo.toml
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
- name: Install Chromium
run: bunx playwright install chromium --with-deps
- name: Playwright E2E
run: bunx playwright test -c e2e
env:
VOICEBOX_PYTHON: python
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
playwright-report
test-results
retention-days: 7
backend-tests:
# Informational: 30 pre-existing pytest files that have never run in CI.
continue-on-error: true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
cache-dependency-path: backend/requirements-ci.txt
- name: Install backend (CPU)
run: |
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install -r backend/requirements-ci.txt
pip install pytest pytest-asyncio
- name: Pytest
run: python -m pytest backend/tests -v --ignore=backend/tests/test_all_models_e2e.py
BIN
View File
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
22
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+26 -24
View File
@@ -18,9 +18,9 @@ Thank you for your interest in contributing to Voicebox! This document provides
curl -fsSL https://bun.sh/install | bash
```
- **[Python 3.12+](https://python.org)** - For backend development
- **[Python 3.11+](https://python.org)** - For backend development
```bash
python --version # Should be 3.12 or higher
python --version # Should be 3.11 or higher
```
- **[Rust](https://rustup.rs)** - For Tauri desktop app (installed automatically by Tauri CLI)
@@ -115,6 +115,14 @@ just build-server
This makes PyInstaller use your local qwen-tts version instead of the pip-installed package.
### Generate OpenAPI Client
After starting the backend server:
```bash
./scripts/generate-api.sh
```
This downloads the OpenAPI schema and generates the TypeScript client in `app/src/lib/api/`
### Convert Assets to Web Formats
To optimize images and videos for the web, run:
@@ -125,7 +133,7 @@ bun run convert:assets
This script:
- Converts PNG → WebP (better compression, same quality)
- Converts MOV → WebM (VP9 codec, smaller file size)
- Processes files in `landing/public/` and `docs/public/`
- Processes files in `docs/public/`
- **Deletes original files** after successful conversion
**Requirements:** Install `webp` and `ffmpeg`:
@@ -204,7 +212,7 @@ export const ProfileCard = (props) => { ... }
- Follow PEP 8 style guide
- Use type hints
- Use async/await for I/O operations
- Format and lint with ruff (configured in `backend/pyproject.toml`)
- Format with Black (if configured)
```python
# Good
@@ -234,12 +242,9 @@ voicebox/
│ ├── lib/ # Utilities and API client
│ └── hooks/ # React hooks
├── backend/ # Python FastAPI server
│ ├── main.py # Entry point (FastAPI app assembled in app.py)
│ ├── routes/ # API routers, one per domain
│ ├── services/ # Business logic (generation, transcription, profiles, ...)
│ ├── backends/ # TTS engine implementations
│ ├── database/ # SQLAlchemy models, sessions, migrations
│ └── tests/ # pytest suite
│ ├── main.py # API routes
│ ├── tts.py # Voice synthesis
│ └── ...
├── tauri/ # Desktop app wrapper
│ └── src-tauri/ # Rust backend
└── scripts/ # Build scripts
@@ -284,26 +289,23 @@ voicebox/
When adding new API endpoints:
1. **Add the route to the relevant router in `backend/routes/`** (new routers get registered in `backend/routes/__init__.py`)
1. **Add route in `backend/main.py`**
2. **Create Pydantic models in `backend/models.py`**
3. **Implement business logic in appropriate module**
4. **Update OpenAPI schema** (automatic with FastAPI)
5. **Update the TypeScript client** — add matching types to `app/src/lib/api/types.ts` and a method to `app/src/lib/api/client.ts`
5. **Regenerate TypeScript client:**
```bash
bun run generate:api
```
6. **Update `backend/README.md`** with endpoint documentation
## Testing
Backend tests live in `backend/tests/` and run with pytest:
Currently, testing is primarily manual. When adding tests:
```bash
cd backend
venv/bin/python -m pytest tests
```
CI runs the backend test suite on every PR, along with frontend lint and typecheck (`bun run lint`, `bun run typecheck`) and a `cargo check` of the Tauri app (see `.github/workflows/ci.yml`). Add backend tests alongside your changes where it makes sense.
- **Frontend**: Vitest for React component tests (coverage is still sparse — contributions welcome)
- **E2E**: Playwright for end-to-end tests (future)
- **Backend**: Use pytest for Python tests
- **Frontend**: Use Vitest for React component tests
- **E2E**: Use Playwright for end-to-end tests (future)
## Pull Request Process
@@ -361,7 +363,7 @@ See [docs/content/docs/overview/troubleshooting.mdx](docs/content/docs/overview/
**Quick fixes:**
- **Backend won't start:** Check Python version (3.12+), ensure venv is activated, install dependencies
- **Backend won't start:** Check Python version (3.11+), ensure venv is activated, install dependencies
- **Tauri build fails:** Ensure Rust is installed, clean build with `cd tauri/src-tauri && cargo clean`
- **OpenAPI client generation fails:** Ensure backend is running, check `curl http://localhost:17493/openapi.json`
@@ -377,7 +379,7 @@ See [docs/content/docs/overview/troubleshooting.mdx](docs/content/docs/overview/
- [README.md](README.md) - Project overview
- [backend/README.md](backend/README.md) - API documentation
- [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) - Living engineering roadmap: architecture, shipped vs in-flight work, prioritized open issues, candidate TTS engines under evaluation, architectural bottlenecks. Keep this updated when you ship significant features, close or backlog a model integration, or identify new bottlenecks.
- [docs/content/docs/developer/autoupdater.mdx](docs/content/docs/developer/autoupdater.mdx) - Auto-updater setup (published at [voicebox.sh docs](https://voicebox.sh/docs/developer/autoupdater))
- [docs/AUTOUPDATER_QUICKSTART.md](docs/AUTOUPDATER_QUICKSTART.md) - Auto-updater setup
- [SECURITY.md](SECURITY.md) - Security policy
- [CHANGELOG.md](CHANGELOG.md) - Version history
+4 -11
View File
@@ -20,11 +20,8 @@ COPY package.json bun.lock CHANGELOG.md ./
COPY app/ ./app/
COPY web/ ./web/
# Normalize line endings first (a Windows CRLF checkout would otherwise
# defeat the `-z 's/,\n ]/…/'` match below, since it's LF-anchored), then
# strip workspaces not needed for web build, and fix trailing comma
RUN sed -i 's/\r$//' package.json && \
sed -i '/"tauri"/d; /"landing"/d' package.json && \
# Strip workspaces not needed for web build, and fix trailing comma
RUN sed -i '/"tauri"/d' package.json && \
sed -i -z 's/,\n ]/\n ]/' package.json
RUN bun install --no-save
# Build frontend (skip tsc — upstream has pre-existing type errors)
@@ -64,7 +61,7 @@ RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
RUN pip install --no-cache-dir --prefix=/install --no-deps chatterbox-tts
RUN pip install --no-cache-dir --prefix=/install --no-deps hume-tada
RUN pip install --no-cache-dir --prefix=/install \
git+https://github.com/QwenLM/Qwen3-TTS.git@022e286b98fbec7e1e916cb940cdf532cd9f488e
git+https://github.com/QwenLM/Qwen3-TTS.git
# === Stage 3: Runtime ===
@@ -103,11 +100,7 @@ EXPOSE 17493
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s \
CMD curl -f http://localhost:17493/health || exit 1
# Entrypoint joins GPU groups then drops to the voicebox user.
# Normalize CRLF (a Windows checkout otherwise leaves the shebang as
# `#!/bin/sh\r`, which Linux can't resolve — reported as a misleading
# "no such file or directory" even though the file exists).
# Entrypoint joins GPU groups then drops to the voicebox user
COPY --chmod=755 scripts/rocm-entrypoint.sh /usr/local/bin/entrypoint.sh
RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "17493"]
+5 -5
View File
@@ -45,7 +45,7 @@
<p align="center">
<a href="https://voicebox.sh">
<img src="landing/public/assets/app-screenshot-1.webp" alt="Voicebox App Screenshot" width="800" />
<img src="docs/public/images/readme/app-screenshot-1.webp" alt="Voicebox App Screenshot" width="800" />
</a>
</p>
@@ -56,11 +56,11 @@
<br/>
<p align="center">
<img src="landing/public/assets/app-screenshot-2.webp" alt="Voicebox Screenshot 2" width="800" />
<img src="docs/public/images/readme/app-screenshot-2.webp" alt="Voicebox Screenshot 2" width="800" />
</p>
<p align="center">
<img src="landing/public/assets/app-screenshot-3.webp" alt="Voicebox Screenshot 3" width="800" />
<img src="docs/public/images/readme/app-screenshot-3.webp" alt="Voicebox Screenshot 3" width="800" />
</p>
<br/>
@@ -270,7 +270,8 @@ Use cases: agent dev loops (dictate a question, hear the answer in a cloned voic
| Platform | Backend | Notes |
| ------------------------ | -------------- | ---------------------------------------------- |
| macOS (Apple Silicon) | MLX (Metal) | 4-5x faster via Neural Engine |
| Windows / Linux (NVIDIA) | PyTorch (CUDA) | Auto-downloads CUDA binary from within the app |
| Windows (NVIDIA) | PyTorch (CUDA) | Auto-downloads CUDA binary from within the app |
| Linux (NVIDIA) | PyTorch (CUDA) | Use a local/remote Python backend with CUDA PyTorch |
| Linux (AMD) | PyTorch (ROCm) | Auto-configures HSA_OVERRIDE_GFX_VERSION |
| Windows (any GPU) | DirectML | Universal Windows GPU support |
| Intel Arc | IPEX/XPU | Intel discrete GPU acceleration |
@@ -441,7 +442,6 @@ voicebox/
├── tauri/ # Desktop app (Tauri + Rust)
├── web/ # Web deployment
├── backend/ # Python FastAPI server
├── landing/ # Marketing website
└── scripts/ # Build & release scripts
```
+4 -4
View File
@@ -6,8 +6,8 @@ We release patches for security vulnerabilities. Which versions are eligible for
| Version | Supported |
| ------- | ------------------ |
| 0.5.x | :white_check_mark: |
| < 0.5 | :x: |
| 0.3.x | :white_check_mark: |
| < 0.3 | :x: |
## Reporting a Vulnerability
@@ -39,7 +39,7 @@ We will:
### For Developers
- **Dependencies** - Keep all dependencies up to date
- **CI checks** - Every PR must pass typecheck, lint, backend tests, and `cargo check` before merging
- **Code review** - All PRs require review before merging
- **Secrets** - Never commit API keys or signing keys
- **Signing** - All releases are cryptographically signed
@@ -82,7 +82,7 @@ Timeline may vary based on severity and complexity.
## Security Updates
Security updates will be:
- Released as patch versions (e.g., 0.5.1)
- Released as patch versions (e.g., 0.3.2)
- Documented in CHANGELOG.md
- Announced via GitHub releases
- Automatically delivered via auto-updater
-29
View File
@@ -1,29 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>voicebox</title>
<script>
(() => {
try {
var theme = 'system';
var raw = localStorage.getItem('voicebox-ui');
if (raw) {
var parsed = JSON.parse(raw);
if (parsed && parsed.state && parsed.state.theme) theme = parsed.state.theme;
}
var resolved = theme === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
if (resolved === 'dark') document.documentElement.classList.add('dark');
} catch (_) {}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-5
View File
@@ -4,11 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "bun test",
"preview": "vite preview",
"lint": "biome lint src",
"lint:fix": "biome lint --write src",
"format": "biome format --write src",
@@ -61,7 +57,6 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@types/bun": "^1.3.4",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
+134
View File
@@ -0,0 +1,134 @@
import { mockIPC } from '@tauri-apps/api/mocks';
import { afterEach, beforeEach, expect, it } from 'vitest';
import App from '@/App';
import { createMockPlatform } from '@/test/mockPlatform';
import { buildModelStatus, buildProfile } from '@/test/msw/fixtures';
import {
captureHandlers,
effectsHandlers,
historyHandlers,
modelHandlers,
profileHandlers,
settingsHandlers,
storyHandlers,
taskHandlers,
} from '@/test/msw/handlers';
import { worker } from '@/test/msw/worker';
import { renderWithProviders } from '@/test/render';
const originalUrl = window.location.href;
// useChordSync and the permission gates call the Tauri IPC modules directly,
// outside the Platform abstraction. There is no Tauri runtime in the test
// browser, so `invoke`/`listen` would reject with a TypeError that some
// callers (e.g. useChordSync's `listen('dictate:warm-request')`) never get a
// chance to handle, surfacing as unhandled rejections. mockIPC installs the
// official in-memory IPC shim; `shouldMockEvents` covers listen/emit too.
//
// Reinstalled per test for a fresh listener map, but never cleared: the
// harness unmounts components after this file's afterEach, and those unmount
// cleanups still `unlisten` through the shim. The per-file iframe throws the
// window state away anyway.
beforeEach(() => {
mockIPC(
(cmd) => {
// Permission checks treat the result as a trusted boolean — grant
// them so no permission banners pop over the UI under test.
if (cmd.startsWith('check_')) return true;
return null;
},
{ shouldMockEvents: true },
);
});
afterEach(() => {
window.history.replaceState(null, '', originalUrl);
delete window.__voiceboxServerStartedByApp;
});
/**
* Everything the index route (MainEditor + app chrome) fetches on mount.
* Unstubbed requests fail the test loudly, so this is the full route budget.
*/
function useHappyPathHandlers() {
worker.use(
...profileHandlers([buildProfile({ name: 'Ada Lovelace' })]),
...historyHandlers([]),
...captureHandlers([]),
...settingsHandlers(),
...modelHandlers([buildModelStatus()]),
...storyHandlers([]),
...effectsHandlers([]),
...taskHandlers(),
);
}
// App reads window.location at render time: `?view=dictate` picks the pill
// window, and the router matches the real browser path. Point the URL at the
// state under test before mounting; afterEach restores the runner's URL.
function setAppUrl(path: string) {
window.history.replaceState(null, '', path);
}
it('skips the startup gate outside Tauri and renders the router', async () => {
useHappyPathHandlers();
setAppUrl('/');
const screen = await renderWithProviders(<App />);
// Index route is MainEditor — the profile list proves the router mounted.
await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
// Web mode assumes an external server: no lifecycle management at all.
expect(screen.platform.lifecycle.startServer).not.toHaveBeenCalled();
expect(screen.platform.lifecycle.setupWindowCloseHandler).not.toHaveBeenCalled();
});
it('skips server auto-start in Tauri dev mode and still reaches the router', async () => {
// App gates auto-start on `import.meta.env.PROD`, which is false under
// vitest. The reachable Tauri branch is therefore the dev one: window
// close handler installed, auto-start skipped, serverReady forced true.
//
// The PROD-only branches — `lifecycle.startServer`, the health-check
// polling fallback, and the startup-error screen with its Retry button —
// are unreachable here without mocking import.meta.env, so they are
// intentionally not covered.
useHappyPathHandlers();
setAppUrl('/');
const platform = createMockPlatform({ metadata: { isTauri: true } });
const screen = await renderWithProviders(<App />, { platform });
await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
expect(platform.lifecycle.startServer).not.toHaveBeenCalled();
expect(platform.lifecycle.setupWindowCloseHandler).toHaveBeenCalled();
// Startup syncs the keep-server-running setting into Rust.
expect(platform.lifecycle.setKeepServerRunning).toHaveBeenCalledWith(expect.any(Boolean));
// Auto-updater runs its mount check in Tauri.
expect(platform.updater.checkForUpdates).toHaveBeenCalled();
// Dev mode records that the app does not own the server process.
expect(window.__voiceboxServerStartedByApp).toBe(false);
});
it('renders the dictate pill window for ?view=dictate without booting the main app', async () => {
// No route handlers on purpose: the dictate view must not touch any of the
// main app's endpoints, and an unhandled request would fail the test.
setAppUrl('/?view=dictate');
const screen = await renderWithProviders(<App />);
// DictateWindow forces the document transparent so the Tauri window takes
// the pill's shape — the observable signal that it mounted without
// throwing under the non-Tauri mock platform.
await expect.poll(() => document.body.style.background).toBe('transparent');
// The pill starts hidden: the wrapper renders but contains no CapturePill.
const wrapper = screen.container.firstElementChild as HTMLElement;
expect(wrapper.className).toContain('h-screen');
expect(wrapper.childElementCount).toBe(0);
// The startup gate never ran — no server lifecycle calls from this window.
expect(screen.platform.lifecycle.startServer).not.toHaveBeenCalled();
expect(screen.platform.lifecycle.setupWindowCloseHandler).not.toHaveBeenCalled();
});
-675
View File
@@ -1,675 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiClient } from '@/lib/api/client';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext';
import { usePlayerStore } from '@/stores/playerStore';
interface AudioDevice {
id: string;
name: string;
is_default: boolean;
}
export function AudioTab() {
const { t } = useTranslation();
const platform = usePlatform();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editingChannel, setEditingChannel] = useState<string | null>(null);
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
const queryClient = useQueryClient();
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
const { data: channels, isLoading: channelsLoading } = useQuery({
queryKey: ['channels'],
queryFn: () => apiClient.listChannels(),
});
const { data: devices, isLoading: devicesLoading } = useQuery({
queryKey: ['audio-devices'],
queryFn: async () => {
if (!platform.metadata.isTauri) {
return [];
}
try {
return await platform.audio.listOutputDevices();
} catch (error) {
console.error('Failed to list audio devices:', error);
return [];
}
},
enabled: platform.metadata.isTauri,
});
const { data: profiles } = useQuery({
queryKey: ['profiles'],
queryFn: () => apiClient.listProfiles(),
});
const createChannel = useMutation({
mutationFn: (data: { name: string; device_ids: string[] }) => apiClient.createChannel(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] });
setCreateDialogOpen(false);
},
});
const updateChannel = useMutation({
mutationFn: ({
channelId,
data,
}: {
channelId: string;
data: { name?: string; device_ids?: string[] };
}) => apiClient.updateChannel(channelId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] });
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
setEditingChannel(null);
},
});
const deleteChannel = useMutation({
mutationFn: (channelId: string) => apiClient.deleteChannel(channelId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] });
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
},
});
const { data: channelVoices } = useQuery({
queryKey: ['channel-voices', editingChannel],
queryFn: async () => {
if (!editingChannel) return { profile_ids: [] };
return apiClient.getChannelVoices(editingChannel);
},
enabled: !!editingChannel,
});
const setChannelVoices = useMutation({
mutationFn: ({ channelId, profileIds }: { channelId: string; profileIds: string[] }) =>
apiClient.setChannelVoices(channelId, profileIds),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channel-voices'] });
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
},
});
if (channelsLoading || devicesLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-muted-foreground">{t('audioChannels.loading')}</div>
</div>
);
}
const handleChannelDelete = async (e: React.MouseEvent, channelId: string) => {
e.stopPropagation();
if (await confirm(t('audioChannels.confirmDelete'))) {
deleteChannel.mutate(channelId);
}
};
const allChannels = channels || [];
const allDevices = devices || [];
const selectedChannel = selectedChannelId
? allChannels.find((c) => c.id === selectedChannelId)
: null;
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between mb-6 shrink-0">
<h2 className="text-2xl font-bold">{t('audioChannels.title')}</h2>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
{t('audioChannels.newChannel')}
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0">
{/* Left Column - Channels */}
<div
className={cn(
'flex flex-col min-h-0 overflow-y-auto',
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)}
>
{allChannels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
<Speaker className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-4">{t('audioChannels.empty.message')}</p>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
{t('audioChannels.empty.action')}
</Button>
</div>
) : (
<div className="space-y-3">
{allChannels.map((channel) => {
const isSelected = selectedChannelId === channel.id;
return (
<button
key={channel.id}
type="button"
className={cn(
'group border rounded-lg p-4 transition-colors cursor-pointer text-left w-full',
isSelected && 'ring-2 ring-primary bg-primary/5 border-primary',
)}
onClick={() => setSelectedChannelId(isSelected ? null : channel.id)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-3">
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Speaker className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex items-center gap-2 min-w-0">
<h3 className="font-semibold text-base truncate">{channel.name}</h3>
</div>
</div>
<div className="space-y-2.5 ml-10">
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">
{t('audioChannels.labels.outputDevices')}
</div>
<div className="flex flex-wrap gap-1.5">
{channel.device_ids.length > 0
? channel.device_ids.map((deviceId) => {
const device = allDevices.find((d) => d.id === deviceId);
return (
<Badge
key={deviceId}
variant="outline"
className="text-xs font-normal"
>
{device?.name || deviceId}
</Badge>
);
})
: (() => {
const defaultDevice = allDevices.find((d) => d.is_default);
return defaultDevice ? (
<Badge variant="outline" className="text-xs font-normal">
{defaultDevice.name}
</Badge>
) : null;
})()}
</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">
{t('audioChannels.labels.assignedVoices')}
</div>
<ChannelVoicesList channelId={channel.id} />
</div>
</div>
</div>
{!channel.is_default && (
<div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
setEditingChannel(channel.id);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => handleChannelDelete(e, channel.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
</button>
);
})}
</div>
)}
</div>
{/* Right Column - Available Devices */}
<div
className={cn(
'flex flex-col min-h-0 overflow-y-auto',
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)}
>
<div className="shrink-0 mb-4">
<h3 className="text-lg font-semibold">{t('audioChannels.devices.title')}</h3>
<p className="text-sm text-muted-foreground mt-1">
{selectedChannelId
? selectedChannel?.is_default
? t('audioChannels.devices.defaultNote')
: t('audioChannels.devices.toggleHint')
: t('audioChannels.devices.selectHint')}
</p>
</div>
{allDevices.length > 0 ? (
<div className="space-y-2">
{allDevices.map((device) => {
const isConnected =
selectedChannelId &&
selectedChannel &&
(selectedChannel.device_ids.length === 0
? device.is_default
: selectedChannel.device_ids.includes(device.id));
const canToggle =
selectedChannelId && selectedChannel && !selectedChannel.is_default;
const handleDeviceClick = () => {
if (!canToggle || !selectedChannel) return;
const currentDeviceIds = selectedChannel.device_ids;
const newDeviceIds = isConnected
? currentDeviceIds.filter((id) => id !== device.id)
: [...currentDeviceIds, device.id];
updateChannel.mutate({
channelId: selectedChannelId,
data: { device_ids: newDeviceIds },
});
};
return (
<button
key={device.id}
type="button"
onClick={handleDeviceClick}
disabled={!canToggle}
className={cn(
'flex items-center gap-2 text-sm p-3 rounded-lg border transition-colors text-left w-full',
isConnected
? 'bg-primary/10 border-primary ring-1 ring-primary/20'
: 'hover:bg-muted/50',
!canToggle && 'cursor-default opacity-60',
canToggle && 'cursor-pointer',
)}
>
{canToggle ? (
<div
className={cn(
'h-4 w-4 rounded border-2 flex items-center justify-center shrink-0',
isConnected ? 'bg-accent border-accent' : 'border-muted-foreground/30',
)}
>
{isConnected && <Check className="h-3 w-3 text-accent-foreground" />}
</div>
) : device.is_default ? (
<CheckCircle2 className="h-4 w-4 text-primary shrink-0" />
) : null}
<span className={cn('truncate flex-1', device.is_default && 'font-medium')}>
{device.name}
</span>
</button>
);
})}
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground text-center">
{platform.metadata.isTauri
? t('audioChannels.devices.empty')
: t('audioChannels.devices.requiresTauri')}
</p>
</div>
)}
</div>
</div>
{/* Create Channel Dialog */}
<CreateChannelDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
devices={devices || []}
onCreate={(name, deviceIds) => {
createChannel.mutate({ name, device_ids: deviceIds });
}}
/>
{/* Edit Channel Dialog */}
{editingChannel &&
(() => {
const channel = channels?.find((c) => c.id === editingChannel);
return channel ? (
<EditChannelDialog
open={!!editingChannel}
onOpenChange={(open) => !open && setEditingChannel(null)}
channel={channel}
devices={devices || []}
profiles={profiles || []}
channelVoices={channelVoices?.profile_ids || []}
onUpdate={(name, deviceIds) => {
updateChannel.mutate({
channelId: editingChannel,
data: { name, device_ids: deviceIds },
});
}}
onSetVoices={(profileIds) => {
setChannelVoices.mutate({
channelId: editingChannel,
profileIds,
});
}}
/>
) : null;
})()}
</div>
);
}
function ChannelVoicesList({ channelId }: { channelId: string }) {
const { t } = useTranslation();
const { data: voices } = useQuery({
queryKey: ['channel-voices', channelId],
queryFn: () => apiClient.getChannelVoices(channelId),
});
const { data: profiles } = useQuery({
queryKey: ['profiles'],
queryFn: () => apiClient.listProfiles(),
});
const voiceNames =
voices?.profile_ids.map((id) => profiles?.find((p) => p.id === id)?.name).filter(Boolean) || [];
return (
<div className="flex flex-wrap gap-1.5">
{voiceNames.length > 0 ? (
voiceNames.map((name) => (
<Badge key={name} variant="outline" className="text-xs font-normal">
{name}
</Badge>
))
) : (
<span className="text-sm text-muted-foreground">{t('audioChannels.noVoicesAssigned')}</span>
)}
</div>
);
}
interface CreateChannelDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
devices: AudioDevice[];
onCreate: (name: string, deviceIds: string[]) => void;
}
function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateChannelDialogProps) {
const { t } = useTranslation();
const [name, setName] = useState('');
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
const handleSubmit = () => {
if (name.trim()) {
onCreate(name.trim(), selectedDevices);
setName('');
setSelectedDevices([]);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('audioChannels.createDialog.title')}</DialogTitle>
<DialogDescription>{t('audioChannels.createDialog.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="channel-name">{t('audioChannels.fields.name')}</Label>
<Input
id="channel-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('audioChannels.fields.namePlaceholder')}
/>
</div>
<div>
<Label>{t('audioChannels.labels.outputDevices')}</Label>
<Select
value={selectedDevices[0] || ''}
onValueChange={(value) => {
if (value && !selectedDevices.includes(value)) {
setSelectedDevices([...selectedDevices, value]);
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t('audioChannels.selectDevice')} />
</SelectTrigger>
<SelectContent>
{devices.map((device) => (
<SelectItem key={device.id} value={device.id}>
{device.name} {device.is_default && `(${t('audioChannels.defaultSuffix')})`}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedDevices.length > 0 && (
<div className="mt-2 space-y-1">
{selectedDevices.map((deviceId) => {
const device = devices.find((d) => d.id === deviceId);
return (
<div
key={deviceId}
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
>
<span>{device?.name || deviceId}</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleSubmit} disabled={!name.trim()}>
{t('audioChannels.createDialog.action')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
interface EditChannelDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
channel: {
id: string;
name: string;
device_ids: string[];
};
devices: AudioDevice[];
profiles: Array<{ id: string; name: string }>;
channelVoices: string[];
onUpdate: (name: string, deviceIds: string[]) => void;
onSetVoices: (profileIds: string[]) => void;
}
function EditChannelDialog({
open,
onOpenChange,
channel,
devices,
profiles,
channelVoices,
onUpdate,
onSetVoices,
}: EditChannelDialogProps) {
const { t } = useTranslation();
const [name, setName] = useState(channel.name);
const [selectedDevices, setSelectedDevices] = useState<string[]>(channel.device_ids);
const [selectedVoices, setSelectedVoices] = useState<string[]>(channelVoices);
const handleSubmit = () => {
if (name.trim()) {
onUpdate(name.trim(), selectedDevices);
onSetVoices(selectedVoices);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('audioChannels.editDialog.title')}</DialogTitle>
<DialogDescription>{t('audioChannels.editDialog.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="edit-channel-name">{t('audioChannels.fields.name')}</Label>
<Input id="edit-channel-name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<Label>{t('audioChannels.labels.outputDevices')}</Label>
<Select
value=""
onValueChange={(value) => {
if (value && !selectedDevices.includes(value)) {
setSelectedDevices([...selectedDevices, value]);
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t('audioChannels.addDevice')} />
</SelectTrigger>
<SelectContent>
{devices.map((device) => (
<SelectItem key={device.id} value={device.id}>
{device.name} {device.is_default && `(${t('audioChannels.defaultSuffix')})`}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedDevices.length > 0 && (
<div className="mt-2 space-y-1">
{selectedDevices.map((deviceId) => {
const device = devices.find((d) => d.id === deviceId);
return (
<div
key={deviceId}
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
>
<span>{device?.name || deviceId}</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
)}
</div>
<div>
<Label>{t('audioChannels.labels.assignedVoices')}</Label>
<Select
value=""
onValueChange={(value) => {
if (value && !selectedVoices.includes(value)) {
setSelectedVoices([...selectedVoices, value]);
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t('audioChannels.addVoice')} />
</SelectTrigger>
<SelectContent>
{profiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedVoices.length > 0 && (
<div className="mt-2 space-y-1">
{selectedVoices.map((profileId) => {
const profile = profiles.find((p) => p.id === profileId);
return (
<div
key={profileId}
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
>
<span>{profile?.name || profileId}</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
setSelectedVoices(selectedVoices.filter((id) => id !== profileId))
}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleSubmit} disabled={!name.trim()}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+110 -94
View File
@@ -1,8 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { save } from '@tauri-apps/plugin-dialog';
import { writeFile, writeTextFile } from '@tauri-apps/plugin-fs';
import {
Captions,
Check,
@@ -27,6 +25,14 @@ import { AudioBars } from '@/components/AudioBars';
import { CapturePill } from '@/components/CapturePill/CapturePill';
import { CaptureInlinePlayer } from '@/components/CapturesTab/CaptureInlinePlayer';
import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist';
import {
ListPane,
ListPaneHeader,
ListPaneScroll,
ListPaneSearch,
ListPaneTitle,
ListPaneTitleRow,
} from '@/components/ListPane';
import {
AlertDialog,
AlertDialogAction,
@@ -48,14 +54,6 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Textarea } from '@/components/ui/textarea';
import {
ListPane,
ListPaneHeader,
ListPaneScroll,
ListPaneSearch,
ListPaneTitle,
ListPaneTitleRow,
} from '@/components/ListPane';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type {
@@ -72,6 +70,7 @@ import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { cn } from '@/lib/utils/cn';
import { formatAbsoluteDate, formatDate } from '@/lib/utils/format';
import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
import { usePlatform } from '@/platform/PlatformContext';
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
@@ -135,6 +134,7 @@ export function CapturesTab() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { toast } = useToast();
const platform = usePlatform();
const fileInputRef = useRef<HTMLInputElement>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
@@ -202,6 +202,7 @@ export function CapturesTab() {
// the race window between ``setSelectedId(new)`` and the refetched list
// actually containing the new row.
useEffect(() => {
if (!platform.metadata.isTauri) return;
const unlistens: Promise<UnlistenFn>[] = [];
unlistens.push(
listen<{ capture: CaptureResponse }>('capture:created', (event) => {
@@ -225,7 +226,7 @@ export function CapturesTab() {
return () => {
for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
};
}, [queryClient]);
}, [queryClient, platform.metadata.isTauri]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
@@ -243,9 +244,7 @@ export function CapturesTab() {
// referenced profile was deleted) fall through to the first profile.
const storedVoiceId = captureSettings?.default_playback_voice_id ?? null;
const playAsVoice =
(storedVoiceId && profiles?.find((p) => p.id === storedVoiceId)) ||
profiles?.[0] ||
null;
(storedVoiceId && profiles?.find((p) => p.id === storedVoiceId)) || profiles?.[0] || null;
const playAsVoiceId = playAsVoice?.id ?? null;
const deleteMutation = useMutation({
@@ -255,12 +254,22 @@ export function CapturesTab() {
queryClient.invalidateQueries({ queryKey: ['captures'] });
},
onError: (err: Error) => {
toast({ title: t('captures.toast.deleteFailed'), description: err.message, variant: 'destructive' });
toast({
title: t('captures.toast.deleteFailed'),
description: err.message,
variant: 'destructive',
});
},
});
const playAsMutation = useMutation({
mutationFn: async ({ capture, voice }: { capture: CaptureResponse; voice: VoiceProfileResponse }) => {
mutationFn: async ({
capture,
voice,
}: {
capture: CaptureResponse;
voice: VoiceProfileResponse;
}) => {
const text = capture.transcript_refined || capture.transcript_raw;
if (!text.trim()) throw new Error(t('captures.noTranscriptError'));
const language = (capture.language || voice.language) as LanguageCode;
@@ -268,8 +277,13 @@ export function CapturesTab() {
// profile's stored engine preference. Cloned profiles without an
// override fall through to whatever the backend picks.
const engine = voice.default_engine as
| 'qwen' | 'qwen_custom_voice' | 'luxtts' | 'chatterbox'
| 'chatterbox_turbo' | 'tada' | 'kokoro'
| 'qwen'
| 'qwen_custom_voice'
| 'luxtts'
| 'chatterbox'
| 'chatterbox_turbo'
| 'tada'
| 'kokoro'
| undefined;
return apiClient.generateSpeech({
profile_id: voice.id,
@@ -286,7 +300,11 @@ export function CapturesTab() {
addPendingGeneration(result.id);
},
onError: (err: Error) => {
toast({ title: t('captures.toast.playAsFailed'), description: err.message, variant: 'destructive' });
toast({
title: t('captures.toast.playAsFailed'),
description: err.message,
variant: 'destructive',
});
},
});
@@ -336,16 +354,15 @@ export function CapturesTab() {
const handleExportAudio = async () => {
if (!selected) return;
try {
const dest = await save({
defaultPath: `capture_${selected.id.slice(0, 8)}.wav`,
filters: [{ name: 'Audio', extensions: ['wav'] }],
});
if (!dest) return;
const res = await fetch(apiClient.getCaptureAudioUrl(selected.id));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = new Uint8Array(await res.arrayBuffer());
await writeFile(dest, buf);
exportToastSuccess(dest);
const blob = new Blob([await res.arrayBuffer()], { type: 'audio/wav' });
const dest = await platform.filesystem.saveFile(
`capture_${selected.id.slice(0, 8)}.wav`,
blob,
[{ name: 'Audio', extensions: ['wav'] }],
);
if (dest) exportToastSuccess(dest);
} catch (err) {
exportToastError(err);
}
@@ -359,13 +376,12 @@ export function CapturesTab() {
return;
}
try {
const dest = await save({
defaultPath: `capture_${selected.id.slice(0, 8)}.txt`,
filters: [{ name: 'Text', extensions: ['txt'] }],
});
if (!dest) return;
await writeTextFile(dest, text);
exportToastSuccess(dest);
const dest = await platform.filesystem.saveFile(
`capture_${selected.id.slice(0, 8)}.txt`,
new Blob([text], { type: 'text/plain' }),
[{ name: 'Text', extensions: ['txt'] }],
);
if (dest) exportToastSuccess(dest);
} catch (err) {
exportToastError(err);
}
@@ -376,7 +392,8 @@ export function CapturesTab() {
lines.push(`# Capture ${capture.id}`, '');
lines.push(`- **Source:** ${capture.source}`);
lines.push(`- **Created:** ${capture.created_at}`);
if (capture.duration_ms != null) lines.push(`- **Duration:** ${formatDuration(capture.duration_ms)}`);
if (capture.duration_ms != null)
lines.push(`- **Duration:** ${formatDuration(capture.duration_ms)}`);
if (capture.language) lines.push(`- **Language:** ${capture.language}`);
if (capture.stt_model) lines.push(`- **STT model:** ${capture.stt_model}`);
if (capture.llm_model) lines.push(`- **LLM model:** ${capture.llm_model}`);
@@ -398,13 +415,12 @@ export function CapturesTab() {
return;
}
try {
const dest = await save({
defaultPath: `capture_${selected.id.slice(0, 8)}.md`,
filters: [{ name: 'Markdown', extensions: ['md'] }],
});
if (!dest) return;
await writeTextFile(dest, buildCaptureMarkdown(selected));
exportToastSuccess(dest);
const dest = await platform.filesystem.saveFile(
`capture_${selected.id.slice(0, 8)}.md`,
new Blob([buildCaptureMarkdown(selected)], { type: 'text/markdown' }),
[{ name: 'Markdown', extensions: ['md'] }],
);
if (dest) exportToastSuccess(dest);
} catch (err) {
exportToastError(err);
}
@@ -486,48 +502,48 @@ export function CapturesTab() {
</div>
) : (
filtered.map((capture) => {
const isActive = selectedId === capture.id;
const refined = !!capture.transcript_refined;
return (
<button
type="button"
key={capture.id}
onClick={() => setSelectedId(capture.id)}
className={cn(
'w-full text-left p-3 rounded-lg transition-colors block',
isActive
? 'bg-muted/70 border border-border'
: 'border border-transparent hover:bg-muted/30',
)}
>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[11px] text-muted-foreground font-medium">
{formatDate(capture.created_at)}
</span>
<div className="flex-1" />
<span className="text-[10px] text-muted-foreground/70 tabular-nums">
{formatDuration(capture.duration_ms)}
</span>
</div>
<div className="text-[13px] text-foreground/90 line-clamp-2 leading-snug mb-2">
{snippetOf(capture)}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<SourceBadge source={capture.source} />
{refined && (
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-accent/10 text-accent border border-accent/20"
>
<Sparkles className="h-2.5 w-2.5" />
{t('captures.transcript.refined')}
</Badge>
const isActive = selectedId === capture.id;
const refined = !!capture.transcript_refined;
return (
<button
type="button"
key={capture.id}
onClick={() => setSelectedId(capture.id)}
className={cn(
'w-full text-left p-3 rounded-lg transition-colors block',
isActive
? 'bg-muted/70 border border-border'
: 'border border-transparent hover:bg-muted/30',
)}
</div>
</button>
);
})
)}
>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[11px] text-muted-foreground font-medium">
{formatDate(capture.created_at)}
</span>
<div className="flex-1" />
<span className="text-[10px] text-muted-foreground/70 tabular-nums">
{formatDuration(capture.duration_ms)}
</span>
</div>
<div className="text-[13px] text-foreground/90 line-clamp-2 leading-snug mb-2">
{snippetOf(capture)}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<SourceBadge source={capture.source} />
{refined && (
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-accent/10 text-accent border border-accent/20"
>
<Sparkles className="h-2.5 w-2.5" />
{t('captures.transcript.refined')}
</Badge>
)}
</div>
</button>
);
})
)}
</div>
</ListPaneScroll>
</ListPane>
@@ -578,7 +594,9 @@ export function CapturesTab() {
) : (
<Upload className="h-4 w-4 mr-2" />
)}
{session.isUploading ? t('captures.actions.importing') : t('captures.actions.import')}
{session.isUploading
? t('captures.actions.importing')
: t('captures.actions.import')}
</Button>
)}
</>
@@ -748,11 +766,7 @@ export function CapturesTab() {
</DropdownMenuLabel>
<DropdownMenuSeparator />
{profiles?.map((v) => (
<DropdownMenuItem
key={v.id}
onClick={() => handlePlayAs(v)}
className="py-2"
>
<DropdownMenuItem key={v.id} onClick={() => handlePlayAs(v)} className="py-2">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{v.name}</div>
<div className="text-[11px] text-muted-foreground truncate">
@@ -864,9 +878,7 @@ export function CapturesTab() {
</div>
) : null}
</div>
<p className="text-sm">
{t('captures.empty.pressShortcut')}
</p>
<p className="text-sm">{t('captures.empty.pressShortcut')}</p>
</div>
) : (
<div className="max-w-sm mx-auto text-center space-y-3">
@@ -888,7 +900,9 @@ export function CapturesTab() {
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('captures.deleteDialog.title')}</AlertDialogTitle>
<AlertDialogDescription>{t('captures.deleteDialog.description')}</AlertDialogDescription>
<AlertDialogDescription>
{t('captures.deleteDialog.description')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
@@ -898,7 +912,9 @@ export function CapturesTab() {
disabled={deleteMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteMutation.isPending ? t('captures.deleteDialog.deleting') : t('common.delete')}
{deleteMutation.isPending
? t('captures.deleteDialog.deleting')
: t('common.delete')}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
@@ -0,0 +1,113 @@
import { expect, it, vi } from 'vitest';
import { ChordPicker } from '@/components/ChordPicker/ChordPicker';
import { renderWithProviders } from '@/test/render';
// ChordPicker listens on window in the capture phase and canonicalizes via
// `event.code`, so raw KeyboardEvents give exact control over which physical
// keys the picker sees (userEvent would depend on the host keyboard layout).
function press(code: string) {
window.dispatchEvent(new KeyboardEvent('keydown', { code, bubbles: true, cancelable: true }));
}
function release(code: string) {
window.dispatchEvent(new KeyboardEvent('keyup', { code, bubbles: true, cancelable: true }));
}
async function renderPicker(initialKeys: string[] = []) {
const onSave = vi.fn();
const onCancel = vi.fn();
const screen = await renderWithProviders(
<ChordPicker
open
title="Push-to-talk shortcut"
initialKeys={initialKeys}
onSave={onSave}
onCancel={onCancel}
/>,
);
return { screen, onSave, onCancel };
}
it('opens empty with save disabled and flags unsupported keys', async () => {
const { screen } = await renderPicker();
await expect.element(screen.getByText('Press your shortcut')).toBeVisible();
await expect.element(screen.getByText('No keys yet')).toBeVisible();
await expect.element(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
// NumpadEnter has no canonical chord name — the picker refuses it and
// stays empty instead of capturing garbage.
press('NumpadEnter');
await expect.element(screen.getByText(/isn't supported in chords/)).toBeVisible();
await expect.element(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
});
it('captures the held keys and saves them after release', async () => {
const { screen, onSave } = await renderPicker();
press('KeyJ');
await expect.element(screen.getByText('Capturing…')).toBeVisible();
await expect.element(screen.getByText('J', { exact: true })).toBeVisible();
press('KeyK');
await expect.element(screen.getByText('K', { exact: true })).toBeVisible();
// Releasing everything freezes the peak so the user can save hands-free.
release('KeyK');
release('KeyJ');
await expect.element(screen.getByText('Press your shortcut')).toBeVisible();
await expect.element(screen.getByText('J', { exact: true })).toBeVisible();
await expect.element(screen.getByText('K', { exact: true })).toBeVisible();
await screen.getByRole('button', { name: 'Save' }).click();
expect(onSave).toHaveBeenCalledExactlyOnceWith(['KeyJ', 'KeyK']);
});
it('keeps the peak set when a key is released mid-chord', async () => {
const { screen, onSave } = await renderPicker();
press('KeyA');
press('KeyB');
press('KeyC');
await expect.element(screen.getByText('B', { exact: true })).toBeVisible();
// Mid-chord the display tracks only the currently held keys...
release('KeyB');
await expect.element(screen.getByText('B', { exact: true })).not.toBeInTheDocument();
await expect.element(screen.getByText('A', { exact: true })).toBeVisible();
// ...but the captured peak still includes the released key.
release('KeyA');
release('KeyC');
await expect.element(screen.getByText('B', { exact: true })).toBeVisible();
await screen.getByRole('button', { name: 'Save' }).click();
expect(onSave).toHaveBeenCalledExactlyOnceWith(['KeyA', 'KeyB', 'KeyC']);
});
it('replaces a longer saved chord with a fresh shorter one', async () => {
const { screen, onSave } = await renderPicker(['KeyA', 'KeyB', 'KeyC']);
await expect.element(screen.getByText('A', { exact: true })).toBeVisible();
// The first key of a new sequence resets the peak, so a single key can
// beat the three-key seed.
press('KeyZ');
release('KeyZ');
await expect.element(screen.getByText('Z', { exact: true })).toBeVisible();
await expect.element(screen.getByText('A', { exact: true })).not.toBeInTheDocument();
await screen.getByRole('button', { name: 'Save' }).click();
expect(onSave).toHaveBeenCalledExactlyOnceWith(['KeyZ']);
});
it('cancel fires the cancel callback and never saves', async () => {
const { screen, onSave, onCancel } = await renderPicker(['KeyA']);
press('KeyQ');
release('KeyQ');
await screen.getByRole('button', { name: 'Cancel' }).click();
expect(onCancel).toHaveBeenCalledOnce();
expect(onSave).not.toHaveBeenCalled();
});
@@ -5,6 +5,7 @@ import { CapturePill } from '@/components/CapturePill/CapturePill';
import { apiClient } from '@/lib/api/client';
import type { FocusSnapshot } from '@/lib/api/types';
import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSession';
import { usePlatform } from '@/platform/PlatformContext';
/**
* Floating dictate surface shown in a separate transparent Tauri window.
@@ -22,6 +23,9 @@ import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSessi
* ``dictate:hide`` so Rust tucks the window away.
*/
export function DictateWindow() {
const platform = usePlatform();
const isTauri = platform.metadata.isTauri;
// Force the host document chrome to be transparent so the Tauri window
// takes on the pill's own shape.
useEffect(() => {
@@ -70,6 +74,7 @@ export function DictateWindow() {
sessionRef.current = session;
useEffect(() => {
if (!isTauri) return;
let disposed = false;
const unlistens: UnlistenFn[] = [];
const registrations = [
@@ -98,7 +103,7 @@ export function DictateWindow() {
disposed = true;
for (const unlisten of unlistens) unlisten();
};
}, []);
}, [isTauri]);
useEffect(() => {
if (micWarm) void session.prewarm();
@@ -157,9 +162,7 @@ export function DictateWindow() {
audio.onplaying = () => {
emit('dictate:show').catch(() => {});
setSpeaking((prev) =>
prev && prev.generationId === generationId
? { ...prev, startedAt: Date.now() }
: prev,
prev && prev.generationId === generationId ? { ...prev, startedAt: Date.now() } : prev,
);
setSpeakElapsed(0);
};
@@ -171,6 +174,7 @@ export function DictateWindow() {
};
useEffect(() => {
if (!isTauri) return;
const unlistens: Promise<UnlistenFn>[] = [];
// Rust emits the SSE payload as a JSON *string* (not a parsed object);
@@ -265,7 +269,7 @@ export function DictateWindow() {
for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
dismissSpeak();
};
}, []);
}, [isTauri]);
// Advance the pill's elapsed-time label while audio is playing. Paused
// during the pre-playback generation window (startedAt is null) so the
@@ -139,7 +139,7 @@ export function EngineModelSelector({ form, compact, selectedProfile }: EngineMo
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent side={compact ? 'top' : undefined}>
<SelectContent>
{availableOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className={itemClass}>
{opt.label}
@@ -0,0 +1,185 @@
import { HttpResponse, http } from 'msw';
import { expect, it } from 'vitest';
import type { VoiceProfileResponse } from '@/lib/api/types';
import { useGenerationStore } from '@/stores/generationStore';
import { useUIStore } from '@/stores/uiStore';
import { buildGeneration, buildModelStatus, buildProfile } from '@/test/msw/fixtures';
import {
captureHandlers,
effectsHandlers,
historyHandlers,
modelHandlers,
profileHandlers,
settingsHandlers,
storyHandlers,
taskHandlers,
} from '@/test/msw/handlers';
import { worker } from '@/test/msw/worker';
import { renderRoute } from '@/test/render';
import { sseController } from '@/test/sse';
/**
* FloatingGenerateBox calls useMatchRoute, so it needs router context; the
* SSE completion loop (useGenerationProgress) lives in the router's root
* layout. Mounting the index route exercises the real wiring for both.
* History handlers are registered per test so requests can be counted.
*/
function stubAppRequests(profiles: VoiceProfileResponse[]) {
worker.use(
...profileHandlers(profiles),
...captureHandlers([]),
...settingsHandlers(),
...modelHandlers([buildModelStatus()]),
...storyHandlers([]),
...effectsHandlers([]),
...taskHandlers(),
);
}
it('renders the generate box wired to the selected profile', async () => {
const profile = buildProfile({ name: 'Ada Lovelace' });
stubAppRequests([profile]);
worker.use(...historyHandlers([]));
useUIStore.getState().setSelectedProfileId(profile.id);
const screen = await renderRoute('/');
await expect
.element(screen.getByPlaceholder('Generate speech using Ada Lovelace…'))
.toBeVisible();
await expect.element(screen.getByRole('button', { name: 'Generate speech' })).toBeEnabled();
expect(useUIStore.getState().selectedProfileId).toBe(profile.id);
});
it('posts to /generate on submit and tracks the pending generation', async () => {
const profile = buildProfile({ name: 'Ada Lovelace' });
const generation = buildGeneration({
profile_id: profile.id,
status: 'generating',
audio_path: undefined,
});
const generateBodies: unknown[] = [];
const sse = sseController();
stubAppRequests([profile]);
worker.use(
...historyHandlers([]),
http.post('*/generate', async ({ request }) => {
generateBodies.push(await request.json());
return HttpResponse.json(generation);
}),
http.get('*/generate/:id/status', () => sse.response()),
);
useUIStore.getState().setSelectedProfileId(profile.id);
const screen = await renderRoute('/');
const input = screen.getByPlaceholder('Generate speech using Ada Lovelace…');
await input.fill('Hello from the browser test');
await screen.getByRole('button', { name: 'Generate speech' }).click();
await expect.poll(() => generateBodies.length).toBe(1);
expect(generateBodies[0]).toMatchObject({
profile_id: profile.id,
text: 'Hello from the browser test',
language: 'en',
engine: 'qwen',
});
await expect
.poll(() => useGenerationStore.getState().pendingGenerationIds.has(generation.id))
.toBe(true);
// The form resets as soon as the request is accepted.
await expect.element(input).toHaveValue('');
sse.close();
});
it('clears pending state and refetches history when SSE reports completion', async () => {
const profile = buildProfile({ name: 'Ada Lovelace' });
const generation = buildGeneration({
profile_id: profile.id,
status: 'generating',
audio_path: undefined,
});
const sse = sseController();
let sseConnections = 0;
let historyGets = 0;
stubAppRequests([profile]);
worker.use(
http.get('*/history', () => {
historyGets += 1;
return HttpResponse.json({ items: [], total: 0 });
}),
http.post('*/generate', () => HttpResponse.json(generation)),
http.get('*/generate/:id/status', () => {
sseConnections += 1;
return sse.response();
}),
// Autoplay is off via settingsHandlers, but keep audio stubbed so a
// completion-triggered player fetch could never fail the run loudly.
http.get(
'*/audio/:id',
() =>
new HttpResponse(new Blob([new Uint8Array(64)]), {
headers: { 'Content-Type': 'audio/wav' },
}),
),
);
useUIStore.getState().setSelectedProfileId(profile.id);
const screen = await renderRoute('/');
await screen.getByPlaceholder('Generate speech using Ada Lovelace…').fill('Progress please');
await screen.getByRole('button', { name: 'Generate speech' }).click();
await expect
.poll(() => useGenerationStore.getState().pendingGenerationIds.has(generation.id))
.toBe(true);
await expect.poll(() => sseConnections).toBe(1);
// Initial mount fetch + post-submit invalidation — wait for both so the
// final count increase can only come from the SSE completion refetch.
await expect.poll(() => historyGets).toBe(2);
sse.push({ data: { id: generation.id, status: 'generating' } });
sse.push({ data: { id: generation.id, status: 'completed', duration: 1.5 } });
await expect.poll(() => useGenerationStore.getState().pendingGenerationIds.size).toBe(0);
await expect.poll(() => historyGets).toBe(3);
sse.close();
});
it('disables the input and generate button when no profile is selected', async () => {
stubAppRequests([]);
worker.use(...historyHandlers([]));
const screen = await renderRoute('/');
await expect
.element(screen.getByRole('button', { name: 'Select a voice profile first' }))
.toBeDisabled();
await expect.element(screen.getByPlaceholder('Select a voice profile above…')).toBeDisabled();
});
it('does not post to /generate when the text is empty', async () => {
const profile = buildProfile({ name: 'Ada Lovelace' });
let generateCalls = 0;
stubAppRequests([profile]);
worker.use(
...historyHandlers([]),
http.post('*/generate', () => {
generateCalls += 1;
return HttpResponse.json(buildGeneration());
}),
);
useUIStore.getState().setSelectedProfileId(profile.id);
const screen = await renderRoute('/');
const button = screen.getByRole('button', { name: 'Generate speech' });
await expect.element(button).toBeEnabled();
await button.click();
// Validation rejects empty text before any request is made — give a
// would-be submission ample time to surface, then assert it never did.
await new Promise((resolve) => setTimeout(resolve, 300));
expect(generateCalls).toBe(0);
expect(useGenerationStore.getState().pendingGenerationIds.size).toBe(0);
});
@@ -555,7 +555,7 @@ export function FloatingGenerateBox({
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all w-full">
<SelectValue placeholder={t('generation.voiceSelector.placeholder')} />
</SelectTrigger>
<SelectContent side="top">
<SelectContent>
{profiles?.map((profile) => (
<SelectItem key={profile.id} value={profile.id} className="text-xs">
{profile.name}
@@ -582,7 +582,7 @@ export function FloatingGenerateBox({
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent side="top">
<SelectContent>
{engineLangs.map((lang) => (
<SelectItem key={lang.value} value={lang.value} className="text-xs">
{lang.label}
@@ -610,7 +610,7 @@ export function FloatingGenerateBox({
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
<SelectValue placeholder={t('generation.effects.none')} />
</SelectTrigger>
<SelectContent side="top">
<SelectContent>
<SelectItem value="none" className="text-xs">
{t('generation.effects.none')}
</SelectItem>
@@ -0,0 +1,133 @@
import { HttpResponse, http } from 'msw';
import { expect, it, vi } from 'vitest';
import { HistoryTable } from '@/components/History/HistoryTable';
import { usePlayerStore } from '@/stores/playerStore';
import { buildHistoryItem } from '@/test/msw/fixtures';
import { historyHandlers } from '@/test/msw/handlers';
import { worker } from '@/test/msw/worker';
import { renderWithProviders } from '@/test/render';
it('renders history rows with profile names and transcripts', async () => {
const ada = buildHistoryItem({
profile_name: 'Ada Lovelace',
text: 'The analytical engine speaks.',
});
const grace = buildHistoryItem({
profile_name: 'Grace Hopper',
text: 'A compiler for the spoken word.',
});
worker.use(...historyHandlers([ada, grace]));
const screen = await renderWithProviders(<HistoryTable />);
await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
await expect.element(screen.getByText('Grace Hopper')).toBeVisible();
await expect
.element(screen.getByRole('textbox', { name: /Transcript for sample from Ada Lovelace/ }))
.toHaveValue('The analytical engine speaks.');
await expect
.element(screen.getByRole('textbox', { name: /Transcript for sample from Grace Hopper/ }))
.toHaveValue('A compiler for the spoken word.');
});
it('shows the empty state when there is no history', async () => {
worker.use(...historyHandlers([]));
const screen = await renderWithProviders(<HistoryTable />);
await expect.element(screen.getByText('No voice generations', { exact: false })).toBeVisible();
});
it('loads a clicked row into the player store with auto-play intent', async () => {
const item = buildHistoryItem({ profile_name: 'Ada Lovelace', text: 'Play me back.' });
worker.use(...historyHandlers([item]));
const screen = await renderWithProviders(<HistoryTable />);
// Click the profile-name cell — the row's mousedown handler ignores clicks
// that land on the transcript textarea.
await screen.getByText('Ada Lovelace').click();
await expect.poll(() => usePlayerStore.getState().audioId).toBe(item.id);
const player = usePlayerStore.getState();
expect(player.audioUrl).toContain(`/audio/${item.id}`);
expect(player.profileId).toBe(item.profile_id);
expect(player.shouldAutoPlay).toBe(true);
// isPlaying flips only once the AudioPlayer (not mounted here) starts playback.
expect(player.isPlaying).toBe(false);
});
it('toggles favorite via POST and reflects the refetched state', async () => {
const item = buildHistoryItem({ profile_name: 'Ada Lovelace' });
let favorited = false;
const favoriteRequests: string[] = [];
worker.use(
http.get('*/history', () =>
HttpResponse.json({ items: [{ ...item, is_favorited: favorited }], total: 1 }),
),
http.post('*/history/:id/favorite', ({ params }) => {
favoriteRequests.push(params.id as string);
favorited = true;
return HttpResponse.json({ is_favorited: favorited });
}),
);
const screen = await renderWithProviders(<HistoryTable />);
await screen.getByRole('button', { name: 'Favorite' }).click();
await expect.poll(() => favoriteRequests).toEqual([item.id]);
// History was invalidated and refetched — the star now reads as favorited.
await expect.element(screen.getByRole('button', { name: 'Unfavorite' })).toBeVisible();
});
it('deletes a generation after confirming the dialog', async () => {
const item = buildHistoryItem({ profile_name: 'Ada Lovelace' });
let items = [item];
const deleteRequests: string[] = [];
worker.use(
http.get('*/history', () => HttpResponse.json({ items, total: items.length })),
http.delete('*/history/:id', ({ params }) => {
deleteRequests.push(params.id as string);
items = items.filter((i) => i.id !== params.id);
return HttpResponse.json({ status: 'deleted' });
}),
);
const screen = await renderWithProviders(<HistoryTable />);
await screen.getByRole('button', { name: 'Actions' }).click();
await screen.getByRole('menuitem', { name: 'Delete' }).click();
await expect.element(screen.getByText('Delete Generation')).toBeVisible();
await screen.getByRole('button', { name: 'Delete' }).click();
await expect.poll(() => deleteRequests).toEqual([item.id]);
// The refetched (now empty) list replaces the row.
await expect.element(screen.getByText('No voice generations', { exact: false })).toBeVisible();
});
it('exports audio through platform.filesystem.saveFile', async () => {
const item = buildHistoryItem({ profile_name: 'Ada Lovelace', text: 'Export me please' });
worker.use(
...historyHandlers([item]),
http.get(
'*/history/:id/export-audio',
() =>
new HttpResponse(new Blob([new Uint8Array(64)]), {
headers: { 'Content-Type': 'audio/wav' },
}),
),
);
const screen = await renderWithProviders(<HistoryTable />);
await screen.getByRole('button', { name: 'Actions' }).click();
await screen.getByRole('menuitem', { name: 'Export Audio' }).click();
const saveFile = vi.mocked(screen.platform.filesystem.saveFile);
await expect.poll(() => saveFile.mock.calls.length).toBe(1);
const [filename, blob, filters] = saveFile.mock.calls[0];
expect(filename).toBe('export-me-please.wav');
expect(blob).toBeInstanceOf(Blob);
expect(filters).toEqual([{ name: 'Audio File', extensions: ['wav'] }]);
});
@@ -0,0 +1,15 @@
import { expect, it } from 'vitest';
import { AboutPage } from '@/components/ServerTab/AboutPage';
import { createMockPlatform } from '@/test/mockPlatform';
import { renderWithProviders } from '@/test/render';
it('renders and shows the platform version', async () => {
const platform = createMockPlatform({
metadata: { getVersion: async () => '9.9.9-test', isTauri: false },
});
const screen = await renderWithProviders(<AboutPage />, { platform });
await expect.element(screen.getByAltText('Voicebox')).toBeVisible();
await expect.element(screen.getByText('9.9.9-test', { exact: false })).toBeVisible();
});
@@ -38,7 +38,6 @@ import {
useUpdateStoryItemVolume,
} from '@/lib/hooks/useStories';
import { cn } from '@/lib/utils/cn';
import { computeTrimValues } from '@/lib/utils/trim';
import { useGenerationStore } from '@/stores/generationStore';
import { useStoryStore } from '@/stores/storyStore';
@@ -601,17 +600,41 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const deltaMs = pixelsToMs(deltaX); // Signed delta in milliseconds
const { item, initialTrimStart, initialTrimEnd } = trimStartItemRef.current;
const newTrimValues = computeTrimValues(
trimSide,
deltaMs,
initialTrimStart,
initialTrimEnd,
item.duration * 1000,
);
if (!newTrimValues) return;
const originalDurationMs = item.duration * 1000;
let newTrimStart = initialTrimStart;
let newTrimEnd = initialTrimEnd;
if (trimSide === 'start') {
// Moving right increases trim_start (trims more from start)
// Moving left decreases trim_start (restores from start)
newTrimStart = Math.round(
Math.max(
0,
Math.min(initialTrimStart + deltaMs, originalDurationMs - initialTrimEnd - 100),
),
);
} else {
// Moving right decreases trim_end (restores from end)
// Moving left increases trim_end (trims more from end)
newTrimEnd = Math.round(
Math.max(
0,
Math.min(initialTrimEnd - deltaMs, originalDurationMs - initialTrimStart - 100),
),
);
}
// Validate that we don't exceed duration
if (newTrimStart + newTrimEnd >= originalDurationMs - 100) {
return; // Don't allow trimming to less than 100ms
}
// Update temporary trim values for visual feedback
setTempTrimValues(newTrimValues);
setTempTrimValues({
trim_start_ms: newTrimStart,
trim_end_ms: newTrimEnd,
});
},
[trimmingItem, trimSide, trimStartX, pixelsToMs],
);
-61
View File
@@ -1,61 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { usePlatform } from '@/platform/PlatformContext';
import type { UpdateStatus } from '@/platform/types';
// Re-export UpdateStatus for backwards compatibility
export type { UpdateStatus };
interface UseAutoUpdaterOptions {
checkOnMount?: boolean;
showToast?: boolean;
}
export function useAutoUpdater(options: boolean | UseAutoUpdaterOptions = false) {
const { checkOnMount } =
typeof options === 'boolean' ? { checkOnMount: options } : { checkOnMount: options.checkOnMount ?? false };
const platform = usePlatform();
const [status, setStatus] = useState<UpdateStatus>(platform.updater.getStatus());
const hasCheckedRef = useRef(false);
// Subscribe to updater status changes
useEffect(() => {
const unsubscribe = platform.updater.subscribe((newStatus) => {
setStatus(newStatus);
});
return unsubscribe;
// Empty dependency array - platform is stable from context
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platform.updater.subscribe]);
const checkForUpdates = useCallback(async () => {
await platform.updater.checkForUpdates();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platform.updater.checkForUpdates]);
const downloadAndInstall = useCallback(async () => {
await platform.updater.downloadAndInstall();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platform.updater.downloadAndInstall]);
const restartAndInstall = useCallback(async () => {
await platform.updater.restartAndInstall();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platform.updater.restartAndInstall]);
useEffect(() => {
if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) {
hasCheckedRef.current = true;
checkForUpdates().catch((error) => {
console.error('Auto update check failed:', error);
});
}
}, [checkOnMount, checkForUpdates, platform.metadata.isTauri]);
return {
status,
checkForUpdates,
downloadAndInstall,
restartAndInstall,
};
}
+10 -1
View File
@@ -2,19 +2,25 @@ import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import { initReactI18next } from 'react-i18next';
import en from './locales/en/translation.json';
import es from './locales/es/translation.json';
import fr from './locales/fr/translation.json';
import it from './locales/it/translation.json';
import ja from './locales/ja/translation.json';
import ko from './locales/ko/translation.json';
import ptBR from './locales/pt-BR/translation.json';
import zhCN from './locales/zh-CN/translation.json';
import zhTW from './locales/zh-TW/translation.json';
import fr from './locales/fr/translation.json';
export const SUPPORTED_LANGUAGES = [
{ code: 'en', label: 'English' },
{ code: 'es', label: 'Español' },
{ code: 'pt-BR', label: 'Português (Brasil)' },
{ code: 'ja', label: '日本語' },
{ code: 'ko', label: '한국어' },
{ code: 'zh-CN', label: '简体中文' },
{ code: 'zh-TW', label: '繁體中文' },
{ code: 'fr', label: 'Français' },
{ code: 'it', label: 'Italiano' },
] as const;
export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]['code'];
@@ -25,11 +31,14 @@ i18n
.init({
resources: {
en: { translation: en },
es: { translation: es },
'pt-BR': { translation: ptBR },
ja: { translation: ja },
ko: { translation: ko },
'zh-CN': { translation: zhCN },
'zh-TW': { translation: zhTW },
fr: { translation: fr },
it: { translation: it },
},
fallbackLng: 'en',
supportedLngs: SUPPORTED_LANGUAGES.map((l) => l.code),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -1,5 +1,4 @@
import type { LanguageCode } from '@/lib/constants/languages';
import { formatErrorDetail } from '@/lib/api/errors';
import { useServerStore } from '@/stores/serverStore';
import type {
ActiveTasksResponse,
@@ -56,6 +55,21 @@ import type {
CloudStatus,
} from './types';
function formatErrorDetail(detail: unknown, fallback: string): string {
if (typeof detail === 'string') return detail;
if (Array.isArray(detail)) {
return detail
.map((e: Record<string, unknown>) => e.msg || e.message || JSON.stringify(e))
.join('; ');
}
if (detail && typeof detail === 'object') {
const obj = detail as Record<string, unknown>;
if (typeof obj.message === 'string') return obj.message;
return JSON.stringify(detail);
}
return fallback;
}
class ApiClient {
private getBaseUrl(): string {
const serverUrl = useServerStore.getState().serverUrl;
-58
View File
@@ -1,58 +0,0 @@
import { describe, expect, test } from 'bun:test';
import { formatErrorDetail } from './errors';
const FALLBACK = 'HTTP error! status: 500';
describe('formatErrorDetail', () => {
test('returns string details as-is', () => {
expect(formatErrorDetail('Profile not found', FALLBACK)).toBe('Profile not found');
});
test('returns empty string details as-is (not the fallback)', () => {
expect(formatErrorDetail('', FALLBACK)).toBe('');
});
test('joins FastAPI validation error arrays on msg', () => {
const detail = [
{ loc: ['body', 'text'], msg: 'field required', type: 'value_error.missing' },
{ loc: ['body', 'seed'], msg: 'value is not a valid integer', type: 'type_error.integer' },
];
expect(formatErrorDetail(detail, FALLBACK)).toBe(
'field required; value is not a valid integer',
);
});
test('falls back to message key within array entries', () => {
expect(formatErrorDetail([{ message: 'boom' }], FALLBACK)).toBe('boom');
});
test('stringifies array entries with neither msg nor message', () => {
expect(formatErrorDetail([{ code: 42 }], FALLBACK)).toBe('{"code":42}');
});
test('returns empty string for an empty array', () => {
expect(formatErrorDetail([], FALLBACK)).toBe('');
});
test('uses message property of object details', () => {
expect(formatErrorDetail({ message: 'engine offline' }, FALLBACK)).toBe('engine offline');
});
test('stringifies objects without a string message', () => {
expect(formatErrorDetail({ message: 42, hint: 'x' }, FALLBACK)).toBe(
'{"message":42,"hint":"x"}',
);
expect(formatErrorDetail({ error: 'nested' }, FALLBACK)).toBe('{"error":"nested"}');
});
test('falls back for null, undefined, and primitives', () => {
expect(formatErrorDetail(null, FALLBACK)).toBe(FALLBACK);
expect(formatErrorDetail(undefined, FALLBACK)).toBe(FALLBACK);
expect(formatErrorDetail(404, FALLBACK)).toBe(FALLBACK);
expect(formatErrorDetail(true, FALLBACK)).toBe(FALLBACK);
});
test('preserves unicode in messages', () => {
expect(formatErrorDetail('模型未加载 🎙️', FALLBACK)).toBe('模型未加载 🎙️');
});
});
-21
View File
@@ -1,21 +0,0 @@
/**
* Normalizes a FastAPI error `detail` payload into a human-readable message.
*
* FastAPI returns `detail` as a plain string for HTTPException, an array of
* validation error objects for 422 responses, or an arbitrary object for
* custom handlers. Anything unrecognized falls back to the provided default.
*/
export function formatErrorDetail(detail: unknown, fallback: string): string {
if (typeof detail === 'string') return detail;
if (Array.isArray(detail)) {
return detail
.map((e: Record<string, unknown>) => e.msg || e.message || JSON.stringify(e))
.join('; ');
}
if (detail && typeof detail === 'object') {
const obj = detail as Record<string, unknown>;
if (typeof obj.message === 'string') return obj.message;
return JSON.stringify(detail);
}
return fallback;
}
+4 -1
View File
@@ -291,7 +291,10 @@ export interface CudaDownloadProgress {
export interface CudaStatus {
available: boolean; // CUDA binary exists on disk
active: boolean; // Currently running the CUDA binary
binary_path?: string;
binary_path: string | null;
cuda_libs_version: string | null;
download_supported: boolean; // Platform has a matching release asset
unsupported_reason: string | null;
downloading: boolean; // Download in progress
download_progress?: CudaDownloadProgress;
}
-70
View File
@@ -1,70 +0,0 @@
import { describe, expect, test } from 'bun:test';
import {
ALL_LANGUAGES,
ENGINE_LANGUAGES,
LANGUAGE_CODES,
LANGUAGE_OPTIONS,
getLanguageOptionsForEngine,
} from './languages';
describe('ENGINE_LANGUAGES', () => {
test('every engine maps only to codes defined in ALL_LANGUAGES', () => {
for (const [engine, codes] of Object.entries(ENGINE_LANGUAGES)) {
for (const code of codes) {
expect(ALL_LANGUAGES[code], `${engine} references unknown code "${code}"`).toBeDefined();
}
}
});
test('no engine lists a language twice', () => {
for (const [engine, codes] of Object.entries(ENGINE_LANGUAGES)) {
expect(new Set(codes).size, `${engine} has duplicate codes`).toBe(codes.length);
}
});
test('every engine supports at least English', () => {
for (const codes of Object.values(ENGINE_LANGUAGES)) {
expect(codes).toContain('en');
}
});
test('English-only engines list exactly one language', () => {
expect(ENGINE_LANGUAGES.luxtts).toEqual(['en']);
expect(ENGINE_LANGUAGES.chatterbox_turbo).toEqual(['en']);
});
test('qwen and qwen_custom_voice support the same languages', () => {
expect(ENGINE_LANGUAGES.qwen_custom_voice).toEqual(ENGINE_LANGUAGES.qwen);
});
});
describe('getLanguageOptionsForEngine', () => {
test('builds value/label pairs from ALL_LANGUAGES', () => {
expect(getLanguageOptionsForEngine('luxtts')).toEqual([{ value: 'en', label: 'English' }]);
});
test('preserves the engine declaration order', () => {
const values = getLanguageOptionsForEngine('qwen').map((o) => o.value);
expect(values).toEqual([...ENGINE_LANGUAGES.qwen]);
});
test('falls back to qwen languages for unknown engines', () => {
expect(getLanguageOptionsForEngine('does-not-exist')).toEqual(
getLanguageOptionsForEngine('qwen'),
);
});
});
describe('language option exports', () => {
test('LANGUAGE_CODES covers every ALL_LANGUAGES key exactly once', () => {
const codes: string[] = [...LANGUAGE_CODES].sort();
expect(codes).toEqual(Object.keys(ALL_LANGUAGES).sort());
expect(new Set(LANGUAGE_CODES).size).toBe(LANGUAGE_CODES.length);
});
test('LANGUAGE_OPTIONS labels match ALL_LANGUAGES', () => {
for (const option of LANGUAGE_OPTIONS) {
expect(option.label).toBe(ALL_LANGUAGES[option.value]);
}
});
});
+4 -8
View File
@@ -47,14 +47,12 @@ export function useExportGeneration() {
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
const blob = await apiClient.exportGeneration(generationId);
// Create safe filename from text. Append a short id so exports of
// similarly-worded generations don't collide on the same filename
// (the first 30 chars are frequently identical).
// Create safe filename from text
const safeText = text
.substring(0, 30)
.replace(/[^a-z0-9]/gi, '-')
.toLowerCase();
const filename = `generation-${safeText}-${generationId.substring(0, 8)}.voicebox.zip`;
const filename = `generation-${safeText}.voicebox.zip`;
await platform.filesystem.saveFile(filename, blob, [
{
@@ -75,14 +73,12 @@ export function useExportGenerationAudio() {
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
const blob = await apiClient.exportGenerationAudio(generationId);
// Create safe filename from text. Append a short id so exports of
// similarly-worded generations don't collide on the same filename
// (the first 30 chars are frequently identical).
// Create safe filename from text
const safeText = text
.substring(0, 30)
.replace(/[^a-z0-9]/gi, '-')
.toLowerCase();
const filename = `${safeText}-${generationId.substring(0, 8)}.wav`;
const filename = `${safeText}.wav`;
await platform.filesystem.saveFile(filename, blob, [
{
-71
View File
@@ -1,71 +0,0 @@
import { describe, expect, test } from 'bun:test';
import { formatDuration, formatEngineName, formatFileSize } from './format';
describe('formatDuration', () => {
test('formats zero', () => {
expect(formatDuration(0)).toBe('0:00');
});
test('pads single-digit seconds', () => {
expect(formatDuration(65)).toBe('1:05');
});
test('handles the minute boundary', () => {
expect(formatDuration(59)).toBe('0:59');
expect(formatDuration(60)).toBe('1:00');
});
test('floors fractional seconds', () => {
expect(formatDuration(89.9)).toBe('1:29');
});
test('does not roll minutes into hours', () => {
expect(formatDuration(3661)).toBe('61:01');
});
});
describe('formatFileSize', () => {
test('special-cases zero', () => {
expect(formatFileSize(0)).toBe('0 Bytes');
});
test('formats bytes below 1 KB', () => {
expect(formatFileSize(512)).toBe('512 Bytes');
expect(formatFileSize(1023)).toBe('1023 Bytes');
});
test('formats KB, MB, and GB boundaries', () => {
expect(formatFileSize(1024)).toBe('1 KB');
expect(formatFileSize(1024 ** 2)).toBe('1 MB');
expect(formatFileSize(1024 ** 3)).toBe('1 GB');
});
test('rounds to two decimal places', () => {
expect(formatFileSize(1536)).toBe('1.5 KB');
expect(formatFileSize(2_684_354_560)).toBe('2.5 GB');
expect(formatFileSize(1_234_567)).toBe('1.18 MB');
});
});
describe('formatEngineName', () => {
test('maps known engines to display names', () => {
expect(formatEngineName('luxtts')).toBe('LuxTTS');
expect(formatEngineName('chatterbox')).toBe('Chatterbox');
expect(formatEngineName('chatterbox_turbo')).toBe('Chatterbox Turbo');
});
test('defaults to Qwen when engine is undefined', () => {
expect(formatEngineName()).toBe('Qwen');
expect(formatEngineName(undefined, '1.7B')).toBe('Qwen');
});
test('appends the model size for qwen only', () => {
expect(formatEngineName('qwen', '1.7B')).toBe('Qwen 1.7B');
expect(formatEngineName('qwen')).toBe('Qwen');
expect(formatEngineName('luxtts', '1.7B')).toBe('LuxTTS');
});
test('passes unknown engines through verbatim', () => {
expect(formatEngineName('kokoro')).toBe('kokoro');
});
});
+17 -16
View File
@@ -1,5 +1,5 @@
import { formatDistance } from 'date-fns';
import { ja, zhCN, zhTW, fr } from 'date-fns/locale';
import { es, fr, ja, zhCN, zhTW } from 'date-fns/locale';
import i18n from '@/i18n';
export function formatDuration(seconds: number): string {
@@ -10,6 +10,8 @@ export function formatDuration(seconds: number): string {
function getDateLocale() {
switch (i18n.language) {
case 'es':
return es;
case 'ja':
return ja;
case 'zh-CN':
@@ -23,28 +25,27 @@ function getDateLocale() {
}
}
// Backend timestamps are naive UTC — append `Z` so JS doesn't parse a
// timezone-less date-time string as local time.
function parseServerDate(date: string | Date): Date {
if (typeof date !== 'string') {
return date;
}
const dateStr = date.trim();
if (!dateStr.includes('Z') && !dateStr.match(/[+-]\d{2}:\d{2}$/)) {
return new Date(`${dateStr}Z`);
}
return new Date(dateStr);
}
export function formatDate(date: string | Date): string {
return formatDistance(parseServerDate(date), new Date(), {
let dateObj: Date;
if (typeof date === 'string') {
const dateStr = date.trim();
if (!dateStr.includes('Z') && !dateStr.match(/[+-]\d{2}:\d{2}$/)) {
dateObj = new Date(`${dateStr}Z`);
} else {
dateObj = new Date(dateStr);
}
} else {
dateObj = date;
}
return formatDistance(dateObj, new Date(), {
addSuffix: true,
locale: getDateLocale(),
}).replace(/^about /i, '');
}
export function formatAbsoluteDate(date: string | Date): string {
const dateObj = parseServerDate(date);
const dateObj = typeof date === 'string' ? new Date(date) : date;
return dateObj.toLocaleString(i18n.language, {
month: 'short',
day: 'numeric',
-69
View File
@@ -1,69 +0,0 @@
import { describe, expect, test } from 'bun:test';
import { parseChangelog } from './parseChangelog';
const SAMPLE = `# Changelog
All notable changes to this project will be documented in this file.
## [0.5.0] - 2026-06-01
### Added
- Story track editor
- Cloud login
## [0.4.1]
### Fixed
- Trim clamping
## [0.4.0] - 2026-04-15
Initial public release.
[0.5.0]: https://example.com/compare/v0.4.1...v0.5.0
[0.4.1]: https://example.com/compare/v0.4.0...v0.4.1
`;
describe('parseChangelog', () => {
test('splits entries on version headings', () => {
const entries = parseChangelog(SAMPLE);
expect(entries.map((e) => e.version)).toEqual(['0.5.0', '0.4.1', '0.4.0']);
});
test('extracts the date when present and null otherwise', () => {
const entries = parseChangelog(SAMPLE);
expect(entries[0].date).toBe('2026-06-01');
expect(entries[1].date).toBeNull();
});
test('keeps the markdown body between headings', () => {
const entries = parseChangelog(SAMPLE);
expect(entries[0].body).toBe('### Added\n\n- Story track editor\n- Cloud login');
expect(entries[2].body).toBe('Initial public release.');
});
test('strips trailing link reference definitions from the last body', () => {
const entries = parseChangelog(SAMPLE);
expect(entries[2].body).toBe('Initial public release.');
expect(entries[2].body).not.toContain('example.com');
});
test('returns an empty array when no headings match', () => {
expect(parseChangelog('')).toEqual([]);
expect(parseChangelog('# Changelog\n\nNothing yet.')).toEqual([]);
});
test('handles a heading with an empty body', () => {
const entries = parseChangelog('## [1.0.0] - 2026-01-01\n');
expect(entries).toEqual([{ version: '1.0.0', date: '2026-01-01', body: '' }]);
});
test('accepts non-semver headings like Unreleased', () => {
const entries = parseChangelog('## [Unreleased]\n\n### Added\n\n- WIP\n');
expect(entries[0].version).toBe('Unreleased');
expect(entries[0].date).toBeNull();
expect(entries[0].body).toBe('### Added\n\n- WIP');
});
});
-106
View File
@@ -1,106 +0,0 @@
import { describe, expect, test } from 'bun:test';
import { MIN_CLIP_DURATION_MS, computeTrimValues } from './trim';
// A 10-second clip with no existing trims unless stated otherwise.
const DURATION = 10_000;
describe('computeTrimValues', () => {
describe('start handle', () => {
test('dragging right trims from the start', () => {
expect(computeTrimValues('start', 500, 0, 0, DURATION)).toEqual({
trim_start_ms: 500,
trim_end_ms: 0,
});
});
test('dragging left restores previously trimmed audio', () => {
expect(computeTrimValues('start', -300, 1000, 0, DURATION)).toEqual({
trim_start_ms: 700,
trim_end_ms: 0,
});
});
test('clamps at zero when restoring past the clip start', () => {
expect(computeTrimValues('start', -5000, 1000, 0, DURATION)).toEqual({
trim_start_ms: 0,
trim_end_ms: 0,
});
});
test('never trims below the minimum clip duration', () => {
const result = computeTrimValues('start', 99_999, 0, 2000, DURATION);
// Clamp lands exactly on the minimum, which the guard rejects.
expect(result).toBeNull();
});
test('rounds fractional millisecond deltas', () => {
expect(computeTrimValues('start', 100.6, 0, 0, DURATION)).toEqual({
trim_start_ms: 101,
trim_end_ms: 0,
});
});
test('preserves the untouched end trim', () => {
expect(computeTrimValues('start', 250, 0, 400, DURATION)).toEqual({
trim_start_ms: 250,
trim_end_ms: 400,
});
});
});
describe('end handle', () => {
test('dragging left trims from the end', () => {
expect(computeTrimValues('end', -500, 0, 0, DURATION)).toEqual({
trim_start_ms: 0,
trim_end_ms: 500,
});
});
test('dragging right restores previously trimmed audio', () => {
expect(computeTrimValues('end', 300, 0, 1000, DURATION)).toEqual({
trim_start_ms: 0,
trim_end_ms: 700,
});
});
test('clamps at zero when restoring past the clip end', () => {
expect(computeTrimValues('end', 5000, 0, 1000, DURATION)).toEqual({
trim_start_ms: 0,
trim_end_ms: 0,
});
});
test('never trims below the minimum clip duration', () => {
expect(computeTrimValues('end', -99_999, 3000, 0, DURATION)).toBeNull();
});
});
describe('minimum duration guard', () => {
test('rejects drags that leave less than the minimum audible clip', () => {
// 9.5s already trimmed; taking 450ms more leaves only 50ms.
expect(computeTrimValues('start', 450, 5000, 4500, DURATION)).toBeNull();
});
test('allows a drag that leaves just over the minimum', () => {
expect(computeTrimValues('start', 399, 5000, 4500, DURATION)).toEqual({
trim_start_ms: 5399,
trim_end_ms: 4500,
});
});
test('boundary: exactly the minimum remaining is rejected', () => {
// trim_start + trim_end === duration - MIN_CLIP_DURATION_MS
expect(
computeTrimValues('start', 400, 5000, 4500, DURATION)?.trim_start_ms ?? null,
).toBeNull();
expect(MIN_CLIP_DURATION_MS).toBe(100);
});
});
test('zero delta is a no-op that returns the initial trims', () => {
expect(computeTrimValues('start', 0, 1200, 800, DURATION)).toEqual({
trim_start_ms: 1200,
trim_end_ms: 800,
});
});
});
-57
View File
@@ -1,57 +0,0 @@
import type { StoryItemTrim } from '@/lib/api/types';
/** Clips are never allowed to shrink below this effective duration. */
export const MIN_CLIP_DURATION_MS = 100;
/**
* Computes new trim values for a clip while a trim handle is being dragged.
*
* `deltaMs` is the signed drag distance converted to milliseconds. Dragging
* the start handle right increases `trim_start_ms` (trims more from the
* start); dragging it left restores. The end handle mirrors this for
* `trim_end_ms`. Both values are clamped so the clip keeps at least
* MIN_CLIP_DURATION_MS of audible content.
*
* Returns null when the drag would leave less than the minimum duration.
*/
export function computeTrimValues(
side: 'start' | 'end',
deltaMs: number,
initialTrimStart: number,
initialTrimEnd: number,
originalDurationMs: number,
): StoryItemTrim | null {
let newTrimStart = initialTrimStart;
let newTrimEnd = initialTrimEnd;
if (side === 'start') {
newTrimStart = Math.round(
Math.max(
0,
Math.min(
initialTrimStart + deltaMs,
originalDurationMs - initialTrimEnd - MIN_CLIP_DURATION_MS,
),
),
);
} else {
newTrimEnd = Math.round(
Math.max(
0,
Math.min(
initialTrimEnd - deltaMs,
originalDurationMs - initialTrimStart - MIN_CLIP_DURATION_MS,
),
),
);
}
if (newTrimStart + newTrimEnd >= originalDurationMs - MIN_CLIP_DURATION_MS) {
return null;
}
return {
trim_start_ms: newTrimStart,
trim_end_ms: newTrimEnd,
};
}
-17
View File
@@ -1,17 +0,0 @@
import { QueryClientProvider } from '@tanstack/react-query';
// import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './i18n';
import './index.css';
import { queryClient } from './lib/queryClient';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
{/* <ReactQueryDevtools initialIsOpen={false} /> */}
</QueryClientProvider>
</React.StrictMode>,
);
+2 -1
View File
@@ -9,7 +9,8 @@ export interface FileFilter {
}
export interface PlatformFilesystem {
saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise<void>;
/** Returns the saved path (or filename on web), or null if the user cancelled. */
saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise<string | null>;
openPath(path: string): Promise<void>;
pickDirectory(title: string): Promise<string | null>;
}
+3 -3
View File
@@ -113,7 +113,7 @@ const voicesRoute = createRoute({
component: VoicesTab,
});
// Captures route (prototype — will replace AudioTab once the new flow is ready)
// Captures route
const capturesRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/captures',
@@ -199,8 +199,8 @@ const serverRedirectRoute = createRoute({
},
});
// Route tree
const routeTree = rootRoute.addChildren([
// Route tree — exported so tests can build routers over memory history
export const routeTree = rootRoute.addChildren([
indexRoute,
storiesRoute,
capturesRoute,
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from 'vitest';
import { queryClient } from '@/lib/queryClient';
import { isLoopbackVoiceboxServerUrl, useServerStore } from '@/stores/serverStore';
describe('serverStore', () => {
it('invalidates all queries when the server url changes', () => {
const spy = vi.spyOn(queryClient, 'invalidateQueries');
useServerStore.getState().setServerUrl('http://10.0.0.5:17493');
expect(useServerStore.getState().serverUrl).toBe('http://10.0.0.5:17493');
expect(spy).toHaveBeenCalledTimes(1);
});
it('does not invalidate queries when the url is unchanged', () => {
const url = useServerStore.getState().serverUrl;
const spy = vi.spyOn(queryClient, 'invalidateQueries');
useServerStore.getState().setServerUrl(url);
expect(spy).not.toHaveBeenCalled();
});
});
describe('isLoopbackVoiceboxServerUrl', () => {
it('matches loopback hosts on the voicebox port', () => {
expect(isLoopbackVoiceboxServerUrl('http://127.0.0.1:17493')).toBe(true);
expect(isLoopbackVoiceboxServerUrl('http://localhost:17493')).toBe(true);
expect(isLoopbackVoiceboxServerUrl('http://[::1]:17493')).toBe(true);
});
it('rejects other hosts, ports, and junk', () => {
expect(isLoopbackVoiceboxServerUrl('http://10.0.0.5:17493')).toBe(false);
expect(isLoopbackVoiceboxServerUrl('http://127.0.0.1:8000')).toBe(false);
expect(isLoopbackVoiceboxServerUrl('not a url')).toBe(false);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { useUIStore } from '@/stores/uiStore';
describe('uiStore', () => {
it('applies the dark class when theme is set to dark', () => {
useUIStore.getState().setTheme('dark');
expect(useUIStore.getState().theme).toBe('dark');
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
it('removes the dark class when theme is set to light', () => {
useUIStore.getState().setTheme('dark');
useUIStore.getState().setTheme('light');
expect(document.documentElement.classList.contains('dark')).toBe(false);
});
it('persists only theme and selectedProfileId', () => {
useUIStore.getState().setTheme('light');
useUIStore.getState().setSidebarOpen(false);
useUIStore.getState().setSelectedEngine('kokoro');
const persisted = JSON.parse(localStorage.getItem('voicebox-ui') ?? '{}');
expect(persisted.state).toEqual({ selectedProfileId: null, theme: 'light' });
});
});
+58
View File
@@ -0,0 +1,58 @@
import { http } from 'msw';
import { expect, it } from 'vitest';
import { buildModelStatus, buildProfile } from './msw/fixtures';
import {
captureHandlers,
effectsHandlers,
historyHandlers,
modelHandlers,
profileHandlers,
settingsHandlers,
storyHandlers,
taskHandlers,
} from './msw/handlers';
import { worker } from './msw/worker';
import { renderRoute } from './render';
import { sseController } from './sse';
function useHappyPathHandlers() {
worker.use(
...profileHandlers([buildProfile({ name: 'Ada Lovelace' })]),
...historyHandlers([]),
...captureHandlers([]),
...settingsHandlers(),
...modelHandlers([buildModelStatus()]),
...storyHandlers([]),
...effectsHandlers([]),
...taskHandlers(),
);
}
it('renders the /voices route with the full app chrome', async () => {
useHappyPathHandlers();
const screen = await renderRoute('/voices');
await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
});
it('feeds EventSource through the SSE controller', async () => {
const sse = sseController();
worker.use(http.get('*/generate/:id/status', () => sse.response()));
const source = new EventSource('/generate/gen-1/status');
const statuses: string[] = [];
source.onmessage = (message) => {
statuses.push((JSON.parse(message.data) as { status: string }).status);
};
await new Promise((resolve) => {
source.onopen = resolve;
});
sse.push({ data: { status: 'generating' } });
sse.push({ data: { status: 'completed' } });
await expect.poll(() => statuses).toEqual(['generating', 'completed']);
source.close();
sse.close();
});
+86
View File
@@ -0,0 +1,86 @@
import { vi } from 'vitest';
import type { Platform, UpdateStatus } from '@/platform/types';
export interface MockPlatform extends Platform {
/** Push a new updater status to all subscribers, as the real updater would. */
emitUpdateStatus(status: UpdateStatus): void;
}
export interface MockPlatformOverrides {
filesystem?: Partial<Platform['filesystem']>;
updater?: Partial<Platform['updater']>;
audio?: Partial<Platform['audio']>;
lifecycle?: Partial<Platform['lifecycle']>;
metadata?: Partial<Platform['metadata']>;
}
const INITIAL_UPDATE_STATUS: UpdateStatus = {
checking: false,
available: false,
downloading: false,
installing: false,
readyToInstall: false,
};
export const TEST_SERVER_URL = 'http://127.0.0.1:17493';
/**
* A fully spy-able Platform. Every method is a vi.fn with a benign default
* (browser-like: no system audio, isTauri false), so tests can assert calls
* or override behavior per section via `overrides`.
*/
export function createMockPlatform(overrides: MockPlatformOverrides = {}): MockPlatform {
let updateStatus = { ...INITIAL_UPDATE_STATUS };
const subscribers = new Set<(status: UpdateStatus) => void>();
return {
filesystem: {
saveFile: vi.fn(async (filename: string) => filename),
openPath: vi.fn(async () => {}),
pickDirectory: vi.fn(async () => null),
...overrides.filesystem,
},
updater: {
checkForUpdates: vi.fn(async () => {}),
downloadAndInstall: vi.fn(async () => {}),
restartAndInstall: vi.fn(async () => {}),
getStatus: vi.fn(() => ({ ...updateStatus })),
subscribe: vi.fn((callback: (status: UpdateStatus) => void) => {
subscribers.add(callback);
callback(updateStatus);
return () => {
subscribers.delete(callback);
};
}),
...overrides.updater,
},
audio: {
isSystemAudioSupported: vi.fn(async () => false),
startSystemAudioCapture: vi.fn(async () => {}),
stopSystemAudioCapture: vi.fn(async () => new Blob()),
listOutputDevices: vi.fn(async () => []),
playToDevices: vi.fn(async () => {}),
stopPlayback: vi.fn(),
...overrides.audio,
},
lifecycle: {
startServer: vi.fn(async () => TEST_SERVER_URL),
stopServer: vi.fn(async () => {}),
restartServer: vi.fn(async () => TEST_SERVER_URL),
setKeepServerRunning: vi.fn(async () => {}),
setBackendOverride: vi.fn(async () => {}),
setupWindowCloseHandler: vi.fn(async () => {}),
subscribeToServerLogs: vi.fn(() => () => {}),
...overrides.lifecycle,
},
metadata: {
getVersion: vi.fn(async () => '0.0.0-test'),
isTauri: false,
...overrides.metadata,
},
emitUpdateStatus(status: UpdateStatus) {
updateStatus = { ...status };
for (const callback of subscribers) callback(updateStatus);
},
};
}
+216
View File
@@ -0,0 +1,216 @@
import type {
CaptureListResponse,
CaptureReadinessResponse,
CaptureResponse,
CaptureSettings,
EffectPresetResponse,
GenerationResponse,
GenerationSettings,
HealthResponse,
HistoryListResponse,
HistoryResponse,
ModelStatus,
StoryDetailResponse,
StoryItemDetail,
StoryResponse,
VoiceProfileResponse,
} from '@/lib/api/types';
// Deterministic id counter — no randomness so failures reproduce exactly.
let seq = 0;
export function nextId(prefix: string): string {
seq += 1;
return `${prefix}-${String(seq).padStart(4, '0')}`;
}
const CREATED_AT = '2026-01-01T00:00:00Z';
export function buildProfile(overrides: Partial<VoiceProfileResponse> = {}): VoiceProfileResponse {
return {
id: nextId('profile'),
name: 'Test Voice',
language: 'en',
voice_type: 'cloned',
generation_count: 0,
sample_count: 1,
created_at: CREATED_AT,
updated_at: CREATED_AT,
...overrides,
};
}
export function buildGeneration(overrides: Partial<GenerationResponse> = {}): GenerationResponse {
return {
id: nextId('gen'),
profile_id: 'profile-0001',
text: 'Hello from the test suite.',
language: 'en',
status: 'completed',
audio_path: '/audio/fake.wav',
duration: 1.5,
created_at: CREATED_AT,
...overrides,
};
}
export function buildHistoryItem(overrides: Partial<HistoryResponse> = {}): HistoryResponse {
return {
...buildGeneration(),
profile_name: 'Test Voice',
...overrides,
};
}
export function buildHistoryList(items: HistoryResponse[]): HistoryListResponse {
return { items, total: items.length };
}
export function buildCapture(overrides: Partial<CaptureResponse> = {}): CaptureResponse {
return {
id: nextId('capture'),
audio_path: '/captures/fake.wav',
source: 'dictation',
language: 'en',
duration_ms: 2400,
transcript_raw: 'raw transcript text',
transcript_refined: 'Refined transcript text.',
created_at: CREATED_AT,
...overrides,
};
}
export function buildCaptureList(items: CaptureResponse[]): CaptureListResponse {
return { items, total: items.length };
}
export function buildCaptureSettings(overrides: Partial<CaptureSettings> = {}): CaptureSettings {
return {
stt_model: 'turbo',
language: 'en',
auto_refine: true,
llm_model: '0.6B',
smart_cleanup: true,
self_correction: true,
preserve_technical: true,
allow_auto_paste: false,
default_playback_voice_id: null,
hotkey_enabled: false,
keep_mic_warm: false,
chord_push_to_talk_keys: [],
chord_toggle_to_talk_keys: [],
...overrides,
};
}
export function buildCaptureReadiness(
overrides: Partial<CaptureReadinessResponse> = {},
): CaptureReadinessResponse {
return {
stt: {
ready: true,
model_name: 'whisper-turbo',
display_name: 'Whisper Turbo',
size: '1.6 GB',
},
llm: {
ready: true,
model_name: 'qwen3-0.6b',
display_name: 'Qwen3 0.6B',
size: '600 MB',
},
...overrides,
};
}
export function buildGenerationSettings(
overrides: Partial<GenerationSettings> = {},
): GenerationSettings {
return {
max_chunk_chars: 400,
crossfade_ms: 60,
normalize_audio: true,
autoplay_on_generate: false,
...overrides,
};
}
export function buildModelStatus(overrides: Partial<ModelStatus> = {}): ModelStatus {
return {
model_name: 'qwen-tts-1.7b',
display_name: 'Qwen TTS 1.7B',
downloaded: true,
downloading: false,
loaded: false,
size_mb: 3400,
...overrides,
};
}
export function buildStory(overrides: Partial<StoryResponse> = {}): StoryResponse {
return {
id: nextId('story'),
name: 'Test Story',
created_at: CREATED_AT,
updated_at: CREATED_AT,
item_count: 0,
...overrides,
};
}
export function buildStoryItem(overrides: Partial<StoryItemDetail> = {}): StoryItemDetail {
return {
id: nextId('story-item'),
story_id: 'story-0001',
generation_id: 'gen-0001',
start_time_ms: 0,
track: 0,
trim_start_ms: 0,
trim_end_ms: 0,
created_at: CREATED_AT,
profile_id: 'profile-0001',
profile_name: 'Test Voice',
text: 'Hello from the test suite.',
language: 'en',
audio_path: '/audio/fake.wav',
duration: 1.5,
volume: 1,
generation_created_at: CREATED_AT,
...overrides,
};
}
export function buildStoryDetail(
overrides: Partial<StoryDetailResponse> = {},
): StoryDetailResponse {
return {
id: 'story-0001',
name: 'Test Story',
created_at: CREATED_AT,
updated_at: CREATED_AT,
items: [],
...overrides,
};
}
export function buildEffectPreset(
overrides: Partial<EffectPresetResponse> = {},
): EffectPresetResponse {
return {
id: nextId('preset'),
name: 'Test Preset',
effects_chain: [{ type: 'reverb', enabled: true, params: { wet: 0.3 } }],
is_builtin: false,
created_at: CREATED_AT,
...overrides,
};
}
export function buildHealth(overrides: Partial<HealthResponse> = {}): HealthResponse {
return {
status: 'ok',
model_loaded: false,
gpu_available: false,
backend_variant: 'cpu',
...overrides,
};
}
+102
View File
@@ -0,0 +1,102 @@
import type { HttpHandler } from 'msw';
import { HttpResponse, http } from 'msw';
import type {
CaptureResponse,
CaptureSettings,
EffectPresetResponse,
GenerationSettings,
HistoryResponse,
ModelStatus,
StoryDetailResponse,
StoryResponse,
VoiceProfileResponse,
} from '@/lib/api/types';
import { buildCaptureReadiness, buildCaptureSettings, buildGenerationSettings } from '../fixtures';
/**
* Happy-path handlers for one domain each. Tests compose what they need:
* worker.use(...profileHandlers([buildProfile()]), ...historyHandlers([]))
* Anything not stubbed fails loudly via onUnhandledRequest: 'error'.
*/
export function profileHandlers(profiles: VoiceProfileResponse[]): HttpHandler[] {
return [
http.get('*/profiles', () => HttpResponse.json(profiles)),
http.get('*/profiles/presets/:engine', () => HttpResponse.json([])),
http.get('*/profiles/:id', ({ params }) => {
const profile = profiles.find((p) => p.id === params.id);
return profile ? HttpResponse.json(profile) : new HttpResponse(null, { status: 404 });
}),
http.get('*/profiles/:id/channels', () => HttpResponse.json([])),
http.get('*/profiles/:id/samples', () => HttpResponse.json([])),
http.get('*/channels', () => HttpResponse.json([])),
];
}
export function historyHandlers(items: HistoryResponse[]): HttpHandler[] {
return [
http.get('*/history', () => HttpResponse.json({ items, total: items.length })),
http.get('*/history/:id', ({ params }) => {
const item = items.find((i) => i.id === params.id);
return item ? HttpResponse.json(item) : new HttpResponse(null, { status: 404 });
}),
];
}
export function captureHandlers(
items: CaptureResponse[],
settings: CaptureSettings = buildCaptureSettings(),
): HttpHandler[] {
return [
http.get('*/captures', () => HttpResponse.json({ items, total: items.length })),
http.get('*/capture/readiness', () => HttpResponse.json(buildCaptureReadiness())),
http.get('*/settings/captures', () => HttpResponse.json(settings)),
http.put('*/settings/captures', async ({ request }) => {
const update = (await request.json()) as Partial<CaptureSettings>;
return HttpResponse.json({ ...settings, ...update });
}),
];
}
export function settingsHandlers(
generation: GenerationSettings = buildGenerationSettings(),
): HttpHandler[] {
return [
http.get('*/settings/generation', () => HttpResponse.json(generation)),
http.put('*/settings/generation', async ({ request }) => {
const update = (await request.json()) as Partial<GenerationSettings>;
return HttpResponse.json({ ...generation, ...update });
}),
];
}
export function modelHandlers(models: ModelStatus[]): HttpHandler[] {
return [
http.get('*/models/status', () => HttpResponse.json({ models })),
http.get('*/models/cache-dir', () => HttpResponse.json({ cache_dir: '/tmp/models' })),
];
}
export function storyHandlers(
stories: StoryResponse[],
details: StoryDetailResponse[] = [],
): HttpHandler[] {
return [
http.get('*/stories', () => HttpResponse.json(stories)),
http.get('*/stories/:id', ({ params }) => {
const detail = details.find((d) => d.id === params.id);
return detail ? HttpResponse.json(detail) : new HttpResponse(null, { status: 404 });
}),
];
}
export function effectsHandlers(presets: EffectPresetResponse[]): HttpHandler[] {
return [
http.get('*/effects/available', () => HttpResponse.json({ effects: [] })),
http.get('*/effects/presets', () => HttpResponse.json(presets)),
];
}
export function taskHandlers(): HttpHandler[] {
return [http.get('*/tasks/active', () => HttpResponse.json({ downloads: [], generations: [] }))];
}
+17
View File
@@ -0,0 +1,17 @@
import { type HttpHandler, HttpResponse, http } from 'msw';
/**
* Baseline handlers for endpoints nearly every screen touches. The health
* payload mirrors backend/routes/health.py closely enough for the UI's
* checks (`status`, `model_loaded`, backend variant fields).
*/
export const serverHandlers: HttpHandler[] = [
http.get('*/health', () =>
HttpResponse.json({
status: 'ok',
model_loaded: false,
device: 'cpu',
backend_variant: 'cpu',
}),
),
];
+9
View File
@@ -0,0 +1,9 @@
import { setupWorker } from 'msw/browser';
import { serverHandlers } from './handlers/server';
/**
* Browser-mode MSW worker. Individual tests layer route-specific handlers
* on top with `worker.use(...)`; `setup.browser.ts` resets them after each
* test. Only the health/baseline handlers are registered globally.
*/
export const worker = setupWorker(...serverHandlers);
+346
View File
@@ -0,0 +1,346 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.15.0';
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e';
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse');
const activeClientIds = new Set();
addEventListener('install', () => {
self.skipWaiting();
});
addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
addEventListener('message', async (event) => {
const clientId = Reflect.get(event.source || {}, 'id');
if (!clientId || !self.clients) {
return;
}
const client = await self.clients.get(clientId);
if (!client) {
return;
}
const allClients = await self.clients.matchAll({
type: 'window',
});
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
});
break;
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
});
break;
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId);
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
});
break;
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId);
const remainingClients = allClients.filter((client) => {
return client.id !== clientId;
});
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister();
}
break;
}
}
});
addEventListener('fetch', (event) => {
const requestInterceptedAt = Date.now();
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return;
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') {
return;
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return;
}
const requestId = crypto.randomUUID();
event.respondWith(handleRequest(event, requestId, requestInterceptedAt));
});
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event);
const requestCloneForEvents = event.request.clone();
const response = await getResponse(event, client, requestId, requestInterceptedAt);
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents);
// Omit the body of server-sent event stream responses.
// Cloning such responses would prevent client-side stream cancelations
// from reaching the original stream (a teed stream only cancels its
// source once both of its branches cancel) and would buffer the
// entire stream into the unconsumed clone indefinitely.
const isEventStreamResponse = response.headers
.get('content-type')
?.toLowerCase()
.startsWith('text/event-stream');
// Clone the response so both the client and the library could consume it.
const responseClone = isEventStreamResponse ? null : response.clone();
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: response.type,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseClone ? responseClone.body : null,
},
},
},
responseClone && responseClone.body ? [serializedRequest.body, responseClone.body] : [],
);
}
return response;
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId);
if (activeClientIds.has(event.clientId)) {
return client;
}
if (client?.frameType === 'top-level') {
return client;
}
const allClients = await self.clients.matchAll({
type: 'window',
});
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible';
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id);
});
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone();
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers);
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept');
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim());
const filteredValues = values.filter((value) => value !== 'msw/passthrough');
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '));
} else {
headers.delete('accept');
}
}
return fetch(requestClone, { headers });
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough();
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough();
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request);
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
);
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data);
}
case 'PASSTHROUGH': {
return passthrough();
}
}
return passthrough();
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error);
}
resolve(event.data);
};
client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]);
});
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error();
}
const mockedResponse = new Response(response.body, response);
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
});
return mockedResponse;
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
};
}
+71
View File
@@ -0,0 +1,71 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router';
import type { ReactNode } from 'react';
import { render } from 'vitest-browser-react';
import { PlatformProvider } from '@/platform/PlatformContext';
import { routeTree } from '@/router';
import { createMockPlatform, type MockPlatform } from './mockPlatform';
export function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
// Retries and interval refetching are disabled so tests are
// deterministic — polling components get their data exactly once.
queries: {
retry: false,
refetchInterval: false,
refetchOnWindowFocus: false,
gcTime: Number.POSITIVE_INFINITY,
},
mutations: { retry: false },
},
});
}
export interface RenderWithProvidersOptions {
platform?: MockPlatform;
queryClient?: QueryClient;
}
// Every client handed to a render is drained on teardown so in-flight
// queries can't fire after MSW handlers reset (noisy unhandled-request
// errors between tests).
const activeQueryClients: QueryClient[] = [];
export async function drainQueryClients(): Promise<void> {
for (const client of activeQueryClients) {
await client.cancelQueries();
client.clear();
}
activeQueryClients.length = 0;
}
export async function renderWithProviders(ui: ReactNode, options: RenderWithProvidersOptions = {}) {
const platform = options.platform ?? createMockPlatform();
const queryClient = options.queryClient ?? createTestQueryClient();
activeQueryClients.push(queryClient);
const result = await render(
<QueryClientProvider client={queryClient}>
<PlatformProvider platform={platform}>{ui}</PlatformProvider>
</QueryClientProvider>,
);
// Object.assign keeps the render result's prototype methods (locators)
// intact — spreading would drop them.
return Object.assign(result, { platform, queryClient });
}
/**
* Mount the real route tree at `route` over memory history — full app chrome
* (sidebar, frame, toasts) included. A throwaway router per call keeps route
* state from leaking between tests.
*/
export async function renderRoute(route: string, options: RenderWithProvidersOptions = {}) {
const router = createRouter({
routeTree,
history: createMemoryHistory({ initialEntries: [route] }),
});
const result = await renderWithProviders(<RouterProvider router={router} />, options);
return Object.assign(result, { router });
}
+36
View File
@@ -0,0 +1,36 @@
import { queryClient } from '@/lib/queryClient';
import { useAudioChannelStore } from '@/stores/audioChannelStore';
import { useEffectsStore } from '@/stores/effectsStore';
import { useGenerationStore } from '@/stores/generationStore';
import { useLogStore } from '@/stores/logStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useServerStore } from '@/stores/serverStore';
import { useStoryStore } from '@/stores/storyStore';
import { useUIStore } from '@/stores/uiStore';
const stores = [
useAudioChannelStore,
useEffectsStore,
useGenerationStore,
useLogStore,
usePlayerStore,
useServerStore,
useStoryStore,
useUIStore,
] as const;
// Snapshot pristine state at module load, before any test mutates anything.
const snapshots = stores.map((store) => store.getState());
/**
* Restore every zustand store to its initial state and clear persisted
* copies so tests can't leak state into each other. Persisted stores write
* through to localStorage on setState, so localStorage is cleared last.
*/
export function resetAllStores(): void {
stores.forEach((store, i) => {
store.setState(snapshots[i] as never, true);
});
queryClient.clear();
localStorage.clear();
}
+18
View File
@@ -0,0 +1,18 @@
import { afterEach, beforeAll } from 'vitest';
import { cleanup } from 'vitest-browser-react';
import { worker } from './msw/worker';
import { drainQueryClients } from './render';
beforeAll(async () => {
await worker.start({ onUnhandledRequest: 'error', quiet: true });
return () => worker.stop();
});
// Registered after setup.ts, so this runs first (afterEach is LIFO):
// unmount → cancel in-flight queries → reset handlers, then setup.ts
// restores stores and mocks.
afterEach(async () => {
await cleanup();
await drainQueryClients();
worker.resetHandlers();
});
+8
View File
@@ -0,0 +1,8 @@
import '@/i18n';
import { afterEach, vi } from 'vitest';
import { resetAllStores } from './resetStores';
afterEach(() => {
vi.restoreAllMocks();
resetAllStores();
});
+81
View File
@@ -0,0 +1,81 @@
import { HttpResponse } from 'msw';
export interface SseEvent {
data: unknown;
event?: string;
}
const SSE_HEADERS = {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
} as const;
const encoder = new TextEncoder();
function frame({ data, event }: SseEvent): Uint8Array {
const payload = typeof data === 'string' ? data : JSON.stringify(data);
const lines = event ? `event: ${event}\ndata: ${payload}\n\n` : `data: ${payload}\n\n`;
return encoder.encode(lines);
}
/**
* An MSW response streaming the given events immediately, then staying open
* (EventSource reconnects on close, so a closed stream would loop the test).
*/
export function sseResponse(events: SseEvent[]): Response {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const event of events) controller.enqueue(frame(event));
},
});
return new HttpResponse(stream, { headers: SSE_HEADERS });
}
export interface SseController {
/** Hand this to an MSW resolver: `http.get(url, () => sse.response())`. */
response(): Response;
/** Push one event to every open stream. */
push(event: SseEvent): void;
/** End all open streams. */
close(): void;
}
/**
* Imperative SSE feed for tests that interleave user actions with server
* events (generation progress, download progress). Each call to `response()`
* opens a stream that receives subsequent `push`es — matching EventSource
* reconnect behavior.
*/
export function sseController(): SseController {
const controllers = new Set<ReadableStreamDefaultController<Uint8Array>>();
return {
response() {
let own: ReadableStreamDefaultController<Uint8Array>;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
own = controller;
controllers.add(controller);
},
cancel() {
controllers.delete(own);
},
});
return new HttpResponse(stream, { headers: SSE_HEADERS });
},
push(event: SseEvent) {
for (const controller of controllers) controller.enqueue(frame(event));
},
close() {
for (const controller of controllers) {
try {
controller.close();
} catch {
// already closed by cancel
}
}
controllers.clear();
},
};
}
+1 -1
View File
@@ -25,7 +25,7 @@
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client", "bun"]
"types": ["vite/client"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
-14
View File
@@ -1,14 +0,0 @@
import path from 'node:path';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import { changelogPlugin } from './plugins/changelog';
export default defineConfig({
plugins: [tailwindcss(), react(), changelogPlugin(path.resolve(__dirname, '..'))],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
+16 -10
View File
@@ -38,6 +38,13 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
# An empty HSA_OVERRIDE_GFX_VERSION poisons the ROCm HSA runtime. It is
# treated as "force-empty" and no GPU is detected, even natively supported
# ones (e.g. gfx1201 / RX 9070 on ROCm 7.2). docker-compose can't
# conditionally omit an env var, so we clean it up here before torch loads.
if not os.environ.get("HSA_OVERRIDE_GFX_VERSION"):
os.environ.pop("HSA_OVERRIDE_GFX_VERSION", None)
# AMD GPU environment variables must be set before torch import
# Only set HSA_OVERRIDE_GFX_VERSION for older GPUs that need it.
# RDNA 3+ (gfx1100+) and RDNA 4 (gfx1200+) are natively supported by ROCm
@@ -95,19 +102,18 @@ if not os.environ.get("HSA_OVERRIDE_GFX_VERSION"):
if not os.environ.get("MIOPEN_LOG_LEVEL"):
os.environ["MIOPEN_LOG_LEVEL"] = "4"
from urllib.parse import quote
import torch
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from urllib.parse import quote
from . import __version__, config, database
from .services import tts, transcribe, llm
from .database import get_db
from .routes import register_routers
from .services import llm, transcribe, tts
from .services.task_queue import create_background_task, init_queue
from .utils.platform_detect import get_backend_type
from .utils.progress import get_progress_manager
from .services.task_queue import create_background_task, init_queue
from .routes import register_routers
def safe_content_disposition(disposition_type: str, filename: str) -> str:
@@ -123,8 +129,8 @@ def safe_content_disposition(disposition_type: str, filename: str) -> str:
def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
from .mcp_server.context import ClientIdMiddleware
from .mcp_server.server import build_mcp_server, compose_lifespan
from .mcp_server.context import ClientIdMiddleware
# Build the MCP app up-front so we can wire its lifespan into FastAPI's —
# FastMCP's Streamable HTTP transport only works if its session manager
@@ -203,8 +209,8 @@ def _mount_frontend(application: FastAPI) -> None:
if not frontend_dir.is_dir():
return
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
# Mount hashed assets (JS, CSS, images) that Vite places under /assets
assets_dir = frontend_dir / "assets"
@@ -244,9 +250,9 @@ def _get_gpu_status() -> str:
if not compatible:
label += " [UNSUPPORTED - see logs]"
return label
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "MPS (Apple Silicon)"
if backend_type == "mlx":
elif backend_type == "mlx":
return "Metal (Apple Silicon via MLX)"
# Intel XPU (Arc / Data Center) via IPEX
@@ -303,7 +309,7 @@ async def _run_startup(application: FastAPI) -> None:
if result.rowcount > 0:
logger.info("Marked %d stale generation(s) as failed", result.rowcount)
from .database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile
from .database import VoiceProfile as DBVoiceProfile, Generation as DBGeneration
profile_count = db.query(DBVoiceProfile).count()
generation_count = db.query(DBGeneration).count()
+32 -47
View File
@@ -10,14 +10,14 @@ and a model config registry that eliminates per-engine dispatch maps.
# import time, which wraps transformers' tokenizer load against the
# unconditional HuggingFace metadata call that otherwise raises on
# HF_HUB_OFFLINE=1 and on network failures.
from ..utils import hf_offline_patch # noqa: F401
import os
import threading
from dataclasses import dataclass, field
from typing import Protocol
import numpy as np
from typing import Protocol, Optional, Tuple, List
from typing_extensions import runtime_checkable
from ..utils import hf_offline_patch
import numpy as np
DEFAULT_LLM_MAX_TOKENS = 512
DEFAULT_LLM_TEMPERATURE = 0.7
@@ -57,7 +57,6 @@ class ModelConfig:
model_size: str = "default"
size_mb: int = 0
needs_trim: bool = False
retries_runaway: bool = False
supports_instruct: bool = False
languages: list[str] = field(default_factory=lambda: ["en"])
@@ -78,7 +77,7 @@ class TTSBackend(Protocol):
audio_path: str,
reference_text: str,
use_cache: bool = True,
) -> tuple[dict, bool]:
) -> Tuple[dict, bool]:
"""
Create voice prompt from reference audio.
@@ -89,9 +88,9 @@ class TTSBackend(Protocol):
async def combine_voice_prompts(
self,
audio_paths: list[str],
reference_texts: list[str],
) -> tuple[np.ndarray, str]:
audio_paths: List[str],
reference_texts: List[str],
) -> Tuple[np.ndarray, str]:
"""
Combine multiple voice prompts.
@@ -105,9 +104,9 @@ class TTSBackend(Protocol):
text: str,
voice_prompt: dict,
language: str = "en",
seed: int | None = None,
instruct: str | None = None,
) -> tuple[np.ndarray, int]:
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> Tuple[np.ndarray, int]:
"""
Generate audio from text.
@@ -145,8 +144,8 @@ class STTBackend(Protocol):
async def transcribe(
self,
audio_path: str,
language: str | None = None,
model_size: str | None = None,
language: Optional[str] = None,
model_size: Optional[str] = None,
) -> str:
"""
Transcribe audio to text.
@@ -176,11 +175,11 @@ class LLMBackend(Protocol):
async def generate(
self,
prompt: str,
system: str | None = None,
system: Optional[str] = None,
max_tokens: int = DEFAULT_LLM_MAX_TOKENS,
temperature: float = DEFAULT_LLM_TEMPERATURE,
model_size: str | None = None,
examples: list[tuple[str, str]] | None = None,
model_size: Optional[str] = None,
examples: Optional[list[tuple[str, str]]] = None,
) -> str:
"""Run a single-turn chat completion and return the assistant reply.
@@ -200,11 +199,10 @@ class LLMBackend(Protocol):
# Global backend instances
_tts_backend: TTSBackend | None = None
_tts_backend: Optional[TTSBackend] = None
_tts_backends: dict[str, TTSBackend] = {}
_tts_backends_lock = threading.Lock()
_stt_backend: STTBackend | None = None
_stt_backend_lock = threading.Lock()
_stt_backend: Optional[STTBackend] = None
_llm_backends: dict[str, LLMBackend] = {}
_llm_backends_lock = threading.Lock()
@@ -235,10 +233,6 @@ def _get_qwen_model_configs() -> list[ModelConfig]:
repo_1_7b = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
repo_0_6b = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
# mlx-audio can continue after an EOS miss with silence followed by
# codec noise. Retry only the affected text as smaller chunks.
retries_runaway = backend_type == "mlx"
return [
ModelConfig(
model_name="qwen-tts-1.7B",
@@ -247,7 +241,6 @@ def _get_qwen_model_configs() -> list[ModelConfig]:
hf_repo_id=repo_1_7b,
model_size="1.7B",
size_mb=3500,
retries_runaway=retries_runaway,
supports_instruct=False, # Base model drops instruct silently
languages=["zh", "en", "ja", "ko", "de", "fr", "ru", "pt", "es", "it"],
),
@@ -258,7 +251,6 @@ def _get_qwen_model_configs() -> list[ModelConfig]:
hf_repo_id=repo_0_6b,
model_size="0.6B",
size_mb=1200,
retries_runaway=retries_runaway,
supports_instruct=False,
languages=["zh", "en", "ja", "ko", "de", "fr", "ru", "pt", "es", "it"],
),
@@ -497,7 +489,7 @@ def get_stt_model_configs() -> list[ModelConfig]:
# Lookup helpers — these replace the if/elif chains in main.py
def get_model_config(model_name: str) -> ModelConfig | None:
def get_model_config(model_name: str) -> Optional[ModelConfig]:
"""Look up a model config by model_name."""
for cfg in get_all_model_configs():
if cfg.model_name == model_name:
@@ -513,14 +505,6 @@ def engine_needs_trim(engine: str) -> bool:
return False
def engine_retries_runaway(engine: str) -> bool:
"""Whether unstable output should be retried in smaller chunks."""
for cfg in get_tts_model_configs():
if cfg.engine == engine:
return cfg.retries_runaway
return False
def engine_has_model_sizes(engine: str) -> bool:
"""Whether this engine supports multiple model sizes (only Qwen currently)."""
configs = [c for c in get_tts_model_configs() if c.engine == engine]
@@ -580,8 +564,8 @@ async def unload_backend(backend) -> None:
async def unload_model_by_config(config: ModelConfig) -> bool:
"""Unload a model given its config. Returns True if it was loaded, False otherwise."""
from ..services import llm as llm_service, transcribe, tts
from . import get_tts_backend_for_engine
from ..services import tts, transcribe, llm as llm_service
if config.engine == "whisper":
whisper_model = transcribe.get_whisper_model()
@@ -624,8 +608,8 @@ async def unload_model_by_config(config: ModelConfig) -> bool:
def check_model_loaded(config: ModelConfig) -> bool:
"""Check if a model is currently loaded."""
from ..services import llm as llm_service, transcribe, tts
from . import get_tts_backend_for_engine
from ..services import tts, transcribe, llm as llm_service
try:
if config.engine == "whisper":
@@ -655,8 +639,8 @@ def check_model_loaded(config: ModelConfig) -> bool:
def get_model_load_func(config: ModelConfig):
"""Return a callable that loads/downloads the model."""
from ..services import llm as llm_service, transcribe, tts
from . import get_tts_backend_for_engine
from ..services import tts, transcribe, llm as llm_service
if config.engine == "whisper":
return lambda: transcribe.get_whisper_model().load_model(config.model_size)
@@ -695,6 +679,13 @@ def get_tts_backend_for_engine(engine: str) -> TTSBackend:
"""
global _tts_backends
# Test mode: every engine resolves to the fake backend so the full
# generation pipeline runs without model weights (see fake_backend.py).
if os.environ.get("VOICEBOX_FAKE_TTS") == "1":
from .fake_backend import get_fake_backend
return get_fake_backend()
# Fast path: check without lock
if engine in _tts_backends:
return _tts_backends[engine]
@@ -755,13 +746,7 @@ def get_stt_backend() -> STTBackend:
"""
global _stt_backend
if _stt_backend is not None:
return _stt_backend
with _stt_backend_lock:
if _stt_backend is not None:
return _stt_backend
if _stt_backend is None:
backend_type = get_backend_type()
if backend_type == "mlx":
@@ -773,7 +758,7 @@ def get_stt_backend() -> STTBackend:
_stt_backend = PyTorchSTTBackend()
return _stt_backend
return _stt_backend
def get_llm_backend() -> LLMBackend:
+9 -8
View File
@@ -9,12 +9,13 @@ import logging
import platform
from contextlib import contextmanager
from pathlib import Path
from typing import Callable, List, Optional, Tuple
import numpy as np
from ..utils.audio import load_audio, normalize_audio
from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
from ..utils.audio import normalize_audio, load_audio
from ..utils.progress import get_progress_manager
from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
from ..utils.tasks import get_task_manager
logger = logging.getLogger(__name__)
@@ -24,7 +25,7 @@ def is_model_cached(
hf_repo: str,
*,
weight_extensions: tuple[str, ...] = (".safetensors", ".bin"),
required_files: list[str] | None = None,
required_files: Optional[list[str]] = None,
) -> bool:
"""
Check if a HuggingFace model is fully cached locally.
@@ -200,11 +201,11 @@ def manual_seed(seed: int, device: str) -> None:
async def combine_voice_prompts(
audio_paths: list[str],
reference_texts: list[str],
audio_paths: List[str],
reference_texts: List[str],
*,
sample_rate: int | None = None,
) -> tuple[np.ndarray, str]:
sample_rate: Optional[int] = None,
) -> Tuple[np.ndarray, str]:
"""
Combine multiple reference audio samples into one.
@@ -234,7 +235,7 @@ async def combine_voice_prompts(
def model_load_progress(
model_name: str,
is_cached: bool,
filter_non_downloads: bool | None = None,
filter_non_downloads: Optional[bool] = None,
):
"""
Context manager for model loading with HF download progress tracking.
+12 -11
View File
@@ -10,16 +10,17 @@ import asyncio
import logging
import threading
from pathlib import Path
from typing import ClassVar
from typing import ClassVar, List, Optional, Tuple
import numpy as np
from . import TTSBackend
from .base import (
combine_voice_prompts as _combine_voice_prompts,
empty_device_cache,
get_torch_device,
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
combine_voice_prompts as _combine_voice_prompts,
model_load_progress,
patch_chatterbox_f32,
)
@@ -126,7 +127,7 @@ class ChatterboxTTSBackend:
audio_path: str,
reference_text: str,
use_cache: bool = True,
) -> tuple[dict, bool]:
) -> Tuple[dict, bool]:
"""
Create voice prompt from reference audio.
@@ -142,9 +143,9 @@ class ChatterboxTTSBackend:
async def combine_voice_prompts(
self,
audio_paths: list[str],
reference_texts: list[str],
) -> tuple[np.ndarray, str]:
audio_paths: List[str],
reference_texts: List[str],
) -> Tuple[np.ndarray, str]:
return await _combine_voice_prompts(audio_paths, reference_texts)
# Per-language generation defaults. Lower temp + higher cfg = clearer speech.
@@ -168,9 +169,9 @@ class ChatterboxTTSBackend:
text: str,
voice_prompt: dict,
language: str = "en",
seed: int | None = None,
instruct: str | None = None,
) -> tuple[np.ndarray, int]:
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> Tuple[np.ndarray, int]:
"""
Generate audio using Chatterbox Multilingual TTS.
+13 -12
View File
@@ -10,16 +10,17 @@ import asyncio
import logging
import threading
from pathlib import Path
from typing import ClassVar
from typing import ClassVar, List, Optional, Tuple
import numpy as np
from . import TTSBackend
from .base import (
combine_voice_prompts as _combine_voice_prompts,
empty_device_cache,
get_torch_device,
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
combine_voice_prompts as _combine_voice_prompts,
model_load_progress,
patch_chatterbox_f32,
)
@@ -80,8 +81,8 @@ class ChatterboxTurboTTSBackend:
logger.info(f"Loading Chatterbox Turbo TTS on {device}...")
import torch
from chatterbox.tts_turbo import ChatterboxTurboTTS
from huggingface_hub import snapshot_download
from chatterbox.tts_turbo import ChatterboxTurboTTS
local_path = snapshot_download(
repo_id=CHATTERBOX_TURBO_HF_REPO,
@@ -125,7 +126,7 @@ class ChatterboxTurboTTSBackend:
audio_path: str,
reference_text: str,
use_cache: bool = True,
) -> tuple[dict, bool]:
) -> Tuple[dict, bool]:
"""
Create voice prompt from reference audio.
@@ -140,9 +141,9 @@ class ChatterboxTurboTTSBackend:
async def combine_voice_prompts(
self,
audio_paths: list[str],
reference_texts: list[str],
) -> tuple[np.ndarray, str]:
audio_paths: List[str],
reference_texts: List[str],
) -> Tuple[np.ndarray, str]:
return await _combine_voice_prompts(audio_paths, reference_texts)
async def generate(
@@ -150,9 +151,9 @@ class ChatterboxTurboTTSBackend:
text: str,
voice_prompt: dict,
language: str = "en",
seed: int | None = None,
instruct: str | None = None,
) -> tuple[np.ndarray, int]:
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> Tuple[np.ndarray, int]:
"""
Generate audio using Chatterbox Turbo TTS.
+92
View File
@@ -0,0 +1,92 @@
"""Fake TTS backend for UI and E2E testing.
Activated by ``VOICEBOX_FAKE_TTS=1``. Every engine resolves to this backend,
which synthesizes a quiet sine tone sized to the input text — so the full
generation pipeline (task queue, SSE progress, database rows, audio serving)
runs exactly as in production, minus model weights and GPU time.
"""
import asyncio
import logging
from typing import ClassVar, Optional
import numpy as np
logger = logging.getLogger(__name__)
SAMPLE_RATE = 24_000
SECONDS_PER_CHAR = 0.02
MIN_DURATION_S = 0.25
TONE_HZ = 440.0
AMPLITUDE = 0.1
class FakeTTSBackend:
"""Implements the TTSBackend protocol without any model."""
MODEL_CONFIGS: ClassVar[list] = []
def __init__(self) -> None:
self._loaded = False
async def load_model(self, model_size: str = "default") -> None:
if self._loaded:
return
# Brief pause so the UI's loading_model state is observable.
await asyncio.sleep(0.1)
self._loaded = True
logger.info("Fake TTS backend loaded (VOICEBOX_FAKE_TTS)")
async def load_model_async(self, model_size: str = "default") -> None:
# Qwen engines are loaded through this variant (see load_engine_model).
await self.load_model(model_size)
async def create_voice_prompt(
self,
audio_path: str,
reference_text: str,
use_cache: bool = True,
) -> tuple[dict, bool]:
return ({"fake": True, "audio_path": audio_path, "reference_text": reference_text}, False)
async def combine_voice_prompts(
self,
audio_paths: list[str],
reference_texts: list[str],
) -> tuple[np.ndarray, str]:
combined_text = " ".join(reference_texts)
return np.zeros(SAMPLE_RATE, dtype=np.float32), combined_text
async def generate(
self,
text: str,
voice_prompt: dict,
language: str = "en",
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> tuple[np.ndarray, int]:
duration_s = max(MIN_DURATION_S, len(text) * SECONDS_PER_CHAR)
# Yield once so cancellation has a window, mirroring real inference.
await asyncio.sleep(0.05)
t = np.linspace(0.0, duration_s, int(SAMPLE_RATE * duration_s), endpoint=False)
audio = (AMPLITUDE * np.sin(2.0 * np.pi * TONE_HZ * t)).astype(np.float32)
return audio, SAMPLE_RATE
def unload_model(self) -> None:
self._loaded = False
def is_loaded(self) -> bool:
return self._loaded
def _get_model_path(self, model_size: str) -> str:
return "fake"
_fake_backend: Optional[FakeTTSBackend] = None
def get_fake_backend() -> FakeTTSBackend:
global _fake_backend
if _fake_backend is None:
_fake_backend = FakeTTSBackend()
return _fake_backend
+20 -21
View File
@@ -16,19 +16,20 @@ causal LM generates speech via flow-matching diffusion.
import asyncio
import logging
import threading
from typing import ClassVar
from typing import ClassVar, List, Optional, Tuple
import numpy as np
from ..utils.cache import cache_voice_prompt, get_cache_key, get_cached_voice_prompt
from . import TTSBackend
from .base import (
combine_voice_prompts as _combine_voice_prompts,
empty_device_cache,
get_torch_device,
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
combine_voice_prompts as _combine_voice_prompts,
model_load_progress,
)
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
logger = logging.getLogger(__name__)
@@ -181,7 +182,7 @@ class HumeTadaBackend:
# getattr(config, "tokenizer_name", "meta-llama/Llama-3.2-1B")
# which hits the gated repo. Pre-load the config from HF,
# inject the local tokenizer path, then pass it in.
from tada.modules.tada import TadaConfig, TadaForCausalLM
from tada.modules.tada import TadaForCausalLM, TadaConfig
logger.info(f"Loading TADA {model_size} model...")
config = TadaConfig.from_pretrained(repo)
@@ -213,7 +214,7 @@ class HumeTadaBackend:
audio_path: str,
reference_text: str,
use_cache: bool = True,
) -> tuple[dict, bool]:
) -> Tuple[dict, bool]:
"""
Create voice prompt from reference audio using TADA's encoder.
@@ -233,8 +234,8 @@ class HumeTadaBackend:
return cached, True
def _encode_sync():
import soundfile as sf
import torch
import soundfile as sf
device = self._device
@@ -247,13 +248,9 @@ class HumeTadaBackend:
audio = audio.T # (samples, channels) -> (channels, samples)
audio = audio.to(device)
# Encode with forced alignment.
# Must run under inference_mode: encoder params still require
# grad by default, and an autograd graph across the DAC/Snake
# stack can balloon VRAM far past the model footprint (#890).
# Encode with forced alignment
text_arg = [reference_text] if reference_text else None
with torch.inference_mode():
prompt = self.encoder(audio, text=text_arg, sample_rate=sr)
prompt = self.encoder(audio, text=text_arg, sample_rate=sr)
# Serialize EncoderOutput to a dict of CPU tensors for caching
prompt_dict = {}
@@ -261,7 +258,9 @@ class HumeTadaBackend:
val = getattr(prompt, field_name)
if isinstance(val, torch.Tensor):
prompt_dict[field_name] = val.detach().cpu()
elif isinstance(val, (list, int, float)):
elif isinstance(val, list):
prompt_dict[field_name] = val
elif isinstance(val, (int, float)):
prompt_dict[field_name] = val
else:
prompt_dict[field_name] = val
@@ -276,9 +275,9 @@ class HumeTadaBackend:
async def combine_voice_prompts(
self,
audio_paths: list[str],
reference_texts: list[str],
) -> tuple[np.ndarray, str]:
audio_paths: List[str],
reference_texts: List[str],
) -> Tuple[np.ndarray, str]:
return await _combine_voice_prompts(audio_paths, reference_texts, sample_rate=24000)
async def generate(
@@ -286,9 +285,9 @@ class HumeTadaBackend:
text: str,
voice_prompt: dict,
language: str = "en",
seed: int | None = None,
instruct: str | None = None,
) -> tuple[np.ndarray, int]:
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> Tuple[np.ndarray, int]:
"""
Generate audio from text using HumeAI TADA.
+14 -10
View File
@@ -17,12 +17,15 @@ Languages supported (via misaki G2P):
import asyncio
import logging
import os
from typing import Optional
import numpy as np
from . import TTSBackend
from .base import (
combine_voice_prompts as _combine_voice_prompts,
get_torch_device,
combine_voice_prompts as _combine_voice_prompts,
model_load_progress,
)
@@ -93,11 +96,16 @@ KOKORO_VOICES = [
("pf_dora", "Dora", "female", "pt"),
("pm_alex", "Alex", "male", "pt"),
("pm_santa", "Santa", "male", "pt"),
# Chinese
# Chinese female
("zf_xiaobei", "Xiaobei", "female", "zh"),
("zf_xiaoni", "Xiaoni", "female", "zh"),
("zf_xiaoxiao", "Xiaoxiao", "female", "zh"),
("zf_xiaoyi", "Xiaoyi", "female", "zh"),
# Chinese male
("zm_yunjian", "Yunjian", "male", "zh"),
("zm_yunxi", "Yunxi", "male", "zh"),
("zm_yunxia", "Yunxia", "male", "zh"),
("zm_yunyang", "Yunyang", "male", "zh"),
]
# Map our ISO language codes to Kokoro lang_code characters
@@ -119,9 +127,8 @@ class KokoroTTSBackend:
def __init__(self):
self._model = None
self._pipelines: dict = {} # lang_code -> KPipeline
self._device: str | None = None
self._device: Optional[str] = None
self.model_size = "default"
self._model_load_lock = asyncio.Lock()
def _get_device(self) -> str:
"""Select device. Kokoro supports CUDA and CPU. MPS needs fallback env var."""
@@ -155,10 +162,7 @@ class KokoroTTSBackend:
"""Load the Kokoro model."""
if self._model is not None:
return
async with self._model_load_lock:
if self._model is not None:
return
await asyncio.to_thread(self._load_model_sync)
await asyncio.to_thread(self._load_model_sync)
def _load_model_sync(self):
"""Synchronous model loading."""
@@ -240,8 +244,8 @@ class KokoroTTSBackend:
text: str,
voice_prompt: dict,
language: str = "en",
seed: int | None = None,
instruct: str | None = None,
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> tuple[np.ndarray, int]:
"""
Generate audio from text using Kokoro.
+12 -13
View File
@@ -7,18 +7,20 @@ Wraps the LuxTTS (ZipVoice) model for zero-shot voice cloning.
import asyncio
import logging
from typing import Optional, Tuple
import numpy as np
from ..utils.cache import cache_voice_prompt, get_cache_key, get_cached_voice_prompt
from . import TTSBackend
from .base import (
combine_voice_prompts as _combine_voice_prompts,
empty_device_cache,
get_torch_device,
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
combine_voice_prompts as _combine_voice_prompts,
model_load_progress,
)
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
logger = logging.getLogger(__name__)
@@ -33,7 +35,6 @@ class LuxTTSBackend:
self.model = None
self.model_size = "default" # LuxTTS has only one model size
self._device = None
self._model_load_lock = asyncio.Lock()
def _get_device(self) -> str:
return get_torch_device(allow_mps=True, allow_xpu=True)
@@ -60,10 +61,8 @@ class LuxTTSBackend:
"""Load the LuxTTS model."""
if self.model is not None:
return
async with self._model_load_lock:
if self.model is not None:
return
await asyncio.to_thread(self._load_model_sync)
await asyncio.to_thread(self._load_model_sync)
def _load_model_sync(self):
model_name = "luxtts"
@@ -106,7 +105,7 @@ class LuxTTSBackend:
audio_path: str,
reference_text: str,
use_cache: bool = True,
) -> tuple[dict, bool]:
) -> Tuple[dict, bool]:
"""
Create voice prompt from reference audio.
@@ -146,9 +145,9 @@ class LuxTTSBackend:
text: str,
voice_prompt: dict,
language: str = "en",
seed: int | None = None,
instruct: str | None = None,
) -> tuple[np.ndarray, int]:
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> Tuple[np.ndarray, int]:
"""
Generate audio from text using LuxTTS.
+53 -40
View File
@@ -2,24 +2,24 @@
MLX backend implementation for TTS and STT using mlx-audio.
"""
from typing import Optional, List, Tuple
import logging
from pathlib import Path
import numpy as np
from pathlib import Path
logger = logging.getLogger(__name__)
# PATCH: Import and apply offline patch BEFORE any huggingface_hub usage
# This prevents mlx_audio from making network requests when models are cached
from ..utils.hf_offline_patch import ensure_original_qwen_config_cached, patch_huggingface_hub_offline
from ..utils.hf_offline_patch import patch_huggingface_hub_offline, ensure_original_qwen_config_cached
patch_huggingface_hub_offline()
ensure_original_qwen_config_cached()
from ..services.mlx_thread import clear_mlx_cache, run_on_mlx_thread
from ..utils.cache import cache_voice_prompt, get_cache_key, get_cached_voice_prompt
from . import LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS
from .base import combine_voice_prompts as _combine_voice_prompts, is_model_cached, model_load_progress
from . import TTSBackend, STTBackend, LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS
from .base import is_model_cached, combine_voice_prompts as _combine_voice_prompts, model_load_progress
from ..services.mlx_thread import run_on_mlx_thread, clear_mlx_cache
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
class MLXTTSBackend:
@@ -63,7 +63,7 @@ class MLXTTSBackend:
weight_extensions=(".safetensors", ".bin", ".npz"),
)
def _ensure_loaded_sync(self, model_size: str | None):
def _ensure_loaded_sync(self, model_size: Optional[str]):
"""Load the model if the requested size isn't already resident.
Runs on the MLX worker thread so it stays serialized with generation.
@@ -79,7 +79,7 @@ class MLXTTSBackend:
self._load_model_sync(model_size)
async def load_model_async(self, model_size: str | None = None):
async def load_model_async(self, model_size: Optional[str] = None):
"""
Lazy load the MLX TTS model.
@@ -126,7 +126,7 @@ class MLXTTSBackend:
audio_path: str,
reference_text: str,
use_cache: bool = True,
) -> tuple[dict, bool]:
) -> Tuple[dict, bool]:
"""
Create voice prompt from reference audio.
@@ -154,8 +154,9 @@ class MLXTTSBackend:
cached_audio_path = cached_prompt.get("ref_audio") or cached_prompt.get("ref_audio_path")
if cached_audio_path and Path(cached_audio_path).exists():
return cached_prompt, True
# Cached file no longer exists, invalidate cache
logger.warning("Cached audio file not found: %s, regenerating prompt", cached_audio_path)
else:
# Cached file no longer exists, invalidate cache
logger.warning("Cached audio file not found: %s, regenerating prompt", cached_audio_path)
# MLX voice prompt format - store audio path and text
# The model will process this during generation
@@ -179,9 +180,9 @@ class MLXTTSBackend:
text: str,
voice_prompt: dict,
language: str = "en",
seed: int | None = None,
instruct: str | None = None,
) -> tuple[np.ndarray, int]:
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> Tuple[np.ndarray, int]:
"""
Generate audio from text using voice prompt.
@@ -227,30 +228,41 @@ class MLXTTSBackend:
# mlx_audio lookups hanging when the network drops mid-inference,
# issue #462) regressed online users because libraries make
# legitimate metadata calls during generation.
# A cloning failure surfaces as a failed generation; substituting
# the model's default voice would silently break the clone the
# user asked for.
if ref_audio:
import inspect
try:
if ref_audio:
# Check if generate accepts ref_audio parameter
import inspect
sig = inspect.signature(self.model.generate)
if "ref_audio" not in sig.parameters:
raise RuntimeError(
"Loaded MLX model does not support voice cloning "
"(generate() has no ref_audio parameter)"
)
for result in self.model.generate(text, ref_audio=ref_audio, ref_text=ref_text, lang_code=lang):
audio_chunks.append(np.array(result.audio))
sample_rate = result.sample_rate
else:
sig = inspect.signature(self.model.generate)
if "ref_audio" in sig.parameters:
# Generate with voice cloning
for result in self.model.generate(text, ref_audio=ref_audio, ref_text=ref_text, lang_code=lang):
audio_chunks.append(np.array(result.audio))
sample_rate = result.sample_rate
else:
# Fallback: generate without voice cloning
for result in self.model.generate(text, lang_code=lang):
audio_chunks.append(np.array(result.audio))
sample_rate = result.sample_rate
else:
# No voice prompt, generate normally
for result in self.model.generate(text, lang_code=lang):
audio_chunks.append(np.array(result.audio))
sample_rate = result.sample_rate
except Exception as e:
# If voice cloning fails, try without it
logger.warning("Voice cloning failed, generating without voice prompt: %s", e)
for result in self.model.generate(text, lang_code=lang):
audio_chunks.append(np.array(result.audio))
sample_rate = result.sample_rate
if not audio_chunks:
raise RuntimeError("Model produced no audio")
# Concatenate all chunks
if audio_chunks:
audio = np.concatenate([np.asarray(chunk, dtype=np.float32) for chunk in audio_chunks])
else:
# Fallback: empty audio
audio = np.array([], dtype=np.float32)
audio = np.concatenate([np.asarray(chunk, dtype=np.float32) for chunk in audio_chunks])
return audio, sample_rate
# Load-if-needed and inference run as one job on the MLX worker so a
@@ -279,7 +291,7 @@ class MLXSTTBackend:
hf_repo = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
return is_model_cached(hf_repo, weight_extensions=(".safetensors", ".bin", ".npz"))
def _ensure_loaded_sync(self, model_size: str | None):
def _ensure_loaded_sync(self, model_size: Optional[str]):
"""Load the model if the requested size isn't already resident.
Runs on the MLX worker thread so it stays serialized with transcription.
@@ -292,7 +304,7 @@ class MLXSTTBackend:
self._load_model_sync(model_size)
async def load_model_async(self, model_size: str | None = None):
async def load_model_async(self, model_size: Optional[str] = None):
"""
Lazy load the MLX Whisper model.
@@ -335,8 +347,8 @@ class MLXSTTBackend:
async def transcribe(
self,
audio_path: str,
language: str | None = None,
model_size: str | None = None,
language: Optional[str] = None,
model_size: Optional[str] = None,
) -> str:
"""
Transcribe audio to text.
@@ -365,11 +377,12 @@ class MLXSTTBackend:
# Extract text from result
if isinstance(result, str):
return result.strip()
if isinstance(result, dict):
elif isinstance(result, dict):
return result.get("text", "").strip()
if hasattr(result, "text"):
elif hasattr(result, "text"):
return result.text.strip()
return str(result).strip()
else:
return str(result).strip()
# Load-if-needed and transcription run as one job on the MLX worker so
# a concurrent unload or load can't land between them.
+21 -21
View File
@@ -2,25 +2,25 @@
PyTorch backend implementation for TTS and STT.
"""
from typing import Optional, List, Tuple
import asyncio
import logging
import numpy as np
import torch
import numpy as np
logger = logging.getLogger(__name__)
from ..utils.audio import load_audio
from ..utils.cache import cache_voice_prompt, get_cache_key, get_cached_voice_prompt
from . import LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS
from . import TTSBackend, STTBackend, LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS
from .base import (
combine_voice_prompts as _combine_voice_prompts,
empty_device_cache,
get_torch_device,
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
combine_voice_prompts as _combine_voice_prompts,
model_load_progress,
)
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
from ..utils.audio import load_audio
class PyTorchTTSBackend:
@@ -63,7 +63,7 @@ class PyTorchTTSBackend:
def _is_model_cached(self, model_size: str) -> bool:
return is_model_cached(self._get_model_path(model_size))
async def load_model_async(self, model_size: str | None = None):
async def load_model_async(self, model_size: Optional[str] = None):
"""
Lazy load the TTS model with automatic downloading from HuggingFace Hub.
@@ -140,7 +140,7 @@ class PyTorchTTSBackend:
audio_path: str,
reference_text: str,
use_cache: bool = True,
) -> tuple[dict, bool]:
) -> Tuple[dict, bool]:
"""
Create voice prompt from reference audio.
@@ -165,7 +165,7 @@ class PyTorchTTSBackend:
# For PyTorch backend, the dict should contain tensors, not file paths
# So we can safely return it
return cached_prompt, True
if isinstance(cached_prompt, torch.Tensor):
elif isinstance(cached_prompt, torch.Tensor):
# Legacy cache format - convert to dict
# This shouldn't happen in practice, but handle it
return {"prompt": cached_prompt}, True
@@ -194,9 +194,9 @@ class PyTorchTTSBackend:
async def combine_voice_prompts(
self,
audio_paths: list[str],
reference_texts: list[str],
) -> tuple[np.ndarray, str]:
audio_paths: List[str],
reference_texts: List[str],
) -> Tuple[np.ndarray, str]:
return await _combine_voice_prompts(audio_paths, reference_texts)
async def generate(
@@ -204,9 +204,9 @@ class PyTorchTTSBackend:
text: str,
voice_prompt: dict,
language: str = "en",
seed: int | None = None,
instruct: str | None = None,
) -> tuple[np.ndarray, int]:
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> Tuple[np.ndarray, int]:
"""
Generate audio from text using voice prompt.
@@ -266,7 +266,7 @@ class PyTorchSTTBackend:
hf_repo = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
return is_model_cached(hf_repo)
async def load_model_async(self, model_size: str | None = None):
async def load_model_async(self, model_size: Optional[str] = None):
"""
Lazy load the Whisper model.
@@ -290,7 +290,7 @@ class PyTorchSTTBackend:
is_cached = self._is_model_cached(model_size)
with model_load_progress(progress_model_name, is_cached):
from transformers import WhisperForConditionalGeneration, WhisperProcessor
from transformers import WhisperProcessor, WhisperForConditionalGeneration
model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
logger.info("Loading Whisper model %s on %s...", model_size, self.device)
@@ -317,8 +317,8 @@ class PyTorchSTTBackend:
async def transcribe(
self,
audio_path: str,
language: str | None = None,
model_size: str | None = None,
language: Optional[str] = None,
model_size: Optional[str] = None,
) -> str:
"""
Transcribe audio to text.
@@ -16,15 +16,16 @@ Languages supported: zh, en, ja, ko, de, fr, ru, pt, es, it
import asyncio
import logging
from typing import Optional
import numpy as np
import torch
from . import LANGUAGE_CODE_TO_NAME
from . import TTSBackend, LANGUAGE_CODE_TO_NAME
from .base import (
combine_voice_prompts as _combine_voice_prompts,
get_torch_device,
is_model_cached,
get_torch_device,
combine_voice_prompts as _combine_voice_prompts,
model_load_progress,
)
@@ -61,7 +62,7 @@ class QwenCustomVoiceBackend:
self.model = None
self.model_size = model_size
self.device = self._get_device()
self._current_model_size: str | None = None
self._current_model_size: Optional[str] = None
def _get_device(self) -> str:
return get_torch_device(allow_xpu=True, allow_directml=True)
@@ -74,11 +75,11 @@ class QwenCustomVoiceBackend:
raise ValueError(f"Unknown model size: {model_size}")
return QWEN_CV_HF_REPOS[model_size]
def _is_model_cached(self, model_size: str | None = None) -> bool:
def _is_model_cached(self, model_size: Optional[str] = None) -> bool:
size = model_size or self.model_size
return is_model_cached(self._get_model_path(size))
async def load_model_async(self, model_size: str | None = None) -> None:
async def load_model_async(self, model_size: Optional[str] = None) -> None:
if model_size is None:
model_size = self.model_size
@@ -163,8 +164,8 @@ class QwenCustomVoiceBackend:
text: str,
voice_prompt: dict,
language: str = "en",
seed: int | None = None,
instruct: str | None = None,
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> tuple[np.ndarray, int]:
"""
Generate audio using Qwen CustomVoice.
+38 -33
View File
@@ -9,16 +9,17 @@ and STT engines.
import asyncio
import logging
from typing import Optional
from ..services.mlx_thread import clear_mlx_cache, run_on_mlx_thread
from ..utils.hf_offline_patch import force_offline_if_cached
from . import DEFAULT_LLM_MAX_TOKENS, DEFAULT_LLM_TEMPERATURE
from . import LLMBackend, DEFAULT_LLM_MAX_TOKENS, DEFAULT_LLM_TEMPERATURE
from .base import (
empty_device_cache,
get_torch_device,
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
model_load_progress,
)
from ..services.mlx_thread import run_on_mlx_thread, clear_mlx_cache
logger = logging.getLogger(__name__)
@@ -42,8 +43,8 @@ def _progress_name(model_size: str) -> str:
def _build_messages(
prompt: str,
system: str | None,
examples: list[tuple[str, str]] | None = None,
system: Optional[str],
examples: Optional[list[tuple[str, str]]] = None,
) -> list[dict]:
messages: list[dict] = []
if system:
@@ -63,7 +64,7 @@ class PyTorchQwenLLMBackend:
self.model = None
self.tokenizer = None
self.model_size = model_size
self._current_model_size: str | None = None
self._current_model_size: Optional[str] = None
self.device = self._get_device()
def _get_device(self) -> str:
@@ -80,7 +81,7 @@ class PyTorchQwenLLMBackend:
def _is_model_cached(self, model_size: str) -> bool:
return is_model_cached(self._get_model_path(model_size))
async def load_model(self, model_size: str | None = None) -> None:
async def load_model(self, model_size: Optional[str] = None) -> None:
if model_size is None:
model_size = self.model_size
@@ -102,15 +103,19 @@ class PyTorchQwenLLMBackend:
with model_load_progress(progress_model_name, is_cached):
logger.info("Loading Qwen3 %s on %s...", model_size, self.device)
with force_offline_if_cached(is_cached, progress_model_name):
self.tokenizer = AutoTokenizer.from_pretrained(repo)
dtype = torch.float16 if self.device in ("cuda", "mps") else torch.float32
self.model = AutoModelForCausalLM.from_pretrained(
repo,
dtype=dtype,
)
self.model.to(self.device)
self.model.eval()
# Loads run with the process's default HF_HUB_OFFLINE state.
# Forcing offline for cached models flips process-global state
# and silently switches every concurrent download/load on other
# threads to offline mode (issue #841) — the same regression
# removed app-wide in #524/#530.
self.tokenizer = AutoTokenizer.from_pretrained(repo)
dtype = torch.float16 if self.device in ("cuda", "mps") else torch.float32
self.model = AutoModelForCausalLM.from_pretrained(
repo,
dtype=dtype,
)
self.model.to(self.device)
self.model.eval()
self._current_model_size = model_size
self.model_size = model_size
@@ -130,11 +135,11 @@ class PyTorchQwenLLMBackend:
async def generate(
self,
prompt: str,
system: str | None = None,
system: Optional[str] = None,
max_tokens: int = DEFAULT_LLM_MAX_TOKENS,
temperature: float = DEFAULT_LLM_TEMPERATURE,
model_size: str | None = None,
examples: list[tuple[str, str]] | None = None,
model_size: Optional[str] = None,
examples: Optional[list[tuple[str, str]]] = None,
) -> str:
await self.load_model(model_size)
return await asyncio.to_thread(
@@ -144,10 +149,10 @@ class PyTorchQwenLLMBackend:
def _generate_sync(
self,
prompt: str,
system: str | None,
system: Optional[str],
max_tokens: int,
temperature: float,
examples: list[tuple[str, str]] | None = None,
examples: Optional[list[tuple[str, str]]] = None,
) -> str:
import torch
@@ -185,7 +190,7 @@ class MLXQwenLLMBackend:
self.model = None
self.tokenizer = None
self.model_size = model_size
self._current_model_size: str | None = None
self._current_model_size: Optional[str] = None
def is_loaded(self) -> bool:
return self.model is not None
@@ -201,7 +206,7 @@ class MLXQwenLLMBackend:
weight_extensions=(".safetensors", ".bin", ".npz"),
)
def _ensure_loaded_sync(self, model_size: str | None) -> None:
def _ensure_loaded_sync(self, model_size: Optional[str]) -> None:
"""Load the model if the requested size isn't already resident.
Runs on the MLX worker thread so it stays serialized with generation.
@@ -217,7 +222,7 @@ class MLXQwenLLMBackend:
self._load_model_sync(model_size)
async def load_model(self, model_size: str | None = None) -> None:
async def load_model(self, model_size: Optional[str] = None) -> None:
await run_on_mlx_thread(self._ensure_loaded_sync, model_size)
async def unload(self) -> None:
@@ -233,8 +238,8 @@ class MLXQwenLLMBackend:
with model_load_progress(progress_model_name, is_cached):
logger.info("Loading Qwen3 %s via MLX...", model_size)
with force_offline_if_cached(is_cached, progress_model_name):
loaded = mlx_load(repo)
# See the PyTorch loader comment — no offline forcing (issue #841).
loaded = mlx_load(repo)
# mlx_lm.load returns (model, tokenizer) by default and
# (model, tokenizer, config) when return_config=True.
@@ -259,11 +264,11 @@ class MLXQwenLLMBackend:
async def generate(
self,
prompt: str,
system: str | None = None,
system: Optional[str] = None,
max_tokens: int = DEFAULT_LLM_MAX_TOKENS,
temperature: float = DEFAULT_LLM_TEMPERATURE,
model_size: str | None = None,
examples: list[tuple[str, str]] | None = None,
model_size: Optional[str] = None,
examples: Optional[list[tuple[str, str]]] = None,
) -> str:
# Load-if-needed and inference run as one job on the MLX worker so a
# concurrent unload or different-size load can't land between them.
@@ -276,10 +281,10 @@ class MLXQwenLLMBackend:
def _generate_sync(
self,
prompt: str,
system: str | None,
system: Optional[str],
max_tokens: int,
temperature: float,
examples: list[tuple[str, str]] | None = None,
examples: Optional[list[tuple[str, str]]] = None,
) -> str:
from mlx_lm import generate as mlx_generate
from mlx_lm.sample_utils import make_sampler
+3
View File
@@ -330,6 +330,9 @@ def build_server(cuda=False, rocm=False):
]
)
if sys.version_info >= (3, 13):
args.extend(["--hidden-import", "audioop"])
# Add CUDA/ROCm-specific hidden imports
if cuda or rocm:
variant = "ROCm" if rocm else "CUDA"
+5
View File
@@ -80,6 +80,11 @@ def resolve_storage_path(path: str | Path | None) -> Path | None:
return None
stored_path = Path(path)
# Empty paths (e.g. failed generations) must not resolve to the data
# dir itself, which exists and would defeat the callers' 404 guards.
# Path("") is truthy, so check parts rather than the raw value.
if not stored_path.parts:
return None
if stored_path.is_absolute():
rebased_path = _path_relative_to_any_data_dir(stored_path)
if rebased_path is not None:
+6 -6
View File
@@ -6,8 +6,8 @@ without changing any importers.
"""
from .models import (
AudioChannel,
Base,
AudioChannel,
Capture,
CaptureSettings,
ChannelDeviceMapping,
@@ -24,12 +24,12 @@ from .models import (
StoryItem,
VoiceProfile,
)
from .session import SessionLocal, _db_path, engine, get_db, init_db
from .session import engine, SessionLocal, _db_path, init_db, get_db
__all__ = [
"AudioChannel",
# Models
"Base",
"AudioChannel",
"Capture",
"CaptureSettings",
"ChannelDeviceMapping",
@@ -42,13 +42,13 @@ __all__ = [
"ProfileChannelMapping",
"ProfileSample",
"Project",
"SessionLocal",
"Story",
"StoryItem",
"VoiceProfile",
"_db_path",
# Session
"engine",
"get_db",
"SessionLocal",
"_db_path",
"init_db",
"get_db",
]
+1 -1
View File
@@ -303,7 +303,7 @@ def _normalize_storage_paths(engine, tables: set[str]) -> None:
"""Normalize stored file paths to be relative to the configured data dir."""
from pathlib import Path
from ..config import get_data_dir, resolve_storage_path, to_storage_path
from ..config import get_data_dir, to_storage_path, resolve_storage_path
data_dir = get_data_dir()
+2 -2
View File
@@ -1,9 +1,9 @@
"""ORM model definitions for the voicebox SQLite database."""
import uuid
from datetime import datetime
import uuid
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean, JSON
from sqlalchemy.ext.declarative import declarative_base
from ..utils.capture_chords import (
+5 -18
View File
@@ -3,20 +3,20 @@
import logging
import uuid
from sqlalchemy import create_engine, event
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from .. import config
from .migrations import run_migrations
from .models import (
AudioChannel,
Base,
AudioChannel,
EffectPreset,
Generation,
GenerationVersion,
ProfileChannelMapping,
VoiceProfile,
)
from .migrations import run_migrations
from .seed import backfill_generation_versions, seed_builtin_presets
logger = logging.getLogger(__name__)
@@ -36,22 +36,9 @@ def init_db() -> None:
engine = create_engine(
f"sqlite:///{_db_path}",
# timeout is sqlite3's busy handler: wait up to 30s on a locked
# database instead of raising "database is locked" immediately.
connect_args={"check_same_thread": False, "timeout": 30},
connect_args={"check_same_thread": False},
)
@event.listens_for(engine, "connect")
def _set_sqlite_pragmas(dbapi_connection, _connection_record):
# WAL lets readers proceed while a writer holds the lock, which is
# the main source of lock racing between the generation worker and
# request handlers. synchronous=NORMAL is the recommended pairing
# (durable across app crashes, fsyncs only on checkpoint).
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.close()
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
run_migrations(engine)
@@ -60,7 +47,7 @@ def init_db() -> None:
# Create default audio channel if it doesn't exist
db = SessionLocal()
try:
default_channel = db.query(AudioChannel).filter(AudioChannel.is_default).first()
default_channel = db.query(AudioChannel).filter(AudioChannel.is_default == True).first()
if not default_channel:
default_channel = AudioChannel(
id=str(uuid.uuid4()),
+1 -2
View File
@@ -5,11 +5,10 @@ entry point for development.
"""
import argparse
import uvicorn
from . import config, database
from .app import app # noqa: F401 -- re-export for uvicorn "backend.main:app"
from . import config, database
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="voicebox backend server")
+3 -2
View File
@@ -11,13 +11,14 @@ import asyncio
import ipaddress
import logging
from contextvars import ContextVar
from datetime import UTC, datetime
from datetime import datetime, timezone
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from starlette.types import ASGIApp
logger = logging.getLogger(__name__)
# Strong refs to in-flight stamp tasks so asyncio.create_task results
@@ -140,7 +141,7 @@ def _stamp_last_seen(client_id: str) -> None:
if row is None:
row = MCPClientBinding(client_id=client_id)
db.add(row)
row.last_seen_at = datetime.now(UTC)
row.last_seen_at = datetime.now(timezone.utc)
db.commit()
except Exception:
logger.debug(
+1
View File
@@ -8,6 +8,7 @@ floating pill surfaces whenever an agent is speaking.
import asyncio
from typing import Any
# Each subscriber gets its own queue. Bounded to drop oldest if a client lags.
_subscribers: set[asyncio.Queue[dict[str, Any]]] = set()
+1 -1
View File
@@ -30,7 +30,7 @@ def resolve_profile(
if client_id:
# Per-client binding. Imported lazily so this module stays importable
# even before the migration adds the table on first boot.
from ..database.models import MCPClientBinding
from ..database.models import MCPClientBinding # noqa: WPS433
binding = (
db.query(MCPClientBinding)
+2 -1
View File
@@ -9,8 +9,8 @@ binary bundled with the desktop app.
from __future__ import annotations
import logging
from collections.abc import Callable
from contextlib import AsyncExitStack, asynccontextmanager
from collections.abc import Callable
from fastapi import FastAPI
from fastmcp import FastMCP
@@ -18,6 +18,7 @@ from fastmcp import FastMCP
from .context import ClientIdMiddleware
from .tools import register_tools
logger = logging.getLogger(__name__)
+4 -15
View File
@@ -12,17 +12,19 @@ import base64 as b64
import logging
import tempfile
from pathlib import Path
from typing import Any, Literal
from typing import Any
from fastmcp import FastMCP
from .. import models
from ..database import get_db
from ..services import captures as captures_service, profiles as profiles_service
from ..services import captures as captures_service
from ..services import profiles as profiles_service
from . import events as mcp_events
from .context import current_client_id, request_is_loopback
from .resolve import resolve_profile
logger = logging.getLogger(__name__)
# Absolute-path transcribes are bounded to keep a bad client from
@@ -47,7 +49,6 @@ def register_tools(mcp: FastMCP) -> None:
engine: str | None = None,
personality: bool | None = None,
language: str | None = None,
model_size: Literal["1.7B", "0.6B", "1B", "3B"] | None = None,
) -> dict[str, Any]:
"""Speak ``text`` in a voice profile.
@@ -60,12 +61,6 @@ def register_tools(mcp: FastMCP) -> None:
LLM before TTS. When omitted, the per-client binding's
``default_personality`` flag decides; when that is unset, the
default is plain TTS.
``model_size`` selects a model variant for engines that ship more
than one — ``qwen`` and ``qwen_custom_voice`` accept "1.7B" (default)
or "0.6B"; ``tada`` accepts "1B" or "3B". Other engines ignore it.
Omit to use the engine default. Requesting a smaller variant (e.g.
"0.6B") is faster and avoids reloading a heavier model between calls.
"""
from ..database.models import MCPClientBinding
@@ -104,7 +99,6 @@ def register_tools(mcp: FastMCP) -> None:
engine=resolved_engine,
language=language,
personality=use_persona,
model_size=model_size,
db=db,
)
finally:
@@ -234,23 +228,18 @@ async def _speak(
engine: str | None,
language: str | None,
personality: bool,
model_size: str | None = None,
db,
) -> dict[str, Any]:
"""Delegate to POST /generate — the route handles personality-rewrite
internally when ``personality=true`` and the profile has a prompt."""
from ..routes.generations import generate_speech
# model_size=None is intentional: generate_speech normalizes it to the
# engine default (see routes/generations.py), so an omitted size behaves
# exactly like the REST /generate endpoint with no model_size in the body.
req = models.GenerationRequest(
profile_id=profile_id,
text=text,
language=language or "en",
engine=engine,
personality=personality,
model_size=model_size,
)
generation = await generate_speech(req, db)
return _speak_response(generation, profile_name, source="mcp")
+1
View File
@@ -23,6 +23,7 @@ from typing import Any
import httpx
CLIENT_ID_HEADER = "X-Voicebox-Client-Id"
SESSION_HEADER = "mcp-session-id"
HEALTH_TIMEOUT_S = 30.0
+153 -153
View File
@@ -2,9 +2,9 @@
Pydantic models for request/response validation.
"""
from datetime import datetime
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from .utils.capture_chords import (
default_push_to_talk_chord,
@@ -16,16 +16,16 @@ class VoiceProfileCreate(BaseModel):
"""Request model for creating a voice profile."""
name: str = Field(..., min_length=1, max_length=100)
description: str | None = Field(None, max_length=500)
description: Optional[str] = Field(None, max_length=500)
language: str = Field(
default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he|ar|da|el|fi|hi|ms|nl|no|pl|sv|sw|tr)$"
)
voice_type: str | None = Field(default="cloned", pattern="^(cloned|preset|designed)$")
preset_engine: str | None = Field(None, max_length=50)
preset_voice_id: str | None = Field(None, max_length=100)
design_prompt: str | None = Field(None, max_length=2000)
default_engine: str | None = Field(None, max_length=50)
personality: str | None = Field(None, max_length=2000)
voice_type: Optional[str] = Field(default="cloned", pattern="^(cloned|preset|designed)$")
preset_engine: Optional[str] = Field(None, max_length=50)
preset_voice_id: Optional[str] = Field(None, max_length=100)
design_prompt: Optional[str] = Field(None, max_length=2000)
default_engine: Optional[str] = Field(None, max_length=50)
personality: Optional[str] = Field(None, max_length=2000)
class VoiceProfileResponse(BaseModel):
@@ -33,16 +33,16 @@ class VoiceProfileResponse(BaseModel):
id: str
name: str
description: str | None
description: Optional[str]
language: str
avatar_path: str | None = None
effects_chain: list["EffectConfig"] | None = None
avatar_path: Optional[str] = None
effects_chain: Optional[List["EffectConfig"]] = None
voice_type: str = "cloned"
preset_engine: str | None = None
preset_voice_id: str | None = None
design_prompt: str | None = None
default_engine: str | None = None
personality: str | None = None
preset_engine: Optional[str] = None
preset_voice_id: Optional[str] = None
design_prompt: Optional[str] = None
default_engine: Optional[str] = None
personality: Optional[str] = None
generation_count: int = 0
sample_count: int = 0
created_at: datetime
@@ -82,10 +82,10 @@ class GenerationRequest(BaseModel):
profile_id: str
text: str = Field(..., min_length=1, max_length=50000)
language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he|ar|da|el|fi|hi|ms|nl|no|pl|sv|sw|tr)$")
seed: int | None = Field(None, ge=0)
model_size: str | None = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B|1B|3B)$")
instruct: str | None = Field(None, max_length=500)
engine: str | None = Field(default="qwen", pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$")
seed: Optional[int] = Field(None, ge=0)
model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B|1B|3B)$")
instruct: Optional[str] = Field(None, max_length=500)
engine: Optional[str] = Field(default="qwen", pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$")
personality: bool = Field(
default=False,
description="When true and the profile has a personality prompt, the input text is rewritten in-character before TTS.",
@@ -97,7 +97,7 @@ class GenerationRequest(BaseModel):
default=50, ge=0, le=500, description="Crossfade duration in ms between chunks (0 for hard cut)"
)
normalize: bool = Field(default=True, description="Normalize output audio volume")
effects_chain: list["EffectConfig"] | None = Field(
effects_chain: Optional[List["EffectConfig"]] = Field(
None, description="Effects chain to apply after generation (overrides profile default)"
)
@@ -109,19 +109,19 @@ class GenerationResponse(BaseModel):
profile_id: str
text: str
language: str
audio_path: str | None = None
duration: float | None = None
seed: int | None = None
instruct: str | None = None
engine: str | None = "qwen"
model_size: str | None = None
audio_path: Optional[str] = None
duration: Optional[float] = None
seed: Optional[int] = None
instruct: Optional[str] = None
engine: Optional[str] = "qwen"
model_size: Optional[str] = None
status: str = "completed"
error: str | None = None
error: Optional[str] = None
is_favorited: bool = False
source: str = "manual"
created_at: datetime
versions: list["GenerationVersionResponse"] | None = None
active_version_id: str | None = None
versions: Optional[List["GenerationVersionResponse"]] = None
active_version_id: Optional[str] = None
class Config:
from_attributes = True
@@ -130,8 +130,8 @@ class GenerationResponse(BaseModel):
class HistoryQuery(BaseModel):
"""Query model for generation history."""
profile_id: str | None = None
search: str | None = None
profile_id: Optional[str] = None
search: Optional[str] = None
limit: int = Field(default=50, ge=1, le=100)
offset: int = Field(default=0, ge=0)
@@ -144,18 +144,18 @@ class HistoryResponse(BaseModel):
profile_name: str
text: str
language: str
audio_path: str | None = None
duration: float | None = None
seed: int | None = None
instruct: str | None = None
engine: str | None = "qwen"
model_size: str | None = None
audio_path: Optional[str] = None
duration: Optional[float] = None
seed: Optional[int] = None
instruct: Optional[str] = None
engine: Optional[str] = "qwen"
model_size: Optional[str] = None
status: str = "completed"
error: str | None = None
error: Optional[str] = None
is_favorited: bool = False
created_at: datetime
versions: list["GenerationVersionResponse"] | None = None
active_version_id: str | None = None
versions: Optional[List["GenerationVersionResponse"]] = None
active_version_id: Optional[str] = None
class Config:
from_attributes = True
@@ -164,15 +164,15 @@ class HistoryResponse(BaseModel):
class HistoryListResponse(BaseModel):
"""Response model for history list."""
items: list[HistoryResponse]
items: List[HistoryResponse]
total: int
class TranscriptionRequest(BaseModel):
"""Request model for audio transcription."""
language: str | None = Field(None, pattern="^(en|zh|ja|ko|de|fr|ru|pt|es|it)$")
model: str | None = Field(None, pattern="^(base|small|medium|large|turbo)$")
language: Optional[str] = Field(None, pattern="^(en|zh|ja|ko|de|fr|ru|pt|es|it)$")
model: Optional[str] = Field(None, pattern="^(base|small|medium|large|turbo)$")
class TranscriptionResponse(BaseModel):
@@ -196,13 +196,13 @@ class CaptureResponse(BaseModel):
id: str
audio_path: str
source: str
language: str | None = None
duration_ms: int | None = None
language: Optional[str] = None
duration_ms: Optional[int] = None
transcript_raw: str
transcript_refined: str | None = None
stt_model: str | None = None
llm_model: str | None = None
refinement_flags: RefinementFlagsModel | None = None
transcript_refined: Optional[str] = None
stt_model: Optional[str] = None
llm_model: Optional[str] = None
refinement_flags: Optional[RefinementFlagsModel] = None
created_at: datetime
class Config:
@@ -212,7 +212,7 @@ class CaptureResponse(BaseModel):
class CaptureListResponse(BaseModel):
"""Response model for paginated capture list."""
items: list[CaptureResponse]
items: List[CaptureResponse]
total: int
@@ -234,15 +234,15 @@ class CaptureCreateResponse(CaptureResponse):
class CaptureRefineRequest(BaseModel):
"""Request to refine a capture's transcript via the LLM."""
flags: RefinementFlagsModel | None = None
model_size: str | None = Field(default=None, pattern="^(0\\.6B|1\\.7B|4B)$")
flags: Optional[RefinementFlagsModel] = None
model_size: Optional[str] = Field(default=None, pattern="^(0\\.6B|1\\.7B|4B)$")
class CaptureRetranscribeRequest(BaseModel):
"""Request to re-run STT on a capture's audio with a different model."""
model: str | None = Field(None, pattern="^(base|small|medium|large|turbo)$")
language: str | None = Field(None, pattern="^(en|zh|ja|ko|de|fr|ru|pt|es|it)$")
model: Optional[str] = Field(None, pattern="^(base|small|medium|large|turbo)$")
language: Optional[str] = Field(None, pattern="^(en|zh|ja|ko|de|fr|ru|pt|es|it)$")
class CaptureSettingsResponse(BaseModel):
@@ -256,13 +256,13 @@ class CaptureSettingsResponse(BaseModel):
self_correction: bool = True
preserve_technical: bool = True
allow_auto_paste: bool = True
default_playback_voice_id: str | None = None
default_playback_voice_id: Optional[str] = None
hotkey_enabled: bool = False
keep_mic_warm: bool = False
chord_push_to_talk_keys: list[str] = Field(
chord_push_to_talk_keys: List[str] = Field(
default_factory=default_push_to_talk_chord
)
chord_toggle_to_talk_keys: list[str] = Field(
chord_toggle_to_talk_keys: List[str] = Field(
default_factory=default_toggle_to_talk_chord
)
@@ -273,19 +273,19 @@ class CaptureSettingsResponse(BaseModel):
class CaptureSettingsUpdate(BaseModel):
"""Partial update for capture settings — every field is optional."""
stt_model: str | None = Field(default=None, pattern="^(base|small|medium|large|turbo)$")
language: str | None = None
auto_refine: bool | None = None
llm_model: str | None = Field(default=None, pattern="^(0\\.6B|1\\.7B|4B)$")
smart_cleanup: bool | None = None
self_correction: bool | None = None
preserve_technical: bool | None = None
allow_auto_paste: bool | None = None
default_playback_voice_id: str | None = None
hotkey_enabled: bool | None = None
keep_mic_warm: bool | None = None
chord_push_to_talk_keys: list[str] | None = Field(default=None, min_length=1, max_length=6)
chord_toggle_to_talk_keys: list[str] | None = Field(default=None, min_length=1, max_length=6)
stt_model: Optional[str] = Field(default=None, pattern="^(base|small|medium|large|turbo)$")
language: Optional[str] = None
auto_refine: Optional[bool] = None
llm_model: Optional[str] = Field(default=None, pattern="^(0\\.6B|1\\.7B|4B)$")
smart_cleanup: Optional[bool] = None
self_correction: Optional[bool] = None
preserve_technical: Optional[bool] = None
allow_auto_paste: Optional[bool] = None
default_playback_voice_id: Optional[str] = None
hotkey_enabled: Optional[bool] = None
keep_mic_warm: Optional[bool] = None
chord_push_to_talk_keys: Optional[List[str]] = Field(default=None, min_length=1, max_length=6)
chord_toggle_to_talk_keys: Optional[List[str]] = Field(default=None, min_length=1, max_length=6)
class GenerationSettingsResponse(BaseModel):
@@ -303,10 +303,10 @@ class GenerationSettingsResponse(BaseModel):
class GenerationSettingsUpdate(BaseModel):
"""Partial update for generation settings — every field is optional."""
max_chunk_chars: int | None = Field(default=None, ge=100, le=5000)
crossfade_ms: int | None = Field(default=None, ge=0, le=500)
normalize_audio: bool | None = None
autoplay_on_generate: bool | None = None
max_chunk_chars: Optional[int] = Field(default=None, ge=100, le=5000)
crossfade_ms: Optional[int] = Field(default=None, ge=0, le=500)
normalize_audio: Optional[bool] = None
autoplay_on_generate: Optional[bool] = None
class MCPClientBindingResponse(BaseModel):
@@ -315,14 +315,14 @@ class MCPClientBindingResponse(BaseModel):
opt-in personality-rewrite default."""
client_id: str
label: str | None = None
profile_id: str | None = None
default_engine: str | None = Field(
label: Optional[str] = None
profile_id: Optional[str] = None
default_engine: Optional[str] = Field(
None,
pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$",
)
default_personality: bool = False
last_seen_at: datetime | None = None
last_seen_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
@@ -334,9 +334,9 @@ class MCPClientBindingUpsert(BaseModel):
"""Create or update a binding. Matched by ``client_id``."""
client_id: str = Field(..., min_length=1, max_length=64)
label: str | None = Field(None, max_length=128)
profile_id: str | None = None
default_engine: str | None = Field(
label: Optional[str] = Field(None, max_length=128)
profile_id: Optional[str] = None
default_engine: Optional[str] = Field(
None,
pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$",
)
@@ -344,26 +344,26 @@ class MCPClientBindingUpsert(BaseModel):
class MCPClientBindingListResponse(BaseModel):
items: list[MCPClientBindingResponse]
items: List[MCPClientBindingResponse]
class SpeakRequest(BaseModel):
"""Body for POST /speak — non-MCP REST surface that mirrors voicebox.speak."""
text: str = Field(..., min_length=1, max_length=10000)
profile: str | None = Field(
profile: Optional[str] = Field(
None,
description="Voice profile name or id. Falls back to per-client binding, then default.",
)
engine: str | None = Field(
engine: Optional[str] = Field(
None,
pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$",
)
personality: bool | None = Field(
personality: Optional[bool] = Field(
None,
description="When true and the profile has a personality prompt, the input text is rewritten in-character before TTS. When null, the per-client binding's default_personality flag decides.",
)
language: str | None = Field(
language: Optional[str] = Field(
None,
pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he|ar|da|el|fi|hi|ms|nl|no|pl|sv|sw|tr)$",
)
@@ -373,15 +373,15 @@ class LLMGenerateRequest(BaseModel):
"""Request model for LLM text generation."""
prompt: str = Field(..., min_length=1, max_length=50000)
system: str | None = Field(None, max_length=4000)
model_size: str | None = Field(default="0.6B", pattern="^(0\\.6B|1\\.7B|4B)$")
system: Optional[str] = Field(None, max_length=4000)
model_size: Optional[str] = Field(default="0.6B", pattern="^(0\\.6B|1\\.7B|4B)$")
max_tokens: int = Field(default=512, ge=1, le=4096)
temperature: float = Field(default=0.7, ge=0.0, le=2.0)
# Few-shot (user, assistant) pairs prepended as real chat turns.
# Used by the refinement service to pin tricky rules (imperatives
# staying imperatives, technical-term punctuation) that small models
# lose when the examples live inline in the system prompt.
examples: list[list[str]] | None = Field(default=None, max_length=8)
examples: Optional[List[List[str]]] = Field(default=None, max_length=8)
class LLMGenerateResponse(BaseModel):
@@ -418,7 +418,7 @@ class ModelReadiness(BaseModel):
model_name: str
display_name: str
size: str
size_mb: int | None = None
size_mb: Optional[int] = None
class CaptureReadinessResponse(BaseModel):
@@ -438,15 +438,15 @@ class HealthResponse(BaseModel):
status: str
model_loaded: bool
model_downloaded: bool | None = None # Whether model is cached/downloaded
model_size: str | None = None # Current model size if loaded
model_downloaded: Optional[bool] = None # Whether model is cached/downloaded
model_size: Optional[str] = None # Current model size if loaded
gpu_available: bool
gpu_type: str | None = None # GPU type (CUDA, MPS, or None)
vram_used_mb: float | None = None
backend_type: str | None = None # Backend type (mlx or pytorch)
backend_variant: str | None = None # Binary variant (cpu, cuda, or rocm)
gpu_type: Optional[str] = None # GPU type (CUDA, MPS, or None)
vram_used_mb: Optional[float] = None
backend_type: Optional[str] = None # Backend type (mlx or pytorch)
backend_variant: Optional[str] = None # Binary variant (cpu, cuda, or rocm)
supports_rocm: bool = False # AMD GPU on Windows — the ROCm backend is applicable
gpu_compatibility_warning: str | None = None # Warning if GPU arch unsupported
gpu_compatibility_warning: Optional[str] = None # Warning if GPU arch unsupported
class DirectoryCheck(BaseModel):
@@ -455,16 +455,16 @@ class DirectoryCheck(BaseModel):
path: str
exists: bool
writable: bool
error: str | None = None
error: Optional[str] = None
class FilesystemHealthResponse(BaseModel):
"""Response model for filesystem health check."""
healthy: bool
disk_free_mb: float | None = None
disk_total_mb: float | None = None
directories: list[DirectoryCheck]
disk_free_mb: Optional[float] = None
disk_total_mb: Optional[float] = None
directories: List[DirectoryCheck]
class ModelStatus(BaseModel):
@@ -472,17 +472,17 @@ class ModelStatus(BaseModel):
model_name: str
display_name: str
hf_repo_id: str | None = None # HuggingFace repository ID
hf_repo_id: Optional[str] = None # HuggingFace repository ID
downloaded: bool
downloading: bool = False # True if download is in progress
size_mb: float | None = None
size_mb: Optional[float] = None
loaded: bool = False
class ModelStatusListResponse(BaseModel):
"""Response model for model status list."""
models: list[ModelStatus]
models: List[ModelStatus]
class ModelDownloadRequest(BaseModel):
@@ -503,11 +503,11 @@ class ActiveDownloadTask(BaseModel):
model_name: str
status: str
started_at: datetime
error: str | None = None
progress: float | None = None # 0-100 percentage
current: int | None = None # bytes downloaded
total: int | None = None # total bytes
filename: str | None = None # current file being downloaded
error: Optional[str] = None
progress: Optional[float] = None # 0-100 percentage
current: Optional[int] = None # bytes downloaded
total: Optional[int] = None # total bytes
filename: Optional[str] = None # current file being downloaded
class ActiveGenerationTask(BaseModel):
@@ -522,22 +522,22 @@ class ActiveGenerationTask(BaseModel):
class ActiveTasksResponse(BaseModel):
"""Response model for active tasks."""
downloads: list[ActiveDownloadTask]
generations: list[ActiveGenerationTask]
downloads: List[ActiveDownloadTask]
generations: List[ActiveGenerationTask]
class AudioChannelCreate(BaseModel):
"""Request model for creating an audio channel."""
name: str = Field(..., min_length=1, max_length=100)
device_ids: list[str] = Field(default_factory=list)
device_ids: List[str] = Field(default_factory=list)
class AudioChannelUpdate(BaseModel):
"""Request model for updating an audio channel."""
name: str | None = Field(None, min_length=1, max_length=100)
device_ids: list[str] | None = None
name: Optional[str] = Field(None, min_length=1, max_length=100)
device_ids: Optional[List[str]] = None
class AudioChannelResponse(BaseModel):
@@ -546,7 +546,7 @@ class AudioChannelResponse(BaseModel):
id: str
name: str
is_default: bool
device_ids: list[str]
device_ids: List[str]
created_at: datetime
class Config:
@@ -556,20 +556,20 @@ class AudioChannelResponse(BaseModel):
class ChannelVoiceAssignment(BaseModel):
"""Request model for assigning voices to a channel."""
profile_ids: list[str]
profile_ids: List[str]
class ProfileChannelAssignment(BaseModel):
"""Request model for assigning channels to a profile."""
channel_ids: list[str]
channel_ids: List[str]
class StoryCreate(BaseModel):
"""Request model for creating a story."""
name: str = Field(..., min_length=1, max_length=100)
description: str | None = Field(None, max_length=500)
description: Optional[str] = Field(None, max_length=500)
class StoryResponse(BaseModel):
@@ -577,7 +577,7 @@ class StoryResponse(BaseModel):
id: str
name: str
description: str | None
description: Optional[str]
created_at: datetime
updated_at: datetime
item_count: int = 0
@@ -592,7 +592,7 @@ class StoryItemDetail(BaseModel):
id: str
story_id: str
generation_id: str
version_id: str | None = None
version_id: Optional[str] = None
start_time_ms: int
track: int = 0
trim_start_ms: int = 0
@@ -605,14 +605,14 @@ class StoryItemDetail(BaseModel):
language: str
audio_path: str
duration: float
seed: int | None
instruct: str | None
engine: str | None = None
seed: Optional[int]
instruct: Optional[str]
engine: Optional[str] = None
volume: float = 1.0
generation_created_at: datetime
# Versions available for this generation
versions: list["GenerationVersionResponse"] | None = None
active_version_id: str | None = None
versions: Optional[List["GenerationVersionResponse"]] = None
active_version_id: Optional[str] = None
class Config:
from_attributes = True
@@ -623,10 +623,10 @@ class StoryDetailResponse(BaseModel):
id: str
name: str
description: str | None
description: Optional[str]
created_at: datetime
updated_at: datetime
items: list[StoryItemDetail] = []
items: List[StoryItemDetail] = []
class Config:
from_attributes = True
@@ -636,8 +636,8 @@ class StoryItemCreate(BaseModel):
"""Request model for adding a generation to a story."""
generation_id: str
start_time_ms: int | None = None # If not provided, will be calculated automatically
track: int | None = 0 # Track number (0 = main track)
start_time_ms: Optional[int] = None # If not provided, will be calculated automatically
track: Optional[int] = 0 # Track number (0 = main track)
class StoryItemUpdateTime(BaseModel):
@@ -650,13 +650,13 @@ class StoryItemUpdateTime(BaseModel):
class StoryItemBatchUpdate(BaseModel):
"""Request model for batch updating story item timecodes."""
updates: list[StoryItemUpdateTime]
updates: List[StoryItemUpdateTime]
class StoryItemReorder(BaseModel):
"""Request model for reordering story items."""
generation_ids: list[str] = Field(..., min_length=1)
generation_ids: List[str] = Field(..., min_length=1)
class StoryItemMove(BaseModel):
@@ -682,7 +682,7 @@ class StoryItemSplit(BaseModel):
class StoryItemVersionUpdate(BaseModel):
"""Request model for setting a story item's pinned version."""
version_id: str | None = None # null = use generation default
version_id: Optional[str] = None # null = use generation default
class StoryItemVolumeUpdate(BaseModel):
@@ -707,23 +707,23 @@ class EffectConfig(BaseModel):
class EffectsChain(BaseModel):
"""An ordered list of effects to apply."""
effects: list[EffectConfig] = Field(default_factory=list)
effects: List[EffectConfig] = Field(default_factory=list)
class EffectPresetCreate(BaseModel):
"""Request model for creating an effect preset."""
name: str = Field(..., min_length=1, max_length=100)
description: str | None = Field(None, max_length=500)
effects_chain: list[EffectConfig]
description: Optional[str] = Field(None, max_length=500)
effects_chain: List[EffectConfig]
class EffectPresetUpdate(BaseModel):
"""Request model for updating an effect preset."""
name: str | None = Field(None, min_length=1, max_length=100)
description: str | None = None
effects_chain: list[EffectConfig] | None = None
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = None
effects_chain: Optional[List[EffectConfig]] = None
class EffectPresetResponse(BaseModel):
@@ -731,8 +731,8 @@ class EffectPresetResponse(BaseModel):
id: str
name: str
description: str | None = None
effects_chain: list[EffectConfig]
description: Optional[str] = None
effects_chain: List[EffectConfig]
is_builtin: bool = False
created_at: datetime
@@ -747,8 +747,8 @@ class GenerationVersionResponse(BaseModel):
generation_id: str
label: str
audio_path: str
effects_chain: list[EffectConfig] | None = None
source_version_id: str | None = None
effects_chain: Optional[List[EffectConfig]] = None
source_version_id: Optional[str] = None
is_default: bool
created_at: datetime
@@ -759,18 +759,18 @@ class GenerationVersionResponse(BaseModel):
class ApplyEffectsRequest(BaseModel):
"""Request to apply effects to an existing generation."""
effects_chain: list[EffectConfig]
source_version_id: str | None = Field(
effects_chain: List[EffectConfig]
source_version_id: Optional[str] = Field(
None, description="Version to use as source audio (defaults to clean/original)"
)
label: str | None = Field(None, max_length=100, description="Label for this version (auto-generated if omitted)")
label: Optional[str] = Field(None, max_length=100, description="Label for this version (auto-generated if omitted)")
set_as_default: bool = Field(default=True, description="Set this version as the default")
class ProfileEffectsUpdate(BaseModel):
"""Request to update the default effects chain on a profile."""
effects_chain: list[EffectConfig] | None = Field(None, description="Effects chain (null to remove)")
effects_chain: Optional[List[EffectConfig]] = Field(None, description="Effects chain (null to remove)")
class AvailableEffectParam(BaseModel):
@@ -795,7 +795,7 @@ class AvailableEffect(BaseModel):
class AvailableEffectsResponse(BaseModel):
"""Response listing all available effect types."""
effects: list[AvailableEffect]
effects: List[AvailableEffect]
# ─── Cloud (backup & sync) ──────────────────────────────────────────────
@@ -812,8 +812,8 @@ class CloudStatusResponse(BaseModel):
"""Current link between this device and a Voicebox Cloud account."""
connected: bool
device_name: str | None = None
account_user_id: str | None = None
key_prefix: str | None = None
connected_at: datetime | None = None
device_name: Optional[str] = None
account_user_id: Optional[str] = None
key_prefix: Optional[str] = None
connected_at: Optional[datetime] = None
dashboard_url: str
+11 -12
View File
@@ -44,7 +44,6 @@ def _patch_torch_from_numpy():
return
try:
import ctypes
import numpy as np
_orig = torch.from_numpy
@@ -54,17 +53,17 @@ def _patch_torch_from_numpy():
# silently corrupt data (e.g. fp16 tensors from some TTS engines),
# so we raise instead.
dtype_map = {
"float16": torch.float16,
"float32": torch.float32,
"float64": torch.float64,
"int8": torch.int8,
"int16": torch.int16,
"int32": torch.int32,
"int64": torch.int64,
"uint8": torch.uint8,
"bool": torch.bool,
"complex64": torch.complex64,
"complex128": torch.complex128,
"float16": _t.float16,
"float32": _t.float32,
"float64": _t.float64,
"int8": _t.int8,
"int16": _t.int16,
"int32": _t.int32,
"int64": _t.int64,
"uint8": _t.uint8,
"bool": _t.bool,
"complex64": _t.complex64,
"complex128": _t.complex128,
}
def _safe_from_numpy(
@@ -56,6 +56,7 @@ import sys
import tempfile
import types
# Diagnostics — log hook activity to a file alongside the bundle so we can
# see what's happening when the server is run as a sidecar (no stdout for
# runtime hook prints). Safe no-op if the file can't be written.
+4 -32
View File
@@ -1,6 +1,6 @@
[project]
name = "voicebox-backend"
version = "0.5.0"
version = "0.2.3"
requires-python = ">=3.12"
# ---------------------------------------------------------------------------
@@ -49,43 +49,19 @@ ignore = [
"SIM108", # use ternary operator (sometimes less readable)
"B008", # function call in default argument (FastAPI Depends() pattern)
"UP007", # use X | Y for union (auto-fixed by UP, but noisy on big diffs)
# Existing-violation baseline so ruff can gate CI. Remove entries from
# this list as the remaining occurrences are fixed; counts are as of
# 2026-07-26 after the auto-fix pass.
"B904", # raise without `from` inside except (49) -- needs per-site from err/from None
"SIM105", # try/except/pass instead of contextlib.suppress (14)
"N806", # non-lowercase variable in function (9)
"RUF002", # ambiguous unicode in docstring (6)
"F841", # unused variable (5)
"N803", # invalid argument name (5)
"B007", # unused loop control variable (4)
"ERA001", # commented-out code (4)
"SIM102", # collapsible if (4)
"SIM117", # multiple with statements (4)
"SIM115", # open() without context manager (3)
"RUF001", # ambiguous unicode in string (2)
"RUF012", # mutable class default (2)
"SIM110", # reimplemented builtin (2)
"RUF006", # asyncio dangling task (1)
"RUF034", # useless if-else (1)
]
# Per-file rule overrides.
[tool.ruff.lint.per-file-ignores]
# Tests can use assert, print, magic values, and script-style setup freely.
"tests/**" = ["S101", "T201", "PLR2004", "ERA001", "E402", "PT011", "PT018", "PT019"]
# Tests can use assert, print, and magic values freely.
"tests/**" = ["S101", "T201", "PLR2004", "ERA001"]
# __init__.py re-exports are expected to have unused imports.
"**/__init__.py" = ["F401"]
# Entry points and scripts legitimately use print.
"server.py" = ["T201"]
"main.py" = ["T201"]
# AMD GPU env vars must be set before torch import.
"app.py" = ["E402"]
# Environment and stdout hardening must run before heavy imports.
"server.py" = ["T201", "E402"]
"backends/__init__.py" = ["E402"]
"backends/mlx_backend.py" = ["E402"]
"backends/pytorch_backend.py" = ["E402"]
[tool.ruff.lint.isort]
known-first-party = ["backend"]
@@ -105,7 +81,3 @@ docstring-code-format = true
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
markers = [
"slow: long-running tests, deselect with '-m \"not slow\"'",
"timeout: per-test timeout in seconds (enforced only when pytest-timeout is installed)",
]
+25
View File
@@ -0,0 +1,25 @@
# Minimal dependency set to boot the backend on a CPU-only CI runner.
# No TTS/STT model libraries — inference is covered by the fake TTS
# backend (VOICEBOX_FAKE_TTS=1). Install CPU torch first on Linux:
# pip install torch --index-url https://download.pytorch.org/whl/cpu
# then: pip install -r backend/requirements-ci.txt
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
pydantic>=2.5.0
sqlalchemy>=2.0.0
alembic>=1.13.0
torch>=2.2.0
huggingface_hub>=0.20.0
numpy
soundfile
python-multipart
sse-starlette
psutil
requests
httpx
fastmcp
librosa
pillow
pydub
pedalboard
+2 -1
View File
@@ -16,7 +16,8 @@ miniaudio>=1.59
# mlx_audio.stt.load) works fine on transformers 4.57.x in practice.
#
# Install it via `pip install --no-deps mlx-audio==0.4.1` after this file
# (see .github/workflows/release.yml). Most other mlx-audio runtime deps
# (see .github/workflows/release.yml and the setup-python recipe in the
# justfile). Most other mlx-audio runtime deps
# (huggingface_hub, librosa, mlx-lm, numba, numpy, protobuf, pyloudnorm,
# sounddevice, tqdm) are already in requirements.txt or pulled in by
# other engines.
+4 -4
View File
@@ -17,10 +17,9 @@ qwen-tts>=0.0.5
# LuxTTS (voice cloning engine)
# piper-phonemize needs custom index (no PyPI wheels)
--find-links https://k2-fsa.github.io/icefall/piper_phonemize.html
# linacodec is a git-only dep of Zipvoice (uv-only source, pip can't resolve it).
# Both are pinned to commits so a force-push upstream can't change what we ship.
linacodec @ git+https://github.com/ysharma3501/LinaCodec.git@c0ae7c7285e121475c27592cfbb600624b714290
Zipvoice @ git+https://github.com/ysharma3501/LuxTTS.git@381b1609cb1d1afbf756b87809c623464cfd8ac5
# linacodec is a git-only dep of Zipvoice (uv-only source, pip can't resolve it)
linacodec @ git+https://github.com/ysharma3501/LinaCodec.git
Zipvoice @ git+https://github.com/ysharma3501/LuxTTS.git
# Chatterbox TTS sub-dependencies (chatterbox-tts itself is installed
# --no-deps in the setup script because it pins numpy<1.26 / torch==2.6
@@ -54,6 +53,7 @@ en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_
unidic-lite>=1.0.8
# Audio processing
audioop-lts>=0.2.1; python_version >= "3.13"
librosa>=0.10.0
soundfile>=0.12.0
numpy>=1.24.0,<2.0
+17 -17
View File
@@ -5,26 +5,26 @@ from fastapi import FastAPI
def register_routers(app: FastAPI) -> None:
"""Include all domain routers on the application."""
from .audio import router as audio_router
from .captures import router as captures_router
from .channels import router as channels_router
from .cloud import router as cloud_router
from .cuda import router as cuda_router
from .effects import router as effects_router
from .events import router as events_router
from .generations import router as generations_router
from .health import router as health_router
from .history import router as history_router
from .llm import router as llm_router
from .mcp_bindings import router as mcp_bindings_router
from .models import router as models_router
from .profiles import router as profiles_router
from .rocm import router as rocm_router
from .settings import router as settings_router
from .speak import router as speak_router
from .stories import router as stories_router
from .tasks import router as tasks_router
from .channels import router as channels_router
from .generations import router as generations_router
from .history import router as history_router
from .transcription import router as transcription_router
from .llm import router as llm_router
from .captures import router as captures_router
from .stories import router as stories_router
from .effects import router as effects_router
from .audio import router as audio_router
from .models import router as models_router
from .settings import router as settings_router
from .tasks import router as tasks_router
from .cuda import router as cuda_router
from .rocm import router as rocm_router
from .speak import router as speak_router
from .mcp_bindings import router as mcp_bindings_router
from .events import router as events_router
from .cloud import router as cloud_router
app.include_router(health_router)
app.include_router(profiles_router)
+11 -6
View File
@@ -7,9 +7,9 @@ from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from .. import config
from ..database import get_db
from .. import config, models
from ..services import history
from ..database import get_db
router = APIRouter()
@@ -34,7 +34,7 @@ async def get_version_audio(version_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="Version not found")
audio_path = config.resolve_storage_path(version.audio_path)
if audio_path is None or not audio_path.exists():
if audio_path is None or not audio_path.is_file():
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(
@@ -52,8 +52,13 @@ async def get_audio(generation_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="Generation not found")
audio_path = config.resolve_storage_path(generation.audio_path)
if audio_path is None or not audio_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found")
if audio_path is None or not audio_path.is_file():
detail = (
"Generation failed; no audio available"
if generation.status == "failed"
else "Audio file not found"
)
raise HTTPException(status_code=404, detail=detail)
return FileResponse(
audio_path,
@@ -72,7 +77,7 @@ async def get_sample_audio(sample_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="Sample not found")
audio_path = config.resolve_storage_path(sample.audio_path)
if audio_path is None or not audio_path.exists():
if audio_path is None or not audio_path.is_file():
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(
+2 -1
View File
@@ -10,7 +10,8 @@ from .. import config, models
from ..backends import get_llm_model_configs, get_stt_model_configs
from ..backends.base import is_model_cached
from ..database import Capture as DBCapture, get_db
from ..services import captures as captures_service, settings as settings_service
from ..services import captures as captures_service
from ..services import settings as settings_service
from ..services.refinement import RefinementFlags
logger = logging.getLogger(__name__)
+1 -1
View File
@@ -4,8 +4,8 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from .. import models
from ..database import get_db
from ..services import channels
from ..database import get_db
router = APIRouter()
+4
View File
@@ -26,6 +26,10 @@ async def download_cuda_backend():
"""Download the CUDA backend binary."""
from ..services import cuda
unsupported_reason = cuda.get_cuda_download_unsupported_reason()
if unsupported_reason:
raise HTTPException(status_code=409, detail=unsupported_reason)
if cuda.get_cuda_binary_path() is not None:
raise HTTPException(status_code=409, detail="CUDA backend already downloaded")
+3 -3
View File
@@ -9,8 +9,8 @@ from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from .. import config, models
from ..database import Generation as DBGeneration, get_db
from ..services import history
from ..database import Generation as DBGeneration, get_db
router = APIRouter()
@@ -29,8 +29,8 @@ async def preview_effects(
raise HTTPException(status_code=400, detail="Generation is not completed")
from ..services import versions as versions_mod
from ..utils.audio import load_audio
from ..utils.effects import apply_effects, validate_effects_chain
from ..utils.audio import load_audio
chain_dicts = [e.model_dump() for e in data.effects_chain]
error = validate_effects_chain(chain_dicts)
@@ -170,8 +170,8 @@ async def apply_effects_to_generation(
raise HTTPException(status_code=400, detail="Generation is not completed")
from ..services import versions as versions_mod
from ..utils.audio import load_audio, save_audio
from ..utils.effects import apply_effects, validate_effects_chain
from ..utils.audio import load_audio, save_audio
chain_dicts = [e.model_dump() for e in data.effects_chain]
error = validate_effects_chain(chain_dicts)
+1
View File
@@ -14,6 +14,7 @@ from sse_starlette.sse import EventSourceResponse
from ..mcp_server import events as mcp_events
logger = logging.getLogger(__name__)
router = APIRouter()

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