Compare commits

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

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

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

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

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

* style(tests): satisfy Ruff import ordering

---------

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

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

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

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

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

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

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

Addresses CodeRabbit review on PR #893.

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

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

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

* style(tests): satisfy Ruff naming rule

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

* fix: align CUDA status nullability

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

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

Fixes #841

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

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

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

---------

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

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

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

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

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

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

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

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

Fixes #925


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

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

profiles.py already solves this for voice samples by keeping the uploaded
extension when it is one of the audio types it accepts, and falling back to
.wav otherwise. Same approach here, same set. The fallback means an unknown
or missing extension behaves exactly as it does today.
2026-07-20 12:41:19 -07:00
Jamie Pine 7e424a20a3 fix(dictation): preserve native focus and fullscreen injection
Carry focus and auto-paste permission per capture, support dictation over macOS fullscreen Spaces, preserve native window behavior flags, and abort paste when the pill cannot be hidden safely.\n\nVerified: frontend CI; cargo check; diff and security scans. Packaged multi-Space validation remains a release gate.
2026-07-19 17:45:52 -07:00
Jamie Pine 9b1beba3e1 fix(dictation): make microphone lifecycle race-safe
Coalesce and invalidate microphone acquisition, preserve immediate stop/cancel events, block overlapping/finalising takes, and add the opt-in keep_mic_warm setting with privacy-preserving default off.\n\nVerified: frontend CI; keep_mic_warm migration default and idempotency; diff checks.
2026-07-19 17:45:52 -07:00
Jamie Pine 0070c04bcf fix(mlx): serialize accelerator lifecycle and inference
Route MLX load, inference, unload, reset, cache cleanup, and shutdown through a single worker. Add affinity and concurrent-unload regression coverage.\n\nVerified: 17 related backend tests; frontend CI; cargo check.
2026-07-19 17:45:52 -07:00
216 changed files with 4150 additions and 13023 deletions
+2 -1
View File
@@ -8,7 +8,8 @@ tauri/
landing/
docs/
mlx-test/
scripts/
scripts/*
!scripts/rocm-entrypoint.sh
# Dependencies & build artifacts (rebuilt in Docker)
node_modules/
+112 -3
View File
@@ -7,9 +7,8 @@ on:
- main
jobs:
frontend-quality:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -22,5 +21,115 @@ jobs:
- name: Typecheck app + web
run: bun run typecheck
- name: Build web smoke test
- name: Build web
run: bun run build:web
- name: Upload web build
uses: actions/upload-artifact@v4
with:
name: web-dist
path: web/dist
retention-days: 1
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
- name: Install Chromium
run: bunx playwright install chromium --with-deps
- name: Vitest (unit + browser)
run: bunx vitest run
e2e:
# Informational while the suite beds in; flip to blocking once it has
# a sustained green run.
continue-on-error: true
needs: quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --frozen-lockfile
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
cache-dependency-path: backend/requirements-ci.txt
- name: Install backend (CPU)
run: |
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install -r backend/requirements-ci.txt
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
- name: Install Chromium
run: bunx playwright install chromium --with-deps
- name: Playwright E2E
run: bunx playwright test -c e2e
env:
VOICEBOX_PYTHON: python
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
playwright-report
test-results
retention-days: 7
backend-tests:
# Informational: 30 pre-existing pytest files that have never run in CI.
continue-on-error: true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
cache-dependency-path: backend/requirements-ci.txt
- name: Install backend (CPU)
run: |
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install -r backend/requirements-ci.txt
pip install pytest pytest-asyncio
- name: Pytest
run: python -m pytest backend/tests -v --ignore=backend/tests/test_all_models_e2e.py
BIN
View File
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
22
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+2 -2
View File
@@ -91,7 +91,7 @@ On Windows, to build with CUDA support for local testing:
just build-local # Build CPU + CUDA server binaries + Tauri installer
```
This builds the CPU sidecar (bundled with the app), the CUDA binary (placed in `%APPDATA%/com.voicebox.app/backends/` for runtime GPU switching), and the installable Tauri app.
This builds the CPU sidecar (bundled with the app), the CUDA binary (placed in `%APPDATA%/sh.voicebox.app/backends/` for runtime GPU switching), and the installable Tauri app.
Creates platform-specific installers (`.dmg`, `.msi`, `.AppImage`) in `tauri/src-tauri/target/release/bundle/`.
@@ -133,7 +133,7 @@ bun run convert:assets
This script:
- Converts PNG → WebP (better compression, same quality)
- Converts MOV → WebM (VP9 codec, smaller file size)
- Processes files in `landing/public/` and `docs/public/`
- Processes files in `docs/public/`
- **Deletes original files** after successful conversion
**Requirements:** Install `webp` and `ffmpeg`:
+1 -1
View File
@@ -21,7 +21,7 @@ COPY app/ ./app/
COPY web/ ./web/
# Strip workspaces not needed for web build, and fix trailing comma
RUN sed -i '/"tauri"/d; /"landing"/d' package.json && \
RUN sed -i '/"tauri"/d' package.json && \
sed -i -z 's/,\n ]/\n ]/' package.json
RUN bun install --no-save
# Build frontend (skip tsc — upstream has pre-existing type errors)
+3 -4
View File
@@ -45,7 +45,7 @@
<p align="center">
<a href="https://voicebox.sh">
<img src="landing/public/assets/app-screenshot-1.webp" alt="Voicebox App Screenshot" width="800" />
<img src="docs/public/images/readme/app-screenshot-1.webp" alt="Voicebox App Screenshot" width="800" />
</a>
</p>
@@ -56,11 +56,11 @@
<br/>
<p align="center">
<img src="landing/public/assets/app-screenshot-2.webp" alt="Voicebox Screenshot 2" width="800" />
<img src="docs/public/images/readme/app-screenshot-2.webp" alt="Voicebox Screenshot 2" width="800" />
</p>
<p align="center">
<img src="landing/public/assets/app-screenshot-3.webp" alt="Voicebox Screenshot 3" width="800" />
<img src="docs/public/images/readme/app-screenshot-3.webp" alt="Voicebox Screenshot 3" width="800" />
</p>
<br/>
@@ -442,7 +442,6 @@ voicebox/
├── tauri/ # Desktop app (Tauri + Rust)
├── web/ # Web deployment
├── backend/ # Python FastAPI server
├── landing/ # Marketing website
└── scripts/ # Build & release scripts
```
-29
View File
@@ -1,29 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>voicebox</title>
<script>
(function () {
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>
-3
View File
@@ -4,10 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc -p tsconfig.json --noEmit",
"preview": "vite preview",
"lint": "biome lint src",
"lint:fix": "biome lint --write src",
"format": "biome format --write src",
+134
View File
@@ -0,0 +1,134 @@
import { mockIPC } from '@tauri-apps/api/mocks';
import { afterEach, beforeEach, expect, it } from 'vitest';
import App from '@/App';
import { createMockPlatform } from '@/test/mockPlatform';
import { buildModelStatus, buildProfile } from '@/test/msw/fixtures';
import {
captureHandlers,
effectsHandlers,
historyHandlers,
modelHandlers,
profileHandlers,
settingsHandlers,
storyHandlers,
taskHandlers,
} from '@/test/msw/handlers';
import { worker } from '@/test/msw/worker';
import { renderWithProviders } from '@/test/render';
const originalUrl = window.location.href;
// useChordSync and the permission gates call the Tauri IPC modules directly,
// outside the Platform abstraction. There is no Tauri runtime in the test
// browser, so `invoke`/`listen` would reject with a TypeError that some
// callers (e.g. useChordSync's `listen('dictate:warm-request')`) never get a
// chance to handle, surfacing as unhandled rejections. mockIPC installs the
// official in-memory IPC shim; `shouldMockEvents` covers listen/emit too.
//
// Reinstalled per test for a fresh listener map, but never cleared: the
// harness unmounts components after this file's afterEach, and those unmount
// cleanups still `unlisten` through the shim. The per-file iframe throws the
// window state away anyway.
beforeEach(() => {
mockIPC(
(cmd) => {
// Permission checks treat the result as a trusted boolean — grant
// them so no permission banners pop over the UI under test.
if (cmd.startsWith('check_')) return true;
return null;
},
{ shouldMockEvents: true },
);
});
afterEach(() => {
window.history.replaceState(null, '', originalUrl);
delete window.__voiceboxServerStartedByApp;
});
/**
* Everything the index route (MainEditor + app chrome) fetches on mount.
* Unstubbed requests fail the test loudly, so this is the full route budget.
*/
function useHappyPathHandlers() {
worker.use(
...profileHandlers([buildProfile({ name: 'Ada Lovelace' })]),
...historyHandlers([]),
...captureHandlers([]),
...settingsHandlers(),
...modelHandlers([buildModelStatus()]),
...storyHandlers([]),
...effectsHandlers([]),
...taskHandlers(),
);
}
// App reads window.location at render time: `?view=dictate` picks the pill
// window, and the router matches the real browser path. Point the URL at the
// state under test before mounting; afterEach restores the runner's URL.
function setAppUrl(path: string) {
window.history.replaceState(null, '', path);
}
it('skips the startup gate outside Tauri and renders the router', async () => {
useHappyPathHandlers();
setAppUrl('/');
const screen = await renderWithProviders(<App />);
// Index route is MainEditor — the profile list proves the router mounted.
await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
// Web mode assumes an external server: no lifecycle management at all.
expect(screen.platform.lifecycle.startServer).not.toHaveBeenCalled();
expect(screen.platform.lifecycle.setupWindowCloseHandler).not.toHaveBeenCalled();
});
it('skips server auto-start in Tauri dev mode and still reaches the router', async () => {
// App gates auto-start on `import.meta.env.PROD`, which is false under
// vitest. The reachable Tauri branch is therefore the dev one: window
// close handler installed, auto-start skipped, serverReady forced true.
//
// The PROD-only branches — `lifecycle.startServer`, the health-check
// polling fallback, and the startup-error screen with its Retry button —
// are unreachable here without mocking import.meta.env, so they are
// intentionally not covered.
useHappyPathHandlers();
setAppUrl('/');
const platform = createMockPlatform({ metadata: { isTauri: true } });
const screen = await renderWithProviders(<App />, { platform });
await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
expect(platform.lifecycle.startServer).not.toHaveBeenCalled();
expect(platform.lifecycle.setupWindowCloseHandler).toHaveBeenCalled();
// Startup syncs the keep-server-running setting into Rust.
expect(platform.lifecycle.setKeepServerRunning).toHaveBeenCalledWith(expect.any(Boolean));
// Auto-updater runs its mount check in Tauri.
expect(platform.updater.checkForUpdates).toHaveBeenCalled();
// Dev mode records that the app does not own the server process.
expect(window.__voiceboxServerStartedByApp).toBe(false);
});
it('renders the dictate pill window for ?view=dictate without booting the main app', async () => {
// No route handlers on purpose: the dictate view must not touch any of the
// main app's endpoints, and an unhandled request would fail the test.
setAppUrl('/?view=dictate');
const screen = await renderWithProviders(<App />);
// DictateWindow forces the document transparent so the Tauri window takes
// the pill's shape — the observable signal that it mounted without
// throwing under the non-Tauri mock platform.
await expect.poll(() => document.body.style.background).toBe('transparent');
// The pill starts hidden: the wrapper renders but contains no CapturePill.
const wrapper = screen.container.firstElementChild as HTMLElement;
expect(wrapper.className).toContain('h-screen');
expect(wrapper.childElementCount).toBe(0);
// The startup gate never ran — no server lifecycle calls from this window.
expect(screen.platform.lifecycle.startServer).not.toHaveBeenCalled();
expect(screen.platform.lifecycle.setupWindowCloseHandler).not.toHaveBeenCalled();
});
-675
View File
@@ -1,675 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiClient } from '@/lib/api/client';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext';
import { usePlayerStore } from '@/stores/playerStore';
interface AudioDevice {
id: string;
name: string;
is_default: boolean;
}
export function AudioTab() {
const { t } = useTranslation();
const platform = usePlatform();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editingChannel, setEditingChannel] = useState<string | null>(null);
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
const queryClient = useQueryClient();
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
const { data: channels, isLoading: channelsLoading } = useQuery({
queryKey: ['channels'],
queryFn: () => apiClient.listChannels(),
});
const { data: devices, isLoading: devicesLoading } = useQuery({
queryKey: ['audio-devices'],
queryFn: async () => {
if (!platform.metadata.isTauri) {
return [];
}
try {
return await platform.audio.listOutputDevices();
} catch (error) {
console.error('Failed to list audio devices:', error);
return [];
}
},
enabled: platform.metadata.isTauri,
});
const { data: profiles } = useQuery({
queryKey: ['profiles'],
queryFn: () => apiClient.listProfiles(),
});
const createChannel = useMutation({
mutationFn: (data: { name: string; device_ids: string[] }) => apiClient.createChannel(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] });
setCreateDialogOpen(false);
},
});
const updateChannel = useMutation({
mutationFn: ({
channelId,
data,
}: {
channelId: string;
data: { name?: string; device_ids?: string[] };
}) => apiClient.updateChannel(channelId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] });
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
setEditingChannel(null);
},
});
const deleteChannel = useMutation({
mutationFn: (channelId: string) => apiClient.deleteChannel(channelId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] });
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
},
});
const { data: channelVoices } = useQuery({
queryKey: ['channel-voices', editingChannel],
queryFn: async () => {
if (!editingChannel) return { profile_ids: [] };
return apiClient.getChannelVoices(editingChannel);
},
enabled: !!editingChannel,
});
const setChannelVoices = useMutation({
mutationFn: ({ channelId, profileIds }: { channelId: string; profileIds: string[] }) =>
apiClient.setChannelVoices(channelId, profileIds),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channel-voices'] });
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
},
});
if (channelsLoading || devicesLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-muted-foreground">{t('audioChannels.loading')}</div>
</div>
);
}
const handleChannelDelete = async (e: React.MouseEvent, channelId: string) => {
e.stopPropagation();
if (await confirm(t('audioChannels.confirmDelete'))) {
deleteChannel.mutate(channelId);
}
};
const allChannels = channels || [];
const allDevices = devices || [];
const selectedChannel = selectedChannelId
? allChannels.find((c) => c.id === selectedChannelId)
: null;
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between mb-6 shrink-0">
<h2 className="text-2xl font-bold">{t('audioChannels.title')}</h2>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
{t('audioChannels.newChannel')}
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0">
{/* Left Column - Channels */}
<div
className={cn(
'flex flex-col min-h-0 overflow-y-auto',
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)}
>
{allChannels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
<Speaker className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-4">{t('audioChannels.empty.message')}</p>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
{t('audioChannels.empty.action')}
</Button>
</div>
) : (
<div className="space-y-3">
{allChannels.map((channel) => {
const isSelected = selectedChannelId === channel.id;
return (
<button
key={channel.id}
type="button"
className={cn(
'group border rounded-lg p-4 transition-colors cursor-pointer text-left w-full',
isSelected && 'ring-2 ring-primary bg-primary/5 border-primary',
)}
onClick={() => setSelectedChannelId(isSelected ? null : channel.id)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-3">
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Speaker className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex items-center gap-2 min-w-0">
<h3 className="font-semibold text-base truncate">{channel.name}</h3>
</div>
</div>
<div className="space-y-2.5 ml-10">
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">
{t('audioChannels.labels.outputDevices')}
</div>
<div className="flex flex-wrap gap-1.5">
{channel.device_ids.length > 0
? channel.device_ids.map((deviceId) => {
const device = allDevices.find((d) => d.id === deviceId);
return (
<Badge
key={deviceId}
variant="outline"
className="text-xs font-normal"
>
{device?.name || deviceId}
</Badge>
);
})
: (() => {
const defaultDevice = allDevices.find((d) => d.is_default);
return defaultDevice ? (
<Badge variant="outline" className="text-xs font-normal">
{defaultDevice.name}
</Badge>
) : null;
})()}
</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">
{t('audioChannels.labels.assignedVoices')}
</div>
<ChannelVoicesList channelId={channel.id} />
</div>
</div>
</div>
{!channel.is_default && (
<div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
setEditingChannel(channel.id);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => handleChannelDelete(e, channel.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
</button>
);
})}
</div>
)}
</div>
{/* Right Column - Available Devices */}
<div
className={cn(
'flex flex-col min-h-0 overflow-y-auto',
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)}
>
<div className="shrink-0 mb-4">
<h3 className="text-lg font-semibold">{t('audioChannels.devices.title')}</h3>
<p className="text-sm text-muted-foreground mt-1">
{selectedChannelId
? selectedChannel?.is_default
? t('audioChannels.devices.defaultNote')
: t('audioChannels.devices.toggleHint')
: t('audioChannels.devices.selectHint')}
</p>
</div>
{allDevices.length > 0 ? (
<div className="space-y-2">
{allDevices.map((device) => {
const isConnected =
selectedChannelId &&
selectedChannel &&
(selectedChannel.device_ids.length === 0
? device.is_default
: selectedChannel.device_ids.includes(device.id));
const canToggle =
selectedChannelId && selectedChannel && !selectedChannel.is_default;
const handleDeviceClick = () => {
if (!canToggle || !selectedChannel) return;
const currentDeviceIds = selectedChannel.device_ids;
const newDeviceIds = isConnected
? currentDeviceIds.filter((id) => id !== device.id)
: [...currentDeviceIds, device.id];
updateChannel.mutate({
channelId: selectedChannelId,
data: { device_ids: newDeviceIds },
});
};
return (
<button
key={device.id}
type="button"
onClick={handleDeviceClick}
disabled={!canToggle}
className={cn(
'flex items-center gap-2 text-sm p-3 rounded-lg border transition-colors text-left w-full',
isConnected
? 'bg-primary/10 border-primary ring-1 ring-primary/20'
: 'hover:bg-muted/50',
!canToggle && 'cursor-default opacity-60',
canToggle && 'cursor-pointer',
)}
>
{canToggle ? (
<div
className={cn(
'h-4 w-4 rounded border-2 flex items-center justify-center shrink-0',
isConnected ? 'bg-accent border-accent' : 'border-muted-foreground/30',
)}
>
{isConnected && <Check className="h-3 w-3 text-accent-foreground" />}
</div>
) : device.is_default ? (
<CheckCircle2 className="h-4 w-4 text-primary shrink-0" />
) : null}
<span className={cn('truncate flex-1', device.is_default && 'font-medium')}>
{device.name}
</span>
</button>
);
})}
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground text-center">
{platform.metadata.isTauri
? t('audioChannels.devices.empty')
: t('audioChannels.devices.requiresTauri')}
</p>
</div>
)}
</div>
</div>
{/* Create Channel Dialog */}
<CreateChannelDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
devices={devices || []}
onCreate={(name, deviceIds) => {
createChannel.mutate({ name, device_ids: deviceIds });
}}
/>
{/* Edit Channel Dialog */}
{editingChannel &&
(() => {
const channel = channels?.find((c) => c.id === editingChannel);
return channel ? (
<EditChannelDialog
open={!!editingChannel}
onOpenChange={(open) => !open && setEditingChannel(null)}
channel={channel}
devices={devices || []}
profiles={profiles || []}
channelVoices={channelVoices?.profile_ids || []}
onUpdate={(name, deviceIds) => {
updateChannel.mutate({
channelId: editingChannel,
data: { name, device_ids: deviceIds },
});
}}
onSetVoices={(profileIds) => {
setChannelVoices.mutate({
channelId: editingChannel,
profileIds,
});
}}
/>
) : null;
})()}
</div>
);
}
function ChannelVoicesList({ channelId }: { channelId: string }) {
const { t } = useTranslation();
const { data: voices } = useQuery({
queryKey: ['channel-voices', channelId],
queryFn: () => apiClient.getChannelVoices(channelId),
});
const { data: profiles } = useQuery({
queryKey: ['profiles'],
queryFn: () => apiClient.listProfiles(),
});
const voiceNames =
voices?.profile_ids.map((id) => profiles?.find((p) => p.id === id)?.name).filter(Boolean) || [];
return (
<div className="flex flex-wrap gap-1.5">
{voiceNames.length > 0 ? (
voiceNames.map((name) => (
<Badge key={name} variant="outline" className="text-xs font-normal">
{name}
</Badge>
))
) : (
<span className="text-sm text-muted-foreground">{t('audioChannels.noVoicesAssigned')}</span>
)}
</div>
);
}
interface CreateChannelDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
devices: AudioDevice[];
onCreate: (name: string, deviceIds: string[]) => void;
}
function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateChannelDialogProps) {
const { t } = useTranslation();
const [name, setName] = useState('');
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
const handleSubmit = () => {
if (name.trim()) {
onCreate(name.trim(), selectedDevices);
setName('');
setSelectedDevices([]);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('audioChannels.createDialog.title')}</DialogTitle>
<DialogDescription>{t('audioChannels.createDialog.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="channel-name">{t('audioChannels.fields.name')}</Label>
<Input
id="channel-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('audioChannels.fields.namePlaceholder')}
/>
</div>
<div>
<Label>{t('audioChannels.labels.outputDevices')}</Label>
<Select
value={selectedDevices[0] || ''}
onValueChange={(value) => {
if (value && !selectedDevices.includes(value)) {
setSelectedDevices([...selectedDevices, value]);
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t('audioChannels.selectDevice')} />
</SelectTrigger>
<SelectContent>
{devices.map((device) => (
<SelectItem key={device.id} value={device.id}>
{device.name} {device.is_default && `(${t('audioChannels.defaultSuffix')})`}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedDevices.length > 0 && (
<div className="mt-2 space-y-1">
{selectedDevices.map((deviceId) => {
const device = devices.find((d) => d.id === deviceId);
return (
<div
key={deviceId}
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
>
<span>{device?.name || deviceId}</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleSubmit} disabled={!name.trim()}>
{t('audioChannels.createDialog.action')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
interface EditChannelDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
channel: {
id: string;
name: string;
device_ids: string[];
};
devices: AudioDevice[];
profiles: Array<{ id: string; name: string }>;
channelVoices: string[];
onUpdate: (name: string, deviceIds: string[]) => void;
onSetVoices: (profileIds: string[]) => void;
}
function EditChannelDialog({
open,
onOpenChange,
channel,
devices,
profiles,
channelVoices,
onUpdate,
onSetVoices,
}: EditChannelDialogProps) {
const { t } = useTranslation();
const [name, setName] = useState(channel.name);
const [selectedDevices, setSelectedDevices] = useState<string[]>(channel.device_ids);
const [selectedVoices, setSelectedVoices] = useState<string[]>(channelVoices);
const handleSubmit = () => {
if (name.trim()) {
onUpdate(name.trim(), selectedDevices);
onSetVoices(selectedVoices);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('audioChannels.editDialog.title')}</DialogTitle>
<DialogDescription>{t('audioChannels.editDialog.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="edit-channel-name">{t('audioChannels.fields.name')}</Label>
<Input id="edit-channel-name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<Label>{t('audioChannels.labels.outputDevices')}</Label>
<Select
value=""
onValueChange={(value) => {
if (value && !selectedDevices.includes(value)) {
setSelectedDevices([...selectedDevices, value]);
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t('audioChannels.addDevice')} />
</SelectTrigger>
<SelectContent>
{devices.map((device) => (
<SelectItem key={device.id} value={device.id}>
{device.name} {device.is_default && `(${t('audioChannels.defaultSuffix')})`}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedDevices.length > 0 && (
<div className="mt-2 space-y-1">
{selectedDevices.map((deviceId) => {
const device = devices.find((d) => d.id === deviceId);
return (
<div
key={deviceId}
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
>
<span>{device?.name || deviceId}</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
)}
</div>
<div>
<Label>{t('audioChannels.labels.assignedVoices')}</Label>
<Select
value=""
onValueChange={(value) => {
if (value && !selectedVoices.includes(value)) {
setSelectedVoices([...selectedVoices, value]);
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t('audioChannels.addVoice')} />
</SelectTrigger>
<SelectContent>
{profiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedVoices.length > 0 && (
<div className="mt-2 space-y-1">
{selectedVoices.map((profileId) => {
const profile = profiles.find((p) => p.id === profileId);
return (
<div
key={profileId}
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
>
<span>{profile?.name || profileId}</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
setSelectedVoices(selectedVoices.filter((id) => id !== profileId))
}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleSubmit} disabled={!name.trim()}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+110 -94
View File
@@ -1,8 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { save } from '@tauri-apps/plugin-dialog';
import { writeFile, writeTextFile } from '@tauri-apps/plugin-fs';
import {
Captions,
Check,
@@ -27,6 +25,14 @@ import { AudioBars } from '@/components/AudioBars';
import { CapturePill } from '@/components/CapturePill/CapturePill';
import { CaptureInlinePlayer } from '@/components/CapturesTab/CaptureInlinePlayer';
import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist';
import {
ListPane,
ListPaneHeader,
ListPaneScroll,
ListPaneSearch,
ListPaneTitle,
ListPaneTitleRow,
} from '@/components/ListPane';
import {
AlertDialog,
AlertDialogAction,
@@ -48,14 +54,6 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Textarea } from '@/components/ui/textarea';
import {
ListPane,
ListPaneHeader,
ListPaneScroll,
ListPaneSearch,
ListPaneTitle,
ListPaneTitleRow,
} from '@/components/ListPane';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type {
@@ -72,6 +70,7 @@ import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { cn } from '@/lib/utils/cn';
import { formatAbsoluteDate, formatDate } from '@/lib/utils/format';
import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
import { usePlatform } from '@/platform/PlatformContext';
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
@@ -135,6 +134,7 @@ export function CapturesTab() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { toast } = useToast();
const platform = usePlatform();
const fileInputRef = useRef<HTMLInputElement>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
@@ -202,6 +202,7 @@ export function CapturesTab() {
// the race window between ``setSelectedId(new)`` and the refetched list
// actually containing the new row.
useEffect(() => {
if (!platform.metadata.isTauri) return;
const unlistens: Promise<UnlistenFn>[] = [];
unlistens.push(
listen<{ capture: CaptureResponse }>('capture:created', (event) => {
@@ -225,7 +226,7 @@ export function CapturesTab() {
return () => {
for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
};
}, [queryClient]);
}, [queryClient, platform.metadata.isTauri]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
@@ -243,9 +244,7 @@ export function CapturesTab() {
// referenced profile was deleted) fall through to the first profile.
const storedVoiceId = captureSettings?.default_playback_voice_id ?? null;
const playAsVoice =
(storedVoiceId && profiles?.find((p) => p.id === storedVoiceId)) ||
profiles?.[0] ||
null;
(storedVoiceId && profiles?.find((p) => p.id === storedVoiceId)) || profiles?.[0] || null;
const playAsVoiceId = playAsVoice?.id ?? null;
const deleteMutation = useMutation({
@@ -255,12 +254,22 @@ export function CapturesTab() {
queryClient.invalidateQueries({ queryKey: ['captures'] });
},
onError: (err: Error) => {
toast({ title: t('captures.toast.deleteFailed'), description: err.message, variant: 'destructive' });
toast({
title: t('captures.toast.deleteFailed'),
description: err.message,
variant: 'destructive',
});
},
});
const playAsMutation = useMutation({
mutationFn: async ({ capture, voice }: { capture: CaptureResponse; voice: VoiceProfileResponse }) => {
mutationFn: async ({
capture,
voice,
}: {
capture: CaptureResponse;
voice: VoiceProfileResponse;
}) => {
const text = capture.transcript_refined || capture.transcript_raw;
if (!text.trim()) throw new Error(t('captures.noTranscriptError'));
const language = (capture.language || voice.language) as LanguageCode;
@@ -268,8 +277,13 @@ export function CapturesTab() {
// profile's stored engine preference. Cloned profiles without an
// override fall through to whatever the backend picks.
const engine = voice.default_engine as
| 'qwen' | 'qwen_custom_voice' | 'luxtts' | 'chatterbox'
| 'chatterbox_turbo' | 'tada' | 'kokoro'
| 'qwen'
| 'qwen_custom_voice'
| 'luxtts'
| 'chatterbox'
| 'chatterbox_turbo'
| 'tada'
| 'kokoro'
| undefined;
return apiClient.generateSpeech({
profile_id: voice.id,
@@ -286,7 +300,11 @@ export function CapturesTab() {
addPendingGeneration(result.id);
},
onError: (err: Error) => {
toast({ title: t('captures.toast.playAsFailed'), description: err.message, variant: 'destructive' });
toast({
title: t('captures.toast.playAsFailed'),
description: err.message,
variant: 'destructive',
});
},
});
@@ -336,16 +354,15 @@ export function CapturesTab() {
const handleExportAudio = async () => {
if (!selected) return;
try {
const dest = await save({
defaultPath: `capture_${selected.id.slice(0, 8)}.wav`,
filters: [{ name: 'Audio', extensions: ['wav'] }],
});
if (!dest) return;
const res = await fetch(apiClient.getCaptureAudioUrl(selected.id));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = new Uint8Array(await res.arrayBuffer());
await writeFile(dest, buf);
exportToastSuccess(dest);
const blob = new Blob([await res.arrayBuffer()], { type: 'audio/wav' });
const dest = await platform.filesystem.saveFile(
`capture_${selected.id.slice(0, 8)}.wav`,
blob,
[{ name: 'Audio', extensions: ['wav'] }],
);
if (dest) exportToastSuccess(dest);
} catch (err) {
exportToastError(err);
}
@@ -359,13 +376,12 @@ export function CapturesTab() {
return;
}
try {
const dest = await save({
defaultPath: `capture_${selected.id.slice(0, 8)}.txt`,
filters: [{ name: 'Text', extensions: ['txt'] }],
});
if (!dest) return;
await writeTextFile(dest, text);
exportToastSuccess(dest);
const dest = await platform.filesystem.saveFile(
`capture_${selected.id.slice(0, 8)}.txt`,
new Blob([text], { type: 'text/plain' }),
[{ name: 'Text', extensions: ['txt'] }],
);
if (dest) exportToastSuccess(dest);
} catch (err) {
exportToastError(err);
}
@@ -376,7 +392,8 @@ export function CapturesTab() {
lines.push(`# Capture ${capture.id}`, '');
lines.push(`- **Source:** ${capture.source}`);
lines.push(`- **Created:** ${capture.created_at}`);
if (capture.duration_ms != null) lines.push(`- **Duration:** ${formatDuration(capture.duration_ms)}`);
if (capture.duration_ms != null)
lines.push(`- **Duration:** ${formatDuration(capture.duration_ms)}`);
if (capture.language) lines.push(`- **Language:** ${capture.language}`);
if (capture.stt_model) lines.push(`- **STT model:** ${capture.stt_model}`);
if (capture.llm_model) lines.push(`- **LLM model:** ${capture.llm_model}`);
@@ -398,13 +415,12 @@ export function CapturesTab() {
return;
}
try {
const dest = await save({
defaultPath: `capture_${selected.id.slice(0, 8)}.md`,
filters: [{ name: 'Markdown', extensions: ['md'] }],
});
if (!dest) return;
await writeTextFile(dest, buildCaptureMarkdown(selected));
exportToastSuccess(dest);
const dest = await platform.filesystem.saveFile(
`capture_${selected.id.slice(0, 8)}.md`,
new Blob([buildCaptureMarkdown(selected)], { type: 'text/markdown' }),
[{ name: 'Markdown', extensions: ['md'] }],
);
if (dest) exportToastSuccess(dest);
} catch (err) {
exportToastError(err);
}
@@ -486,48 +502,48 @@ export function CapturesTab() {
</div>
) : (
filtered.map((capture) => {
const isActive = selectedId === capture.id;
const refined = !!capture.transcript_refined;
return (
<button
type="button"
key={capture.id}
onClick={() => setSelectedId(capture.id)}
className={cn(
'w-full text-left p-3 rounded-lg transition-colors block',
isActive
? 'bg-muted/70 border border-border'
: 'border border-transparent hover:bg-muted/30',
)}
>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[11px] text-muted-foreground font-medium">
{formatDate(capture.created_at)}
</span>
<div className="flex-1" />
<span className="text-[10px] text-muted-foreground/70 tabular-nums">
{formatDuration(capture.duration_ms)}
</span>
</div>
<div className="text-[13px] text-foreground/90 line-clamp-2 leading-snug mb-2">
{snippetOf(capture)}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<SourceBadge source={capture.source} />
{refined && (
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-accent/10 text-accent border border-accent/20"
>
<Sparkles className="h-2.5 w-2.5" />
{t('captures.transcript.refined')}
</Badge>
const isActive = selectedId === capture.id;
const refined = !!capture.transcript_refined;
return (
<button
type="button"
key={capture.id}
onClick={() => setSelectedId(capture.id)}
className={cn(
'w-full text-left p-3 rounded-lg transition-colors block',
isActive
? 'bg-muted/70 border border-border'
: 'border border-transparent hover:bg-muted/30',
)}
</div>
</button>
);
})
)}
>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[11px] text-muted-foreground font-medium">
{formatDate(capture.created_at)}
</span>
<div className="flex-1" />
<span className="text-[10px] text-muted-foreground/70 tabular-nums">
{formatDuration(capture.duration_ms)}
</span>
</div>
<div className="text-[13px] text-foreground/90 line-clamp-2 leading-snug mb-2">
{snippetOf(capture)}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<SourceBadge source={capture.source} />
{refined && (
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-accent/10 text-accent border border-accent/20"
>
<Sparkles className="h-2.5 w-2.5" />
{t('captures.transcript.refined')}
</Badge>
)}
</div>
</button>
);
})
)}
</div>
</ListPaneScroll>
</ListPane>
@@ -578,7 +594,9 @@ export function CapturesTab() {
) : (
<Upload className="h-4 w-4 mr-2" />
)}
{session.isUploading ? t('captures.actions.importing') : t('captures.actions.import')}
{session.isUploading
? t('captures.actions.importing')
: t('captures.actions.import')}
</Button>
)}
</>
@@ -748,11 +766,7 @@ export function CapturesTab() {
</DropdownMenuLabel>
<DropdownMenuSeparator />
{profiles?.map((v) => (
<DropdownMenuItem
key={v.id}
onClick={() => handlePlayAs(v)}
className="py-2"
>
<DropdownMenuItem key={v.id} onClick={() => handlePlayAs(v)} className="py-2">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{v.name}</div>
<div className="text-[11px] text-muted-foreground truncate">
@@ -864,9 +878,7 @@ export function CapturesTab() {
</div>
) : null}
</div>
<p className="text-sm">
{t('captures.empty.pressShortcut')}
</p>
<p className="text-sm">{t('captures.empty.pressShortcut')}</p>
</div>
) : (
<div className="max-w-sm mx-auto text-center space-y-3">
@@ -888,7 +900,9 @@ export function CapturesTab() {
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('captures.deleteDialog.title')}</AlertDialogTitle>
<AlertDialogDescription>{t('captures.deleteDialog.description')}</AlertDialogDescription>
<AlertDialogDescription>
{t('captures.deleteDialog.description')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
@@ -898,7 +912,9 @@ export function CapturesTab() {
disabled={deleteMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteMutation.isPending ? t('captures.deleteDialog.deleting') : t('common.delete')}
{deleteMutation.isPending
? t('captures.deleteDialog.deleting')
: t('common.delete')}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
@@ -0,0 +1,113 @@
import { expect, it, vi } from 'vitest';
import { ChordPicker } from '@/components/ChordPicker/ChordPicker';
import { renderWithProviders } from '@/test/render';
// ChordPicker listens on window in the capture phase and canonicalizes via
// `event.code`, so raw KeyboardEvents give exact control over which physical
// keys the picker sees (userEvent would depend on the host keyboard layout).
function press(code: string) {
window.dispatchEvent(new KeyboardEvent('keydown', { code, bubbles: true, cancelable: true }));
}
function release(code: string) {
window.dispatchEvent(new KeyboardEvent('keyup', { code, bubbles: true, cancelable: true }));
}
async function renderPicker(initialKeys: string[] = []) {
const onSave = vi.fn();
const onCancel = vi.fn();
const screen = await renderWithProviders(
<ChordPicker
open
title="Push-to-talk shortcut"
initialKeys={initialKeys}
onSave={onSave}
onCancel={onCancel}
/>,
);
return { screen, onSave, onCancel };
}
it('opens empty with save disabled and flags unsupported keys', async () => {
const { screen } = await renderPicker();
await expect.element(screen.getByText('Press your shortcut')).toBeVisible();
await expect.element(screen.getByText('No keys yet')).toBeVisible();
await expect.element(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
// NumpadEnter has no canonical chord name — the picker refuses it and
// stays empty instead of capturing garbage.
press('NumpadEnter');
await expect.element(screen.getByText(/isn't supported in chords/)).toBeVisible();
await expect.element(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
});
it('captures the held keys and saves them after release', async () => {
const { screen, onSave } = await renderPicker();
press('KeyJ');
await expect.element(screen.getByText('Capturing…')).toBeVisible();
await expect.element(screen.getByText('J', { exact: true })).toBeVisible();
press('KeyK');
await expect.element(screen.getByText('K', { exact: true })).toBeVisible();
// Releasing everything freezes the peak so the user can save hands-free.
release('KeyK');
release('KeyJ');
await expect.element(screen.getByText('Press your shortcut')).toBeVisible();
await expect.element(screen.getByText('J', { exact: true })).toBeVisible();
await expect.element(screen.getByText('K', { exact: true })).toBeVisible();
await screen.getByRole('button', { name: 'Save' }).click();
expect(onSave).toHaveBeenCalledExactlyOnceWith(['KeyJ', 'KeyK']);
});
it('keeps the peak set when a key is released mid-chord', async () => {
const { screen, onSave } = await renderPicker();
press('KeyA');
press('KeyB');
press('KeyC');
await expect.element(screen.getByText('B', { exact: true })).toBeVisible();
// Mid-chord the display tracks only the currently held keys...
release('KeyB');
await expect.element(screen.getByText('B', { exact: true })).not.toBeInTheDocument();
await expect.element(screen.getByText('A', { exact: true })).toBeVisible();
// ...but the captured peak still includes the released key.
release('KeyA');
release('KeyC');
await expect.element(screen.getByText('B', { exact: true })).toBeVisible();
await screen.getByRole('button', { name: 'Save' }).click();
expect(onSave).toHaveBeenCalledExactlyOnceWith(['KeyA', 'KeyB', 'KeyC']);
});
it('replaces a longer saved chord with a fresh shorter one', async () => {
const { screen, onSave } = await renderPicker(['KeyA', 'KeyB', 'KeyC']);
await expect.element(screen.getByText('A', { exact: true })).toBeVisible();
// The first key of a new sequence resets the peak, so a single key can
// beat the three-key seed.
press('KeyZ');
release('KeyZ');
await expect.element(screen.getByText('Z', { exact: true })).toBeVisible();
await expect.element(screen.getByText('A', { exact: true })).not.toBeInTheDocument();
await screen.getByRole('button', { name: 'Save' }).click();
expect(onSave).toHaveBeenCalledExactlyOnceWith(['KeyZ']);
});
it('cancel fires the cancel callback and never saves', async () => {
const { screen, onSave, onCancel } = await renderPicker(['KeyA']);
press('KeyQ');
release('KeyQ');
await screen.getByRole('button', { name: 'Cancel' }).click();
expect(onCancel).toHaveBeenCalledOnce();
expect(onSave).not.toHaveBeenCalled();
});
@@ -5,6 +5,7 @@ import { CapturePill } from '@/components/CapturePill/CapturePill';
import { apiClient } from '@/lib/api/client';
import type { FocusSnapshot } from '@/lib/api/types';
import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSession';
import { usePlatform } from '@/platform/PlatformContext';
/**
* Floating dictate surface shown in a separate transparent Tauri window.
@@ -22,6 +23,9 @@ import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSessi
* ``dictate:hide`` so Rust tucks the window away.
*/
export function DictateWindow() {
const platform = usePlatform();
const isTauri = platform.metadata.isTauri;
// Force the host document chrome to be transparent so the Tauri window
// takes on the pill's own shape.
useEffect(() => {
@@ -35,19 +39,17 @@ export function DictateWindow() {
};
}, []);
// Snapshot of the focused UI element at chord-start, shipped over from
// Rust on the ``dictate:start`` payload. Held in a ref so it survives
// the 1–2 s transcribe + refine window — the paste only fires once the
// final text comes back.
const focusRef = useRef<FocusSnapshot | null>(null);
// Mirrored from the main window: true only when dictation is armed and the
// user opted into keeping the microphone ready.
const [micWarm, setMicWarm] = useState(false);
const session = useCaptureRecordingSession({
onFinalText: async (text, _capture, allowAutoPaste) => {
const focus = focusRef.current;
// Consume-once: a second chord before this fires would overwrite
// focusRef, but nulling it here guards against the late-arriving
// refine-result firing a paste after the user has moved on.
focusRef.current = null;
keepMicWarm: micWarm,
onFinalText: async (text, _capture, allowAutoPaste, context) => {
// Focus is the snapshot taken at chord-start and threaded through as this
// take's context, so it survives the 1–2 s transcribe + refine window and
// overlapping dictations can't paste into each other's target.
const focus = context as FocusSnapshot | null;
if (!allowAutoPaste) return;
if (!focus || !text.trim()) return;
try {
@@ -72,22 +74,41 @@ export function DictateWindow() {
sessionRef.current = session;
useEffect(() => {
const unlistens: Promise<UnlistenFn>[] = [];
unlistens.push(
if (!isTauri) return;
let disposed = false;
const unlistens: UnlistenFn[] = [];
const registrations = [
listen<{ focus: FocusSnapshot | null }>('dictate:start', (event) => {
focusRef.current = event.payload?.focus ?? null;
sessionRef.current.startRecording();
sessionRef.current.startRecording(event.payload?.focus ?? null);
}),
);
unlistens.push(
listen('dictate:stop', () => {
if (sessionRef.current.isRecording) sessionRef.current.stopRecording();
// Forward stops that arrive while getUserMedia is still resolving.
sessionRef.current.stopRecording();
}),
);
listen<boolean>('dictate:warm', (event) => {
setMicWarm(Boolean(event.payload));
}),
];
Promise.all(registrations)
.then((registered) => {
if (disposed) {
for (const unlisten of registered) unlisten();
return;
}
unlistens.push(...registered);
emit('dictate:warm-request').catch(() => {});
})
.catch((err) => console.warn('[dictate] event listener registration failed:', err));
return () => {
for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
disposed = true;
for (const unlisten of unlistens) unlisten();
};
}, []);
}, [isTauri]);
useEffect(() => {
if (micWarm) void session.prewarm();
else session.releaseWarm();
}, [micWarm, session.prewarm, session.releaseWarm]);
// --- Agent-speak cycle ---------------------------------------------------
@@ -141,9 +162,7 @@ export function DictateWindow() {
audio.onplaying = () => {
emit('dictate:show').catch(() => {});
setSpeaking((prev) =>
prev && prev.generationId === generationId
? { ...prev, startedAt: Date.now() }
: prev,
prev && prev.generationId === generationId ? { ...prev, startedAt: Date.now() } : prev,
);
setSpeakElapsed(0);
};
@@ -155,6 +174,7 @@ export function DictateWindow() {
};
useEffect(() => {
if (!isTauri) return;
const unlistens: Promise<UnlistenFn>[] = [];
// Rust emits the SSE payload as a JSON *string* (not a parsed object);
@@ -249,7 +269,7 @@ export function DictateWindow() {
for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
dismissSpeak();
};
}, []);
}, [isTauri]);
// Advance the pill's elapsed-time label while audio is playing. Paused
// during the pre-playback generation window (startedAt is null) so the
@@ -0,0 +1,185 @@
import { HttpResponse, http } from 'msw';
import { expect, it } from 'vitest';
import type { VoiceProfileResponse } from '@/lib/api/types';
import { useGenerationStore } from '@/stores/generationStore';
import { useUIStore } from '@/stores/uiStore';
import { buildGeneration, buildModelStatus, buildProfile } from '@/test/msw/fixtures';
import {
captureHandlers,
effectsHandlers,
historyHandlers,
modelHandlers,
profileHandlers,
settingsHandlers,
storyHandlers,
taskHandlers,
} from '@/test/msw/handlers';
import { worker } from '@/test/msw/worker';
import { renderRoute } from '@/test/render';
import { sseController } from '@/test/sse';
/**
* FloatingGenerateBox calls useMatchRoute, so it needs router context; the
* SSE completion loop (useGenerationProgress) lives in the router's root
* layout. Mounting the index route exercises the real wiring for both.
* History handlers are registered per test so requests can be counted.
*/
function stubAppRequests(profiles: VoiceProfileResponse[]) {
worker.use(
...profileHandlers(profiles),
...captureHandlers([]),
...settingsHandlers(),
...modelHandlers([buildModelStatus()]),
...storyHandlers([]),
...effectsHandlers([]),
...taskHandlers(),
);
}
it('renders the generate box wired to the selected profile', async () => {
const profile = buildProfile({ name: 'Ada Lovelace' });
stubAppRequests([profile]);
worker.use(...historyHandlers([]));
useUIStore.getState().setSelectedProfileId(profile.id);
const screen = await renderRoute('/');
await expect
.element(screen.getByPlaceholder('Generate speech using Ada Lovelace…'))
.toBeVisible();
await expect.element(screen.getByRole('button', { name: 'Generate speech' })).toBeEnabled();
expect(useUIStore.getState().selectedProfileId).toBe(profile.id);
});
it('posts to /generate on submit and tracks the pending generation', async () => {
const profile = buildProfile({ name: 'Ada Lovelace' });
const generation = buildGeneration({
profile_id: profile.id,
status: 'generating',
audio_path: undefined,
});
const generateBodies: unknown[] = [];
const sse = sseController();
stubAppRequests([profile]);
worker.use(
...historyHandlers([]),
http.post('*/generate', async ({ request }) => {
generateBodies.push(await request.json());
return HttpResponse.json(generation);
}),
http.get('*/generate/:id/status', () => sse.response()),
);
useUIStore.getState().setSelectedProfileId(profile.id);
const screen = await renderRoute('/');
const input = screen.getByPlaceholder('Generate speech using Ada Lovelace…');
await input.fill('Hello from the browser test');
await screen.getByRole('button', { name: 'Generate speech' }).click();
await expect.poll(() => generateBodies.length).toBe(1);
expect(generateBodies[0]).toMatchObject({
profile_id: profile.id,
text: 'Hello from the browser test',
language: 'en',
engine: 'qwen',
});
await expect
.poll(() => useGenerationStore.getState().pendingGenerationIds.has(generation.id))
.toBe(true);
// The form resets as soon as the request is accepted.
await expect.element(input).toHaveValue('');
sse.close();
});
it('clears pending state and refetches history when SSE reports completion', async () => {
const profile = buildProfile({ name: 'Ada Lovelace' });
const generation = buildGeneration({
profile_id: profile.id,
status: 'generating',
audio_path: undefined,
});
const sse = sseController();
let sseConnections = 0;
let historyGets = 0;
stubAppRequests([profile]);
worker.use(
http.get('*/history', () => {
historyGets += 1;
return HttpResponse.json({ items: [], total: 0 });
}),
http.post('*/generate', () => HttpResponse.json(generation)),
http.get('*/generate/:id/status', () => {
sseConnections += 1;
return sse.response();
}),
// Autoplay is off via settingsHandlers, but keep audio stubbed so a
// completion-triggered player fetch could never fail the run loudly.
http.get(
'*/audio/:id',
() =>
new HttpResponse(new Blob([new Uint8Array(64)]), {
headers: { 'Content-Type': 'audio/wav' },
}),
),
);
useUIStore.getState().setSelectedProfileId(profile.id);
const screen = await renderRoute('/');
await screen.getByPlaceholder('Generate speech using Ada Lovelace…').fill('Progress please');
await screen.getByRole('button', { name: 'Generate speech' }).click();
await expect
.poll(() => useGenerationStore.getState().pendingGenerationIds.has(generation.id))
.toBe(true);
await expect.poll(() => sseConnections).toBe(1);
// Initial mount fetch + post-submit invalidation — wait for both so the
// final count increase can only come from the SSE completion refetch.
await expect.poll(() => historyGets).toBe(2);
sse.push({ data: { id: generation.id, status: 'generating' } });
sse.push({ data: { id: generation.id, status: 'completed', duration: 1.5 } });
await expect.poll(() => useGenerationStore.getState().pendingGenerationIds.size).toBe(0);
await expect.poll(() => historyGets).toBe(3);
sse.close();
});
it('disables the input and generate button when no profile is selected', async () => {
stubAppRequests([]);
worker.use(...historyHandlers([]));
const screen = await renderRoute('/');
await expect
.element(screen.getByRole('button', { name: 'Select a voice profile first' }))
.toBeDisabled();
await expect.element(screen.getByPlaceholder('Select a voice profile above…')).toBeDisabled();
});
it('does not post to /generate when the text is empty', async () => {
const profile = buildProfile({ name: 'Ada Lovelace' });
let generateCalls = 0;
stubAppRequests([profile]);
worker.use(
...historyHandlers([]),
http.post('*/generate', () => {
generateCalls += 1;
return HttpResponse.json(buildGeneration());
}),
);
useUIStore.getState().setSelectedProfileId(profile.id);
const screen = await renderRoute('/');
const button = screen.getByRole('button', { name: 'Generate speech' });
await expect.element(button).toBeEnabled();
await button.click();
// Validation rejects empty text before any request is made — give a
// would-be submission ample time to surface, then assert it never did.
await new Promise((resolve) => setTimeout(resolve, 300));
expect(generateCalls).toBe(0);
expect(useGenerationStore.getState().pendingGenerationIds.size).toBe(0);
});
@@ -0,0 +1,133 @@
import { HttpResponse, http } from 'msw';
import { expect, it, vi } from 'vitest';
import { HistoryTable } from '@/components/History/HistoryTable';
import { usePlayerStore } from '@/stores/playerStore';
import { buildHistoryItem } from '@/test/msw/fixtures';
import { historyHandlers } from '@/test/msw/handlers';
import { worker } from '@/test/msw/worker';
import { renderWithProviders } from '@/test/render';
it('renders history rows with profile names and transcripts', async () => {
const ada = buildHistoryItem({
profile_name: 'Ada Lovelace',
text: 'The analytical engine speaks.',
});
const grace = buildHistoryItem({
profile_name: 'Grace Hopper',
text: 'A compiler for the spoken word.',
});
worker.use(...historyHandlers([ada, grace]));
const screen = await renderWithProviders(<HistoryTable />);
await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
await expect.element(screen.getByText('Grace Hopper')).toBeVisible();
await expect
.element(screen.getByRole('textbox', { name: /Transcript for sample from Ada Lovelace/ }))
.toHaveValue('The analytical engine speaks.');
await expect
.element(screen.getByRole('textbox', { name: /Transcript for sample from Grace Hopper/ }))
.toHaveValue('A compiler for the spoken word.');
});
it('shows the empty state when there is no history', async () => {
worker.use(...historyHandlers([]));
const screen = await renderWithProviders(<HistoryTable />);
await expect.element(screen.getByText('No voice generations', { exact: false })).toBeVisible();
});
it('loads a clicked row into the player store with auto-play intent', async () => {
const item = buildHistoryItem({ profile_name: 'Ada Lovelace', text: 'Play me back.' });
worker.use(...historyHandlers([item]));
const screen = await renderWithProviders(<HistoryTable />);
// Click the profile-name cell — the row's mousedown handler ignores clicks
// that land on the transcript textarea.
await screen.getByText('Ada Lovelace').click();
await expect.poll(() => usePlayerStore.getState().audioId).toBe(item.id);
const player = usePlayerStore.getState();
expect(player.audioUrl).toContain(`/audio/${item.id}`);
expect(player.profileId).toBe(item.profile_id);
expect(player.shouldAutoPlay).toBe(true);
// isPlaying flips only once the AudioPlayer (not mounted here) starts playback.
expect(player.isPlaying).toBe(false);
});
it('toggles favorite via POST and reflects the refetched state', async () => {
const item = buildHistoryItem({ profile_name: 'Ada Lovelace' });
let favorited = false;
const favoriteRequests: string[] = [];
worker.use(
http.get('*/history', () =>
HttpResponse.json({ items: [{ ...item, is_favorited: favorited }], total: 1 }),
),
http.post('*/history/:id/favorite', ({ params }) => {
favoriteRequests.push(params.id as string);
favorited = true;
return HttpResponse.json({ is_favorited: favorited });
}),
);
const screen = await renderWithProviders(<HistoryTable />);
await screen.getByRole('button', { name: 'Favorite' }).click();
await expect.poll(() => favoriteRequests).toEqual([item.id]);
// History was invalidated and refetched — the star now reads as favorited.
await expect.element(screen.getByRole('button', { name: 'Unfavorite' })).toBeVisible();
});
it('deletes a generation after confirming the dialog', async () => {
const item = buildHistoryItem({ profile_name: 'Ada Lovelace' });
let items = [item];
const deleteRequests: string[] = [];
worker.use(
http.get('*/history', () => HttpResponse.json({ items, total: items.length })),
http.delete('*/history/:id', ({ params }) => {
deleteRequests.push(params.id as string);
items = items.filter((i) => i.id !== params.id);
return HttpResponse.json({ status: 'deleted' });
}),
);
const screen = await renderWithProviders(<HistoryTable />);
await screen.getByRole('button', { name: 'Actions' }).click();
await screen.getByRole('menuitem', { name: 'Delete' }).click();
await expect.element(screen.getByText('Delete Generation')).toBeVisible();
await screen.getByRole('button', { name: 'Delete' }).click();
await expect.poll(() => deleteRequests).toEqual([item.id]);
// The refetched (now empty) list replaces the row.
await expect.element(screen.getByText('No voice generations', { exact: false })).toBeVisible();
});
it('exports audio through platform.filesystem.saveFile', async () => {
const item = buildHistoryItem({ profile_name: 'Ada Lovelace', text: 'Export me please' });
worker.use(
...historyHandlers([item]),
http.get(
'*/history/:id/export-audio',
() =>
new HttpResponse(new Blob([new Uint8Array(64)]), {
headers: { 'Content-Type': 'audio/wav' },
}),
),
);
const screen = await renderWithProviders(<HistoryTable />);
await screen.getByRole('button', { name: 'Actions' }).click();
await screen.getByRole('menuitem', { name: 'Export Audio' }).click();
const saveFile = vi.mocked(screen.platform.filesystem.saveFile);
await expect.poll(() => saveFile.mock.calls.length).toBe(1);
const [filename, blob, filters] = saveFile.mock.calls[0];
expect(filename).toBe('export-me-please.wav');
expect(blob).toBeInstanceOf(Blob);
expect(filters).toEqual([{ name: 'Audio File', extensions: ['wav'] }]);
});
@@ -0,0 +1,15 @@
import { expect, it } from 'vitest';
import { AboutPage } from '@/components/ServerTab/AboutPage';
import { createMockPlatform } from '@/test/mockPlatform';
import { renderWithProviders } from '@/test/render';
it('renders and shows the platform version', async () => {
const platform = createMockPlatform({
metadata: { getVersion: async () => '9.9.9-test', isTauri: false },
});
const screen = await renderWithProviders(<AboutPage />, { platform });
await expect.element(screen.getByAltText('Voicebox')).toBeVisible();
await expect.element(screen.getByText('9.9.9-test', { exact: false })).toBeVisible();
});
@@ -138,6 +138,7 @@ export function CapturesPage() {
const allowAutoPaste = settings?.allow_auto_paste ?? true;
const defaultVoiceId = settings?.default_playback_voice_id ?? null;
const hotkeyEnabled = settings?.hotkey_enabled ?? false;
const keepMicWarm = settings?.keep_mic_warm ?? false;
const pushToTalkKeys = settings?.chord_push_to_talk_keys ?? defaultChordKeys('push');
const toggleToTalkKeys = settings?.chord_toggle_to_talk_keys ?? defaultChordKeys('toggle');
@@ -221,6 +222,22 @@ export function CapturesPage() {
<InputMonitoringNotice enabled={hotkeyEnabled} />
</div>
<SettingRow
title={t('settings.captures.dictation.keepMicWarm.title')}
description={t('settings.captures.dictation.keepMicWarm.description')}
htmlFor="keepMicWarm"
action={
<Toggle
id="keepMicWarm"
checked={keepMicWarm}
disabled={!hotkeyEnabled}
onCheckedChange={(v) => {
update({ keep_mic_warm: v });
}}
/>
}
/>
<SettingRow
title={t('settings.captures.dictation.pushToTalk.title')}
description={t('settings.captures.dictation.pushToTalk.description')}
-61
View File
@@ -1,61 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { usePlatform } from '@/platform/PlatformContext';
import type { UpdateStatus } from '@/platform/types';
// Re-export UpdateStatus for backwards compatibility
export type { UpdateStatus };
interface UseAutoUpdaterOptions {
checkOnMount?: boolean;
showToast?: boolean;
}
export function useAutoUpdater(options: boolean | UseAutoUpdaterOptions = false) {
const { checkOnMount } =
typeof options === 'boolean' ? { checkOnMount: options } : { checkOnMount: options.checkOnMount ?? false };
const platform = usePlatform();
const [status, setStatus] = useState<UpdateStatus>(platform.updater.getStatus());
const hasCheckedRef = useRef(false);
// Subscribe to updater status changes
useEffect(() => {
const unsubscribe = platform.updater.subscribe((newStatus) => {
setStatus(newStatus);
});
return unsubscribe;
// Empty dependency array - platform is stable from context
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platform.updater.subscribe]);
const checkForUpdates = useCallback(async () => {
await platform.updater.checkForUpdates();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platform.updater.checkForUpdates]);
const downloadAndInstall = useCallback(async () => {
await platform.updater.downloadAndInstall();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platform.updater.downloadAndInstall]);
const restartAndInstall = useCallback(async () => {
await platform.updater.restartAndInstall();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platform.updater.restartAndInstall]);
useEffect(() => {
if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) {
hasCheckedRef.current = true;
checkForUpdates().catch((error) => {
console.error('Auto update check failed:', error);
});
}
}, [checkOnMount, checkForUpdates, platform.metadata.isTauri]);
return {
status,
checkForUpdates,
downloadAndInstall,
restartAndInstall,
};
}
+4
View File
@@ -887,6 +887,10 @@
"title": "Global shortcut",
"description": "Hold the shortcut to record from anywhere on your machine. Release to transcribe."
},
"keepMicWarm": {
"title": "Keep microphone ready",
"description": "Hold the microphone open while dictation is enabled so the first words are never clipped. The macOS microphone indicator stays lit while it's on."
},
"pushToTalk": {
"title": "Push-to-talk shortcut",
"description": "Hold these keys anywhere on your system to record. Release to stop and transcribe.",
-25
View File
@@ -1,25 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ApiRequestOptions } from './ApiRequestOptions';
import type { ApiResult } from './ApiResult';
export class ApiError extends Error {
public readonly url: string;
public readonly status: number;
public readonly statusText: string;
public readonly body: any;
public readonly request: ApiRequestOptions;
constructor(request: ApiRequestOptions, response: ApiResult, message: string) {
super(message);
this.name = 'ApiError';
this.url = response.url;
this.status = response.status;
this.statusText = response.statusText;
this.body = response.body;
this.request = request;
}
}
-17
View File
@@ -1,17 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ApiRequestOptions = {
readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH';
readonly url: string;
readonly path?: Record<string, any>;
readonly cookies?: Record<string, any>;
readonly headers?: Record<string, any>;
readonly query?: Record<string, any>;
readonly formData?: Record<string, any>;
readonly body?: any;
readonly mediaType?: string;
readonly responseHeader?: string;
readonly errors?: Record<number, string>;
};
-11
View File
@@ -1,11 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ApiResult = {
readonly url: string;
readonly ok: boolean;
readonly status: number;
readonly statusText: string;
readonly body: any;
};
-130
View File
@@ -1,130 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export class CancelError extends Error {
constructor(message: string) {
super(message);
this.name = 'CancelError';
}
public get isCancelled(): boolean {
return true;
}
}
export interface OnCancel {
readonly isResolved: boolean;
readonly isRejected: boolean;
readonly isCancelled: boolean;
(cancelHandler: () => void): void;
}
export class CancelablePromise<T> implements Promise<T> {
#isResolved: boolean;
#isRejected: boolean;
#isCancelled: boolean;
readonly #cancelHandlers: (() => void)[];
readonly #promise: Promise<T>;
#resolve?: (value: T | PromiseLike<T>) => void;
#reject?: (reason?: any) => void;
constructor(
executor: (
resolve: (value: T | PromiseLike<T>) => void,
reject: (reason?: any) => void,
onCancel: OnCancel,
) => void,
) {
this.#isResolved = false;
this.#isRejected = false;
this.#isCancelled = false;
this.#cancelHandlers = [];
this.#promise = new Promise<T>((resolve, reject) => {
this.#resolve = resolve;
this.#reject = reject;
const onResolve = (value: T | PromiseLike<T>): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isResolved = true;
if (this.#resolve) this.#resolve(value);
};
const onReject = (reason?: any): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isRejected = true;
if (this.#reject) this.#reject(reason);
};
const onCancel = (cancelHandler: () => void): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#cancelHandlers.push(cancelHandler);
};
Object.defineProperty(onCancel, 'isResolved', {
get: (): boolean => this.#isResolved,
});
Object.defineProperty(onCancel, 'isRejected', {
get: (): boolean => this.#isRejected,
});
Object.defineProperty(onCancel, 'isCancelled', {
get: (): boolean => this.#isCancelled,
});
return executor(onResolve, onReject, onCancel as OnCancel);
});
}
get [Symbol.toStringTag]() {
return 'Cancellable Promise';
}
public then<TResult1 = T, TResult2 = never>(
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2> {
return this.#promise.then(onFulfilled, onRejected);
}
public catch<TResult = never>(
onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null,
): Promise<T | TResult> {
return this.#promise.catch(onRejected);
}
public finally(onFinally?: (() => void) | null): Promise<T> {
return this.#promise.finally(onFinally);
}
public cancel(): void {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isCancelled = true;
if (this.#cancelHandlers.length) {
try {
for (const cancelHandler of this.#cancelHandlers) {
cancelHandler();
}
} catch (error) {
console.warn('Cancellation threw an error', error);
return;
}
}
this.#cancelHandlers.length = 0;
if (this.#reject) this.#reject(new CancelError('Request aborted'));
}
public get isCancelled(): boolean {
return this.#isCancelled;
}
}
-32
View File
@@ -1,32 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ApiRequestOptions } from './ApiRequestOptions';
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
type Headers = Record<string, string>;
export type OpenAPIConfig = {
BASE: string;
VERSION: string;
WITH_CREDENTIALS: boolean;
CREDENTIALS: 'include' | 'omit' | 'same-origin';
TOKEN?: string | Resolver<string> | undefined;
USERNAME?: string | Resolver<string> | undefined;
PASSWORD?: string | Resolver<string> | undefined;
HEADERS?: Headers | Resolver<Headers> | undefined;
ENCODE_PATH?: ((path: string) => string) | undefined;
};
export const OpenAPI: OpenAPIConfig = {
BASE: '',
VERSION: '0.1.0',
WITH_CREDENTIALS: false,
CREDENTIALS: 'include',
TOKEN: undefined,
USERNAME: undefined,
PASSWORD: undefined,
HEADERS: undefined,
ENCODE_PATH: undefined,
};
-341
View File
@@ -1,341 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import { ApiError } from './ApiError';
import type { ApiRequestOptions } from './ApiRequestOptions';
import type { ApiResult } from './ApiResult';
import { CancelablePromise } from './CancelablePromise';
import type { OnCancel } from './CancelablePromise';
import type { OpenAPIConfig } from './OpenAPI';
export const isDefined = <T>(
value: T | null | undefined,
): value is Exclude<T, null | undefined> => {
return value !== undefined && value !== null;
};
export const isString = (value: any): value is string => {
return typeof value === 'string';
};
export const isStringWithValue = (value: any): value is string => {
return isString(value) && value !== '';
};
export const isBlob = (value: any): value is Blob => {
return (
typeof value === 'object' &&
typeof value.type === 'string' &&
typeof value.stream === 'function' &&
typeof value.arrayBuffer === 'function' &&
typeof value.constructor === 'function' &&
typeof value.constructor.name === 'string' &&
/^(Blob|File)$/.test(value.constructor.name) &&
/^(Blob|File)$/.test(value[Symbol.toStringTag])
);
};
export const isFormData = (value: any): value is FormData => {
return value instanceof FormData;
};
export const base64 = (str: string): string => {
try {
return btoa(str);
} catch (err) {
// @ts-ignore
return Buffer.from(str).toString('base64');
}
};
export const getQueryString = (params: Record<string, any>): string => {
const qs: string[] = [];
const append = (key: string, value: any) => {
qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
};
const process = (key: string, value: any) => {
if (isDefined(value)) {
if (Array.isArray(value)) {
value.forEach((v) => {
process(key, v);
});
} else if (typeof value === 'object') {
Object.entries(value).forEach(([k, v]) => {
process(`${key}[${k}]`, v);
});
} else {
append(key, value);
}
}
};
Object.entries(params).forEach(([key, value]) => {
process(key, value);
});
if (qs.length > 0) {
return `?${qs.join('&')}`;
}
return '';
};
const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => {
const encoder = config.ENCODE_PATH || encodeURI;
const path = options.url
.replace('{api-version}', config.VERSION)
.replace(/{(.*?)}/g, (substring: string, group: string) => {
if (options.path?.hasOwnProperty(group)) {
return encoder(String(options.path[group]));
}
return substring;
});
const url = `${config.BASE}${path}`;
if (options.query) {
return `${url}${getQueryString(options.query)}`;
}
return url;
};
export const getFormData = (options: ApiRequestOptions): FormData | undefined => {
if (options.formData) {
const formData = new FormData();
const process = (key: string, value: any) => {
if (isString(value) || isBlob(value)) {
formData.append(key, value);
} else {
formData.append(key, JSON.stringify(value));
}
};
Object.entries(options.formData)
.filter(([_, value]) => isDefined(value))
.forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((v) => process(key, v));
} else {
process(key, value);
}
});
return formData;
}
return undefined;
};
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
export const resolve = async <T>(
options: ApiRequestOptions,
resolver?: T | Resolver<T>,
): Promise<T | undefined> => {
if (typeof resolver === 'function') {
return (resolver as Resolver<T>)(options);
}
return resolver;
};
export const getHeaders = async (
config: OpenAPIConfig,
options: ApiRequestOptions,
): Promise<Headers> => {
const [token, username, password, additionalHeaders] = await Promise.all([
resolve(options, config.TOKEN),
resolve(options, config.USERNAME),
resolve(options, config.PASSWORD),
resolve(options, config.HEADERS),
]);
const headers = Object.entries({
Accept: 'application/json',
...additionalHeaders,
...options.headers,
})
.filter(([_, value]) => isDefined(value))
.reduce(
(headers, [key, value]) => ({
...headers,
[key]: String(value),
}),
{} as Record<string, string>,
);
if (isStringWithValue(token)) {
headers['Authorization'] = `Bearer ${token}`;
}
if (isStringWithValue(username) && isStringWithValue(password)) {
const credentials = base64(`${username}:${password}`);
headers['Authorization'] = `Basic ${credentials}`;
}
if (options.body !== undefined) {
if (options.mediaType) {
headers['Content-Type'] = options.mediaType;
} else if (isBlob(options.body)) {
headers['Content-Type'] = options.body.type || 'application/octet-stream';
} else if (isString(options.body)) {
headers['Content-Type'] = 'text/plain';
} else if (!isFormData(options.body)) {
headers['Content-Type'] = 'application/json';
}
}
return new Headers(headers);
};
export const getRequestBody = (options: ApiRequestOptions): any => {
if (options.body !== undefined) {
if (options.mediaType?.includes('/json')) {
return JSON.stringify(options.body);
} else if (isString(options.body) || isBlob(options.body) || isFormData(options.body)) {
return options.body;
} else {
return JSON.stringify(options.body);
}
}
return undefined;
};
export const sendRequest = async (
config: OpenAPIConfig,
options: ApiRequestOptions,
url: string,
body: any,
formData: FormData | undefined,
headers: Headers,
onCancel: OnCancel,
): Promise<Response> => {
const controller = new AbortController();
const request: RequestInit = {
headers,
body: body ?? formData,
method: options.method,
signal: controller.signal,
};
if (config.WITH_CREDENTIALS) {
request.credentials = config.CREDENTIALS;
}
onCancel(() => controller.abort());
return await fetch(url, request);
};
export const getResponseHeader = (
response: Response,
responseHeader?: string,
): string | undefined => {
if (responseHeader) {
const content = response.headers.get(responseHeader);
if (isString(content)) {
return content;
}
}
return undefined;
};
export const getResponseBody = async (response: Response): Promise<any> => {
if (response.status !== 204) {
try {
const contentType = response.headers.get('Content-Type');
if (contentType) {
const jsonTypes = ['application/json', 'application/problem+json'];
const isJSON = jsonTypes.some((type) => contentType.toLowerCase().startsWith(type));
if (isJSON) {
return await response.json();
} else {
return await response.text();
}
}
} catch (error) {
console.error(error);
}
}
return undefined;
};
export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => {
const errors: Record<number, string> = {
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
500: 'Internal Server Error',
502: 'Bad Gateway',
503: 'Service Unavailable',
...options.errors,
};
const error = errors[result.status];
if (error) {
throw new ApiError(options, result, error);
}
if (!result.ok) {
const errorStatus = result.status ?? 'unknown';
const errorStatusText = result.statusText ?? 'unknown';
const errorBody = (() => {
try {
return JSON.stringify(result.body, null, 2);
} catch (e) {
return undefined;
}
})();
throw new ApiError(
options,
result,
`Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`,
);
}
};
/**
* Request method
* @param config The OpenAPI configuration object
* @param options The request options from the service
* @returns CancelablePromise<T>
* @throws ApiError
*/
export const request = <T>(
config: OpenAPIConfig,
options: ApiRequestOptions,
): CancelablePromise<T> => {
return new CancelablePromise(async (resolve, reject, onCancel) => {
try {
const url = getUrl(config, options);
const formData = getFormData(options);
const body = getRequestBody(options);
const headers = await getHeaders(config, options);
if (!onCancel.isCancelled) {
const response = await sendRequest(config, options, url, body, formData, headers, onCancel);
const responseBody = await getResponseBody(response);
const responseHeader = getResponseHeader(response, options.responseHeader);
const result: ApiResult = {
url,
ok: response.ok,
status: response.status,
statusText: response.statusText,
body: responseHeader ?? responseBody,
};
catchErrorCodes(options, result);
resolve(result.body);
}
} catch (error) {
reject(error);
}
});
};
-44
View File
@@ -1,44 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export { ApiError } from './core/ApiError';
export { CancelablePromise, CancelError } from './core/CancelablePromise';
export { OpenAPI } from './core/OpenAPI';
export type { OpenAPIConfig } from './core/OpenAPI';
export type { Body_add_profile_sample_profiles__profile_id__samples_post } from './models/Body_add_profile_sample_profiles__profile_id__samples_post';
export type { Body_transcribe_audio_transcribe_post } from './models/Body_transcribe_audio_transcribe_post';
export type { GenerationRequest } from './models/GenerationRequest';
export type { GenerationResponse } from './models/GenerationResponse';
export type { HealthResponse } from './models/HealthResponse';
export type { HistoryListResponse } from './models/HistoryListResponse';
export type { HistoryResponse } from './models/HistoryResponse';
export type { HTTPValidationError } from './models/HTTPValidationError';
export type { ModelDownloadRequest } from './models/ModelDownloadRequest';
export type { ModelStatus } from './models/ModelStatus';
export type { ModelStatusListResponse } from './models/ModelStatusListResponse';
export type { ProfileSampleResponse } from './models/ProfileSampleResponse';
export type { TranscriptionResponse } from './models/TranscriptionResponse';
export type { ValidationError } from './models/ValidationError';
export type { VoiceProfileCreate } from './models/VoiceProfileCreate';
export type { VoiceProfileResponse } from './models/VoiceProfileResponse';
export { $Body_add_profile_sample_profiles__profile_id__samples_post } from './schemas/$Body_add_profile_sample_profiles__profile_id__samples_post';
export { $Body_transcribe_audio_transcribe_post } from './schemas/$Body_transcribe_audio_transcribe_post';
export { $GenerationRequest } from './schemas/$GenerationRequest';
export { $GenerationResponse } from './schemas/$GenerationResponse';
export { $HealthResponse } from './schemas/$HealthResponse';
export { $HistoryListResponse } from './schemas/$HistoryListResponse';
export { $HistoryResponse } from './schemas/$HistoryResponse';
export { $HTTPValidationError } from './schemas/$HTTPValidationError';
export { $ModelDownloadRequest } from './schemas/$ModelDownloadRequest';
export { $ModelStatus } from './schemas/$ModelStatus';
export { $ModelStatusListResponse } from './schemas/$ModelStatusListResponse';
export { $ProfileSampleResponse } from './schemas/$ProfileSampleResponse';
export { $TranscriptionResponse } from './schemas/$TranscriptionResponse';
export { $ValidationError } from './schemas/$ValidationError';
export { $VoiceProfileCreate } from './schemas/$VoiceProfileCreate';
export { $VoiceProfileResponse } from './schemas/$VoiceProfileResponse';
export { DefaultService } from './services/DefaultService';
@@ -1,8 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type Body_add_profile_sample_profiles__profile_id__samples_post = {
file: Blob;
reference_text: string;
};
@@ -1,8 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type Body_transcribe_audio_transcribe_post = {
file: Blob;
language?: string | null;
};
@@ -1,15 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Request model for voice generation.
*/
export type GenerationRequest = {
profile_id: string;
text: string;
language?: string;
seed?: number | null;
model_size?: string | null;
instruct?: string | null;
};
@@ -1,18 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Response model for voice generation.
*/
export type GenerationResponse = {
id: string;
profile_id: string;
text: string;
language: string;
audio_path: string;
duration: number;
seed: number | null;
instruct: string | null;
created_at: string;
};
@@ -1,8 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ValidationError } from './ValidationError';
export type HTTPValidationError = {
detail?: Array<ValidationError>;
};
-15
View File
@@ -1,15 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Response model for health check.
*/
export type HealthResponse = {
status: string;
model_loaded: boolean;
model_downloaded?: boolean | null;
model_size?: string | null;
gpu_available: boolean;
vram_used_mb?: number | null;
};
@@ -1,12 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { HistoryResponse } from './HistoryResponse';
/**
* Response model for history list.
*/
export type HistoryListResponse = {
items: Array<HistoryResponse>;
total: number;
};
-19
View File
@@ -1,19 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Response model for history entry (includes profile name).
*/
export type HistoryResponse = {
id: string;
profile_id: string;
profile_name: string;
text: string;
language: string;
audio_path: string;
duration: number;
seed: number | null;
instruct: string | null;
created_at: string;
};
@@ -1,10 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Request model for triggering model download.
*/
export type ModelDownloadRequest = {
model_name: string;
};
-15
View File
@@ -1,15 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Response model for model status.
*/
export type ModelStatus = {
model_name: string;
display_name: string;
downloaded: boolean;
downloading?: boolean; // True if download is in progress
size_mb?: number | null;
loaded?: boolean;
};
@@ -1,11 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ModelStatus } from './ModelStatus';
/**
* Response model for model status list.
*/
export type ModelStatusListResponse = {
models: Array<ModelStatus>;
};
@@ -1,13 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Response model for profile sample.
*/
export type ProfileSampleResponse = {
id: string;
profile_id: string;
audio_path: string;
reference_text: string;
};
@@ -1,11 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Response model for transcription.
*/
export type TranscriptionResponse = {
text: string;
duration: number;
};
@@ -1,9 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ValidationError = {
loc: Array<string | number>;
msg: string;
type: string;
};
@@ -1,12 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Request model for creating a voice profile.
*/
export type VoiceProfileCreate = {
name: string;
description?: string | null;
language?: string;
};
@@ -1,15 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Response model for voice profile.
*/
export type VoiceProfileResponse = {
id: string;
name: string;
description: string | null;
language: string;
created_at: string;
updated_at: string;
};
@@ -1,17 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $Body_add_profile_sample_profiles__profile_id__samples_post = {
properties: {
file: {
type: 'binary',
isRequired: true,
format: 'binary',
},
reference_text: {
type: 'string',
isRequired: true,
},
},
} as const;
@@ -1,24 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $Body_transcribe_audio_transcribe_post = {
properties: {
file: {
type: 'binary',
isRequired: true,
format: 'binary',
},
language: {
type: 'any-of',
contains: [
{
type: 'string',
},
{
type: 'null',
},
],
},
},
} as const;
@@ -1,46 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $GenerationRequest = {
description: `Request model for voice generation.`,
properties: {
profile_id: {
type: 'string',
isRequired: true,
},
text: {
type: 'string',
isRequired: true,
maxLength: 5000,
minLength: 1,
},
language: {
type: 'string',
pattern: '^(en|zh)$',
},
seed: {
type: 'any-of',
contains: [
{
type: 'number',
},
{
type: 'null',
},
],
},
model_size: {
type: 'any-of',
contains: [
{
type: 'string',
pattern: '^(1\\.7B|0\\.6B)$',
},
{
type: 'null',
},
],
},
},
} as const;
@@ -1,50 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $GenerationResponse = {
description: `Response model for voice generation.`,
properties: {
id: {
type: 'string',
isRequired: true,
},
profile_id: {
type: 'string',
isRequired: true,
},
text: {
type: 'string',
isRequired: true,
},
language: {
type: 'string',
isRequired: true,
},
audio_path: {
type: 'string',
isRequired: true,
},
duration: {
type: 'number',
isRequired: true,
},
seed: {
type: 'any-of',
contains: [
{
type: 'number',
},
{
type: 'null',
},
],
isRequired: true,
},
created_at: {
type: 'string',
isRequired: true,
format: 'date-time',
},
},
} as const;
@@ -1,14 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $HTTPValidationError = {
properties: {
detail: {
type: 'array',
contains: {
type: 'ValidationError',
},
},
},
} as const;
@@ -1,54 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $HealthResponse = {
description: `Response model for health check.`,
properties: {
status: {
type: 'string',
isRequired: true,
},
model_loaded: {
type: 'boolean',
isRequired: true,
},
model_downloaded: {
type: 'any-of',
contains: [
{
type: 'boolean',
},
{
type: 'null',
},
],
},
model_size: {
type: 'any-of',
contains: [
{
type: 'string',
},
{
type: 'null',
},
],
},
gpu_available: {
type: 'boolean',
isRequired: true,
},
vram_used_mb: {
type: 'any-of',
contains: [
{
type: 'number',
},
{
type: 'null',
},
],
},
},
} as const;
@@ -1,20 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $HistoryListResponse = {
description: `Response model for history list.`,
properties: {
items: {
type: 'array',
contains: {
type: 'HistoryResponse',
},
isRequired: true,
},
total: {
type: 'number',
isRequired: true,
},
},
} as const;
@@ -1,54 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $HistoryResponse = {
description: `Response model for history entry (includes profile name).`,
properties: {
id: {
type: 'string',
isRequired: true,
},
profile_id: {
type: 'string',
isRequired: true,
},
profile_name: {
type: 'string',
isRequired: true,
},
text: {
type: 'string',
isRequired: true,
},
language: {
type: 'string',
isRequired: true,
},
audio_path: {
type: 'string',
isRequired: true,
},
duration: {
type: 'number',
isRequired: true,
},
seed: {
type: 'any-of',
contains: [
{
type: 'number',
},
{
type: 'null',
},
],
isRequired: true,
},
created_at: {
type: 'string',
isRequired: true,
format: 'date-time',
},
},
} as const;
@@ -1,13 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $ModelDownloadRequest = {
description: `Request model for triggering model download.`,
properties: {
model_name: {
type: 'string',
isRequired: true,
},
},
} as const;
-35
View File
@@ -1,35 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $ModelStatus = {
description: `Response model for model status.`,
properties: {
model_name: {
type: 'string',
isRequired: true,
},
display_name: {
type: 'string',
isRequired: true,
},
downloaded: {
type: 'boolean',
isRequired: true,
},
size_mb: {
type: 'any-of',
contains: [
{
type: 'number',
},
{
type: 'null',
},
],
},
loaded: {
type: 'boolean',
},
},
} as const;
@@ -1,16 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $ModelStatusListResponse = {
description: `Response model for model status list.`,
properties: {
models: {
type: 'array',
contains: {
type: 'ModelStatus',
},
isRequired: true,
},
},
} as const;
@@ -1,25 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $ProfileSampleResponse = {
description: `Response model for profile sample.`,
properties: {
id: {
type: 'string',
isRequired: true,
},
profile_id: {
type: 'string',
isRequired: true,
},
audio_path: {
type: 'string',
isRequired: true,
},
reference_text: {
type: 'string',
isRequired: true,
},
},
} as const;
@@ -1,17 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $TranscriptionResponse = {
description: `Response model for transcription.`,
properties: {
text: {
type: 'string',
isRequired: true,
},
duration: {
type: 'number',
isRequired: true,
},
},
} as const;
@@ -1,31 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $ValidationError = {
properties: {
loc: {
type: 'array',
contains: {
type: 'any-of',
contains: [
{
type: 'string',
},
{
type: 'number',
},
],
},
isRequired: true,
},
msg: {
type: 'string',
isRequired: true,
},
type: {
type: 'string',
isRequired: true,
},
},
} as const;
@@ -1,31 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $VoiceProfileCreate = {
description: `Request model for creating a voice profile.`,
properties: {
name: {
type: 'string',
isRequired: true,
maxLength: 100,
minLength: 1,
},
description: {
type: 'any-of',
contains: [
{
type: 'string',
maxLength: 500,
},
{
type: 'null',
},
],
},
language: {
type: 'string',
pattern: '^(en|zh)$',
},
},
} as const;
@@ -1,43 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $VoiceProfileResponse = {
description: `Response model for voice profile.`,
properties: {
id: {
type: 'string',
isRequired: true,
},
name: {
type: 'string',
isRequired: true,
},
description: {
type: 'any-of',
contains: [
{
type: 'string',
},
{
type: 'null',
},
],
isRequired: true,
},
language: {
type: 'string',
isRequired: true,
},
created_at: {
type: 'string',
isRequired: true,
format: 'date-time',
},
updated_at: {
type: 'string',
isRequired: true,
format: 'date-time',
},
},
} as const;
-459
View File
@@ -1,459 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { Body_add_profile_sample_profiles__profile_id__samples_post } from '../models/Body_add_profile_sample_profiles__profile_id__samples_post';
import type { Body_transcribe_audio_transcribe_post } from '../models/Body_transcribe_audio_transcribe_post';
import type { GenerationRequest } from '../models/GenerationRequest';
import type { GenerationResponse } from '../models/GenerationResponse';
import type { HealthResponse } from '../models/HealthResponse';
import type { HistoryListResponse } from '../models/HistoryListResponse';
import type { HistoryResponse } from '../models/HistoryResponse';
import type { ModelDownloadRequest } from '../models/ModelDownloadRequest';
import type { ModelStatusListResponse } from '../models/ModelStatusListResponse';
import type { ProfileSampleResponse } from '../models/ProfileSampleResponse';
import type { TranscriptionResponse } from '../models/TranscriptionResponse';
import type { VoiceProfileCreate } from '../models/VoiceProfileCreate';
import type { VoiceProfileResponse } from '../models/VoiceProfileResponse';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
export class DefaultService {
/**
* Root
* Root endpoint.
* @returns any Successful Response
* @throws ApiError
*/
public static rootGet(): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/',
});
}
/**
* Health
* Health check endpoint.
* @returns HealthResponse Successful Response
* @throws ApiError
*/
public static healthHealthGet(): CancelablePromise<HealthResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/health',
});
}
/**
* List Profiles
* List all voice profiles.
* @returns VoiceProfileResponse Successful Response
* @throws ApiError
*/
public static listProfilesProfilesGet(): CancelablePromise<Array<VoiceProfileResponse>> {
return __request(OpenAPI, {
method: 'GET',
url: '/profiles',
});
}
/**
* Create Profile
* Create a new voice profile.
* @returns VoiceProfileResponse Successful Response
* @throws ApiError
*/
public static createProfileProfilesPost({
requestBody,
}: {
requestBody: VoiceProfileCreate;
}): CancelablePromise<VoiceProfileResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/profiles',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Get Profile
* Get a voice profile by ID.
* @returns VoiceProfileResponse Successful Response
* @throws ApiError
*/
public static getProfileProfilesProfileIdGet({
profileId,
}: {
profileId: string;
}): CancelablePromise<VoiceProfileResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/profiles/{profile_id}',
path: {
profile_id: profileId,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* Update Profile
* Update a voice profile.
* @returns VoiceProfileResponse Successful Response
* @throws ApiError
*/
public static updateProfileProfilesProfileIdPut({
profileId,
requestBody,
}: {
profileId: string;
requestBody: VoiceProfileCreate;
}): CancelablePromise<VoiceProfileResponse> {
return __request(OpenAPI, {
method: 'PUT',
url: '/profiles/{profile_id}',
path: {
profile_id: profileId,
},
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* Delete Profile
* Delete a voice profile.
* @returns any Successful Response
* @throws ApiError
*/
public static deleteProfileProfilesProfileIdDelete({
profileId,
}: {
profileId: string;
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/profiles/{profile_id}',
path: {
profile_id: profileId,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* Add Profile Sample
* Add a sample to a voice profile.
* @returns ProfileSampleResponse Successful Response
* @throws ApiError
*/
public static addProfileSampleProfilesProfileIdSamplesPost({
profileId,
formData,
}: {
profileId: string;
formData: Body_add_profile_sample_profiles__profile_id__samples_post;
}): CancelablePromise<ProfileSampleResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/profiles/{profile_id}/samples',
path: {
profile_id: profileId,
},
formData: formData,
mediaType: 'multipart/form-data',
errors: {
422: `Validation Error`,
},
});
}
/**
* Get Profile Samples
* Get all samples for a profile.
* @returns ProfileSampleResponse Successful Response
* @throws ApiError
*/
public static getProfileSamplesProfilesProfileIdSamplesGet({
profileId,
}: {
profileId: string;
}): CancelablePromise<Array<ProfileSampleResponse>> {
return __request(OpenAPI, {
method: 'GET',
url: '/profiles/{profile_id}/samples',
path: {
profile_id: profileId,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* Delete Profile Sample
* Delete a profile sample.
* @returns any Successful Response
* @throws ApiError
*/
public static deleteProfileSampleProfilesSamplesSampleIdDelete({
sampleId,
}: {
sampleId: string;
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/profiles/samples/{sample_id}',
path: {
sample_id: sampleId,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* Generate Speech
* Generate speech from text using a voice profile.
* @returns GenerationResponse Successful Response
* @throws ApiError
*/
public static generateSpeechGeneratePost({
requestBody,
}: {
requestBody: GenerationRequest;
}): CancelablePromise<GenerationResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/generate',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* List History
* List generation history with optional filters.
* @returns HistoryListResponse Successful Response
* @throws ApiError
*/
public static listHistoryHistoryGet({
profileId,
search,
limit = 50,
offset,
}: {
profileId?: string | null;
search?: string | null;
limit?: number;
offset?: number;
}): CancelablePromise<HistoryListResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/history',
query: {
profile_id: profileId,
search: search,
limit: limit,
offset: offset,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* Get Generation
* Get a generation by ID.
* @returns HistoryResponse Successful Response
* @throws ApiError
*/
public static getGenerationHistoryGenerationIdGet({
generationId,
}: {
generationId: string;
}): CancelablePromise<HistoryResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/history/{generation_id}',
path: {
generation_id: generationId,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* Delete Generation
* Delete a generation.
* @returns any Successful Response
* @throws ApiError
*/
public static deleteGenerationHistoryGenerationIdDelete({
generationId,
}: {
generationId: string;
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/history/{generation_id}',
path: {
generation_id: generationId,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* Get Stats
* Get generation statistics.
* @returns any Successful Response
* @throws ApiError
*/
public static getStatsHistoryStatsGet(): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/history/stats',
});
}
/**
* Transcribe Audio
* Transcribe audio file to text.
* @returns TranscriptionResponse Successful Response
* @throws ApiError
*/
public static transcribeAudioTranscribePost({
formData,
}: {
formData: Body_transcribe_audio_transcribe_post;
}): CancelablePromise<TranscriptionResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/transcribe',
formData: formData,
mediaType: 'multipart/form-data',
errors: {
422: `Validation Error`,
},
});
}
/**
* Get Audio
* Serve generated audio file.
* @returns any Successful Response
* @throws ApiError
*/
public static getAudioAudioGenerationIdGet({
generationId,
}: {
generationId: string;
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/audio/{generation_id}',
path: {
generation_id: generationId,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* Load Model
* Manually load TTS model.
* @returns any Successful Response
* @throws ApiError
*/
public static loadModelModelsLoadPost({
modelSize = '1.7B',
}: {
modelSize?: string;
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'POST',
url: '/models/load',
query: {
model_size: modelSize,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* Unload Model
* Unload TTS model to free memory.
* @returns any Successful Response
* @throws ApiError
*/
public static unloadModelModelsUnloadPost(): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'POST',
url: '/models/unload',
});
}
/**
* Get Model Progress
* Get model download progress via Server-Sent Events.
* @returns any Successful Response
* @throws ApiError
*/
public static getModelProgressModelsProgressModelNameGet({
modelName,
}: {
modelName: string;
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/models/progress/{model_name}',
path: {
model_name: modelName,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* Get Model Status
* Get status of all available models.
* @returns ModelStatusListResponse Successful Response
* @throws ApiError
*/
public static getModelStatusModelsStatusGet(): CancelablePromise<ModelStatusListResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/models/status',
});
}
/**
* Trigger Model Download
* Trigger download of a specific model.
* @returns any Successful Response
* @throws ApiError
*/
public static triggerModelDownloadModelsDownloadPost({
requestBody,
}: {
requestBody: ModelDownloadRequest;
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'POST',
url: '/models/download',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
}
+4
View File
@@ -213,6 +213,10 @@ export interface CaptureSettings {
/** Whether the global keyboard hotkey is armed. Off by default — turning
* this on triggers the macOS Input Monitoring TCC prompt. */
hotkey_enabled: boolean;
/** Hold the mic open while dictation is enabled so push-to-talk doesn't clip
* the first words. Off by default — when on, the OS mic indicator stays lit
* the whole time dictation is enabled. */
keep_mic_warm: boolean;
/** keytap key names. Defaults are platform-specific right-hand modifiers. */
chord_push_to_talk_keys: string[];
/** keytap key names. Toggle adds Space to the platform-specific PTT chord. */
+369 -137
View File
@@ -4,12 +4,45 @@ import { convertToWav } from '@/lib/utils/audio';
interface UseAudioRecordingOptions {
maxDurationSeconds?: number;
onRecordingComplete?: (blob: Blob, duration?: number) => void;
// ``context`` is whatever was handed to ``startRecording`` for this take,
// threaded back untouched so callers can correlate the result with the
// recording it came from (the dictate window pairs it with the focus
// snapshot captured at chord-start).
onRecordingComplete?: (blob: Blob, duration?: number, context?: unknown) => void;
/**
* Keep the microphone ``MediaStream`` open between recordings instead of
* tearing it down on every stop. This is what removes the "first words get
* clipped" problem on push-to-talk dictation: ``getUserMedia`` on macOS can
* take several hundred ms — up to a second cold — to hand back a stream, and
* ``MediaRecorder`` only starts capturing *after* it resolves, so everything
* spoken in that window is lost. With a warm stream already open, the next
* ``startRecording`` skips ``getUserMedia`` entirely.
*
* Off by default: the voice-clone sample recorders release the device
* immediately, and the dictation session only opts in when the user enables
* the "keep microphone ready" setting. While on, the warm stream stays open —
* and the OS mic-in-use indicator stays lit — until it's explicitly released
* (dictation disabled or the setting turned off), so the trade-off is visible
* and user-controlled rather than a background mic that's always warm.
*/
keepWarm?: boolean;
}
// Audio constraints for capture. Kept identical to the previous inline value so
// this change is purely about *when* the stream is opened, not *how*.
const AUDIO_CONSTRAINTS: MediaTrackConstraints = {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
};
const streamHasLiveAudio = (stream: MediaStream | null): stream is MediaStream =>
!!stream && stream.getAudioTracks().some((t) => t.readyState === 'live');
export function useAudioRecording({
maxDurationSeconds,
onRecordingComplete,
keepWarm = false,
}: UseAudioRecordingOptions = {}) {
const platform = usePlatform();
const [isRecording, setIsRecording] = useState(false);
@@ -17,195 +50,392 @@ export function useAudioRecording({
const [error, setError] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
// The stream currently backing the MediaRecorder. When ``keepWarm`` is set
// this is the same object as ``warmStreamRef`` and is *not* torn down on
// stop; otherwise it's stopped as soon as the recording completes.
const streamRef = useRef<MediaStream | null>(null);
// Persistent pre-opened stream reused across recordings when ``keepWarm``.
const warmStreamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<number | null>(null);
const startTimeRef = useRef<number | null>(null);
const cancelledRef = useRef<boolean>(false);
// Mirror of ``isRecording`` for reads inside callbacks that would otherwise
// close over a stale render.
const isRecordingRef = useRef(false);
// A ``getUserMedia`` call in flight, shared so concurrent acquirers (prewarm
// plus an immediate chord) coalesce onto one stream instead of each opening —
// and orphaning — their own.
const acquiringRef = useRef<Promise<MediaStream> | null>(null);
// True from ``startRecording`` entry until the recorder is actually running
// (or has failed), so a stop that arrives mid-acquisition can be deferred.
const startingRef = useRef(false);
// True from MediaRecorder.stop() until onstop has snapshotted the take's
// shared refs. React state and MediaRecorder.state both flip before onstop,
// so without this gate a rapid next chord can clear chunks/duration/cancel
// state out from under the recorder that is still finalising.
const finishingRef = useRef(false);
const pendingStopRef = useRef(false);
// Bumped per recording so a stale recorder's ``onstop`` can tell it's no
// longer the active one before it touches the shared stream refs.
const recordingCounterRef = useRef(0);
// Bumped whenever the warm stream is released/aborted so a ``getUserMedia``
// still in flight can tell its result is stale and stop it instead of
// adopting a live mic after disable/unmount.
const acquireGenRef = useRef(0);
// Set when a release is requested mid-recording; the onstop path performs the
// deferred release once capture finishes rather than yanking the device now.
const releaseAfterStopRef = useRef(false);
const startRecording = useCallback(async () => {
try {
setError(null);
chunksRef.current = [];
cancelledRef.current = false;
setDuration(0);
// Keeps the ref in lockstep with the state so the synchronous stop path reads
// a fresh value without waiting for a rerender.
const setRecording = useCallback((next: boolean) => {
isRecordingRef.current = next;
setIsRecording(next);
}, []);
// Check if getUserMedia is available
// In Tauri, navigator.mediaDevices might not be available immediately
if (typeof navigator === 'undefined') {
const errorMsg =
'Navigator API is not available. This might be a Tauri configuration issue.';
setError(errorMsg);
throw new Error(errorMsg);
}
const releaseWarmStream = useCallback(() => {
// Invalidate any getUserMedia still in flight so its stream is stopped on
// resolve rather than adopted as the warm stream.
acquireGenRef.current += 1;
// Don't tear the device out from under an active/starting recording — the
// warm stream is the one backing it; defer to the onstop path instead.
if (isRecordingRef.current || startingRef.current) {
releaseAfterStopRef.current = true;
return;
}
warmStreamRef.current?.getTracks().forEach((track) => {
track.stop();
});
warmStreamRef.current = null;
}, []);
// Assert that getUserMedia is reachable, mirroring the previous inline guard
// (Tauri webviews occasionally expose ``navigator.mediaDevices`` a beat late).
const assertMediaDevices = useCallback(async () => {
if (typeof navigator === 'undefined') {
throw new Error('Navigator API is not available. This might be a Tauri configuration issue.');
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
await new Promise((resolve) => setTimeout(resolve, 100));
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
// Try waiting a bit for Tauri webview to initialize
await new Promise((resolve) => setTimeout(resolve, 100));
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
console.error('MediaDevices check:', {
hasNavigator: typeof navigator !== 'undefined',
hasMediaDevices: !!navigator?.mediaDevices,
hasGetUserMedia: !!navigator?.mediaDevices?.getUserMedia,
isTauri: platform.metadata.isTauri,
});
const errorMsg = platform.metadata.isTauri
throw new Error(
platform.metadata.isTauri
? 'Microphone access is not available. Please ensure:\n1. The app has microphone permissions in System Settings (macOS: System Settings > Privacy & Security > Microphone)\n2. You restart the app after granting permissions\n3. You are using Tauri v2 with a webview that supports getUserMedia'
: 'Microphone access is not available. Please ensure you are using a secure context (HTTPS or localhost) and that your browser has microphone permissions enabled.';
setError(errorMsg);
throw new Error(errorMsg);
}
: 'Microphone access is not available. Please ensure you are using a secure context (HTTPS or localhost) and that your browser has microphone permissions enabled.',
);
}
}
}, [platform.metadata.isTauri]);
// Request microphone access
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
// Return a live capture stream, reusing the warm one when available so the
// hot path (chord-down → record) never waits on getUserMedia.
const acquireStream = useCallback(async (): Promise<MediaStream> => {
// Captured separately so it stays typed as the full stream after the live
// check narrows ``warmStreamRef.current`` itself.
const existing = warmStreamRef.current;
if (streamHasLiveAudio(warmStreamRef.current)) {
return warmStreamRef.current;
}
// Coalesce concurrent acquirers onto one getUserMedia call so prewarm and
// an immediate chord can't open two streams.
if (acquiringRef.current) return acquiringRef.current;
// A dead warm stream (device unplugged / tracks ended) — drop it and reopen.
if (existing) {
existing.getTracks().forEach((track) => {
track.stop();
});
streamRef.current = stream;
// Create MediaRecorder with preferred MIME type
const options: MediaRecorderOptions = {
mimeType: 'audio/webm;codecs=opus',
};
// Fallback to default if webm not supported
if (!MediaRecorder.isTypeSupported(options.mimeType!)) {
delete options.mimeType;
}
const mediaRecorder = new MediaRecorder(stream, options);
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = async () => {
// Snapshot the cancellation flag and recorded duration immediately —
// cancelRecording() clears chunks and sets cancelledRef synchronously
// before this async handler runs, so we must check it first.
const wasCancelled = cancelledRef.current;
const recordedDuration = startTimeRef.current
? (Date.now() - startTimeRef.current) / 1000
: undefined;
const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
// Stop all tracks now that we have the data
streamRef.current?.getTracks().forEach((track) => {
warmStreamRef.current = null;
}
const gen = acquireGenRef.current;
const acquisition = (async () => {
await assertMediaDevices();
const stream = await navigator.mediaDevices.getUserMedia({
audio: AUDIO_CONSTRAINTS,
});
// Released / disabled / unmounted while acquiring — this stream is stale,
// so stop it instead of leaving a live mic open, and abort the caller.
if (gen !== acquireGenRef.current) {
stream.getTracks().forEach((track) => {
track.stop();
});
streamRef.current = null;
throw new Error('microphone acquisition aborted');
}
if (keepWarm) warmStreamRef.current = stream;
return stream;
})();
acquiringRef.current = acquisition;
try {
return await acquisition;
} finally {
if (acquiringRef.current === acquisition) acquiringRef.current = null;
}
}, [assertMediaDevices, keepWarm]);
// Don't fire completion callback if the recording was cancelled
if (wasCancelled) return;
/**
* Open the microphone ahead of the first recording so the initial dictation
* doesn't clip. No-op unless ``keepWarm`` is set. Safe to call repeatedly and
* safe to fail (e.g. permission not yet granted) — ``startRecording`` still
* surfaces a real error if capture is genuinely unavailable.
*/
const prewarm = useCallback(async () => {
if (!keepWarm) return;
try {
await acquireStream();
} catch {
// Permission missing / device busy / aborted — recording will report a
// real error if capture is genuinely unavailable.
}
}, [keepWarm, acquireStream]);
// Convert to WAV format to avoid needing ffmpeg on backend
try {
const wavBlob = await convertToWav(webmBlob);
onRecordingComplete?.(wavBlob, recordedDuration);
} catch (err) {
console.error('Error converting audio to WAV:', err);
// Fallback to original blob if conversion fails
onRecordingComplete?.(webmBlob, recordedDuration);
const startRecording = useCallback(
async (context?: unknown) => {
// A second chord can arrive while the first one is still waiting on
// getUserMedia. Never create overlapping MediaRecorders on the same
// coalesced stream; the original take will honor any deferred stop.
if (
startingRef.current ||
finishingRef.current ||
mediaRecorderRef.current?.state === 'recording'
)
return;
startingRef.current = true;
pendingStopRef.current = false;
// A new recording supersedes any release deferred from a prior take.
releaseAfterStopRef.current = false;
const recordingId = ++recordingCounterRef.current;
try {
setError(null);
chunksRef.current = [];
cancelledRef.current = false;
setDuration(0);
// Reuse the warm stream when present (instant); otherwise open one now.
const stream = await acquireStream();
streamRef.current = stream;
// Create MediaRecorder with preferred MIME type
const options: MediaRecorderOptions = {
mimeType: 'audio/webm;codecs=opus',
};
// Fallback to default if webm not supported
if (!MediaRecorder.isTypeSupported(options.mimeType!)) {
delete options.mimeType;
}
};
mediaRecorder.onerror = (event) => {
setError('Recording error occurred');
console.error('MediaRecorder error:', event);
};
const mediaRecorder = new MediaRecorder(stream, options);
mediaRecorderRef.current = mediaRecorder;
// WebKit's MediaRecorder drops the WebM EBML header from chunks when
// started with a timeslice, so concatenated blobs fail to parse in
// both AudioContext and ffmpeg. Starting with no timeslice produces
// exactly one dataavailable on stop() with a valid container.
mediaRecorder.start();
setIsRecording(true);
startTimeRef.current = Date.now();
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
// Start timer
timerRef.current = window.setInterval(() => {
if (startTimeRef.current) {
const elapsed = (Date.now() - startTimeRef.current) / 1000;
setDuration(elapsed);
mediaRecorder.onstop = async () => {
// Whether this recorder is still the active one. A stale onstop (an
// older recorder stopping after a newer startRecording) must not touch
// the shared stream refs.
const isCurrent = recordingCounterRef.current === recordingId;
// Snapshot the cancellation flag and recorded duration immediately —
// cancelRecording() clears chunks and sets cancelledRef synchronously
// before this async handler runs, so we must check it first.
const wasCancelled = cancelledRef.current;
const recordedDuration = startTimeRef.current
? (Date.now() - startTimeRef.current) / 1000
: undefined;
// Auto-stop at max duration when the caller opts in — dictation
// sessions pass undefined and run until the user releases the
// chord or hits stop; voice-clone sample recorders pass 29s to
// keep reference clips short.
if (maxDurationSeconds !== undefined && elapsed >= maxDurationSeconds) {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
setIsRecording(false);
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
// Release the device unless we're keeping it warm for the next capture.
// Act on this recorder's own stream; only touch the shared refs when
// this is still the current recording.
if (keepWarm) {
if (isCurrent) {
streamRef.current = null;
// A release requested mid-recording (dictation disabled) is
// honored now that capture has finished; otherwise the warm
// stream stays open for the next take.
if (releaseAfterStopRef.current) {
releaseAfterStopRef.current = false;
releaseWarmStream();
}
}
} else {
stream.getTracks().forEach((track) => {
track.stop();
});
if (isCurrent) streamRef.current = null;
}
// All shared per-take refs have now been snapshotted and stream
// cleanup is complete. A new take may begin while WAV conversion and
// upload continue using the local values above.
finishingRef.current = false;
// Don't fire completion callback if the recording was cancelled
if (wasCancelled) return;
// Convert to WAV format to avoid needing ffmpeg on backend
try {
const wavBlob = await convertToWav(webmBlob);
onRecordingComplete?.(wavBlob, recordedDuration, context);
} catch (err) {
console.error('Error converting audio to WAV:', err);
// Fallback to original blob if conversion fails
onRecordingComplete?.(webmBlob, recordedDuration, context);
}
};
mediaRecorder.onerror = (event) => {
setError('Recording error occurred');
console.error('MediaRecorder error:', event);
};
// WebKit's MediaRecorder drops the WebM EBML header from chunks when
// started with a timeslice, so concatenated blobs fail to parse in
// both AudioContext and ffmpeg. Starting with no timeslice produces
// exactly one dataavailable on stop() with a valid container.
mediaRecorder.start();
setRecording(true);
startTimeRef.current = Date.now();
startingRef.current = false;
// A stop (chord release) that landed while the mic was still opening —
// honor it now that capture has actually begun.
if (pendingStopRef.current) {
pendingStopRef.current = false;
finishingRef.current = true;
mediaRecorder.stop();
setRecording(false);
return;
}
// Start timer
timerRef.current = window.setInterval(() => {
if (startTimeRef.current) {
const elapsed = (Date.now() - startTimeRef.current) / 1000;
setDuration(elapsed);
// Auto-stop at max duration when the caller opts in — dictation
// sessions pass undefined and run until the user releases the
// chord or hits stop; voice-clone sample recorders pass 29s to
// keep reference clips short.
if (maxDurationSeconds !== undefined && elapsed >= maxDurationSeconds) {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
finishingRef.current = true;
mediaRecorderRef.current.stop();
setRecording(false);
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
}
}
}, 100);
} catch (err) {
const errorMessage =
err instanceof Error
? err.message
: 'Failed to access microphone. Please check permissions.';
// A fresh (non-warm) stream opened before the failure must be released
// so the mic doesn't stay lit; a warm stream is reusable, so it's kept.
if (!keepWarm) {
streamRef.current?.getTracks().forEach((track) => {
track.stop();
});
streamRef.current = null;
}
}, 100);
} catch (err) {
const errorMessage =
err instanceof Error
? err.message
: 'Failed to access microphone. Please check permissions.';
setError(errorMessage);
setIsRecording(false);
}
}, [maxDurationSeconds, onRecordingComplete]);
startingRef.current = false;
finishingRef.current = false;
pendingStopRef.current = false;
setError(errorMessage);
setRecording(false);
}
},
[
maxDurationSeconds,
onRecordingComplete,
acquireStream,
keepWarm,
releaseWarmStream,
setRecording,
],
);
const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
// The recorder's own state is the lifecycle authority — React ``isRecording``
// lags a render behind ``mediaRecorder.start()``, so a chord release in that
// window would otherwise be dropped.
const recorder = mediaRecorderRef.current;
if (recorder && recorder.state === 'recording') {
finishingRef.current = true;
recorder.stop();
setRecording(false);
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
} else if (startingRef.current) {
// Stop arrived before capture began (mic still opening) — defer it so
// startRecording stops as soon as the recorder goes live.
pendingStopRef.current = true;
}
}, [isRecording]);
}, [setRecording]);
const cancelRecording = useCallback(() => {
if (mediaRecorderRef.current) {
cancelledRef.current = true; // Must be set before stop() triggers onstop
cancelledRef.current = true; // Must be set before stop() triggers onstop
const recorder = mediaRecorderRef.current;
if (recorder && recorder.state !== 'inactive') {
chunksRef.current = [];
mediaRecorderRef.current.stop();
setIsRecording(false);
finishingRef.current = true;
recorder.stop();
setRecording(false);
setDuration(0);
} else if (startingRef.current) {
// Cancel during mic acquisition — stop as soon as capture begins; the
// cancelled flag suppresses the completion callback.
pendingStopRef.current = true;
}
// Stop all tracks
streamRef.current?.getTracks().forEach((track) => {
track.stop();
});
streamRef.current = null;
// Keep the device warm for the next capture when opted in; otherwise stop
// the tracks so the mic is released immediately.
if (keepWarm) {
streamRef.current = null;
if (releaseAfterStopRef.current) {
releaseAfterStopRef.current = false;
releaseWarmStream();
}
} else {
streamRef.current?.getTracks().forEach((track) => {
track.stop();
});
streamRef.current = null;
}
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
}, [keepWarm, releaseWarmStream, setRecording]);
// Cleanup on unmount
// Cleanup on unmount — always fully release the device, warm or not.
useEffect(() => {
return () => {
// Invalidate any in-flight acquisition so a stream resolving after unmount
// stops itself instead of leaking a live mic.
acquireGenRef.current += 1;
if (timerRef.current !== null) {
clearInterval(timerRef.current);
}
streamRef.current?.getTracks().forEach((track) => {
track.stop();
});
warmStreamRef.current?.getTracks().forEach((track) => {
track.stop();
});
};
}, []);
@@ -216,5 +446,7 @@ export function useAudioRecording({
startRecording,
stopRecording,
cancelRecording,
prewarm,
releaseWarm: releaseWarmStream,
};
}
+60 -23
View File
@@ -54,11 +54,15 @@ const SHORT_RECORDING_MESSAGE = 'Recording too short, canceled';
export type CapturePillState = PillState | 'hidden';
export interface UseCaptureRecordingSessionOptions {
/** Keep the microphone stream open between dictations when explicitly
* enabled. Off by default so normal recorders release the device. */
keepMicWarm?: boolean;
/**
* Fired after a capture row is created on the server. Callers can use this
* to select the new capture or emit a Tauri event to a sibling window.
* ``context`` is whatever was passed to ``startRecording`` for this take.
*/
onCaptureCreated?: (capture: CaptureResponse) => void;
onCaptureCreated?: (capture: CaptureResponse, context?: unknown) => void;
/**
* Fired with the final delivered text — refined if ``auto_refine`` was on
* for this capture, raw transcript otherwise. Used by the floating
@@ -66,12 +70,14 @@ export interface UseCaptureRecordingSessionOptions {
*
* ``allowAutoPaste`` snapshots the setting at chord-start so a refine that
* lands after the user flips the toggle still uses the value the capture
* was created under.
* was created under. ``context`` is the value passed to ``startRecording``
* for this take, so overlapping dictations can't cross their targets.
*/
onFinalText?: (
text: string,
capture: CaptureResponse,
allowAutoPaste: boolean,
context?: unknown,
) => void;
}
@@ -82,12 +88,14 @@ export interface UseCaptureRecordingSessionResult {
isRecording: boolean;
isUploading: boolean;
isRefining: boolean;
startRecording: () => void;
startRecording: (context?: unknown) => void;
stopRecording: () => void;
toggleRecording: () => void;
dismissError: () => void;
uploadFile: (file: File, source: CaptureSource) => void;
refine: (captureId: string) => void;
prewarm: () => Promise<void>;
releaseWarm: () => void;
}
/**
@@ -123,10 +131,13 @@ export function useCaptureRecordingSession(
const onFinalTextRef = useRef(options.onFinalText);
onFinalTextRef.current = options.onFinalText;
// Snapshot of ``allow_auto_paste`` from the capture-create response —
// held so the refine onSuccess (which only sees the plain CaptureResponse)
// can still pass the original setting through to onFinalText.
const allowAutoPasteRef = useRef<boolean>(true);
// Per-capture recording context and its ``allow_auto_paste`` snapshot, keyed
// by capture id so a refine that resolves after another dictation started
// still delivers to the right target with the setting the capture was created
// under. Populated on capture-create and consumed once the final text lands.
const captureDeliveryRef = useRef<Map<string, { context: unknown; allowAutoPaste: boolean }>>(
new Map(),
);
const clearRestTimer = useCallback(() => {
if (restTimerRef.current !== null) {
@@ -192,20 +203,34 @@ export function useCaptureRecordingSession(
queryClient.invalidateQueries({ queryKey: ['captures'] });
broadcastUpdated(captureId);
if (pillStateRef.current === 'refining') scheduleHidePill();
const delivery = captureDeliveryRef.current.get(captureId);
captureDeliveryRef.current.delete(captureId);
const finalText = data.transcript_refined ?? data.transcript_raw;
if (finalText) {
onFinalTextRef.current?.(finalText, data, allowAutoPasteRef.current);
onFinalTextRef.current?.(
finalText,
data,
delivery?.allowAutoPaste ?? true,
delivery?.context,
);
}
},
onError: (err: Error) => {
onError: (err: Error, captureId) => {
captureDeliveryRef.current.delete(captureId);
showError(err.message || 'Refinement failed');
},
});
const uploadMutation = useMutation({
mutationFn: async ({ file, source }: { file: File; source: CaptureSource }) =>
apiClient.createCapture(file, { source }),
onSuccess: (capture) => {
mutationFn: async ({
file,
source,
}: {
file: File;
source: CaptureSource;
context?: unknown;
}) => apiClient.createCapture(file, { source }),
onSuccess: (capture, { context }) => {
queryClient.setQueryData<CaptureListResponse>(['captures'], (prev) => {
if (!prev) return prev;
if (prev.items.some((c) => c.id === capture.id)) return prev;
@@ -213,9 +238,12 @@ export function useCaptureRecordingSession(
});
queryClient.invalidateQueries({ queryKey: ['captures'] });
broadcastCreated(capture);
onCaptureCreatedRef.current?.(capture);
allowAutoPasteRef.current = capture.allow_auto_paste;
onCaptureCreatedRef.current?.(capture, context);
if (capture.auto_refine) {
captureDeliveryRef.current.set(capture.id, {
context,
allowAutoPaste: capture.allow_auto_paste,
});
setPillState('refining');
refineMutation.mutate(capture.id);
} else {
@@ -225,6 +253,7 @@ export function useCaptureRecordingSession(
capture.transcript_raw,
capture,
capture.allow_auto_paste,
context,
);
}
}
@@ -249,8 +278,11 @@ export function useCaptureRecordingSession(
startRecording: beginAudioRecording,
stopRecording,
error: recordError,
prewarm,
releaseWarm,
} = useAudioRecording({
onRecordingComplete: (blob, recordedDuration) => {
keepWarm: options.keepMicWarm ?? false,
onRecordingComplete: (blob, recordedDuration, context) => {
// Trigger-happy tap — MediaRecorder hasn't emitted a usable chunk yet
// so the blob is empty or unparseable. Surface it as a transient pill
// so the user sees their recording was recognised and canceled.
@@ -268,7 +300,7 @@ export function useCaptureRecordingSession(
const file = new File([blob], `dictation-${Date.now()}.${extension}`, {
type: blob.type,
});
uploadMutation.mutate({ file, source: 'dictation' });
uploadMutation.mutate({ file, source: 'dictation', context });
},
});
@@ -278,13 +310,16 @@ export function useCaptureRecordingSession(
}
}, [recordError, showError]);
const startRecording = useCallback(() => {
if (isRecording) return;
clearRestTimer();
setFrozenElapsedMs(0);
setPillState('recording');
beginAudioRecording();
}, [isRecording, beginAudioRecording, clearRestTimer]);
const startRecording = useCallback(
(context?: unknown) => {
if (isRecording) return;
clearRestTimer();
setFrozenElapsedMs(0);
setPillState('recording');
beginAudioRecording(context);
},
[isRecording, beginAudioRecording, clearRestTimer],
);
const toggleRecording = useCallback(() => {
if (isRecording) {
@@ -324,5 +359,7 @@ export function useCaptureRecordingSession(
dismissError,
uploadFile,
refine,
prewarm,
releaseWarm,
};
}
+26 -1
View File
@@ -1,5 +1,6 @@
import { invoke } from '@tauri-apps/api/core';
import { useEffect } from 'react';
import { emit, listen } from '@tauri-apps/api/event';
import { useEffect, useRef } from 'react';
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { usePlatform } from '@/platform/PlatformContext';
@@ -30,21 +31,45 @@ export function useChordSync() {
const { settings } = useCaptureSettings();
const { canRecord } = useDictationReadiness();
const enabled = settings?.hotkey_enabled;
const keepMicWarm = settings?.keep_mic_warm;
const pushKeys = settings?.chord_push_to_talk_keys;
const toggleKeys = settings?.chord_toggle_to_talk_keys;
// Latest warm state, so the dictate window's mount-time request can be
// answered even between the dep-driven emits below.
const shouldWarmRef = useRef(false);
// The floating dictate window holds the mic warm ahead of the first chord to
// avoid clipping, but it's a separate webview with no view of settings. Mirror
// the decision to it: warm only when dictation is armed AND the user enabled
// "keep microphone ready". Gating here is what stops the always-mounted pill
// from opening the mic — or prompting for access — when the user hasn't asked.
useEffect(() => {
if (!platform.metadata.isTauri) return;
const unlisten = listen('dictate:warm-request', () => {
emit('dictate:warm', shouldWarmRef.current).catch(() => {});
});
return () => {
unlisten.then((fn) => fn()).catch(() => {});
};
}, [platform.metadata.isTauri]);
useEffect(() => {
if (!platform.metadata.isTauri) return;
if (enabled === undefined || !pushKeys || !toggleKeys) return;
const shouldArm = enabled && canRecord;
const shouldWarm = shouldArm && (keepMicWarm ?? false);
shouldWarmRef.current = shouldWarm;
const command = shouldArm ? 'enable_hotkey' : 'disable_hotkey';
const args = shouldArm ? { pushToTalk: pushKeys, toggleToTalk: toggleKeys } : {};
invoke(command, args).catch((err) => {
console.warn(`[chord-sync] ${command} failed:`, err);
});
emit('dictate:warm', shouldWarm).catch(() => {});
}, [
platform.metadata.isTauri,
enabled,
keepMicWarm,
canRecord,
// Stringify so a referentially-new array with the same content
// doesn't fire a redundant invoke on every settings refetch.
-17
View File
@@ -1,17 +0,0 @@
import { QueryClientProvider } from '@tanstack/react-query';
// import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './i18n';
import './index.css';
import { queryClient } from './lib/queryClient';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
{/* <ReactQueryDevtools initialIsOpen={false} /> */}
</QueryClientProvider>
</React.StrictMode>,
);
+2 -1
View File
@@ -9,7 +9,8 @@ export interface FileFilter {
}
export interface PlatformFilesystem {
saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise<void>;
/** Returns the saved path (or filename on web), or null if the user cancelled. */
saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise<string | null>;
openPath(path: string): Promise<void>;
pickDirectory(title: string): Promise<string | null>;
}
+3 -3
View File
@@ -113,7 +113,7 @@ const voicesRoute = createRoute({
component: VoicesTab,
});
// Captures route (prototype — will replace AudioTab once the new flow is ready)
// Captures route
const capturesRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/captures',
@@ -199,8 +199,8 @@ const serverRedirectRoute = createRoute({
},
});
// Route tree
const routeTree = rootRoute.addChildren([
// Route tree — exported so tests can build routers over memory history
export const routeTree = rootRoute.addChildren([
indexRoute,
storiesRoute,
capturesRoute,
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from 'vitest';
import { queryClient } from '@/lib/queryClient';
import { isLoopbackVoiceboxServerUrl, useServerStore } from '@/stores/serverStore';
describe('serverStore', () => {
it('invalidates all queries when the server url changes', () => {
const spy = vi.spyOn(queryClient, 'invalidateQueries');
useServerStore.getState().setServerUrl('http://10.0.0.5:17493');
expect(useServerStore.getState().serverUrl).toBe('http://10.0.0.5:17493');
expect(spy).toHaveBeenCalledTimes(1);
});
it('does not invalidate queries when the url is unchanged', () => {
const url = useServerStore.getState().serverUrl;
const spy = vi.spyOn(queryClient, 'invalidateQueries');
useServerStore.getState().setServerUrl(url);
expect(spy).not.toHaveBeenCalled();
});
});
describe('isLoopbackVoiceboxServerUrl', () => {
it('matches loopback hosts on the voicebox port', () => {
expect(isLoopbackVoiceboxServerUrl('http://127.0.0.1:17493')).toBe(true);
expect(isLoopbackVoiceboxServerUrl('http://localhost:17493')).toBe(true);
expect(isLoopbackVoiceboxServerUrl('http://[::1]:17493')).toBe(true);
});
it('rejects other hosts, ports, and junk', () => {
expect(isLoopbackVoiceboxServerUrl('http://10.0.0.5:17493')).toBe(false);
expect(isLoopbackVoiceboxServerUrl('http://127.0.0.1:8000')).toBe(false);
expect(isLoopbackVoiceboxServerUrl('not a url')).toBe(false);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { useUIStore } from '@/stores/uiStore';
describe('uiStore', () => {
it('applies the dark class when theme is set to dark', () => {
useUIStore.getState().setTheme('dark');
expect(useUIStore.getState().theme).toBe('dark');
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
it('removes the dark class when theme is set to light', () => {
useUIStore.getState().setTheme('dark');
useUIStore.getState().setTheme('light');
expect(document.documentElement.classList.contains('dark')).toBe(false);
});
it('persists only theme and selectedProfileId', () => {
useUIStore.getState().setTheme('light');
useUIStore.getState().setSidebarOpen(false);
useUIStore.getState().setSelectedEngine('kokoro');
const persisted = JSON.parse(localStorage.getItem('voicebox-ui') ?? '{}');
expect(persisted.state).toEqual({ selectedProfileId: null, theme: 'light' });
});
});
+58
View File
@@ -0,0 +1,58 @@
import { http } from 'msw';
import { expect, it } from 'vitest';
import { buildModelStatus, buildProfile } from './msw/fixtures';
import {
captureHandlers,
effectsHandlers,
historyHandlers,
modelHandlers,
profileHandlers,
settingsHandlers,
storyHandlers,
taskHandlers,
} from './msw/handlers';
import { worker } from './msw/worker';
import { renderRoute } from './render';
import { sseController } from './sse';
function useHappyPathHandlers() {
worker.use(
...profileHandlers([buildProfile({ name: 'Ada Lovelace' })]),
...historyHandlers([]),
...captureHandlers([]),
...settingsHandlers(),
...modelHandlers([buildModelStatus()]),
...storyHandlers([]),
...effectsHandlers([]),
...taskHandlers(),
);
}
it('renders the /voices route with the full app chrome', async () => {
useHappyPathHandlers();
const screen = await renderRoute('/voices');
await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
});
it('feeds EventSource through the SSE controller', async () => {
const sse = sseController();
worker.use(http.get('*/generate/:id/status', () => sse.response()));
const source = new EventSource('/generate/gen-1/status');
const statuses: string[] = [];
source.onmessage = (message) => {
statuses.push((JSON.parse(message.data) as { status: string }).status);
};
await new Promise((resolve) => {
source.onopen = resolve;
});
sse.push({ data: { status: 'generating' } });
sse.push({ data: { status: 'completed' } });
await expect.poll(() => statuses).toEqual(['generating', 'completed']);
source.close();
sse.close();
});
+86
View File
@@ -0,0 +1,86 @@
import { vi } from 'vitest';
import type { Platform, UpdateStatus } from '@/platform/types';
export interface MockPlatform extends Platform {
/** Push a new updater status to all subscribers, as the real updater would. */
emitUpdateStatus(status: UpdateStatus): void;
}
export interface MockPlatformOverrides {
filesystem?: Partial<Platform['filesystem']>;
updater?: Partial<Platform['updater']>;
audio?: Partial<Platform['audio']>;
lifecycle?: Partial<Platform['lifecycle']>;
metadata?: Partial<Platform['metadata']>;
}
const INITIAL_UPDATE_STATUS: UpdateStatus = {
checking: false,
available: false,
downloading: false,
installing: false,
readyToInstall: false,
};
export const TEST_SERVER_URL = 'http://127.0.0.1:17493';
/**
* A fully spy-able Platform. Every method is a vi.fn with a benign default
* (browser-like: no system audio, isTauri false), so tests can assert calls
* or override behavior per section via `overrides`.
*/
export function createMockPlatform(overrides: MockPlatformOverrides = {}): MockPlatform {
let updateStatus = { ...INITIAL_UPDATE_STATUS };
const subscribers = new Set<(status: UpdateStatus) => void>();
return {
filesystem: {
saveFile: vi.fn(async (filename: string) => filename),
openPath: vi.fn(async () => {}),
pickDirectory: vi.fn(async () => null),
...overrides.filesystem,
},
updater: {
checkForUpdates: vi.fn(async () => {}),
downloadAndInstall: vi.fn(async () => {}),
restartAndInstall: vi.fn(async () => {}),
getStatus: vi.fn(() => ({ ...updateStatus })),
subscribe: vi.fn((callback: (status: UpdateStatus) => void) => {
subscribers.add(callback);
callback(updateStatus);
return () => {
subscribers.delete(callback);
};
}),
...overrides.updater,
},
audio: {
isSystemAudioSupported: vi.fn(async () => false),
startSystemAudioCapture: vi.fn(async () => {}),
stopSystemAudioCapture: vi.fn(async () => new Blob()),
listOutputDevices: vi.fn(async () => []),
playToDevices: vi.fn(async () => {}),
stopPlayback: vi.fn(),
...overrides.audio,
},
lifecycle: {
startServer: vi.fn(async () => TEST_SERVER_URL),
stopServer: vi.fn(async () => {}),
restartServer: vi.fn(async () => TEST_SERVER_URL),
setKeepServerRunning: vi.fn(async () => {}),
setBackendOverride: vi.fn(async () => {}),
setupWindowCloseHandler: vi.fn(async () => {}),
subscribeToServerLogs: vi.fn(() => () => {}),
...overrides.lifecycle,
},
metadata: {
getVersion: vi.fn(async () => '0.0.0-test'),
isTauri: false,
...overrides.metadata,
},
emitUpdateStatus(status: UpdateStatus) {
updateStatus = { ...status };
for (const callback of subscribers) callback(updateStatus);
},
};
}
+216
View File
@@ -0,0 +1,216 @@
import type {
CaptureListResponse,
CaptureReadinessResponse,
CaptureResponse,
CaptureSettings,
EffectPresetResponse,
GenerationResponse,
GenerationSettings,
HealthResponse,
HistoryListResponse,
HistoryResponse,
ModelStatus,
StoryDetailResponse,
StoryItemDetail,
StoryResponse,
VoiceProfileResponse,
} from '@/lib/api/types';
// Deterministic id counter — no randomness so failures reproduce exactly.
let seq = 0;
export function nextId(prefix: string): string {
seq += 1;
return `${prefix}-${String(seq).padStart(4, '0')}`;
}
const CREATED_AT = '2026-01-01T00:00:00Z';
export function buildProfile(overrides: Partial<VoiceProfileResponse> = {}): VoiceProfileResponse {
return {
id: nextId('profile'),
name: 'Test Voice',
language: 'en',
voice_type: 'cloned',
generation_count: 0,
sample_count: 1,
created_at: CREATED_AT,
updated_at: CREATED_AT,
...overrides,
};
}
export function buildGeneration(overrides: Partial<GenerationResponse> = {}): GenerationResponse {
return {
id: nextId('gen'),
profile_id: 'profile-0001',
text: 'Hello from the test suite.',
language: 'en',
status: 'completed',
audio_path: '/audio/fake.wav',
duration: 1.5,
created_at: CREATED_AT,
...overrides,
};
}
export function buildHistoryItem(overrides: Partial<HistoryResponse> = {}): HistoryResponse {
return {
...buildGeneration(),
profile_name: 'Test Voice',
...overrides,
};
}
export function buildHistoryList(items: HistoryResponse[]): HistoryListResponse {
return { items, total: items.length };
}
export function buildCapture(overrides: Partial<CaptureResponse> = {}): CaptureResponse {
return {
id: nextId('capture'),
audio_path: '/captures/fake.wav',
source: 'dictation',
language: 'en',
duration_ms: 2400,
transcript_raw: 'raw transcript text',
transcript_refined: 'Refined transcript text.',
created_at: CREATED_AT,
...overrides,
};
}
export function buildCaptureList(items: CaptureResponse[]): CaptureListResponse {
return { items, total: items.length };
}
export function buildCaptureSettings(overrides: Partial<CaptureSettings> = {}): CaptureSettings {
return {
stt_model: 'turbo',
language: 'en',
auto_refine: true,
llm_model: '0.6B',
smart_cleanup: true,
self_correction: true,
preserve_technical: true,
allow_auto_paste: false,
default_playback_voice_id: null,
hotkey_enabled: false,
keep_mic_warm: false,
chord_push_to_talk_keys: [],
chord_toggle_to_talk_keys: [],
...overrides,
};
}
export function buildCaptureReadiness(
overrides: Partial<CaptureReadinessResponse> = {},
): CaptureReadinessResponse {
return {
stt: {
ready: true,
model_name: 'whisper-turbo',
display_name: 'Whisper Turbo',
size: '1.6 GB',
},
llm: {
ready: true,
model_name: 'qwen3-0.6b',
display_name: 'Qwen3 0.6B',
size: '600 MB',
},
...overrides,
};
}
export function buildGenerationSettings(
overrides: Partial<GenerationSettings> = {},
): GenerationSettings {
return {
max_chunk_chars: 400,
crossfade_ms: 60,
normalize_audio: true,
autoplay_on_generate: false,
...overrides,
};
}
export function buildModelStatus(overrides: Partial<ModelStatus> = {}): ModelStatus {
return {
model_name: 'qwen-tts-1.7b',
display_name: 'Qwen TTS 1.7B',
downloaded: true,
downloading: false,
loaded: false,
size_mb: 3400,
...overrides,
};
}
export function buildStory(overrides: Partial<StoryResponse> = {}): StoryResponse {
return {
id: nextId('story'),
name: 'Test Story',
created_at: CREATED_AT,
updated_at: CREATED_AT,
item_count: 0,
...overrides,
};
}
export function buildStoryItem(overrides: Partial<StoryItemDetail> = {}): StoryItemDetail {
return {
id: nextId('story-item'),
story_id: 'story-0001',
generation_id: 'gen-0001',
start_time_ms: 0,
track: 0,
trim_start_ms: 0,
trim_end_ms: 0,
created_at: CREATED_AT,
profile_id: 'profile-0001',
profile_name: 'Test Voice',
text: 'Hello from the test suite.',
language: 'en',
audio_path: '/audio/fake.wav',
duration: 1.5,
volume: 1,
generation_created_at: CREATED_AT,
...overrides,
};
}
export function buildStoryDetail(
overrides: Partial<StoryDetailResponse> = {},
): StoryDetailResponse {
return {
id: 'story-0001',
name: 'Test Story',
created_at: CREATED_AT,
updated_at: CREATED_AT,
items: [],
...overrides,
};
}
export function buildEffectPreset(
overrides: Partial<EffectPresetResponse> = {},
): EffectPresetResponse {
return {
id: nextId('preset'),
name: 'Test Preset',
effects_chain: [{ type: 'reverb', enabled: true, params: { wet: 0.3 } }],
is_builtin: false,
created_at: CREATED_AT,
...overrides,
};
}
export function buildHealth(overrides: Partial<HealthResponse> = {}): HealthResponse {
return {
status: 'ok',
model_loaded: false,
gpu_available: false,
backend_variant: 'cpu',
...overrides,
};
}
+102
View File
@@ -0,0 +1,102 @@
import type { HttpHandler } from 'msw';
import { HttpResponse, http } from 'msw';
import type {
CaptureResponse,
CaptureSettings,
EffectPresetResponse,
GenerationSettings,
HistoryResponse,
ModelStatus,
StoryDetailResponse,
StoryResponse,
VoiceProfileResponse,
} from '@/lib/api/types';
import { buildCaptureReadiness, buildCaptureSettings, buildGenerationSettings } from '../fixtures';
/**
* Happy-path handlers for one domain each. Tests compose what they need:
* worker.use(...profileHandlers([buildProfile()]), ...historyHandlers([]))
* Anything not stubbed fails loudly via onUnhandledRequest: 'error'.
*/
export function profileHandlers(profiles: VoiceProfileResponse[]): HttpHandler[] {
return [
http.get('*/profiles', () => HttpResponse.json(profiles)),
http.get('*/profiles/presets/:engine', () => HttpResponse.json([])),
http.get('*/profiles/:id', ({ params }) => {
const profile = profiles.find((p) => p.id === params.id);
return profile ? HttpResponse.json(profile) : new HttpResponse(null, { status: 404 });
}),
http.get('*/profiles/:id/channels', () => HttpResponse.json([])),
http.get('*/profiles/:id/samples', () => HttpResponse.json([])),
http.get('*/channels', () => HttpResponse.json([])),
];
}
export function historyHandlers(items: HistoryResponse[]): HttpHandler[] {
return [
http.get('*/history', () => HttpResponse.json({ items, total: items.length })),
http.get('*/history/:id', ({ params }) => {
const item = items.find((i) => i.id === params.id);
return item ? HttpResponse.json(item) : new HttpResponse(null, { status: 404 });
}),
];
}
export function captureHandlers(
items: CaptureResponse[],
settings: CaptureSettings = buildCaptureSettings(),
): HttpHandler[] {
return [
http.get('*/captures', () => HttpResponse.json({ items, total: items.length })),
http.get('*/capture/readiness', () => HttpResponse.json(buildCaptureReadiness())),
http.get('*/settings/captures', () => HttpResponse.json(settings)),
http.put('*/settings/captures', async ({ request }) => {
const update = (await request.json()) as Partial<CaptureSettings>;
return HttpResponse.json({ ...settings, ...update });
}),
];
}
export function settingsHandlers(
generation: GenerationSettings = buildGenerationSettings(),
): HttpHandler[] {
return [
http.get('*/settings/generation', () => HttpResponse.json(generation)),
http.put('*/settings/generation', async ({ request }) => {
const update = (await request.json()) as Partial<GenerationSettings>;
return HttpResponse.json({ ...generation, ...update });
}),
];
}
export function modelHandlers(models: ModelStatus[]): HttpHandler[] {
return [
http.get('*/models/status', () => HttpResponse.json({ models })),
http.get('*/models/cache-dir', () => HttpResponse.json({ cache_dir: '/tmp/models' })),
];
}
export function storyHandlers(
stories: StoryResponse[],
details: StoryDetailResponse[] = [],
): HttpHandler[] {
return [
http.get('*/stories', () => HttpResponse.json(stories)),
http.get('*/stories/:id', ({ params }) => {
const detail = details.find((d) => d.id === params.id);
return detail ? HttpResponse.json(detail) : new HttpResponse(null, { status: 404 });
}),
];
}
export function effectsHandlers(presets: EffectPresetResponse[]): HttpHandler[] {
return [
http.get('*/effects/available', () => HttpResponse.json({ effects: [] })),
http.get('*/effects/presets', () => HttpResponse.json(presets)),
];
}
export function taskHandlers(): HttpHandler[] {
return [http.get('*/tasks/active', () => HttpResponse.json({ downloads: [], generations: [] }))];
}
+17
View File
@@ -0,0 +1,17 @@
import { type HttpHandler, HttpResponse, http } from 'msw';
/**
* Baseline handlers for endpoints nearly every screen touches. The health
* payload mirrors backend/routes/health.py closely enough for the UI's
* checks (`status`, `model_loaded`, backend variant fields).
*/
export const serverHandlers: HttpHandler[] = [
http.get('*/health', () =>
HttpResponse.json({
status: 'ok',
model_loaded: false,
device: 'cpu',
backend_variant: 'cpu',
}),
),
];
+9
View File
@@ -0,0 +1,9 @@
import { setupWorker } from 'msw/browser';
import { serverHandlers } from './handlers/server';
/**
* Browser-mode MSW worker. Individual tests layer route-specific handlers
* on top with `worker.use(...)`; `setup.browser.ts` resets them after each
* test. Only the health/baseline handlers are registered globally.
*/
export const worker = setupWorker(...serverHandlers);
+346
View File
@@ -0,0 +1,346 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.15.0';
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e';
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse');
const activeClientIds = new Set();
addEventListener('install', () => {
self.skipWaiting();
});
addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
addEventListener('message', async (event) => {
const clientId = Reflect.get(event.source || {}, 'id');
if (!clientId || !self.clients) {
return;
}
const client = await self.clients.get(clientId);
if (!client) {
return;
}
const allClients = await self.clients.matchAll({
type: 'window',
});
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
});
break;
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
});
break;
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId);
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
});
break;
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId);
const remainingClients = allClients.filter((client) => {
return client.id !== clientId;
});
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister();
}
break;
}
}
});
addEventListener('fetch', (event) => {
const requestInterceptedAt = Date.now();
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return;
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') {
return;
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return;
}
const requestId = crypto.randomUUID();
event.respondWith(handleRequest(event, requestId, requestInterceptedAt));
});
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event);
const requestCloneForEvents = event.request.clone();
const response = await getResponse(event, client, requestId, requestInterceptedAt);
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents);
// Omit the body of server-sent event stream responses.
// Cloning such responses would prevent client-side stream cancelations
// from reaching the original stream (a teed stream only cancels its
// source once both of its branches cancel) and would buffer the
// entire stream into the unconsumed clone indefinitely.
const isEventStreamResponse = response.headers
.get('content-type')
?.toLowerCase()
.startsWith('text/event-stream');
// Clone the response so both the client and the library could consume it.
const responseClone = isEventStreamResponse ? null : response.clone();
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: response.type,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseClone ? responseClone.body : null,
},
},
},
responseClone && responseClone.body ? [serializedRequest.body, responseClone.body] : [],
);
}
return response;
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId);
if (activeClientIds.has(event.clientId)) {
return client;
}
if (client?.frameType === 'top-level') {
return client;
}
const allClients = await self.clients.matchAll({
type: 'window',
});
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible';
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id);
});
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone();
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers);
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept');
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim());
const filteredValues = values.filter((value) => value !== 'msw/passthrough');
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '));
} else {
headers.delete('accept');
}
}
return fetch(requestClone, { headers });
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough();
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough();
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request);
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
);
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data);
}
case 'PASSTHROUGH': {
return passthrough();
}
}
return passthrough();
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error);
}
resolve(event.data);
};
client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]);
});
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error();
}
const mockedResponse = new Response(response.body, response);
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
});
return mockedResponse;
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
};
}
+71
View File
@@ -0,0 +1,71 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router';
import type { ReactNode } from 'react';
import { render } from 'vitest-browser-react';
import { PlatformProvider } from '@/platform/PlatformContext';
import { routeTree } from '@/router';
import { createMockPlatform, type MockPlatform } from './mockPlatform';
export function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
// Retries and interval refetching are disabled so tests are
// deterministic — polling components get their data exactly once.
queries: {
retry: false,
refetchInterval: false,
refetchOnWindowFocus: false,
gcTime: Number.POSITIVE_INFINITY,
},
mutations: { retry: false },
},
});
}
export interface RenderWithProvidersOptions {
platform?: MockPlatform;
queryClient?: QueryClient;
}
// Every client handed to a render is drained on teardown so in-flight
// queries can't fire after MSW handlers reset (noisy unhandled-request
// errors between tests).
const activeQueryClients: QueryClient[] = [];
export async function drainQueryClients(): Promise<void> {
for (const client of activeQueryClients) {
await client.cancelQueries();
client.clear();
}
activeQueryClients.length = 0;
}
export async function renderWithProviders(ui: ReactNode, options: RenderWithProvidersOptions = {}) {
const platform = options.platform ?? createMockPlatform();
const queryClient = options.queryClient ?? createTestQueryClient();
activeQueryClients.push(queryClient);
const result = await render(
<QueryClientProvider client={queryClient}>
<PlatformProvider platform={platform}>{ui}</PlatformProvider>
</QueryClientProvider>,
);
// Object.assign keeps the render result's prototype methods (locators)
// intact — spreading would drop them.
return Object.assign(result, { platform, queryClient });
}
/**
* Mount the real route tree at `route` over memory history — full app chrome
* (sidebar, frame, toasts) included. A throwaway router per call keeps route
* state from leaking between tests.
*/
export async function renderRoute(route: string, options: RenderWithProvidersOptions = {}) {
const router = createRouter({
routeTree,
history: createMemoryHistory({ initialEntries: [route] }),
});
const result = await renderWithProviders(<RouterProvider router={router} />, options);
return Object.assign(result, { router });
}
+36
View File
@@ -0,0 +1,36 @@
import { queryClient } from '@/lib/queryClient';
import { useAudioChannelStore } from '@/stores/audioChannelStore';
import { useEffectsStore } from '@/stores/effectsStore';
import { useGenerationStore } from '@/stores/generationStore';
import { useLogStore } from '@/stores/logStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useServerStore } from '@/stores/serverStore';
import { useStoryStore } from '@/stores/storyStore';
import { useUIStore } from '@/stores/uiStore';
const stores = [
useAudioChannelStore,
useEffectsStore,
useGenerationStore,
useLogStore,
usePlayerStore,
useServerStore,
useStoryStore,
useUIStore,
] as const;
// Snapshot pristine state at module load, before any test mutates anything.
const snapshots = stores.map((store) => store.getState());
/**
* Restore every zustand store to its initial state and clear persisted
* copies so tests can't leak state into each other. Persisted stores write
* through to localStorage on setState, so localStorage is cleared last.
*/
export function resetAllStores(): void {
stores.forEach((store, i) => {
store.setState(snapshots[i] as never, true);
});
queryClient.clear();
localStorage.clear();
}
+18
View File
@@ -0,0 +1,18 @@
import { afterEach, beforeAll } from 'vitest';
import { cleanup } from 'vitest-browser-react';
import { worker } from './msw/worker';
import { drainQueryClients } from './render';
beforeAll(async () => {
await worker.start({ onUnhandledRequest: 'error', quiet: true });
return () => worker.stop();
});
// Registered after setup.ts, so this runs first (afterEach is LIFO):
// unmount → cancel in-flight queries → reset handlers, then setup.ts
// restores stores and mocks.
afterEach(async () => {
await cleanup();
await drainQueryClients();
worker.resetHandlers();
});
+8
View File
@@ -0,0 +1,8 @@
import '@/i18n';
import { afterEach, vi } from 'vitest';
import { resetAllStores } from './resetStores';
afterEach(() => {
vi.restoreAllMocks();
resetAllStores();
});
+81
View File
@@ -0,0 +1,81 @@
import { HttpResponse } from 'msw';
export interface SseEvent {
data: unknown;
event?: string;
}
const SSE_HEADERS = {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
} as const;
const encoder = new TextEncoder();
function frame({ data, event }: SseEvent): Uint8Array {
const payload = typeof data === 'string' ? data : JSON.stringify(data);
const lines = event ? `event: ${event}\ndata: ${payload}\n\n` : `data: ${payload}\n\n`;
return encoder.encode(lines);
}
/**
* An MSW response streaming the given events immediately, then staying open
* (EventSource reconnects on close, so a closed stream would loop the test).
*/
export function sseResponse(events: SseEvent[]): Response {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const event of events) controller.enqueue(frame(event));
},
});
return new HttpResponse(stream, { headers: SSE_HEADERS });
}
export interface SseController {
/** Hand this to an MSW resolver: `http.get(url, () => sse.response())`. */
response(): Response;
/** Push one event to every open stream. */
push(event: SseEvent): void;
/** End all open streams. */
close(): void;
}
/**
* Imperative SSE feed for tests that interleave user actions with server
* events (generation progress, download progress). Each call to `response()`
* opens a stream that receives subsequent `push`es — matching EventSource
* reconnect behavior.
*/
export function sseController(): SseController {
const controllers = new Set<ReadableStreamDefaultController<Uint8Array>>();
return {
response() {
let own: ReadableStreamDefaultController<Uint8Array>;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
own = controller;
controllers.add(controller);
},
cancel() {
controllers.delete(own);
},
});
return new HttpResponse(stream, { headers: SSE_HEADERS });
},
push(event: SseEvent) {
for (const controller of controllers) controller.enqueue(frame(event));
},
close() {
for (const controller of controllers) {
try {
controller.close();
} catch {
// already closed by cancel
}
}
controllers.clear();
},
};
}
-14
View File
@@ -1,14 +0,0 @@
import path from 'node:path';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import { changelogPlugin } from './plugins/changelog';
export default defineConfig({
plugins: [tailwindcss(), react(), changelogPlugin(path.resolve(__dirname, '..'))],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
+10 -3
View File
@@ -38,6 +38,13 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
# An empty HSA_OVERRIDE_GFX_VERSION poisons the ROCm HSA runtime. It is
# treated as "force-empty" and no GPU is detected, even natively supported
# ones (e.g. gfx1201 / RX 9070 on ROCm 7.2). docker-compose can't
# conditionally omit an env var, so we clean it up here before torch loads.
if not os.environ.get("HSA_OVERRIDE_GFX_VERSION"):
os.environ.pop("HSA_OVERRIDE_GFX_VERSION", None)
# AMD GPU environment variables must be set before torch import
# Only set HSA_OVERRIDE_GFX_VERSION for older GPUs that need it.
# RDNA 3+ (gfx1100+) and RDNA 4 (gfx1200+) are natively supported by ROCm
@@ -353,15 +360,15 @@ async def _run_shutdown() -> None:
"""Unload models on lifespan exit."""
logger.info("Voicebox server shutting down...")
try:
tts.unload_tts_model()
await tts.unload_tts_model()
except Exception:
logger.exception("Failed to unload TTS model")
try:
transcribe.unload_whisper_model()
await transcribe.unload_whisper_model()
except Exception:
logger.exception("Failed to unload Whisper model")
try:
llm.unload_llm_model()
await llm.unload_llm_model()
except Exception:
logger.exception("Failed to unload LLM model")
+28 -6
View File
@@ -12,6 +12,7 @@ and a model config registry that eliminates per-engine dispatch maps.
# 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
@@ -547,7 +548,21 @@ async def ensure_model_cached_or_raise(engine: str, model_size: str = "default")
)
def unload_model_by_config(config: ModelConfig) -> bool:
async def unload_backend(backend) -> None:
"""Free a backend's model, serialized onto the MLX worker when it has one.
MLX backends expose an async ``unload`` that runs the free on the dedicated
MLX thread so it can't collide with an in-flight load/generate. Other
backends only carry the synchronous ``unload_model``.
"""
unload = getattr(backend, "unload", None)
if unload is not None:
await unload()
else:
backend.unload_model()
async def unload_model_by_config(config: ModelConfig) -> bool:
"""Unload a model given its config. Returns True if it was loaded, False otherwise."""
from . import get_tts_backend_for_engine
from ..services import tts, transcribe, llm as llm_service
@@ -555,7 +570,7 @@ def unload_model_by_config(config: ModelConfig) -> bool:
if config.engine == "whisper":
whisper_model = transcribe.get_whisper_model()
if whisper_model.is_loaded() and whisper_model.model_size == config.model_size:
transcribe.unload_whisper_model()
await unload_backend(whisper_model)
return True
return False
@@ -563,7 +578,7 @@ def unload_model_by_config(config: ModelConfig) -> bool:
backend = llm_service.get_llm_model()
loaded_size = getattr(backend, "_current_model_size", None) or getattr(backend, "model_size", None)
if backend.is_loaded() and loaded_size == config.model_size:
backend.unload_model()
await unload_backend(backend)
return True
return False
@@ -571,7 +586,7 @@ def unload_model_by_config(config: ModelConfig) -> bool:
tts_model = tts.get_tts_model()
loaded_size = getattr(tts_model, "_current_model_size", None) or getattr(tts_model, "model_size", None)
if tts_model.is_loaded() and loaded_size == config.model_size:
tts.unload_tts_model()
await unload_backend(tts_model)
return True
return False
@@ -579,14 +594,14 @@ def unload_model_by_config(config: ModelConfig) -> bool:
backend = get_tts_backend_for_engine(config.engine)
loaded_size = getattr(backend, "_current_model_size", None) or getattr(backend, "model_size", None)
if backend.is_loaded() and loaded_size == config.model_size:
backend.unload_model()
await unload_backend(backend)
return True
return False
# All other TTS engines
backend = get_tts_backend_for_engine(config.engine)
if backend.is_loaded():
backend.unload_model()
await unload_backend(backend)
return True
return False
@@ -664,6 +679,13 @@ def get_tts_backend_for_engine(engine: str) -> TTSBackend:
"""
global _tts_backends
# Test mode: every engine resolves to the fake backend so the full
# generation pipeline runs without model weights (see fake_backend.py).
if os.environ.get("VOICEBOX_FAKE_TTS") == "1":
from .fake_backend import get_fake_backend
return get_fake_backend()
# Fast path: check without lock
if engine in _tts_backends:
return _tts_backends[engine]
+92
View File
@@ -0,0 +1,92 @@
"""Fake TTS backend for UI and E2E testing.
Activated by ``VOICEBOX_FAKE_TTS=1``. Every engine resolves to this backend,
which synthesizes a quiet sine tone sized to the input text — so the full
generation pipeline (task queue, SSE progress, database rows, audio serving)
runs exactly as in production, minus model weights and GPU time.
"""
import asyncio
import logging
from typing import ClassVar, Optional
import numpy as np
logger = logging.getLogger(__name__)
SAMPLE_RATE = 24_000
SECONDS_PER_CHAR = 0.02
MIN_DURATION_S = 0.25
TONE_HZ = 440.0
AMPLITUDE = 0.1
class FakeTTSBackend:
"""Implements the TTSBackend protocol without any model."""
MODEL_CONFIGS: ClassVar[list] = []
def __init__(self) -> None:
self._loaded = False
async def load_model(self, model_size: str = "default") -> None:
if self._loaded:
return
# Brief pause so the UI's loading_model state is observable.
await asyncio.sleep(0.1)
self._loaded = True
logger.info("Fake TTS backend loaded (VOICEBOX_FAKE_TTS)")
async def load_model_async(self, model_size: str = "default") -> None:
# Qwen engines are loaded through this variant (see load_engine_model).
await self.load_model(model_size)
async def create_voice_prompt(
self,
audio_path: str,
reference_text: str,
use_cache: bool = True,
) -> tuple[dict, bool]:
return ({"fake": True, "audio_path": audio_path, "reference_text": reference_text}, False)
async def combine_voice_prompts(
self,
audio_paths: list[str],
reference_texts: list[str],
) -> tuple[np.ndarray, str]:
combined_text = " ".join(reference_texts)
return np.zeros(SAMPLE_RATE, dtype=np.float32), combined_text
async def generate(
self,
text: str,
voice_prompt: dict,
language: str = "en",
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> tuple[np.ndarray, int]:
duration_s = max(MIN_DURATION_S, len(text) * SECONDS_PER_CHAR)
# Yield once so cancellation has a window, mirroring real inference.
await asyncio.sleep(0.05)
t = np.linspace(0.0, duration_s, int(SAMPLE_RATE * duration_s), endpoint=False)
audio = (AMPLITUDE * np.sin(2.0 * np.pi * TONE_HZ * t)).astype(np.float32)
return audio, SAMPLE_RATE
def unload_model(self) -> None:
self._loaded = False
def is_loaded(self) -> bool:
return self._loaded
def _get_model_path(self, model_size: str) -> str:
return "fake"
_fake_backend: Optional[FakeTTSBackend] = None
def get_fake_backend() -> FakeTTSBackend:
global _fake_backend
if _fake_backend is None:
_fake_backend = FakeTTSBackend()
return _fake_backend
+55 -29
View File
@@ -3,7 +3,6 @@ MLX backend implementation for TTS and STT using mlx-audio.
"""
from typing import Optional, List, Tuple
import asyncio
import logging
import numpy as np
from pathlib import Path
@@ -19,6 +18,7 @@ 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
@@ -63,6 +63,22 @@ class MLXTTSBackend:
weight_extensions=(".safetensors", ".bin", ".npz"),
)
def _ensure_loaded_sync(self, model_size: Optional[str]):
"""Load the model if the requested size isn't already resident.
Runs on the MLX worker thread so it stays serialized with generation.
"""
if model_size is None:
model_size = self.model_size
if self.model is not None and self._current_model_size == model_size:
return
if self.model is not None and self._current_model_size != model_size:
self.unload_model()
self._load_model_sync(model_size)
async def load_model_async(self, model_size: Optional[str] = None):
"""
Lazy load the MLX TTS model.
@@ -70,23 +86,15 @@ class MLXTTSBackend:
Args:
model_size: Model size to load (1.7B or 0.6B)
"""
if model_size is None:
model_size = self.model_size
# If already loaded with correct size, return
if self.model is not None and self._current_model_size == model_size:
return
# Unload existing model if different size requested
if self.model is not None and self._current_model_size != model_size:
self.unload_model()
# Run blocking load in thread pool
await asyncio.to_thread(self._load_model_sync, model_size)
await run_on_mlx_thread(self._ensure_loaded_sync, model_size)
# Alias for compatibility
load_model = load_model_async
async def unload(self):
"""Free the model, serialized onto the MLX worker thread."""
await run_on_mlx_thread(self.unload_model)
def _load_model_sync(self, model_size: str):
"""Synchronous model loading."""
model_path = self._get_model_path(model_size)
@@ -110,6 +118,7 @@ class MLXTTSBackend:
del self.model
self.model = None
self._current_model_size = None
clear_mlx_cache()
logger.info("MLX TTS model unloaded")
async def create_voice_prompt(
@@ -187,8 +196,6 @@ class MLXTTSBackend:
Returns:
Tuple of (audio_array, sample_rate)
"""
await self.load_model_async(None)
logger.info("Generating audio for text: %s", text)
def _generate_sync():
@@ -258,8 +265,13 @@ class MLXTTSBackend:
return audio, sample_rate
# Run blocking inference in thread pool
audio, sample_rate = await asyncio.to_thread(_generate_sync)
# 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.
def _load_and_generate():
self._ensure_loaded_sync(None)
return _generate_sync()
audio, sample_rate = await run_on_mlx_thread(_load_and_generate)
return audio, sample_rate
@@ -279,12 +291,10 @@ 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"))
async def load_model_async(self, model_size: Optional[str] = None):
"""
Lazy load the MLX Whisper model.
def _ensure_loaded_sync(self, model_size: Optional[str]):
"""Load the model if the requested size isn't already resident.
Args:
model_size: Model size (tiny, base, small, medium, large)
Runs on the MLX worker thread so it stays serialized with transcription.
"""
if model_size is None:
model_size = self.model_size
@@ -292,12 +302,24 @@ class MLXSTTBackend:
if self.model is not None and self.model_size == model_size:
return
# Run blocking load in thread pool
await asyncio.to_thread(self._load_model_sync, model_size)
self._load_model_sync(model_size)
async def load_model_async(self, model_size: Optional[str] = None):
"""
Lazy load the MLX Whisper model.
Args:
model_size: Model size (tiny, base, small, medium, large)
"""
await run_on_mlx_thread(self._ensure_loaded_sync, model_size)
# Alias for compatibility
load_model = load_model_async
async def unload(self):
"""Free the model, serialized onto the MLX worker thread."""
await run_on_mlx_thread(self.unload_model)
def _load_model_sync(self, model_size: str):
"""Synchronous model loading."""
progress_model_name = f"whisper-{model_size}"
@@ -319,6 +341,7 @@ class MLXSTTBackend:
if self.model is not None:
del self.model
self.model = None
clear_mlx_cache()
logger.info("MLX Whisper model unloaded")
async def transcribe(
@@ -338,8 +361,6 @@ class MLXSTTBackend:
Returns:
Transcribed text
"""
await self.load_model_async(model_size)
def _transcribe_sync():
"""Run synchronous transcription in thread pool."""
# MLX Whisper transcription using generate method
@@ -363,5 +384,10 @@ class MLXSTTBackend:
else:
return str(result).strip()
# Run blocking transcription in thread pool
return await asyncio.to_thread(_transcribe_sync)
# Load-if-needed and transcription run as one job on the MLX worker so
# a concurrent unload or load can't land between them.
def _load_and_transcribe():
self._ensure_loaded_sync(model_size)
return _transcribe_sync()
return await run_on_mlx_thread(_load_and_transcribe)
+22 -6
View File
@@ -19,6 +19,7 @@ from .base import (
manual_seed,
model_load_progress,
)
from ..services.mlx_thread import run_on_mlx_thread, clear_mlx_cache
logger = logging.getLogger(__name__)
@@ -205,7 +206,11 @@ class MLXQwenLLMBackend:
weight_extensions=(".safetensors", ".bin", ".npz"),
)
async def load_model(self, model_size: Optional[str] = None) -> None:
def _ensure_loaded_sync(self, model_size: Optional[str]) -> None:
"""Load the model if the requested size isn't already resident.
Runs on the MLX worker thread so it stays serialized with generation.
"""
if model_size is None:
model_size = self.model_size
@@ -215,7 +220,14 @@ class MLXQwenLLMBackend:
if self.model is not None and self._current_model_size != model_size:
self.unload_model()
await asyncio.to_thread(self._load_model_sync, model_size)
self._load_model_sync(model_size)
async def load_model(self, model_size: Optional[str] = None) -> None:
await run_on_mlx_thread(self._ensure_loaded_sync, model_size)
async def unload(self) -> None:
"""Free the model, serialized onto the MLX worker thread."""
await run_on_mlx_thread(self.unload_model)
def _load_model_sync(self, model_size: str) -> None:
from mlx_lm import load as mlx_load
@@ -246,6 +258,7 @@ class MLXQwenLLMBackend:
self.model = None
self.tokenizer = None
self._current_model_size = None
clear_mlx_cache()
logger.info("Qwen3 (MLX) unloaded")
async def generate(
@@ -257,10 +270,13 @@ class MLXQwenLLMBackend:
model_size: Optional[str] = None,
examples: Optional[list[tuple[str, str]]] = None,
) -> str:
await self.load_model(model_size)
return await asyncio.to_thread(
self._generate_sync, prompt, system, max_tokens, temperature, examples
)
# 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.
def _load_and_generate() -> str:
self._ensure_loaded_sync(model_size)
return self._generate_sync(prompt, system, max_tokens, temperature, examples)
return await run_on_mlx_thread(_load_and_generate)
def _generate_sync(
self,
+3
View File
@@ -330,6 +330,9 @@ def build_server(cuda=False, rocm=False):
]
)
if sys.version_info >= (3, 13):
args.extend(["--hidden-import", "audioop"])
# Add CUDA/ROCm-specific hidden imports
if cuda or rocm:
variant = "ROCm" if rocm else "CUDA"
+5
View File
@@ -80,6 +80,11 @@ def resolve_storage_path(path: str | Path | None) -> Path | None:
return None
stored_path = Path(path)
# Empty paths (e.g. failed generations) must not resolve to the data
# dir itself, which exists and would defeat the callers' 404 guards.
# Path("") is truthy, so check parts rather than the raw value.
if not stored_path.parts:
return None
if stored_path.is_absolute():
rebased_path = _path_relative_to_any_data_dir(stored_path)
if rebased_path is not None:
+7
View File
@@ -243,6 +243,13 @@ def _migrate_capture_settings(engine, inspector, tables: set[str]) -> None:
"hotkey_enabled BOOLEAN NOT NULL DEFAULT 0",
"hotkey_enabled",
)
if "keep_mic_warm" not in columns:
_add_column(
engine,
"capture_settings",
"keep_mic_warm BOOLEAN NOT NULL DEFAULT 0",
"keep_mic_warm",
)
def _migrate_mcp_bindings(engine, inspector, tables: set[str]) -> None:
+4
View File
@@ -210,6 +210,10 @@ class CaptureSettings(Base):
# "Voicebox would like to receive keystrokes from any application" dialog
# before they've even opened the Captures tab.
hotkey_enabled = Column(Boolean, nullable=False, default=False)
# Hold the microphone open while dictation is enabled so push-to-talk
# doesn't clip the first words. Off by default — when on, the OS mic-in-use
# indicator stays lit the whole time dictation is enabled.
keep_mic_warm = Column(Boolean, nullable=False, default=False)
# Lists of keytap key names (e.g. "MetaRight", "ControlRight"). Right-hand
# modifiers by default so they don't collide with left-hand shortcuts.
chord_push_to_talk_keys = Column(
+2
View File
@@ -258,6 +258,7 @@ class CaptureSettingsResponse(BaseModel):
allow_auto_paste: bool = True
default_playback_voice_id: Optional[str] = None
hotkey_enabled: bool = False
keep_mic_warm: bool = False
chord_push_to_talk_keys: List[str] = Field(
default_factory=default_push_to_talk_chord
)
@@ -282,6 +283,7 @@ class CaptureSettingsUpdate(BaseModel):
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)
+25
View File
@@ -0,0 +1,25 @@
# Minimal dependency set to boot the backend on a CPU-only CI runner.
# No TTS/STT model libraries — inference is covered by the fake TTS
# backend (VOICEBOX_FAKE_TTS=1). Install CPU torch first on Linux:
# pip install torch --index-url https://download.pytorch.org/whl/cpu
# then: pip install -r backend/requirements-ci.txt
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
pydantic>=2.5.0
sqlalchemy>=2.0.0
alembic>=1.13.0
torch>=2.2.0
huggingface_hub>=0.20.0
numpy
soundfile
python-multipart
sse-starlette
psutil
requests
httpx
fastmcp
librosa
pillow
pydub
pedalboard
+2 -1
View File
@@ -16,7 +16,8 @@ miniaudio>=1.59
# mlx_audio.stt.load) works fine on transformers 4.57.x in practice.
#
# Install it via `pip install --no-deps mlx-audio==0.4.1` after this file
# (see .github/workflows/release.yml). Most other mlx-audio runtime deps
# (see .github/workflows/release.yml and the setup-python recipe in the
# justfile). Most other mlx-audio runtime deps
# (huggingface_hub, librosa, mlx-lm, numba, numpy, protobuf, pyloudnorm,
# sounddevice, tqdm) are already in requirements.txt or pulled in by
# other engines.
+1
View File
@@ -53,6 +53,7 @@ en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_
unidic-lite>=1.0.8
# Audio processing
audioop-lts>=0.2.1; python_version >= "3.13"
librosa>=0.10.0
soundfile>=0.12.0
numpy>=1.24.0,<2.0
+9 -4
View File
@@ -34,7 +34,7 @@ async def get_version_audio(version_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="Version not found")
audio_path = config.resolve_storage_path(version.audio_path)
if audio_path is None or not audio_path.exists():
if audio_path is None or not audio_path.is_file():
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(
@@ -52,8 +52,13 @@ async def get_audio(generation_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="Generation not found")
audio_path = config.resolve_storage_path(generation.audio_path)
if audio_path is None or not audio_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found")
if audio_path is None or not audio_path.is_file():
detail = (
"Generation failed; no audio available"
if generation.status == "failed"
else "Audio file not found"
)
raise HTTPException(status_code=404, detail=detail)
return FileResponse(
audio_path,
@@ -72,7 +77,7 @@ async def get_sample_audio(sample_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="Sample not found")
audio_path = config.resolve_storage_path(sample.audio_path)
if audio_path is None or not audio_path.exists():
if audio_path is None or not audio_path.is_file():
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(
+3 -3
View File
@@ -66,7 +66,7 @@ async def unload_model():
from ..services import tts
try:
tts.unload_tts_model()
await tts.unload_tts_model()
return {"message": "Model unloaded successfully"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -82,7 +82,7 @@ async def unload_model_by_name(model_name: str):
raise HTTPException(status_code=400, detail=f"Unknown model: {model_name}")
try:
was_loaded = unload_model_by_config(config)
was_loaded = await unload_model_by_config(config)
if not was_loaded:
return {"message": f"Model {model_name} is not loaded"}
return {"message": f"Model {model_name} unloaded successfully"}
@@ -457,7 +457,7 @@ async def delete_model(model_name: str):
hf_repo_id = config.hf_repo_id
try:
unload_model_by_config(config)
await unload_model_by_config(config)
cache_dir = hf_constants.HF_HUB_CACHE
repo_cache_dir = Path(cache_dir) / ("models--" + hf_repo_id.replace("/", "--"))
+4 -4
View File
@@ -2,7 +2,7 @@
LLM inference module - delegates to backend abstraction layer.
"""
from ..backends import get_llm_backend, LLMBackend
from ..backends import LLMBackend, get_llm_backend, unload_backend
def get_llm_model() -> LLMBackend:
@@ -10,6 +10,6 @@ def get_llm_model() -> LLMBackend:
return get_llm_backend()
def unload_llm_model() -> None:
"""Unload LLM model to free memory."""
get_llm_backend().unload_model()
async def unload_llm_model() -> None:
"""Unload LLM model to free memory, serialized onto the MLX worker."""
await unload_backend(get_llm_backend())

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