Compare commits

..
Author SHA1 Message Date
Jamie Pine 59782e9321 chore: ignore output/ directory (spotted in #946)
The Docker compose file bind-mounts output/ but it was never ignored.
2026-07-27 19:20:56 -07:00
Jamie Pine 777a9143cc chore(backend): sort imports in cherry-picked transcription fix 2026-07-26 23:34:33 -07:00
Alex SummerandJamie Pine 81df93ae7b fix(docs): update quick start guide to reflect correct terminology for voice profiles (#963) 2026-07-26 23:33:48 -07:00
a6d736e277 fix(ui): open FloatingGenerateBox selects upward to prevent clipping (fixes #928) (#936)
The floating generate box is fixed at the bottom of the viewport, so
all of its Select dropdowns (voice profile, language, engine, effects)
opened downward into — or beyond — the window edge. Add side="top" to
each SelectContent so the menus appear above their trigger instead.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-07-26 23:33:48 -07:00
Sai Sridhar TarraandJamie Pine 365c2c31ab fix(key_codes): add Function key arm so macOS fn can be bound to a chord (#950)
key_from_str() had no arm for "Function", so it fell through to
None. Since build_chord propagates that as a hard Err via ?, binding
any chord containing fn made build_chord_bindings fail entirely —
HotkeyMonitor was never spawned, silently killing both push-to-talk
and toggle-to-talk until the chord was reverted.

Every other layer (keytap's macOS key tap, Key::Function itself, the
frontend's canonicalKeyFromEvent/displayLabelForKey) already handles
fn — only this string-to-Key bridge was missing the arm.

Fixes #941
2026-07-26 23:33:48 -07:00
8d699e3bf6 fix(export): disambiguate export filenames with generation id (#956)
Export filenames were derived from only the first 30 characters of the
generation text. Generations with similar wording (a common workflow when
iterating on the same line) produced identical filenames, so exports
collided on disk — the browser appended " (1)"/" (2)" and users ended up
opening audio that didn't match the expected filename.

Append the first 8 chars of the generation id to the .wav and .voicebox.zip
export filenames, in both the backend Content-Disposition headers and the
frontend save-file hooks.

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-07-26 23:33:48 -07:00
f767c8e058 fix(backend): guard avatar upload against a missing filename (#954)
`UploadFile.filename` can be None, and `Path(None)` raises TypeError. On the
avatar endpoint this happens before the try/except, so a filename-less upload
surfaces as an unhandled 500 instead of a clean response. Every other upload
handler already guards this with `file.filename or ""` (add_profile_sample,
transcription, generations); apply the same guard here.

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-26 23:33:48 -07:00
f4d7c86ec0 fix(ui): parse naive-UTC timestamps consistently in formatAbsoluteDate (#953)
* fix(ui): parse naive-UTC timestamps consistently in formatAbsoluteDate

Backend timestamps are naive UTC (Python `datetime.utcnow()`) and are
serialized without a timezone suffix. `formatDate` already normalizes
these by appending `Z` before parsing, but `formatAbsoluteDate` called
`new Date(date)` directly. Per the ES spec, a timezone-less date-time
string is parsed as local time, so absolute timestamps were shown off by
the viewer's UTC offset (e.g. +9h in JST) — and disagreed with the
relative time rendered by `formatDate` for the same value (visible in the
Captures detail panel, which uses both on `capture.created_at`).

Extract the normalization into a shared `parseServerDate` helper and use
it in both formatters.

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

* docs(format): clarify parseServerDate comment on date-only vs date-time parsing

ECMAScript parses date-only strings ("2026-07-23") as UTC but timezone-less
date-time strings ("2026-07-23T10:00:00") as local time. The backend emits the
latter, which is the case this helper normalizes. Corrects the comment per PR
review feedback.

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

* docs(format): trim parseServerDate comment to match surrounding style

Reduce the multi-line explanation to a single why-comment consistent with
other utils comments.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-26 23:33:48 -07:00
AhmedIrfanandJamie Pine abe16de6ee fix(mcp): add model_size parameter to voicebox.speak (#895)
The MCP voicebox.speak tool built its GenerationRequest without a
model_size, so every agent-triggered generation fell back to the schema
default ("1.7B"). There was no way to reach the 0.6B Qwen variant (or
TADA's 1B/3B) through MCP, and callers paid a model reload whenever the
requested size differed from what was already loaded.

Thread an optional model_size through voicebox.speak and the _speak
helper into GenerationRequest, mirroring the REST /generate surface.
Omitting it passes None, which generate_speech normalizes to the engine
default, so existing callers are unaffected.

Add backend/tests/test_mcp_speak.py covering the forwarded value, the
omitted-default path, and rejection of an invalid size.

Fixes #884
2026-07-26 23:33:48 -07:00
XariannandJamie Pine af2308ea38 fix(rocm): add MIOpen stability env vars to docker-compose.rocm.yml (#865)
Add three environment variables to prevent miopenStatusUnknownError and
system stuttering during inference on RDNA4 GPUs:

- MIOPEN_USER_DB_PATH: redirect MIOpen kernel cache to writable, persistent dir
- MIOPEN_CUSTOM_CACHE_DIR: same, for custom operator cache
- MIOPEN_FIND_MODE=FAST: use heuristic kernel selection instead of exhaustive
  benchmarking, which fails on RDNA4 with ptr: 0 size: 0 workspace warnings

MIOPEN_FIND_MODE=FAST does not affect output quality. All MIOpen kernel
variants produce the same numerical result; fast mode selects a known-good
kernel using heuristics instead of benchmarking every variant on the GPU.

Tested on RX 9070 (gfx1201) with ROCm 7.2 and PyTorch 2.12.1+rocm7.2.

Hardware note: tested on Ryzen 7 9800X3D + RX 9070 with Gigabyte B650M DS3H
motherboard. The exhaustive benchmarking failures may be related to IOMMU
behavior on this platform. This system was affected by an IOMMU bug patched
upstream in kernel 6.19.10, which may be a contributing factor. May not
affect all RDNA4 systems. MIOPEN_FIND_MODE=FAST is a safe default regardless.

Depends on PR #862 which fixes the broken ROCm Docker build.
2026-07-26 23:33:48 -07:00
Sai Sridhar TarraandJamie Pine 0c730104e2 fix(docker): normalize CRLF line endings on Windows checkouts (#951)
A Windows Git checkout with checkout-time CRLF conversion enabled
produces CRLF working-tree copies of package.json and
scripts/rocm-entrypoint.sh, breaking the Docker build two ways:

- The frontend stage's `sed -i -z 's/,\n  ]/…/'` is LF-anchored, so
  it doesn't match against \r\n and leaves an invalid trailing comma
  in package.json, which then fails JSON parsing in the vite build.
- The final stage copies rocm-entrypoint.sh straight from the build
  context; with a CRLF shebang the container reports the misleading
  "no such file or directory" for an entrypoint that plainly exists,
  because Linux can't resolve "/bin/sh\r" as an interpreter.

Add .gitattributes forcing LF for both files at checkout time, plus a
sed normalization step in each Dockerfile stage for resilience with
clones that predate the .gitattributes rule.

Fixes #915
2026-07-26 23:33:48 -07:00
Sai Sridhar TarraandJamie Pine 9116d381cc fix(linux-audio): select monitor device by name instead of setting PULSE_SOURCE (#949)
std::env::set_var is not thread-safe on Unix (unsafe as of Rust 2024
edition) and calling it from a spawned capture thread while other
threads (tokio runtime, webview, Tauri plugins) may read the
environment is a data race risk. It also never got unset, so the
monitor source would leak into any later cpal/ALSA init in the same
process.

Replace the env-var indirection with direct device selection: when
pactl reports a monitor source name, search cpal's input device
enumeration for an exact match. Fall back to a substring match on
'monitor' (the original pactl-unavailable path), then the host's
default input device. This is the 'pass the source name directly to
cpal' option from the issue - no env mutation, no leakage between
capture sessions, and it still re-detects the current default sink's
monitor on every start_capture call.

Fixes #471
2026-07-26 23:33:48 -07:00
e70e639838 fix(transcription): transcode uploads to WAV before STT (#957)
The /transcribe endpoint passed the raw uploaded file straight to the STT
backend (mlx_audio.stt -> miniaudio), which only decodes WAV/FLAC/MP3/Vorbis.
Browser recordings arrive as WebM/Opus (Chrome/Firefox MediaRecorder), so
web-mode dictation failed with 500 "unsupported file format". The Tauri app
was unaffected because WebKit produces MP4.

librosa already fully decodes the upload to compute duration (falling back to
audioread/ffmpeg for exotic containers), so re-encode that PCM to a temp WAV
and hand it to Whisper. WAV inputs pass through unchanged; the temp file is
cleaned up in the finally block.

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-07-26 23:33:48 -07:00
eab3d45192 Fix runaway MLX Qwen audio chunks (#964)
* fix runaway MLX Qwen audio chunks

* test: tighten runaway retry coverage

---------

Co-authored-by: huanghua01 <[email protected]>
2026-07-26 23:33:09 -07:00
0d31687638 fix(tada): run voice-prompt encode under torch.inference_mode (#955)
Encoder.eval() alone still builds an autograd graph because parameters
require grad by default. On 8GB GPUs that ballooned TADA encode VRAM far
past the model footprint (issue 890). Wrap the encode forward in
inference_mode and add a unit test that asserts the flag is set.

Co-authored-by: fooSynaptic <[email protected]>
2026-07-26 23:32:25 -07:00
Kyle BuxtonandJamie Pine f7b45cc89e fix(macos): set Command flag on Cmd-down event so Electron apps paste (#952)
The macOS auto-paste sequence in `send_paste` posted the Cmd-down
CGEvent with flags = 0, setting the Command flag only on the V events.

On real hardware the Cmd keyDown (a flagsChanged event) already carries
kCGEventFlagMaskCommand, and Chromium/Electron builds its tracked
modifier state from that flag. With flags = 0 the tracker stays at
"Command up", so the following V matches neither the Cmd+V accelerator
(tracker says no modifier) nor plain-text insertion (the V event's own
flags say Command is held) — Electron drops it silently, producing no
paste and no stray "v". AppKit reads the V event's own modifier flags
and pastes regardless, which is why native apps (Notes, TextEdit,
Warp) worked while Electron targets (Slack, VS Code, VS Code Insiders)
silently no-op'd.

Setting kCGEventFlagMaskCommand on the Cmd-down event makes the
flagsChanged event well-formed; Chromium then registers Command=down
and Cmd+V matches. Likely fixes #762 and #643.
2026-07-26 23:32:25 -07:00
Jamie Pine 65b1e3cc6e chore: remaining biome lint fixes
Safe fixes from the same pass as the biome baseline commit (import
type, arrow function in an inline script) that were missed when
staging by file.
2026-07-26 23:18:23 -07:00
Jamie Pine cb5a800445 test(app): add bun test infra and first unit tests
bun test as the runner — already the toolchain, zero new deps beyond
@types/bun. 54 tests across 5 files covering the already-pure logic:
FastAPI error normalization (extracted verbatim to lib/api/errors.ts),
clip trim clamping (extracted from StoryTrackEditor to lib/utils/trim.ts,
magic 100 now MIN_CLIP_DURATION_MS), duration/size/engine formatters,
engine-language map consistency, and changelog parsing. Wired into CI
after typecheck.

Known issues surfaced by the tests, left as-is for now: parseChangelog's
heading regex uses \s* which matches newlines, so a dateless heading
followed directly by a bullet swallows that bullet; formatFileSize has
no TB unit; ENGINE_DISPLAY_NAMES lacks tada/kokoro.
2026-07-26 23:17:58 -07:00
Jamie Pine 000c13b6b9 docs: add code quality audit
Point-in-time review of the backend, frontend, Rust shell, and
CI/hygiene, with per-area grades, file:line evidence, a priority
list, and a follow-up ledger of what was fixed the same day.
2026-07-26 23:17:34 -07:00
Jamie Pine 827ce2bf0a docs: refresh contributor docs and drop root requirements.txt
CONTRIBUTING.md caught up with reality: ruff not Black, Python 3.12,
sh.voicebox.app, the routes/services/backends layout, the actual
pytest + CI story, and a working autoupdater link. SECURITY.md now
says 0.5.x and describes what CI actually enforces. The root
requirements.txt was vestigial (unpinned, included torchvision,
referenced by nothing) — deleted, with two stale doc references
repointed at backend/requirements.txt. backend/pyproject.toml is now
covered by bumpversion; its version was stuck at 0.2.3 and is synced
to 0.5.0.
2026-07-26 23:17:34 -07:00
Jamie Pine f2e55ba50f fix(dictation): run hotkey commands off the main thread
enable_hotkey/disable_hotkey/update_chord_bindings were sync commands,
which Tauri runs on the main thread. update_bindings joins the
dispatcher thread, and the dispatcher's chord-effect path blocks on
main-thread window calls (outer_size, current_monitor, set_position) —
toggling the hotkey while an effect was in flight could deadlock.
Async commands run on the runtime pool, so the join no longer blocks
the thread the dispatcher is waiting on.
2026-07-26 23:17:20 -07:00
Jamie Pine b7b7d62b7d perf(db): enable WAL and busy timeout for SQLite
The generation worker and request handlers race on the database —
enough that orphan-recovery code exists in two places to clean up
after 'database is locked' failures. WAL lets readers proceed during a
write, synchronous=NORMAL is the recommended pairing, and the 30s
sqlite3 timeout waits on a locked database instead of raising
immediately.
2026-07-26 23:17:20 -07:00
Jamie Pine c5f9d3b0f3 fix(backend): close model load races
Kokoro and LuxTTS load_model had no lock, so two concurrent requests
could both observe an unloaded model and double-load; they now use the
same double-checked asyncio.Lock pattern as the Chatterbox backends.
get_stt_backend gets the threading.Lock treatment the TTS and LLM
factories already had. Includes the ruff-era typing cleanup for
backends/__init__.
2026-07-26 23:17:20 -07:00
Jamie Pine 11934c2b7d fix(mlx): fail generation when voice cloning fails
A cloning failure was caught and retried without the voice prompt, so
the user got the model's default voice recorded as a successful
generation. The error now propagates and the worker records the
generation as failed with the real message. Two sibling silent paths
raise as well: a model whose generate() lacks ref_audio support, and a
generation that produces no audio chunks.
2026-07-26 23:17:08 -07:00
Jamie Pine 9fddf3f799 build: pin git dependencies to commits
linacodec and Zipvoice (both from a third-party personal account) and
Qwen3-TTS installed from branch HEADs, so a force-push upstream could
silently change what a release ships. linacodec/Zipvoice are pinned to
the commits resolved in the working venv; Qwen3-TTS to current
upstream HEAD, verified to be the installed 0.1.1.
2026-07-26 23:17:08 -07:00
Jamie Pine 7c7421177d chore(app): delete unused generated API client
app/src/lib/api/{core,models,schemas,services,index.ts} was
openapi-typescript-codegen output imported by nothing — the live
client is the hand-written client.ts/types.ts pair — and its types
had drifted (no engine, personality, or effects_chain on
GenerationRequest). Removes the generator with it: generate-api.sh,
the generate:api script, and the just recipe. Docs that described the
codegen workflow now describe updating the hand-written client.
2026-07-26 23:16:59 -07:00
Jamie Pine f82cf67acd ci: gate lint, backend tests, and cargo check on PRs
The previous gate was typecheck + web build only — no lint, no Python,
no Rust, none of the 24 backend test files. Adds:
- biome lint to the frontend job
- backend-quality on macos-14 (matches the primary user platform so
  the MLX-path tests run): just setup-python, ruff check, pytest
- rust-quality: cargo check with stub sidecar binaries, since
  tauri-build validates externalBin paths and real sidecars only exist
  in the release pipeline
- concurrency group so superseded runs cancel

All gates verified green locally before being wired in.
2026-07-26 23:16:30 -07:00
Jamie Pine 02fa924b42 chore: move biome config to biome.jsonc and baseline existing violations
biome.json is strict JSON; the jsonc extension allows the annotations
on the baseline entries. Rules that currently fail are downgraded to
warn with a note against issue #421 — restore each to error as its
occurrences are fixed. files.experimentalScannerIgnores keeps the
scanner out of .worktrees/ so a local worktree's own config can't
conflict with the root one.

Also fixes the handful of auto-fixable errors (unused imports in docs/
and landing/, @ts-expect-error over @ts-ignore). bun run lint is now
green: 0 errors, 96 warnings visible as debt.
2026-07-26 23:16:30 -07:00
Jamie Pine b434db22f6 chore(backend): repair test suite and bring ruff to green
The suite hadn't run green since the routes refactor:
- test_profile_duplicate_names.py imported the pre-refactor module
  layout and broke collection; now imports backend.services.profiles
- tests/conftest.py puts the repo root and backend dir on sys.path so
  files collect standalone instead of depending on run order
- test_cors.py tested a hand-copied mirror of the origin list that had
  drifted from app.py (missing http://tauri.localhost); it now builds
  the app via the real create_app() factory
- test_progress.py simulated a 1KB download, below the tracker's 1MB
  reporting threshold; simulation raised to 5MB
- slow/timeout markers registered in pyproject

Ruff: ~900 violations auto-fixed (typing modernization, import
sorting, unused imports, whitespace). The remaining rules are baselined
in pyproject.toml with per-rule counts to burn down, plus per-file
carve-outs for deliberate env-before-import ordering. ruff check is
now clean; suite is 134 passed, 2 skipped.
2026-07-26 23:16:09 -07:00
Jamie Pine 766c51a8a1 fix(backend): numpy compat hook silently failed to patch torch
dtype_map referenced _t, which is only bound as a default argument on
the inner function, so building the map raised NameError. The
surrounding except swallowed it and returned, meaning the from_numpy
fallback this hook exists for never applied in frozen builds.
2026-07-26 23:15:53 -07:00
Jamie Pine 3b0c29249b fix: repair .gitignore encoding and cover local workspace dirs
The tail of the file had been appended as UTF-16LE with CRLF line
endings, so git couldn't parse those patterns (including
.claude/settings.local.json). Rewritten as UTF-8/LF throughout, and
.worktrees/, .hermes/, and mlx-test/ are now ignored for everyone
instead of via .git/info/exclude.
2026-07-26 23:15:53 -07:00
Jamie Pine 88e72d5da2 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:44:25 -07:00
Jamie Pine 5787e36611 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:42:37 -07:00
Jamie Pine 5fd95b3dcc 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:40:03 -07:00
287 changed files with 13451 additions and 8571 deletions
+4
View File
@@ -37,3 +37,7 @@ 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}"
+1 -2
View File
@@ -8,8 +8,7 @@ tauri/
landing/
docs/
mlx-test/
scripts/*
!scripts/rocm-entrypoint.sh
scripts/
# Dependencies & build artifacts (rebuilt in Docker)
node_modules/
+2
View File
@@ -0,0 +1,2 @@
package.json text eol=lf
scripts/*.sh text eol=lf
+50 -93
View File
@@ -6,9 +6,14 @@ on:
branches:
- main
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
frontend-quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -18,118 +23,70 @@ 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: Build web
- name: Unit tests
run: bun run test
- name: Build web smoke test
run: bun run build:web
- name: Upload web build
uses: actions/upload-artifact@v4
with:
name: web-dist
path: web/dist
retention-days: 1
backend-quality:
# macOS arm64 matches the primary user platform and lets the MLX-path
# tests run instead of being skipped.
runs-on: macos-14
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
node-version: 22
python-version: "3.12"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Cache Playwright browsers
- name: Cache pip downloads
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
path: ~/Library/Caches/pip
key: pip-${{ runner.os }}-${{ hashFiles('backend/requirements*.txt', 'justfile') }}
restore-keys: pip-${{ runner.os }}-
- name: Install Chromium
run: bunx playwright install chromium --with-deps
- name: Install just
run: brew install just
- name: Vitest (unit + browser)
run: bunx vitest run
- 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
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
- uses: actions/setup-node@v4
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
node-version: 22
workspaces: tauri/src-tauri
- 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)
- name: Stub sidecar binaries
# tauri-build validates externalBin paths; real sidecars are only
# produced by the release pipeline.
run: |
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install -r backend/requirements-ci.txt
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
- 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
- name: Cargo check
run: cargo check --manifest-path tauri/src-tauri/Cargo.toml
BIN
View File
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
22
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

+24 -26
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.11+](https://python.org)** - For backend development
- **[Python 3.12+](https://python.org)** - For backend development
```bash
python --version # Should be 3.11 or higher
python --version # Should be 3.12 or higher
```
- **[Rust](https://rustup.rs)** - For Tauri desktop app (installed automatically by Tauri CLI)
@@ -115,14 +115,6 @@ 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:
@@ -133,7 +125,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 `docs/public/`
- Processes files in `landing/public/` and `docs/public/`
- **Deletes original files** after successful conversion
**Requirements:** Install `webp` and `ffmpeg`:
@@ -212,7 +204,7 @@ export const ProfileCard = (props) => { ... }
- Follow PEP 8 style guide
- Use type hints
- Use async/await for I/O operations
- Format with Black (if configured)
- Format and lint with ruff (configured in `backend/pyproject.toml`)
```python
# Good
@@ -242,9 +234,12 @@ voicebox/
│ ├── lib/ # Utilities and API client
│ └── hooks/ # React hooks
├── backend/ # Python FastAPI server
│ ├── main.py # API routes
│ ├── tts.py # Voice synthesis
│ └── ...
│ ├── 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
├── tauri/ # Desktop app wrapper
│ └── src-tauri/ # Rust backend
└── scripts/ # Build scripts
@@ -289,23 +284,26 @@ voicebox/
When adding new API endpoints:
1. **Add route in `backend/main.py`**
1. **Add the route to the relevant router in `backend/routes/`** (new routers get registered in `backend/routes/__init__.py`)
2. **Create Pydantic models in `backend/models.py`**
3. **Implement business logic in appropriate module**
4. **Update OpenAPI schema** (automatic with FastAPI)
5. **Regenerate TypeScript client:**
```bash
bun run generate:api
```
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`
6. **Update `backend/README.md`** with endpoint documentation
## Testing
Currently, testing is primarily manual. When adding tests:
Backend tests live in `backend/tests/` and run with pytest:
- **Backend**: Use pytest for Python tests
- **Frontend**: Use Vitest for React component tests
- **E2E**: Use Playwright for end-to-end tests (future)
```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)
## Pull Request Process
@@ -363,7 +361,7 @@ See [docs/content/docs/overview/troubleshooting.mdx](docs/content/docs/overview/
**Quick fixes:**
- **Backend won't start:** Check Python version (3.11+), ensure venv is activated, install dependencies
- **Backend won't start:** Check Python version (3.12+), 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`
@@ -379,7 +377,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/AUTOUPDATER_QUICKSTART.md](docs/AUTOUPDATER_QUICKSTART.md) - Auto-updater setup
- [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))
- [SECURITY.md](SECURITY.md) - Security policy
- [CHANGELOG.md](CHANGELOG.md) - Version history
+11 -4
View File
@@ -20,8 +20,11 @@ COPY package.json bun.lock CHANGELOG.md ./
COPY app/ ./app/
COPY web/ ./web/
# Strip workspaces not needed for web build, and fix trailing comma
RUN sed -i '/"tauri"/d' package.json && \
# 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 && \
sed -i -z 's/,\n ]/\n ]/' package.json
RUN bun install --no-save
# Build frontend (skip tsc — upstream has pre-existing type errors)
@@ -61,7 +64,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
git+https://github.com/QwenLM/Qwen3-TTS.git@022e286b98fbec7e1e916cb940cdf532cd9f488e
# === Stage 3: Runtime ===
@@ -100,7 +103,11 @@ 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
# 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).
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="docs/public/images/readme/app-screenshot-1.webp" alt="Voicebox App Screenshot" width="800" />
<img src="landing/public/assets/app-screenshot-1.webp" alt="Voicebox App Screenshot" width="800" />
</a>
</p>
@@ -56,11 +56,11 @@
<br/>
<p align="center">
<img src="docs/public/images/readme/app-screenshot-2.webp" alt="Voicebox Screenshot 2" width="800" />
<img src="landing/public/assets/app-screenshot-2.webp" alt="Voicebox Screenshot 2" width="800" />
</p>
<p align="center">
<img src="docs/public/images/readme/app-screenshot-3.webp" alt="Voicebox Screenshot 3" width="800" />
<img src="landing/public/assets/app-screenshot-3.webp" alt="Voicebox Screenshot 3" width="800" />
</p>
<br/>
@@ -270,8 +270,7 @@ 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 (NVIDIA) | PyTorch (CUDA) | Auto-downloads CUDA binary from within the app |
| Linux (NVIDIA) | PyTorch (CUDA) | Use a local/remote Python backend with CUDA PyTorch |
| Windows / Linux (NVIDIA) | PyTorch (CUDA) | Auto-downloads CUDA binary from within the app |
| 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 |
@@ -442,6 +441,7 @@ 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.3.x | :white_check_mark: |
| < 0.3 | :x: |
| 0.5.x | :white_check_mark: |
| < 0.5 | :x: |
## Reporting a Vulnerability
@@ -39,7 +39,7 @@ We will:
### For Developers
- **Dependencies** - Keep all dependencies up to date
- **Code review** - All PRs require review before merging
- **CI checks** - Every PR must pass typecheck, lint, backend tests, and `cargo check` 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.3.2)
- Released as patch versions (e.g., 0.5.1)
- Documented in CHANGELOG.md
- Announced via GitHub releases
- Automatically delivered via auto-updater
+29
View File
@@ -0,0 +1,29 @@
<!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,7 +4,11 @@
"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",
@@ -57,6 +61,7 @@
},
"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
@@ -1,134 +0,0 @@
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
@@ -0,0 +1,675 @@
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>
);
}
+94 -110
View File
@@ -1,6 +1,8 @@
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,
@@ -25,14 +27,6 @@ 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,
@@ -54,6 +48,14 @@ 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 {
@@ -70,7 +72,6 @@ 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';
@@ -134,7 +135,6 @@ 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,7 +202,6 @@ 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) => {
@@ -226,7 +225,7 @@ export function CapturesTab() {
return () => {
for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
};
}, [queryClient, platform.metadata.isTauri]);
}, [queryClient]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
@@ -244,7 +243,9 @@ 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({
@@ -254,22 +255,12 @@ 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;
@@ -277,13 +268,8 @@ 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,
@@ -300,11 +286,7 @@ 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' });
},
});
@@ -354,15 +336,16 @@ 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 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);
const buf = new Uint8Array(await res.arrayBuffer());
await writeFile(dest, buf);
exportToastSuccess(dest);
} catch (err) {
exportToastError(err);
}
@@ -376,12 +359,13 @@ export function CapturesTab() {
return;
}
try {
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);
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);
} catch (err) {
exportToastError(err);
}
@@ -392,8 +376,7 @@ 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}`);
@@ -415,12 +398,13 @@ export function CapturesTab() {
return;
}
try {
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);
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);
} catch (err) {
exportToastError(err);
}
@@ -502,48 +486,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',
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>
)}
>
<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>
</button>
);
})
)}
</div>
</ListPaneScroll>
</ListPane>
@@ -594,9 +578,7 @@ 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>
)}
</>
@@ -766,7 +748,11 @@ 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">
@@ -878,7 +864,9 @@ 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">
@@ -900,9 +888,7 @@ 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>
@@ -912,9 +898,7 @@ 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>
@@ -1,113 +0,0 @@
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,7 +5,6 @@ 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.
@@ -23,9 +22,6 @@ import { usePlatform } from '@/platform/PlatformContext';
* ``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(() => {
@@ -74,7 +70,6 @@ export function DictateWindow() {
sessionRef.current = session;
useEffect(() => {
if (!isTauri) return;
let disposed = false;
const unlistens: UnlistenFn[] = [];
const registrations = [
@@ -103,7 +98,7 @@ export function DictateWindow() {
disposed = true;
for (const unlisten of unlistens) unlisten();
};
}, [isTauri]);
}, []);
useEffect(() => {
if (micWarm) void session.prewarm();
@@ -162,7 +157,9 @@ 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);
};
@@ -174,7 +171,6 @@ export function DictateWindow() {
};
useEffect(() => {
if (!isTauri) return;
const unlistens: Promise<UnlistenFn>[] = [];
// Rust emits the SSE payload as a JSON *string* (not a parsed object);
@@ -269,7 +265,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>
<SelectContent side={compact ? 'top' : undefined}>
{availableOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className={itemClass}>
{opt.label}
@@ -1,185 +0,0 @@
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>
<SelectContent side="top">
{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>
<SelectContent side="top">
{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>
<SelectContent side="top">
<SelectItem value="none" className="text-xs">
{t('generation.effects.none')}
</SelectItem>
@@ -1,133 +0,0 @@
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'] }]);
});
@@ -1,15 +0,0 @@
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,6 +38,7 @@ 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';
@@ -600,41 +601,17 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const deltaMs = pixelsToMs(deltaX); // Signed delta in milliseconds
const { item, initialTrimStart, initialTrimEnd } = trimStartItemRef.current;
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
}
const newTrimValues = computeTrimValues(
trimSide,
deltaMs,
initialTrimStart,
initialTrimEnd,
item.duration * 1000,
);
if (!newTrimValues) return;
// Update temporary trim values for visual feedback
setTempTrimValues({
trim_start_ms: newTrimStart,
trim_end_ms: newTrimEnd,
});
setTempTrimValues(newTrimValues);
},
[trimmingItem, trimSide, trimStartX, pixelsToMs],
);
+61
View File
@@ -0,0 +1,61 @@
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,
};
}
+1 -10
View File
@@ -2,25 +2,19 @@ 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'];
@@ -31,14 +25,11 @@ 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
+1 -15
View File
@@ -1,4 +1,5 @@
import type { LanguageCode } from '@/lib/constants/languages';
import { formatErrorDetail } from '@/lib/api/errors';
import { useServerStore } from '@/stores/serverStore';
import type {
ActiveTasksResponse,
@@ -55,21 +56,6 @@ 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
@@ -0,0 +1,58 @@
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
@@ -0,0 +1,21 @@
/**
* 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;
}
+1 -4
View File
@@ -291,10 +291,7 @@ export interface CudaDownloadProgress {
export interface CudaStatus {
available: boolean; // CUDA binary exists on disk
active: boolean; // Currently running the CUDA binary
binary_path: string | null;
cuda_libs_version: string | null;
download_supported: boolean; // Platform has a matching release asset
unsupported_reason: string | null;
binary_path?: string;
downloading: boolean; // Download in progress
download_progress?: CudaDownloadProgress;
}
+70
View File
@@ -0,0 +1,70 @@
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]);
}
});
});
+8 -4
View File
@@ -47,12 +47,14 @@ export function useExportGeneration() {
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
const blob = await apiClient.exportGeneration(generationId);
// Create safe filename from text
// 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).
const safeText = text
.substring(0, 30)
.replace(/[^a-z0-9]/gi, '-')
.toLowerCase();
const filename = `generation-${safeText}.voicebox.zip`;
const filename = `generation-${safeText}-${generationId.substring(0, 8)}.voicebox.zip`;
await platform.filesystem.saveFile(filename, blob, [
{
@@ -73,12 +75,14 @@ export function useExportGenerationAudio() {
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
const blob = await apiClient.exportGenerationAudio(generationId);
// Create safe filename from text
// 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).
const safeText = text
.substring(0, 30)
.replace(/[^a-z0-9]/gi, '-')
.toLowerCase();
const filename = `${safeText}.wav`;
const filename = `${safeText}-${generationId.substring(0, 8)}.wav`;
await platform.filesystem.saveFile(filename, blob, [
{
+71
View File
@@ -0,0 +1,71 @@
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');
});
});
+15 -16
View File
@@ -1,5 +1,5 @@
import { formatDistance } from 'date-fns';
import { es, fr, ja, zhCN, zhTW } from 'date-fns/locale';
import { ja, zhCN, zhTW, fr } from 'date-fns/locale';
import i18n from '@/i18n';
export function formatDuration(seconds: number): string {
@@ -10,8 +10,6 @@ export function formatDuration(seconds: number): string {
function getDateLocale() {
switch (i18n.language) {
case 'es':
return es;
case 'ja':
return ja;
case 'zh-CN':
@@ -25,27 +23,28 @@ function getDateLocale() {
}
}
export function formatDate(date: string | Date): string {
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;
// 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);
}
return formatDistance(dateObj, new Date(), {
export function formatDate(date: string | Date): string {
return formatDistance(parseServerDate(date), new Date(), {
addSuffix: true,
locale: getDateLocale(),
}).replace(/^about /i, '');
}
export function formatAbsoluteDate(date: string | Date): string {
const dateObj = typeof date === 'string' ? new Date(date) : date;
const dateObj = parseServerDate(date);
return dateObj.toLocaleString(i18n.language, {
month: 'short',
day: 'numeric',
+69
View File
@@ -0,0 +1,69 @@
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
@@ -0,0 +1,106 @@
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
@@ -0,0 +1,57 @@
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
@@ -0,0 +1,17 @@
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>,
);
+1 -2
View File
@@ -9,8 +9,7 @@ export interface FileFilter {
}
export interface PlatformFilesystem {
/** Returns the saved path (or filename on web), or null if the user cancelled. */
saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise<string | null>;
saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise<void>;
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
// Captures route (prototype — will replace AudioTab once the new flow is ready)
const capturesRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/captures',
@@ -199,8 +199,8 @@ const serverRedirectRoute = createRoute({
},
});
// Route tree — exported so tests can build routers over memory history
export const routeTree = rootRoute.addChildren([
// Route tree
const routeTree = rootRoute.addChildren([
indexRoute,
storiesRoute,
capturesRoute,
-37
View File
@@ -1,37 +0,0 @@
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
@@ -1,27 +0,0 @@
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
@@ -1,58 +0,0 @@
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
@@ -1,86 +0,0 @@
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
@@ -1,216 +0,0 @@
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
@@ -1,102 +0,0 @@
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
@@ -1,17 +0,0 @@
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
@@ -1,9 +0,0 @@
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
@@ -1,346 +0,0 @@
/* 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
@@ -1,71 +0,0 @@
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
@@ -1,36 +0,0 @@
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
@@ -1,18 +0,0 @@
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
@@ -1,8 +0,0 @@
import '@/i18n';
import { afterEach, vi } from 'vitest';
import { resetAllStores } from './resetStores';
afterEach(() => {
vi.restoreAllMocks();
resetAllStores();
});
-81
View File
@@ -1,81 +0,0 @@
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"]
"types": ["vite/client", "bun"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
+14
View File
@@ -0,0 +1,14 @@
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'),
},
},
});
+10 -16
View File
@@ -38,13 +38,6 @@ 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
@@ -102,18 +95,19 @@ 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:
@@ -129,8 +123,8 @@ def safe_content_disposition(disposition_type: str, filename: str) -> str:
def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
from .mcp_server.server import build_mcp_server, compose_lifespan
from .mcp_server.context import ClientIdMiddleware
from .mcp_server.server import build_mcp_server, compose_lifespan
# 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
@@ -209,8 +203,8 @@ def _mount_frontend(application: FastAPI) -> None:
if not frontend_dir.is_dir():
return
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
# Mount hashed assets (JS, CSS, images) that Vite places under /assets
assets_dir = frontend_dir / "assets"
@@ -250,9 +244,9 @@ def _get_gpu_status() -> str:
if not compatible:
label += " [UNSUPPORTED - see logs]"
return label
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "MPS (Apple Silicon)"
elif backend_type == "mlx":
if backend_type == "mlx":
return "Metal (Apple Silicon via MLX)"
# Intel XPU (Arc / Data Center) via IPEX
@@ -309,7 +303,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 VoiceProfile as DBVoiceProfile, Generation as DBGeneration
from .database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile
profile_count = db.query(DBVoiceProfile).count()
generation_count = db.query(DBGeneration).count()
+47 -32
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, Optional, Tuple, List
from typing_extensions import runtime_checkable
from typing import Protocol
import numpy as np
from typing_extensions import runtime_checkable
from ..utils import hf_offline_patch
DEFAULT_LLM_MAX_TOKENS = 512
DEFAULT_LLM_TEMPERATURE = 0.7
@@ -57,6 +57,7 @@ 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"])
@@ -77,7 +78,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.
@@ -88,9 +89,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.
@@ -104,9 +105,9 @@ class TTSBackend(Protocol):
text: str,
voice_prompt: dict,
language: str = "en",
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> Tuple[np.ndarray, int]:
seed: int | None = None,
instruct: str | None = None,
) -> tuple[np.ndarray, int]:
"""
Generate audio from text.
@@ -144,8 +145,8 @@ class STTBackend(Protocol):
async def transcribe(
self,
audio_path: str,
language: Optional[str] = None,
model_size: Optional[str] = None,
language: str | None = None,
model_size: str | None = None,
) -> str:
"""
Transcribe audio to text.
@@ -175,11 +176,11 @@ class LLMBackend(Protocol):
async def generate(
self,
prompt: str,
system: Optional[str] = None,
system: str | None = None,
max_tokens: int = DEFAULT_LLM_MAX_TOKENS,
temperature: float = DEFAULT_LLM_TEMPERATURE,
model_size: Optional[str] = None,
examples: Optional[list[tuple[str, str]]] = None,
model_size: str | None = None,
examples: list[tuple[str, str]] | None = None,
) -> str:
"""Run a single-turn chat completion and return the assistant reply.
@@ -199,10 +200,11 @@ class LLMBackend(Protocol):
# Global backend instances
_tts_backend: Optional[TTSBackend] = None
_tts_backend: TTSBackend | None = None
_tts_backends: dict[str, TTSBackend] = {}
_tts_backends_lock = threading.Lock()
_stt_backend: Optional[STTBackend] = None
_stt_backend: STTBackend | None = None
_stt_backend_lock = threading.Lock()
_llm_backends: dict[str, LLMBackend] = {}
_llm_backends_lock = threading.Lock()
@@ -233,6 +235,10 @@ 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",
@@ -241,6 +247,7 @@ 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"],
),
@@ -251,6 +258,7 @@ 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"],
),
@@ -489,7 +497,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) -> Optional[ModelConfig]:
def get_model_config(model_name: str) -> ModelConfig | None:
"""Look up a model config by model_name."""
for cfg in get_all_model_configs():
if cfg.model_name == model_name:
@@ -505,6 +513,14 @@ 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]
@@ -564,8 +580,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()
@@ -608,8 +624,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":
@@ -639,8 +655,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)
@@ -679,13 +695,6 @@ 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]
@@ -746,7 +755,13 @@ def get_stt_backend() -> STTBackend:
"""
global _stt_backend
if _stt_backend is None:
if _stt_backend is not None:
return _stt_backend
with _stt_backend_lock:
if _stt_backend is not None:
return _stt_backend
backend_type = get_backend_type()
if backend_type == "mlx":
@@ -758,7 +773,7 @@ def get_stt_backend() -> STTBackend:
_stt_backend = PyTorchSTTBackend()
return _stt_backend
return _stt_backend
def get_llm_backend() -> LLMBackend:
+8 -9
View File
@@ -9,13 +9,12 @@ 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 normalize_audio, load_audio
from ..utils.progress import get_progress_manager
from ..utils.audio import load_audio, normalize_audio
from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
from ..utils.progress import get_progress_manager
from ..utils.tasks import get_task_manager
logger = logging.getLogger(__name__)
@@ -25,7 +24,7 @@ def is_model_cached(
hf_repo: str,
*,
weight_extensions: tuple[str, ...] = (".safetensors", ".bin"),
required_files: Optional[list[str]] = None,
required_files: list[str] | None = None,
) -> bool:
"""
Check if a HuggingFace model is fully cached locally.
@@ -201,11 +200,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: Optional[int] = None,
) -> Tuple[np.ndarray, str]:
sample_rate: int | None = None,
) -> tuple[np.ndarray, str]:
"""
Combine multiple reference audio samples into one.
@@ -235,7 +234,7 @@ async def combine_voice_prompts(
def model_load_progress(
model_name: str,
is_cached: bool,
filter_non_downloads: Optional[bool] = None,
filter_non_downloads: bool | None = None,
):
"""
Context manager for model loading with HF download progress tracking.
+12 -13
View File
@@ -10,17 +10,16 @@ import asyncio
import logging
import threading
from pathlib import Path
from typing import ClassVar, List, Optional, Tuple
from typing import ClassVar
import numpy as np
from . import TTSBackend
from .base import (
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
combine_voice_prompts as _combine_voice_prompts,
empty_device_cache,
get_torch_device,
is_model_cached,
manual_seed,
model_load_progress,
patch_chatterbox_f32,
)
@@ -127,7 +126,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.
@@ -143,9 +142,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.
@@ -169,9 +168,9 @@ class ChatterboxTTSBackend:
text: str,
voice_prompt: dict,
language: str = "en",
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> Tuple[np.ndarray, int]:
seed: int | None = None,
instruct: str | None = None,
) -> tuple[np.ndarray, int]:
"""
Generate audio using Chatterbox Multilingual TTS.
+13 -14
View File
@@ -10,17 +10,16 @@ import asyncio
import logging
import threading
from pathlib import Path
from typing import ClassVar, List, Optional, Tuple
from typing import ClassVar
import numpy as np
from . import TTSBackend
from .base import (
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
combine_voice_prompts as _combine_voice_prompts,
empty_device_cache,
get_torch_device,
is_model_cached,
manual_seed,
model_load_progress,
patch_chatterbox_f32,
)
@@ -81,8 +80,8 @@ class ChatterboxTurboTTSBackend:
logger.info(f"Loading Chatterbox Turbo TTS on {device}...")
import torch
from huggingface_hub import snapshot_download
from chatterbox.tts_turbo import ChatterboxTurboTTS
from huggingface_hub import snapshot_download
local_path = snapshot_download(
repo_id=CHATTERBOX_TURBO_HF_REPO,
@@ -126,7 +125,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.
@@ -141,9 +140,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(
@@ -151,9 +150,9 @@ class ChatterboxTurboTTSBackend:
text: str,
voice_prompt: dict,
language: str = "en",
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> Tuple[np.ndarray, int]:
seed: int | None = None,
instruct: str | None = None,
) -> tuple[np.ndarray, int]:
"""
Generate audio using Chatterbox Turbo TTS.
-92
View File
@@ -1,92 +0,0 @@
"""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
+22 -21
View File
@@ -16,20 +16,19 @@ causal LM generates speech via flow-matching diffusion.
import asyncio
import logging
import threading
from typing import ClassVar, List, Optional, Tuple
from typing import ClassVar
import numpy as np
from . import TTSBackend
from ..utils.cache import cache_voice_prompt, get_cache_key, get_cached_voice_prompt
from .base import (
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
combine_voice_prompts as _combine_voice_prompts,
empty_device_cache,
get_torch_device,
is_model_cached,
manual_seed,
model_load_progress,
)
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
logger = logging.getLogger(__name__)
@@ -182,7 +181,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 TadaForCausalLM, TadaConfig
from tada.modules.tada import TadaConfig, TadaForCausalLM
logger.info(f"Loading TADA {model_size} model...")
config = TadaConfig.from_pretrained(repo)
@@ -214,7 +213,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.
@@ -234,8 +233,8 @@ class HumeTadaBackend:
return cached, True
def _encode_sync():
import torch
import soundfile as sf
import torch
device = self._device
@@ -248,9 +247,13 @@ class HumeTadaBackend:
audio = audio.T # (samples, channels) -> (channels, samples)
audio = audio.to(device)
# Encode with forced alignment
# 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).
text_arg = [reference_text] if reference_text else None
prompt = self.encoder(audio, text=text_arg, sample_rate=sr)
with torch.inference_mode():
prompt = self.encoder(audio, text=text_arg, sample_rate=sr)
# Serialize EncoderOutput to a dict of CPU tensors for caching
prompt_dict = {}
@@ -258,9 +261,7 @@ class HumeTadaBackend:
val = getattr(prompt, field_name)
if isinstance(val, torch.Tensor):
prompt_dict[field_name] = val.detach().cpu()
elif isinstance(val, list):
prompt_dict[field_name] = val
elif isinstance(val, (int, float)):
elif isinstance(val, (list, int, float)):
prompt_dict[field_name] = val
else:
prompt_dict[field_name] = val
@@ -275,9 +276,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(
@@ -285,9 +286,9 @@ class HumeTadaBackend:
text: str,
voice_prompt: dict,
language: str = "en",
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> Tuple[np.ndarray, int]:
seed: int | None = None,
instruct: str | None = None,
) -> tuple[np.ndarray, int]:
"""
Generate audio from text using HumeAI TADA.
+10 -14
View File
@@ -17,15 +17,12 @@ 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 (
get_torch_device,
combine_voice_prompts as _combine_voice_prompts,
get_torch_device,
model_load_progress,
)
@@ -96,16 +93,11 @@ KOKORO_VOICES = [
("pf_dora", "Dora", "female", "pt"),
("pm_alex", "Alex", "male", "pt"),
("pm_santa", "Santa", "male", "pt"),
# Chinese female
# Chinese
("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
@@ -127,8 +119,9 @@ class KokoroTTSBackend:
def __init__(self):
self._model = None
self._pipelines: dict = {} # lang_code -> KPipeline
self._device: Optional[str] = None
self._device: str | None = 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."""
@@ -162,7 +155,10 @@ class KokoroTTSBackend:
"""Load the Kokoro model."""
if self._model is not None:
return
await asyncio.to_thread(self._load_model_sync)
async with self._model_load_lock:
if self._model is not None:
return
await asyncio.to_thread(self._load_model_sync)
def _load_model_sync(self):
"""Synchronous model loading."""
@@ -244,8 +240,8 @@ class KokoroTTSBackend:
text: str,
voice_prompt: dict,
language: str = "en",
seed: Optional[int] = None,
instruct: Optional[str] = None,
seed: int | None = None,
instruct: str | None = None,
) -> tuple[np.ndarray, int]:
"""
Generate audio from text using Kokoro.
+14 -13
View File
@@ -7,20 +7,18 @@ Wraps the LuxTTS (ZipVoice) model for zero-shot voice cloning.
import asyncio
import logging
from typing import Optional, Tuple
import numpy as np
from . import TTSBackend
from ..utils.cache import cache_voice_prompt, get_cache_key, get_cached_voice_prompt
from .base import (
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
combine_voice_prompts as _combine_voice_prompts,
empty_device_cache,
get_torch_device,
is_model_cached,
manual_seed,
model_load_progress,
)
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
logger = logging.getLogger(__name__)
@@ -35,6 +33,7 @@ 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)
@@ -61,8 +60,10 @@ class LuxTTSBackend:
"""Load the LuxTTS model."""
if self.model is not None:
return
await asyncio.to_thread(self._load_model_sync)
async with self._model_load_lock:
if self.model is not None:
return
await asyncio.to_thread(self._load_model_sync)
def _load_model_sync(self):
model_name = "luxtts"
@@ -105,7 +106,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.
@@ -145,9 +146,9 @@ class LuxTTSBackend:
text: str,
voice_prompt: dict,
language: str = "en",
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> Tuple[np.ndarray, int]:
seed: int | None = None,
instruct: str | None = None,
) -> tuple[np.ndarray, int]:
"""
Generate audio from text using LuxTTS.
+40 -53
View File
@@ -2,24 +2,24 @@
MLX backend implementation for TTS and STT using mlx-audio.
"""
from typing import Optional, List, Tuple
import logging
import numpy as np
from pathlib import Path
import numpy as np
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 patch_huggingface_hub_offline, ensure_original_qwen_config_cached
from ..utils.hf_offline_patch import ensure_original_qwen_config_cached, patch_huggingface_hub_offline
patch_huggingface_hub_offline()
ensure_original_qwen_config_cached()
from . import TTSBackend, STTBackend, LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS
from .base import is_model_cached, combine_voice_prompts as _combine_voice_prompts, model_load_progress
from ..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
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
class MLXTTSBackend:
@@ -63,7 +63,7 @@ class MLXTTSBackend:
weight_extensions=(".safetensors", ".bin", ".npz"),
)
def _ensure_loaded_sync(self, model_size: Optional[str]):
def _ensure_loaded_sync(self, model_size: 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.
@@ -79,7 +79,7 @@ class MLXTTSBackend:
self._load_model_sync(model_size)
async def load_model_async(self, model_size: Optional[str] = None):
async def load_model_async(self, model_size: str | None = 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,9 +154,8 @@ 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
else:
# Cached file no longer exists, invalidate cache
logger.warning("Cached audio file not found: %s, regenerating prompt", cached_audio_path)
# 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
@@ -180,9 +179,9 @@ class MLXTTSBackend:
text: str,
voice_prompt: dict,
language: str = "en",
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> Tuple[np.ndarray, int]:
seed: int | None = None,
instruct: str | None = None,
) -> tuple[np.ndarray, int]:
"""
Generate audio from text using voice prompt.
@@ -228,41 +227,30 @@ 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.
try:
if ref_audio:
# Check if generate accepts ref_audio parameter
import inspect
# 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
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)
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:
for result in self.model.generate(text, lang_code=lang):
audio_chunks.append(np.array(result.audio))
sample_rate = result.sample_rate
# 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)
if not audio_chunks:
raise RuntimeError("Model produced no audio")
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
@@ -291,7 +279,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: Optional[str]):
def _ensure_loaded_sync(self, model_size: str | None):
"""Load the model if the requested size isn't already resident.
Runs on the MLX worker thread so it stays serialized with transcription.
@@ -304,7 +292,7 @@ class MLXSTTBackend:
self._load_model_sync(model_size)
async def load_model_async(self, model_size: Optional[str] = None):
async def load_model_async(self, model_size: str | None = None):
"""
Lazy load the MLX Whisper model.
@@ -347,8 +335,8 @@ class MLXSTTBackend:
async def transcribe(
self,
audio_path: str,
language: Optional[str] = None,
model_size: Optional[str] = None,
language: str | None = None,
model_size: str | None = None,
) -> str:
"""
Transcribe audio to text.
@@ -377,12 +365,11 @@ class MLXSTTBackend:
# Extract text from result
if isinstance(result, str):
return result.strip()
elif isinstance(result, dict):
if isinstance(result, dict):
return result.get("text", "").strip()
elif hasattr(result, "text"):
if hasattr(result, "text"):
return result.text.strip()
else:
return str(result).strip()
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.
+22 -22
View File
@@ -2,25 +2,25 @@
PyTorch backend implementation for TTS and STT.
"""
from typing import Optional, List, Tuple
import asyncio
import logging
import torch
import numpy as np
import torch
logger = logging.getLogger(__name__)
from . import TTSBackend, STTBackend, LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS
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 .base import (
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
combine_voice_prompts as _combine_voice_prompts,
empty_device_cache,
get_torch_device,
is_model_cached,
manual_seed,
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: Optional[str] = None):
async def load_model_async(self, model_size: str | None = 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
elif isinstance(cached_prompt, torch.Tensor):
if 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: Optional[int] = None,
instruct: Optional[str] = None,
) -> Tuple[np.ndarray, int]:
seed: int | None = None,
instruct: str | None = 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: Optional[str] = None):
async def load_model_async(self, model_size: str | None = 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 WhisperProcessor, WhisperForConditionalGeneration
from transformers import WhisperForConditionalGeneration, WhisperProcessor
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: Optional[str] = None,
model_size: Optional[str] = None,
language: str | None = None,
model_size: str | None = None,
) -> str:
"""
Transcribe audio to text.
@@ -16,16 +16,15 @@ 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 TTSBackend, LANGUAGE_CODE_TO_NAME
from . import LANGUAGE_CODE_TO_NAME
from .base import (
is_model_cached,
get_torch_device,
combine_voice_prompts as _combine_voice_prompts,
get_torch_device,
is_model_cached,
model_load_progress,
)
@@ -62,7 +61,7 @@ class QwenCustomVoiceBackend:
self.model = None
self.model_size = model_size
self.device = self._get_device()
self._current_model_size: Optional[str] = None
self._current_model_size: str | None = None
def _get_device(self) -> str:
return get_torch_device(allow_xpu=True, allow_directml=True)
@@ -75,11 +74,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: Optional[str] = None) -> bool:
def _is_model_cached(self, model_size: str | None = 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: Optional[str] = None) -> None:
async def load_model_async(self, model_size: str | None = None) -> None:
if model_size is None:
model_size = self.model_size
@@ -164,8 +163,8 @@ class QwenCustomVoiceBackend:
text: str,
voice_prompt: dict,
language: str = "en",
seed: Optional[int] = None,
instruct: Optional[str] = None,
seed: int | None = None,
instruct: str | None = None,
) -> tuple[np.ndarray, int]:
"""
Generate audio using Qwen CustomVoice.
+33 -38
View File
@@ -9,17 +9,16 @@ and STT engines.
import asyncio
import logging
from typing import Optional
from . import LLMBackend, DEFAULT_LLM_MAX_TOKENS, DEFAULT_LLM_TEMPERATURE
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 .base import (
is_model_cached,
get_torch_device,
empty_device_cache,
manual_seed,
get_torch_device,
is_model_cached,
model_load_progress,
)
from ..services.mlx_thread import run_on_mlx_thread, clear_mlx_cache
logger = logging.getLogger(__name__)
@@ -43,8 +42,8 @@ def _progress_name(model_size: str) -> str:
def _build_messages(
prompt: str,
system: Optional[str],
examples: Optional[list[tuple[str, str]]] = None,
system: str | None,
examples: list[tuple[str, str]] | None = None,
) -> list[dict]:
messages: list[dict] = []
if system:
@@ -64,7 +63,7 @@ class PyTorchQwenLLMBackend:
self.model = None
self.tokenizer = None
self.model_size = model_size
self._current_model_size: Optional[str] = None
self._current_model_size: str | None = None
self.device = self._get_device()
def _get_device(self) -> str:
@@ -81,7 +80,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: Optional[str] = None) -> None:
async def load_model(self, model_size: str | None = None) -> None:
if model_size is None:
model_size = self.model_size
@@ -103,19 +102,15 @@ class PyTorchQwenLLMBackend:
with model_load_progress(progress_model_name, is_cached):
logger.info("Loading Qwen3 %s on %s...", model_size, self.device)
# 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()
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()
self._current_model_size = model_size
self.model_size = model_size
@@ -135,11 +130,11 @@ class PyTorchQwenLLMBackend:
async def generate(
self,
prompt: str,
system: Optional[str] = None,
system: str | None = None,
max_tokens: int = DEFAULT_LLM_MAX_TOKENS,
temperature: float = DEFAULT_LLM_TEMPERATURE,
model_size: Optional[str] = None,
examples: Optional[list[tuple[str, str]]] = None,
model_size: str | None = None,
examples: list[tuple[str, str]] | None = None,
) -> str:
await self.load_model(model_size)
return await asyncio.to_thread(
@@ -149,10 +144,10 @@ class PyTorchQwenLLMBackend:
def _generate_sync(
self,
prompt: str,
system: Optional[str],
system: str | None,
max_tokens: int,
temperature: float,
examples: Optional[list[tuple[str, str]]] = None,
examples: list[tuple[str, str]] | None = None,
) -> str:
import torch
@@ -190,7 +185,7 @@ class MLXQwenLLMBackend:
self.model = None
self.tokenizer = None
self.model_size = model_size
self._current_model_size: Optional[str] = None
self._current_model_size: str | None = None
def is_loaded(self) -> bool:
return self.model is not None
@@ -206,7 +201,7 @@ class MLXQwenLLMBackend:
weight_extensions=(".safetensors", ".bin", ".npz"),
)
def _ensure_loaded_sync(self, model_size: Optional[str]) -> None:
def _ensure_loaded_sync(self, model_size: str | None) -> None:
"""Load the model if the requested size isn't already resident.
Runs on the MLX worker thread so it stays serialized with generation.
@@ -222,7 +217,7 @@ class MLXQwenLLMBackend:
self._load_model_sync(model_size)
async def load_model(self, model_size: Optional[str] = None) -> None:
async def load_model(self, model_size: str | None = None) -> None:
await run_on_mlx_thread(self._ensure_loaded_sync, model_size)
async def unload(self) -> None:
@@ -238,8 +233,8 @@ class MLXQwenLLMBackend:
with model_load_progress(progress_model_name, is_cached):
logger.info("Loading Qwen3 %s via MLX...", model_size)
# See the PyTorch loader comment — no offline forcing (issue #841).
loaded = mlx_load(repo)
with force_offline_if_cached(is_cached, progress_model_name):
loaded = mlx_load(repo)
# mlx_lm.load returns (model, tokenizer) by default and
# (model, tokenizer, config) when return_config=True.
@@ -264,11 +259,11 @@ class MLXQwenLLMBackend:
async def generate(
self,
prompt: str,
system: Optional[str] = None,
system: str | None = None,
max_tokens: int = DEFAULT_LLM_MAX_TOKENS,
temperature: float = DEFAULT_LLM_TEMPERATURE,
model_size: Optional[str] = None,
examples: Optional[list[tuple[str, str]]] = None,
model_size: str | None = None,
examples: list[tuple[str, str]] | None = 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.
@@ -281,10 +276,10 @@ class MLXQwenLLMBackend:
def _generate_sync(
self,
prompt: str,
system: Optional[str],
system: str | None,
max_tokens: int,
temperature: float,
examples: Optional[list[tuple[str, str]]] = None,
examples: list[tuple[str, str]] | None = None,
) -> str:
from mlx_lm import generate as mlx_generate
from mlx_lm.sample_utils import make_sampler
-3
View File
@@ -330,9 +330,6 @@ 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,11 +80,6 @@ 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 (
Base,
AudioChannel,
Base,
Capture,
CaptureSettings,
ChannelDeviceMapping,
@@ -24,12 +24,12 @@ from .models import (
StoryItem,
VoiceProfile,
)
from .session import engine, SessionLocal, _db_path, init_db, get_db
from .session import SessionLocal, _db_path, engine, get_db, init_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",
"SessionLocal",
"_db_path",
"init_db",
"get_db",
"init_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, to_storage_path, resolve_storage_path
from ..config import get_data_dir, resolve_storage_path, to_storage_path
data_dir = get_data_dir()
+2 -2
View File
@@ -1,9 +1,9 @@
"""ORM model definitions for the voicebox SQLite database."""
from datetime import datetime
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean, JSON
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy.ext.declarative import declarative_base
from ..utils.capture_chords import (
+18 -5
View File
@@ -3,20 +3,20 @@
import logging
import uuid
from sqlalchemy import create_engine
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from .. import config
from .migrations import run_migrations
from .models import (
Base,
AudioChannel,
Base,
EffectPreset,
Generation,
GenerationVersion,
ProfileChannelMapping,
VoiceProfile,
)
from .migrations import run_migrations
from .seed import backfill_generation_versions, seed_builtin_presets
logger = logging.getLogger(__name__)
@@ -36,9 +36,22 @@ def init_db() -> None:
engine = create_engine(
f"sqlite:///{_db_path}",
connect_args={"check_same_thread": False},
# 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},
)
@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)
@@ -47,7 +60,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 == True).first()
default_channel = db.query(AudioChannel).filter(AudioChannel.is_default).first()
if not default_channel:
default_channel = AudioChannel(
id=str(uuid.uuid4()),
+2 -1
View File
@@ -5,10 +5,11 @@ entry point for development.
"""
import argparse
import uvicorn
from .app import app # noqa: F401 -- re-export for uvicorn "backend.main:app"
from . import config, database
from .app import app # noqa: F401 -- re-export for uvicorn "backend.main:app"
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="voicebox backend server")
+2 -3
View File
@@ -11,14 +11,13 @@ import asyncio
import ipaddress
import logging
from contextvars import ContextVar
from datetime import datetime, timezone
from datetime import UTC, datetime
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
@@ -141,7 +140,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(timezone.utc)
row.last_seen_at = datetime.now(UTC)
db.commit()
except Exception:
logger.debug(
-1
View File
@@ -8,7 +8,6 @@ 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 # noqa: WPS433
from ..database.models import MCPClientBinding
binding = (
db.query(MCPClientBinding)
+1 -2
View File
@@ -9,8 +9,8 @@ binary bundled with the desktop app.
from __future__ import annotations
import logging
from contextlib import AsyncExitStack, asynccontextmanager
from collections.abc import Callable
from contextlib import AsyncExitStack, asynccontextmanager
from fastapi import FastAPI
from fastmcp import FastMCP
@@ -18,7 +18,6 @@ from fastmcp import FastMCP
from .context import ClientIdMiddleware
from .tools import register_tools
logger = logging.getLogger(__name__)
+15 -4
View File
@@ -12,19 +12,17 @@ import base64 as b64
import logging
import tempfile
from pathlib import Path
from typing import Any
from typing import Any, Literal
from fastmcp import FastMCP
from .. import models
from ..database import get_db
from ..services import captures as captures_service
from ..services import profiles as profiles_service
from ..services import captures as captures_service, 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
@@ -49,6 +47,7 @@ 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.
@@ -61,6 +60,12 @@ 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
@@ -99,6 +104,7 @@ def register_tools(mcp: FastMCP) -> None:
engine=resolved_engine,
language=language,
personality=use_persona,
model_size=model_size,
db=db,
)
finally:
@@ -228,18 +234,23 @@ 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,7 +23,6 @@ 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,10 +2,10 @@
Pydantic models for request/response validation.
"""
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from pydantic import BaseModel, Field
from .utils.capture_chords import (
default_push_to_talk_chord,
default_toggle_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: Optional[str] = Field(None, max_length=500)
description: str | None = 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: 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)
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)
class VoiceProfileResponse(BaseModel):
@@ -33,16 +33,16 @@ class VoiceProfileResponse(BaseModel):
id: str
name: str
description: Optional[str]
description: str | None
language: str
avatar_path: Optional[str] = None
effects_chain: Optional[List["EffectConfig"]] = None
avatar_path: str | None = None
effects_chain: list["EffectConfig"] | None = None
voice_type: str = "cloned"
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
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
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: 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)$")
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)$")
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: Optional[List["EffectConfig"]] = Field(
effects_chain: list["EffectConfig"] | None = 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: Optional[str] = None
duration: Optional[float] = None
seed: Optional[int] = None
instruct: Optional[str] = None
engine: Optional[str] = "qwen"
model_size: Optional[str] = None
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
status: str = "completed"
error: Optional[str] = None
error: str | None = None
is_favorited: bool = False
source: str = "manual"
created_at: datetime
versions: Optional[List["GenerationVersionResponse"]] = None
active_version_id: Optional[str] = None
versions: list["GenerationVersionResponse"] | None = None
active_version_id: str | None = None
class Config:
from_attributes = True
@@ -130,8 +130,8 @@ class GenerationResponse(BaseModel):
class HistoryQuery(BaseModel):
"""Query model for generation history."""
profile_id: Optional[str] = None
search: Optional[str] = None
profile_id: str | None = None
search: str | None = 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: Optional[str] = None
duration: Optional[float] = None
seed: Optional[int] = None
instruct: Optional[str] = None
engine: Optional[str] = "qwen"
model_size: Optional[str] = None
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
status: str = "completed"
error: Optional[str] = None
error: str | None = None
is_favorited: bool = False
created_at: datetime
versions: Optional[List["GenerationVersionResponse"]] = None
active_version_id: Optional[str] = None
versions: list["GenerationVersionResponse"] | None = None
active_version_id: str | None = 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: 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)$")
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)$")
class TranscriptionResponse(BaseModel):
@@ -196,13 +196,13 @@ class CaptureResponse(BaseModel):
id: str
audio_path: str
source: str
language: Optional[str] = None
duration_ms: Optional[int] = None
language: str | None = None
duration_ms: int | None = None
transcript_raw: str
transcript_refined: Optional[str] = None
stt_model: Optional[str] = None
llm_model: Optional[str] = None
refinement_flags: Optional[RefinementFlagsModel] = None
transcript_refined: str | None = None
stt_model: str | None = None
llm_model: str | None = None
refinement_flags: RefinementFlagsModel | None = 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: Optional[RefinementFlagsModel] = None
model_size: Optional[str] = Field(default=None, pattern="^(0\\.6B|1\\.7B|4B)$")
flags: RefinementFlagsModel | None = None
model_size: str | None = 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: 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)$")
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)$")
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: Optional[str] = None
default_playback_voice_id: str | None = 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: 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)
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)
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: 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
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
class MCPClientBindingResponse(BaseModel):
@@ -315,14 +315,14 @@ class MCPClientBindingResponse(BaseModel):
opt-in personality-rewrite default."""
client_id: str
label: Optional[str] = None
profile_id: Optional[str] = None
default_engine: Optional[str] = Field(
label: str | None = None
profile_id: str | None = None
default_engine: str | None = Field(
None,
pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$",
)
default_personality: bool = False
last_seen_at: Optional[datetime] = None
last_seen_at: datetime | None = 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: Optional[str] = Field(None, max_length=128)
profile_id: Optional[str] = None
default_engine: Optional[str] = Field(
label: str | None = Field(None, max_length=128)
profile_id: str | None = None
default_engine: str | None = 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: Optional[str] = Field(
profile: str | None = Field(
None,
description="Voice profile name or id. Falls back to per-client binding, then default.",
)
engine: Optional[str] = Field(
engine: str | None = Field(
None,
pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$",
)
personality: Optional[bool] = Field(
personality: bool | None = 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: Optional[str] = Field(
language: str | None = 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: Optional[str] = Field(None, max_length=4000)
model_size: Optional[str] = Field(default="0.6B", pattern="^(0\\.6B|1\\.7B|4B)$")
system: str | None = Field(None, max_length=4000)
model_size: str | None = 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: Optional[List[List[str]]] = Field(default=None, max_length=8)
examples: list[list[str]] | None = 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: Optional[int] = None
size_mb: int | None = None
class CaptureReadinessResponse(BaseModel):
@@ -438,15 +438,15 @@ class HealthResponse(BaseModel):
status: str
model_loaded: bool
model_downloaded: Optional[bool] = None # Whether model is cached/downloaded
model_size: Optional[str] = None # Current model size if loaded
model_downloaded: bool | None = None # Whether model is cached/downloaded
model_size: str | None = None # Current model size if loaded
gpu_available: bool
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)
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)
supports_rocm: bool = False # AMD GPU on Windows — the ROCm backend is applicable
gpu_compatibility_warning: Optional[str] = None # Warning if GPU arch unsupported
gpu_compatibility_warning: str | None = None # Warning if GPU arch unsupported
class DirectoryCheck(BaseModel):
@@ -455,16 +455,16 @@ class DirectoryCheck(BaseModel):
path: str
exists: bool
writable: bool
error: Optional[str] = None
error: str | None = None
class FilesystemHealthResponse(BaseModel):
"""Response model for filesystem health check."""
healthy: bool
disk_free_mb: Optional[float] = None
disk_total_mb: Optional[float] = None
directories: List[DirectoryCheck]
disk_free_mb: float | None = None
disk_total_mb: float | None = None
directories: list[DirectoryCheck]
class ModelStatus(BaseModel):
@@ -472,17 +472,17 @@ class ModelStatus(BaseModel):
model_name: str
display_name: str
hf_repo_id: Optional[str] = None # HuggingFace repository ID
hf_repo_id: str | None = None # HuggingFace repository ID
downloaded: bool
downloading: bool = False # True if download is in progress
size_mb: Optional[float] = None
size_mb: float | None = 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: 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
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
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: Optional[str] = Field(None, min_length=1, max_length=100)
device_ids: Optional[List[str]] = None
name: str | None = Field(None, min_length=1, max_length=100)
device_ids: list[str] | None = 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: Optional[str] = Field(None, max_length=500)
description: str | None = Field(None, max_length=500)
class StoryResponse(BaseModel):
@@ -577,7 +577,7 @@ class StoryResponse(BaseModel):
id: str
name: str
description: Optional[str]
description: str | None
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: Optional[str] = None
version_id: str | None = 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: Optional[int]
instruct: Optional[str]
engine: Optional[str] = None
seed: int | None
instruct: str | None
engine: str | None = None
volume: float = 1.0
generation_created_at: datetime
# Versions available for this generation
versions: Optional[List["GenerationVersionResponse"]] = None
active_version_id: Optional[str] = None
versions: list["GenerationVersionResponse"] | None = None
active_version_id: str | None = None
class Config:
from_attributes = True
@@ -623,10 +623,10 @@ class StoryDetailResponse(BaseModel):
id: str
name: str
description: Optional[str]
description: str | None
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: Optional[int] = None # If not provided, will be calculated automatically
track: Optional[int] = 0 # Track number (0 = main track)
start_time_ms: int | None = None # If not provided, will be calculated automatically
track: int | None = 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: Optional[str] = None # null = use generation default
version_id: str | None = 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: Optional[str] = Field(None, max_length=500)
effects_chain: List[EffectConfig]
description: str | None = Field(None, max_length=500)
effects_chain: list[EffectConfig]
class EffectPresetUpdate(BaseModel):
"""Request model for updating an effect preset."""
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = None
effects_chain: Optional[List[EffectConfig]] = None
name: str | None = Field(None, min_length=1, max_length=100)
description: str | None = None
effects_chain: list[EffectConfig] | None = None
class EffectPresetResponse(BaseModel):
@@ -731,8 +731,8 @@ class EffectPresetResponse(BaseModel):
id: str
name: str
description: Optional[str] = None
effects_chain: List[EffectConfig]
description: str | None = 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: Optional[List[EffectConfig]] = None
source_version_id: Optional[str] = None
effects_chain: list[EffectConfig] | None = None
source_version_id: str | None = 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: Optional[str] = Field(
effects_chain: list[EffectConfig]
source_version_id: str | None = Field(
None, description="Version to use as source audio (defaults to clean/original)"
)
label: Optional[str] = Field(None, max_length=100, description="Label for this version (auto-generated if omitted)")
label: str | None = 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: Optional[List[EffectConfig]] = Field(None, description="Effects chain (null to remove)")
effects_chain: list[EffectConfig] | None = 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: Optional[str] = None
account_user_id: Optional[str] = None
key_prefix: Optional[str] = None
connected_at: Optional[datetime] = None
device_name: str | None = None
account_user_id: str | None = None
key_prefix: str | None = None
connected_at: datetime | None = None
dashboard_url: str
+12 -11
View File
@@ -44,6 +44,7 @@ def _patch_torch_from_numpy():
return
try:
import ctypes
import numpy as np
_orig = torch.from_numpy
@@ -53,17 +54,17 @@ def _patch_torch_from_numpy():
# silently corrupt data (e.g. fp16 tensors from some TTS engines),
# so we raise instead.
dtype_map = {
"float16": _t.float16,
"float32": _t.float32,
"float64": _t.float64,
"int8": _t.int8,
"int16": _t.int16,
"int32": _t.int32,
"int64": _t.int64,
"uint8": _t.uint8,
"bool": _t.bool,
"complex64": _t.complex64,
"complex128": _t.complex128,
"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,
}
def _safe_from_numpy(
@@ -56,7 +56,6 @@ 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.
+32 -4
View File
@@ -1,6 +1,6 @@
[project]
name = "voicebox-backend"
version = "0.2.3"
version = "0.5.0"
requires-python = ">=3.12"
# ---------------------------------------------------------------------------
@@ -49,19 +49,43 @@ 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, and magic values freely.
"tests/**" = ["S101", "T201", "PLR2004", "ERA001"]
# Tests can use assert, print, magic values, and script-style setup freely.
"tests/**" = ["S101", "T201", "PLR2004", "ERA001", "E402", "PT011", "PT018", "PT019"]
# __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"]
@@ -81,3 +105,7 @@ 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
@@ -1,25 +0,0 @@
# 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
+1 -2
View File
@@ -16,8 +16,7 @@ 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 and the setup-python recipe in the
# justfile). Most other mlx-audio runtime deps
# (see .github/workflows/release.yml). 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,9 +17,10 @@ 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)
linacodec @ git+https://github.com/ysharma3501/LinaCodec.git
Zipvoice @ git+https://github.com/ysharma3501/LuxTTS.git
# 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
# Chatterbox TTS sub-dependencies (chatterbox-tts itself is installed
# --no-deps in the setup script because it pins numpy<1.26 / torch==2.6
@@ -53,7 +54,6 @@ 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
+18 -18
View File
@@ -5,26 +5,26 @@ from fastapi import FastAPI
def register_routers(app: FastAPI) -> None:
"""Include all domain routers on the application."""
from .health import router as health_router
from .profiles import router as profiles_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 .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 .transcription import router as transcription_router
app.include_router(health_router)
app.include_router(profiles_router)
+6 -11
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, models
from ..services import history
from .. import config
from ..database import get_db
from ..services import history
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.is_file():
if audio_path is None or not audio_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(
@@ -52,13 +52,8 @@ 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.is_file():
detail = (
"Generation failed; no audio available"
if generation.status == "failed"
else "Audio file not found"
)
raise HTTPException(status_code=404, detail=detail)
if audio_path is None or not audio_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(
audio_path,
@@ -77,7 +72,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.is_file():
if audio_path is None or not audio_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(
+1 -2
View File
@@ -10,8 +10,7 @@ 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
from ..services import settings as settings_service
from ..services import captures as captures_service, 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 ..services import channels
from ..database import get_db
from ..services import channels
router = APIRouter()
-4
View File
@@ -26,10 +26,6 @@ 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 ..services import history
from ..database import Generation as DBGeneration, get_db
from ..services import history
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.effects import apply_effects, validate_effects_chain
from ..utils.audio import load_audio
from ..utils.effects import apply_effects, validate_effects_chain
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.effects import apply_effects, validate_effects_chain
from ..utils.audio import load_audio, save_audio
from ..utils.effects import apply_effects, validate_effects_chain
chain_dicts = [e.model_dump() for e in data.effects_chain]
error = validate_effects_chain(chain_dicts)
-1
View File
@@ -14,7 +14,6 @@ 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