Compare commits

...
Author SHA1 Message Date
Alex SummerandGitHub 51f49dea19 fix(docs): update quick start guide to reflect correct terminology for voice profiles (#963) 2026-07-26 23:32:02 -07:00
80610d880e fix(ui): open FloatingGenerateBox selects upward to prevent clipping (fixes #928) (#936)
The floating generate box is fixed at the bottom of the viewport, so
all of its Select dropdowns (voice profile, language, engine, effects)
opened downward into — or beyond — the window edge. Add side="top" to
each SelectContent so the menus appear above their trigger instead.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

Fixes #884
2026-07-26 23:31:41 -07:00
XariannandGitHub 1db0fdf645 fix(rocm): add MIOpen stability env vars to docker-compose.rocm.yml (#865)
Add three environment variables to prevent miopenStatusUnknownError and
system stuttering during inference on RDNA4 GPUs:

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

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

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

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

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

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

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

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

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

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

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

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

* test: tighten runaway retry coverage

---------

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

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

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

Setting kCGEventFlagMaskCommand on the Cmd-down event makes the
flagsChanged event well-formed; Chromium then registers Command=down
and Cmd+V matches. Likely fixes #762 and #643.
2026-07-26 23:31:17 -07:00
52f8d8dd38 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:23 -07:00
fb1e16d2ce 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:04 -07:00
f750596364 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:26:46 -07:00
XariannandGitHub 91cd6df108 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:26:25 -07:00
484a39ad9f 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:26:04 -07:00
3bfcbdc819 fix: the justfile syntax errror, GPU information cannot be read (#669)
Co-authored-by: xor_s <[email protected]>
2026-07-20 22:25:43 -07:00
Shabeer VPKandGitHub 190bc5e8a8 Update .dockerignore (#861)
whitelist ROCm entrypoint
2026-07-20 22:25:20 -07:00
jitendra kumar sainiandGitHub 80af641b61 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:03 -07:00
neuron-tech-aiandGitHub 6936789a88 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:14:46 -07:00
youtsuhoandGitHub f3eca34d33 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:33 -07:00
30db291b01 fix(kokoro): add missing male Mandarin voices (#788)
Co-authored-by: Siddharth Chintawar <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-07-20 20:51:06 -07:00
Andrew BarnesandGitHub 71b51366bc Fix CUDA downloads on unsupported platforms (#770)
* Fix CUDA downloads on unsupported platforms

* fix: align CUDA status nullability

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

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

Fixes #841


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

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-20 18:47:10 -07:00
e6cf50c7f7 feat(i18n): add Korean (ko) locale with 559 translation keys (#814)
* feat(i18n): add Korean (ko) locale with 559 translation keys

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

---------

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

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

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

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

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

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

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

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

Fixes #925


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

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

profiles.py already solves this for voice samples by keeping the uploaded
extension when it is one of the audio types it accepts, and falling back to
.wav otherwise. Same approach here, same set. The fallback means an unknown
or missing extension behaves exactly as it does today.
2026-07-20 12:39:46 -07:00
Jamie PineandGitHub f2cf2a729d Add "Log in with browser" cloud device login (#812)
* Add "Log in with browser" cloud device login

Connects the desktop app to Voicebox Cloud without the user ever handling an
API key. One button in Settings → General opens the system browser to
voicebox.sh, the user authorizes while signed in, and the credential lands
back in the app automatically.

Backend (FastAPI):
- /cloud/login/start opens the browser to the cloud authorize page with a
  state we mint; the existing loopback server catches the redirect at
  /cloud/callback and exchanges the one-time code (server-to-server, over TLS)
  for a voicebox_ API key, verifies it against the API, and stores it.
- /cloud/status and /cloud/disconnect back the settings UI.
- state round-trip guards against login-CSRF; the key never crosses a browser
  URL and is never exposed to the frontend (status returns a prefix only).
- CloudSettings singleton row; config gains VOICEBOX_CLOUD_URL /
  VOICEBOX_CLOUD_API_URL (default the prod hosts, overridable for dev).

Frontend (React):
- CloudSection in Settings → General: "Log in with browser", polls status,
  shows the connected device + a dashboard link. API keys are the advanced
  path only, surfaced in the web dashboard.

The key is stored in the local app DB for now; OS keychain is a marked
follow-up.

* Address review feedback on cloud login

- time out status polling after 2 min so an abandoned browser flow
  doesn't leave the button stuck on "Waiting for browser…"
- handle non-JSON / non-object payloads from the exchange and account
  endpoints instead of 500ing after the state is consumed
- make singleton row creation race-safe (IntegrityError -> re-query)
- clear device_name on disconnect along with the rest of the metadata
- serve the dashboard URL from /cloud/status so the Manage link follows
  VOICEBOX_CLOUD_URL instead of hardcoding production
- keep a "Disconnecting…" label on the disconnect button while pending

* Remove orphaned react-qr-code entries from lockfile

bun.lock was out of date with package.json (react-qr-code was removed
without reinstalling), failing the frozen-lockfile install in CI.
2026-07-05 03:18:30 -07:00
James Pine b542768429 Update PROJECT_STATUS.md 2026-07-02 16:12:33 -07:00
e766c7cbfb feat(windows): Native AMD ROCm GPU Acceleration (Resolves #531) (#538)
* feat(windows): add native ROCm support for AMD GPUs

Implements native ROCm architecture for Windows.

- Adds backend build pipeline for voicebox-server-rocm.exe

- Detects AMD GPUs dynamically and routes PyTorch allocations

- Adds automatic download and update logic for ROCm dependencies

- Refactors UI in GpuPage.tsx and GpuAcceleration.tsx to add AMD flows

- Fixes 'Switch to CPU' lock on Windows via Tauri backend_override state

- Resolves PyInstaller/rocm_sdk UnboundLocalError silent crashes

- Resolves Numba/NumPy 2.x incompatibilities during Qwen3-TTS load

- Resolves HF_HUB_OFFLINE Catch-22 for CustomVoice processor caching

* fix(rocm): host libs archive under the app release tag, drop offline-load regression

Align the ROCm libs download with the CUDA pattern: both the server core and
the libs archive are published under the app-version release tag, with the libs
content version encoded in the filename only. The previous code fetched libs
from a separate rocm7.2-v1 tag, which disagreed with the download test.

Also revert the unrelated Qwen CustomVoice changes that wrapped model loading in
force_offline_if_cached (not imported — a NameError on load for every platform)
and re-added a Base-model cache gate. The inference-path offline guard was
deliberately removed previously.

* feat(rocm): gate download on AMD detection and persist the backend variant

The ROCm download section now only shows when the backend reports an AMD GPU on
Windows (new supports_rocm health field, backed by the memoized
is_amd_gpu_windows detection that was previously unused), or when ROCm is already
downloaded/active.

Make the backend override honor a pinned variant: set_backend_override persists
the choice to disk so it survives an app restart, start_server reads it back,
and a cuda/rocm pin now actually selects that variant instead of always
preferring ROCm. A stale pin to a deleted backend self-heals to the default
order rather than forcing CPU. Add the web no-op stub for the new method.

* chore(rocm): drop incomplete vitest harness for the unused GpuAcceleration component

GpuAcceleration.tsx is not routed anywhere (GpuPage is the live settings view),
and the added vitest setup referenced testing-library/vitest deps that were not
in the lockfile, breaking the web typecheck. Remove the dead component's test
and its scaffolding to keep this PR scoped to the ROCm feature.

* ci(rocm): add ROCm release-artifact pipeline

Mirror the CUDA packaging path for ROCm so the runtime download has artifacts to
fetch. scripts/package_rocm.py splits the PyInstaller --rocm onedir into
voicebox-server-rocm.tar.gz (core) + rocm-libs-rocm7.2-v1.tar.gz (AMD runtime:
HIP DLLs, rocBLAS Tensile data, MIOpen kernel DBs) + rocm-libs.json, matching
the names services/rocm.py expects, both under the app-version release tag.

The new build-rocm-windows job in release.yml builds on windows-latest/cp312 and
lets build_binary.py --rocm pull the official AMD Radeon wheels.

The file classifier can't be validated against a real AMD build on CI, so it has
unit coverage (test_package_rocm.py) against a synthetic onedir layout. The
prefixes/dir markers may need a tweak after the first real build on AMD
hardware — the packager hard-fails loudly if it classifies zero ROCm files.

---------

Co-authored-by: Jamie Pine <[email protected]>
2026-06-30 15:43:18 -07:00
Mike KeyandGitHub c2282b256a fix: ROCm setup for Linux AMD GPUs (#817)
* Fix ROCm setup for Linux AMD GPUs

- Ensure Docker ROCm builds resolve PyTorch packages from the ROCm wheel index so later dependency installs do not replace them with CUDA wheels.
- Move ROCm device group handling to a runtime entrypoint that joins the groups owning /dev/kfd and /dev/dri, avoiding distro-specific render/video GID defaults.
- Leave HSA_OVERRIDE_GFX_VERSION unset by default in the ROCm compose overlay so newer RDNA GPUs can use native ROCm detection.
- Add Linux GPU detection to the Unix setup recipe so AMD systems install ROCm torch wheels and NVIDIA systems install CUDA wheels before backend dependencies.

* docs(changelog): add Linux ROCm setup entry

* fix(setup): pin ROCm torch wheels and prefer NVIDIA over amdgpu

- Install torch/torchaudio from the ROCm index only, before the pooled
  requirements install, so a plain PyPI (CUDA) wheel can't outrank +rocm
- Detect NVIDIA before AMD and gate ROCm on /dev/kfd, so hybrid
  AMD+NVIDIA hosts get CUDA instead of ROCm
2026-06-30 15:43:15 -07:00
cabef1bfe0 fix(docker): add ROCm GPU support via compose overlay (#630)
* fix(docker): add ROCm GPU support via compose overlay

Fixes #618. The Docker image installs CPU-only PyTorch from PyPI by
default, so even when users correctly pass /dev/kfd and /dev/dri device
nodes into the container, torch.cuda.is_available() returns False and
the GPU is reported as "None (CPU only)".

Changes:
- Dockerfile: add PYTORCH_VARIANT build arg (default: cpu). When set to
  "rocm", the ROCm-enabled PyTorch wheels are installed from the
  pytorch.org/whl/rocm6.3 index before requirements.txt runs, so pip
  sees the ROCm build as already satisfying the torch>=2.2.0 constraint
  and does not overwrite it with the CPU wheel. The render and video
  groups are created with parameterised GIDs (RENDER_GID / VIDEO_GID,
  defaulting to Ubuntu 22.04 values) and the voicebox user is added to
  both groups so it can open /dev/kfd and /dev/dri.

- docker-compose.rocm.yml: new compose overlay that wires everything
  together — PYTORCH_VARIANT=rocm build arg, /dev/kfd + /dev/dri device
  passthrough, group_add for render/video, HSA_OVERRIDE_GFX_VERSION
  (defaults to 11.0.0 for RDNA3/Strix Halo with a comment listing
  values for RDNA2/RDNA1/Vega), and PYTORCH_HIP_ALLOC_CONF for the
  memory allocator. Usage:
    docker compose -f docker-compose.yml -f docker-compose.rocm.yml up --build

- docker-compose.yml: add a comment pointing to the ROCm overlay.

The CPU default path is unchanged — no extra build time, no size increase.

Co-authored-by: Cursor <[email protected]>

* fix(docker): address review comments on ROCm overlay

Two issues raised in PR review:

1. CodeRabbit: `docker compose up --build-arg` is not supported by the
   `up` subcommand. Replaced the GID override instructions with the
   correct env-var export pattern. Added RENDER_GID and VIDEO_GID to
   `build.args` using ${VAR:-default} interpolation so a single export
   covers both the Dockerfile group creation and the runtime group_add.
   Changed group_add entries from hardcoded strings to the same
   interpolated vars so host GIDs stay in sync end-to-end.

2. @Xarianne: ROCm 6.3 does not support RDNA 4 (RX 9000 series) cards.
   Added a ROCM_VERSION build arg (default 6.3) to both the Dockerfile
   and docker-compose.rocm.yml so users can set ROCM_VERSION=7.2 for
   RDNA 4 support without editing any files. Added RDNA 4 / 12.0.0 to
   the HSA_OVERRIDE_GFX_VERSION comment table.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-06-29 18:09:55 -07:00
Amitesh GuptaandGitHub 3835b63bd8 fix(backend): detect AMD GPU before setting HSA_OVERRIDE_GFX_VERSION (#785)
Previously, HSA_OVERRIDE_GFX_VERSION=10.3.0 was unconditionally set for
all AMD GPUs, which caused suboptimal performance on RDNA 3/4 GPUs
(gfx11xx/gfx12xx) that have native ROCm support.

Now uses rocminfo to detect all GPUs and only sets the override for
systems where the oldest GPU needs it (RDNA 2 and older, gfx10xx and
below). Newer GPUs are left untouched.

Addresses CodeRabbit review:
- Case-insensitive regex matching on lowercased line
- Log level changed to INFO for rocminfo failures
- Multi-GPU support: iterates all GPUs, uses oldest for decision

Fixes #469

Signed-off-by: Amitesh Gupta

Signed-off-by: Amitesh Gupta
Signed-off-by: singlaamitesh <[email protected]>
2026-06-29 17:58:08 -07:00
Jamie Pine da79e37ef5 remove redundent section 2026-06-28 22:48:39 -07:00
Jamie Pine 6e4989313c remove clorb 2026-06-28 21:31:00 -07:00
James Pine 42b9cae216 Add transparency stats to landing page 2026-06-28 21:24:00 -07:00
youtsuhoandGitHub b9bb2f075c feat: french translation (#802)
* Add French (fr) language support

- Create app/src/i18n/locales/fr/translation.json with full UI translations
- Register French in SUPPORTED_LANGUAGES and i18next resources
- Wire French locale from date-fns for relative date formatting

* Fix Vite file watcher EBUSY error on Windows by excluding Rust target directory
2026-06-28 16:33:27 -07:00
e294b9c8f0 feat(i18n): add Brazilian Portuguese (pt-BR) locale (#810)
Adds a complete pt-BR translation (832 strings, full parity with en)
and registers it in the i18n config and language selector.

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-28 16:33:00 -07:00
102 changed files with 12058 additions and 564 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/
+2
View File
@@ -0,0 +1,2 @@
package.json text eol=lf
scripts/*.sh text eol=lf
+61
View File
@@ -340,3 +340,64 @@ jobs:
name: voicebox-server-cuda-windows
path: backend/dist/voicebox-server-cuda/
retention-days: 7
build-rocm-windows:
runs-on: windows-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
# ROCm wheels are cp312-cp312-specific — build_binary.py --rocm enforces this.
python-version: "3.12"
cache: "pip"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install -r backend/requirements.txt
pip install --no-deps chatterbox-tts
pip install --no-deps hume-tada
- name: Build ROCm server binary (onedir)
shell: bash
working-directory: backend
# build_binary.py --rocm pulls the official AMD Radeon torch + rocm_sdk
# wheels (rocm-rel-7.2.1) itself when ROCm torch is not already present,
# then restores the dev torch afterwards.
run: python build_binary.py --rocm
- name: Package into server core + ROCm libs archives
shell: bash
run: |
python scripts/package_rocm.py \
backend/dist/voicebox-server-rocm/ \
--output release-assets/ \
--rocm-libs-version rocm7.2-v1 \
--torch-compat ">=2.9.0,<2.10.0"
- name: Upload archives to GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
files: |
release-assets/voicebox-server-rocm.tar.gz
release-assets/voicebox-server-rocm.tar.gz.sha256
release-assets/rocm-libs-rocm7.2-v1.tar.gz
release-assets/rocm-libs-rocm7.2-v1.tar.gz.sha256
release-assets/rocm-libs.json
draft: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload onedir as workflow artifact
uses: actions/upload-artifact@v4
with:
name: voicebox-server-rocm-windows
path: backend/dist/voicebox-server-rocm/
retention-days: 7
BIN
View File
Binary file not shown.
+11
View File
@@ -5,6 +5,17 @@
# Changelog
## [Unreleased]
### Linux
- **ROCm setup works on Linux AMD systems.** Docker ROCm builds now keep PyTorch
on the ROCm wheel index during dependency installation, so later installs do
not replace it with CUDA wheels. The ROCm compose overlay no longer assumes
Ubuntu render/video group IDs; the container joins the groups that own the GPU
device nodes at startup. Native Linux setup now picks ROCm wheels for AMD GPUs
and CUDA wheels for NVIDIA GPUs before installing backend dependencies.
## [0.5.0] - 2026-04-22
**The Capture release.** Voicebox stops being just a voice-cloning studio and becomes a full AI voice studio. Hold a key anywhere on your machine, speak, release — the transcript lands in the focused text field. Flip the primitive around and any MCP-aware agent — Claude Code, Cursor, Spacebot — speaks back through an on-screen pill in one of your cloned voices. A local LLM sits between the two, so transcripts come out clean and voice profiles can carry a personality that reshapes what the agent says before it gets spoken.
+1 -1
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/`.
+39 -9
View File
@@ -1,8 +1,15 @@
# ============================================================
# Voicebox — Local TTS Server with Web UI (CPU)
# Voicebox — Local TTS Server with Web UI
# 3-stage build: Frontend → Python deps → Runtime
#
# Build variants:
# CPU (default): docker compose up --build
# ROCm (AMD GPU): docker compose -f docker-compose.yml -f docker-compose.rocm.yml up --build
# ============================================================
# Top-level ARG so it is visible to all stages.
ARG PYTORCH_VARIANT=cpu
# === Stage 1: Build frontend ===
FROM oven/bun:1 AS frontend
@@ -13,8 +20,11 @@ COPY package.json bun.lock CHANGELOG.md ./
COPY app/ ./app/
COPY web/ ./web/
# Strip workspaces not needed for web build, and fix trailing comma
RUN sed -i '/"tauri"/d; /"landing"/d' package.json && \
# Normalize line endings first (a Windows CRLF checkout would otherwise
# defeat the `-z 's/,\n ]/…/'` match below, since it's LF-anchored), then
# strip workspaces not needed for web build, and fix trailing comma
RUN sed -i 's/\r$//' package.json && \
sed -i '/"tauri"/d; /"landing"/d' package.json && \
sed -i -z 's/,\n ]/\n ]/' package.json
RUN bun install --no-save
# Build frontend (skip tsc — upstream has pre-existing type errors)
@@ -24,6 +34,9 @@ RUN cd web && bunx --bun vite build
# === Stage 2: Build Python dependencies ===
FROM python:3.11-slim AS backend-builder
# Re-declare ARG inside the stage (Docker scoping requirement).
ARG PYTORCH_VARIANT=cpu
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -34,6 +47,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN pip install --no-cache-dir --upgrade pip
COPY backend/requirements.txt .
# ROCm wheel index. Default 6.3 (RDNA1/2/3); set ROCM_VERSION=7.2 for RDNA4.
ARG ROCM_VERSION=6.3
# For ROCm, make the PyTorch ROCm index primary so every install below resolves
# torch to ROCm wheels instead of the default CUDA build.
RUN if [ "$PYTORCH_VARIANT" = "rocm" ]; then \
pip install --no-cache-dir --prefix=/install \
--index-url "https://download.pytorch.org/whl/rocm${ROCM_VERSION}" \
torch torchaudio && \
printf '[global]\nindex-url = https://download.pytorch.org/whl/rocm%s\nextra-index-url = https://pypi.org/simple\n' "$ROCM_VERSION" > /etc/pip.conf; \
fi
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
RUN pip install --no-cache-dir --prefix=/install --no-deps chatterbox-tts
RUN pip install --no-cache-dir --prefix=/install --no-deps hume-tada
@@ -44,16 +70,17 @@ RUN pip install --no-cache-dir --prefix=/install \
# === Stage 3: Runtime ===
FROM python:3.11-slim
# Create non-root user for security
# Create non-root user; the entrypoint joins GPU device groups at runtime.
RUN groupadd -r voicebox && \
useradd -r -g voicebox -m -s /bin/bash voicebox
WORKDIR /app
# Install only runtime system dependencies
# Install only runtime system dependencies (gosu drops root in the entrypoint)
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
curl \
gosu \
&& rm -rf /var/lib/apt/lists/*
# Copy installed Python packages from builder stage
@@ -69,9 +96,6 @@ COPY --from=frontend --chown=voicebox:voicebox /build/web/dist /app/frontend/
RUN mkdir -p /app/data/generations /app/data/profiles /app/data/cache \
&& chown -R voicebox:voicebox /app/data
# Switch to non-root user
USER voicebox
# Expose the API port
EXPOSE 17493
@@ -79,5 +103,11 @@ EXPOSE 17493
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s \
CMD curl -f http://localhost:17493/health || exit 1
# Start the FastAPI server
# Entrypoint joins GPU groups then drops to the voicebox user.
# Normalize CRLF (a Windows checkout otherwise leaves the shebang as
# `#!/bin/sh\r`, which Linux can't resolve — reported as a misleading
# "no such file or directory" even though the file exists).
COPY --chmod=755 scripts/rocm-entrypoint.sh /usr/local/bin/entrypoint.sh
RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "17493"]
+2 -1
View File
@@ -270,7 +270,8 @@ Use cases: agent dev loops (dictate a question, hear the answer in a cloned voic
| Platform | Backend | Notes |
| ------------------------ | -------------- | ---------------------------------------------- |
| macOS (Apple Silicon) | MLX (Metal) | 4-5x faster via Neural Engine |
| Windows / Linux (NVIDIA) | PyTorch (CUDA) | Auto-downloads CUDA binary from within the app |
| Windows (NVIDIA) | PyTorch (CUDA) | Auto-downloads CUDA binary from within the app |
| Linux (NVIDIA) | PyTorch (CUDA) | Use a local/remote Python backend with CUDA PyTorch |
| Linux (AMD) | PyTorch (ROCm) | Auto-configures HSA_OVERRIDE_GFX_VERSION |
| Windows (any GPU) | DirectML | Universal Windows GPU support |
| Intel Arc | IPEX/XPU | Intel discrete GPU acceleration |
@@ -139,7 +139,7 @@ export function EngineModelSelector({ form, compact, selectedProfile }: EngineMo
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectContent side={compact ? 'top' : undefined}>
{availableOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className={itemClass}>
{opt.label}
@@ -555,7 +555,7 @@ export function FloatingGenerateBox({
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all w-full">
<SelectValue placeholder={t('generation.voiceSelector.placeholder')} />
</SelectTrigger>
<SelectContent>
<SelectContent side="top">
{profiles?.map((profile) => (
<SelectItem key={profile.id} value={profile.id} className="text-xs">
{profile.name}
@@ -582,7 +582,7 @@ export function FloatingGenerateBox({
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectContent side="top">
{engineLangs.map((lang) => (
<SelectItem key={lang.value} value={lang.value} className="text-xs">
{lang.label}
@@ -610,7 +610,7 @@ export function FloatingGenerateBox({
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
<SelectValue placeholder={t('generation.effects.none')} />
</SelectTrigger>
<SelectContent>
<SelectContent side="top">
<SelectItem value="none" className="text-xs">
{t('generation.effects.none')}
</SelectItem>
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { apiClient } from '@/lib/api/client';
import type { CudaDownloadProgress } from '@/lib/api/types';
import type { CudaDownloadProgress, RocmDownloadProgress } from '@/lib/api/types';
import { useServerHealth } from '@/lib/hooks/useServer';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
@@ -21,6 +21,9 @@ export function GpuAcceleration() {
const [restartPhase, setRestartPhase] = useState<RestartPhase>('idle');
const [error, setError] = useState<string | null>(null);
const [downloadProgress, setDownloadProgress] = useState<CudaDownloadProgress | null>(null);
const [rocmDownloadProgress, setRocmDownloadProgress] = useState<RocmDownloadProgress | null>(
null,
);
const healthPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Query CUDA backend status
@@ -36,10 +39,26 @@ export function GpuAcceleration() {
enabled: !!health, // Only fetch when backend is reachable
});
// Query ROCm backend status
const {
data: rocmStatus,
isLoading: _rocmStatusLoading,
refetch: refetchRocmStatus,
} = useQuery({
queryKey: ['rocm-status', serverUrl],
queryFn: () => apiClient.getRocmStatus(),
refetchInterval: (query) => (query.state.status === 'pending' ? false : 10000),
retry: 1,
enabled: !!health, // Only fetch when backend is reachable
});
// Derived state
const isCurrentlyCuda = health?.backend_variant === 'cuda';
const isCurrentlyRocm = health?.backend_variant === 'rocm';
const cudaAvailable = cudaStatus?.available ?? false;
const cudaDownloading = cudaStatus?.downloading ?? false;
const rocmAvailable = rocmStatus?.available ?? false;
const rocmDownloading = rocmStatus?.downloading ?? false;
// Clean up health poll on unmount
useEffect(() => {
@@ -51,7 +70,7 @@ export function GpuAcceleration() {
};
}, []);
// SSE progress tracking during download
// SSE progress tracking during CUDA download
useEffect(() => {
if (!cudaDownloading || !serverUrl) {
return;
@@ -88,6 +107,43 @@ export function GpuAcceleration() {
};
}, [cudaDownloading, serverUrl, refetchCudaStatus]);
// SSE progress tracking during ROCm download
useEffect(() => {
if (!rocmDownloading || !serverUrl) {
return;
}
const eventSource = new EventSource(`${serverUrl}/backend/rocm-progress`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as RocmDownloadProgress;
setRocmDownloadProgress(data);
if (data.status === 'complete') {
eventSource.close();
setRocmDownloadProgress(null);
refetchRocmStatus();
} else if (data.status === 'error') {
eventSource.close();
setError(data.error || 'Download failed');
setRocmDownloadProgress(null);
refetchRocmStatus();
}
} catch (e) {
console.error('Error parsing ROCm progress event:', e);
}
};
eventSource.onerror = () => {
eventSource.close();
};
return () => {
eventSource.close();
};
}, [rocmDownloading, serverUrl, refetchRocmStatus]);
// Start aggressive health polling during restart
const startHealthPolling = useCallback(() => {
if (healthPollRef.current) return;
@@ -113,7 +169,7 @@ export function GpuAcceleration() {
}, 1000);
}, [queryClient]);
const handleDownload = async () => {
const handleDownloadCuda = async () => {
setError(null);
try {
await apiClient.downloadCudaBackend();
@@ -128,6 +184,21 @@ export function GpuAcceleration() {
}
};
const handleDownloadRocm = async () => {
setError(null);
try {
await apiClient.downloadRocmBackend();
refetchRocmStatus();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Failed to start download';
if (msg.includes('already downloaded')) {
refetchRocmStatus();
} else {
setError(msg);
}
}
};
const handleRestart = async () => {
setError(null);
setRestartPhase('stopping');
@@ -154,18 +225,17 @@ export function GpuAcceleration() {
}
};
const handleSwitchToCpu = async () => {
// To switch to CPU: delete the CUDA binary, then restart.
// start_server always prefers CUDA if present, so we must remove it first.
const handleSwitchToCpuFromCuda = async () => {
setError(null);
setRestartPhase('stopping');
try {
await apiClient.deleteCudaBackend();
// Tell Rust launcher to skip GPU binary detection on next start.
// We cannot delete an active .exe on Windows, so we override instead.
await platform.lifecycle.setBackendOverride('cpu');
setRestartPhase('waiting');
startHealthPolling();
await platform.lifecycle.restartServer();
// Invoke resolved — server is likely ready
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
@@ -184,7 +254,36 @@ export function GpuAcceleration() {
}
};
const handleDelete = async () => {
const handleSwitchToCpuFromRocm = async () => {
setError(null);
setRestartPhase('stopping');
try {
// Tell Rust launcher to skip GPU binary detection on next start.
// We cannot delete an active .exe on Windows, so we override instead.
await platform.lifecycle.setBackendOverride('cpu');
setRestartPhase('waiting');
startHealthPolling();
await platform.lifecycle.restartServer();
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
}
setRestartPhase('ready');
queryClient.invalidateQueries();
setTimeout(() => setRestartPhase('idle'), 2000);
} catch (e: unknown) {
setRestartPhase('idle');
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
}
setError(e instanceof Error ? e.message : 'Failed to switch to CPU');
refetchRocmStatus();
}
};
const handleDeleteCuda = async () => {
setError(null);
try {
await apiClient.deleteCudaBackend();
@@ -194,6 +293,16 @@ export function GpuAcceleration() {
}
};
const handleDeleteRocm = async () => {
setError(null);
try {
await apiClient.deleteRocmBackend();
refetchRocmStatus();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to delete ROCm backend');
}
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
@@ -205,7 +314,7 @@ export function GpuAcceleration() {
// Don't render until health data is available
if (!health) return null;
// If the system already has native GPU (MPS, etc.), only show info - no CUDA needed
// If the system already has native GPU (MPS, ROCm active, etc.), only show info - no download needed
const hasNativeGpu =
health.gpu_available &&
!isCurrentlyCuda &&
@@ -241,8 +350,6 @@ export function GpuAcceleration() {
)}
</div>
{/* Native GPU detected - no CUDA download needed */}
{/* Currently running CUDA - show switch back to CPU */}
{isCurrentlyCuda && platform.metadata.isTauri && (
<>
@@ -261,7 +368,12 @@ export function GpuAcceleration() {
Running with CUDA GPU acceleration. Switch back to CPU if needed (you can
re-download later).
</p>
<Button onClick={handleSwitchToCpu} variant="outline" className="w-full" size="sm">
<Button
onClick={handleSwitchToCpuFromCuda}
variant="outline"
className="w-full"
size="sm"
>
<RotateCw className="h-4 w-4 mr-2" />
Switch to CPU Backend
</Button>
@@ -276,10 +388,52 @@ export function GpuAcceleration() {
</>
)}
{/* CUDA download/manage section - show when no native GPU and not currently running CUDA */}
{!hasNativeGpu && !isCurrentlyCuda && (
{/* Currently running ROCm - show switch back to CPU */}
{isCurrentlyRocm && platform.metadata.isTauri && (
<>
{/* Download progress (manual download or auto-update) */}
{restartPhase !== 'idle' ? (
<div className="flex items-center gap-2 p-3 rounded-lg bg-primary/5 border">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">
{restartPhase === 'stopping' && 'Stopping server...'}
{restartPhase === 'waiting' && 'Restarting server...'}
{restartPhase === 'ready' && 'Server restarted successfully!'}
</span>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Running with ROCm GPU acceleration for AMD. Switch back to CPU if needed (you can
re-download later).
</p>
<Button
onClick={handleSwitchToCpuFromRocm}
variant="outline"
className="w-full"
size="sm"
>
<RotateCw className="h-4 w-4 mr-2" />
Switch to CPU Backend
</Button>
</div>
)}
{error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
</>
)}
{/* Backend download/manage sections - show when no native GPU and not currently running GPU */}
{!hasNativeGpu && !isCurrentlyCuda && !isCurrentlyRocm && (
<>
{/* CUDA Section */}
<div className="space-y-4">
<div className="text-sm font-medium">NVIDIA (CUDA)</div>
{/* CUDA Download progress */}
{cudaDownloading && downloadProgress && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
@@ -310,6 +464,132 @@ export function GpuAcceleration() {
</div>
)}
{/* CUDA Actions */}
{restartPhase === 'idle' && !cudaDownloading && (
<div className="space-y-2">
{!cudaAvailable && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Download the CUDA backend (~2.4 GB) for NVIDIA GPU acceleration. Requires an
NVIDIA GPU with CUDA support.
</p>
<Button onClick={handleDownloadCuda} className="w-full" size="sm">
<Download className="h-4 w-4 mr-2" />
Download CUDA Backend
</Button>
</div>
)}
{cudaAvailable && platform.metadata.isTauri && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
CUDA backend is downloaded and ready. Restart the server to enable GPU
acceleration.
</p>
<Button onClick={handleRestart} className="w-full" size="sm">
<RotateCw className="h-4 w-4 mr-2" />
Switch to CUDA Backend
</Button>
</div>
)}
{cudaAvailable && (
<Button
onClick={handleDeleteCuda}
variant="ghost"
className="w-full text-muted-foreground hover:text-destructive"
size="sm"
>
<Trash2 className="h-4 w-4 mr-2" />
Remove CUDA Backend
</Button>
)}
</div>
)}
</div>
{/* Divider */}
<div className="border-t" />
{/* ROCm Section */}
<div className="space-y-4">
<div className="text-sm font-medium">AMD (ROCm)</div>
{/* ROCm Download progress */}
{rocmDownloading && rocmDownloadProgress && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>
{rocmDownloadProgress.filename ||
(rocmAvailable
? 'Updating ROCm backend...'
: 'Downloading ROCm backend...')}
</span>
</div>
{rocmDownloadProgress.total > 0 && (
<span className="text-muted-foreground">
{rocmDownloadProgress.progress.toFixed(1)}%
</span>
)}
</div>
{rocmDownloadProgress.total > 0 && (
<>
<Progress value={rocmDownloadProgress.progress} className="h-2" />
<div className="text-xs text-muted-foreground">
{formatBytes(rocmDownloadProgress.current)} /{' '}
{formatBytes(rocmDownloadProgress.total)}
</div>
</>
)}
</div>
)}
{/* ROCm Actions */}
{restartPhase === 'idle' && !rocmDownloading && (
<div className="space-y-2">
{!rocmAvailable && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Download the ROCm backend (~2-3 GB) for AMD GPU acceleration. Requires an
AMD Radeon GPU with ROCm support.
</p>
<Button onClick={handleDownloadRocm} className="w-full" size="sm">
<Download className="h-4 w-4 mr-2" />
Download AMD ROCm Backend
</Button>
</div>
)}
{rocmAvailable && platform.metadata.isTauri && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
ROCm backend is downloaded and ready. Restart the server to enable AMD GPU
acceleration.
</p>
<Button onClick={handleRestart} className="w-full" size="sm">
<RotateCw className="h-4 w-4 mr-2" />
Switch to ROCm Backend
</Button>
</div>
)}
{rocmAvailable && (
<Button
onClick={handleDeleteRocm}
variant="ghost"
className="w-full text-muted-foreground hover:text-destructive"
size="sm"
>
<Trash2 className="h-4 w-4 mr-2" />
Remove ROCm Backend
</Button>
)}
</div>
)}
</div>
{/* Restart in progress */}
{restartPhase !== 'idle' && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-primary/5 border">
@@ -329,52 +609,6 @@ export function GpuAcceleration() {
<span>{error}</span>
</div>
)}
{/* Actions */}
{restartPhase === 'idle' && !cudaDownloading && (
<div className="space-y-2">
{/* Not downloaded yet - show download button */}
{!cudaAvailable && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Download the CUDA backend (~2.4 GB) for NVIDIA GPU acceleration. Requires an
NVIDIA GPU with CUDA support.
</p>
<Button onClick={handleDownload} className="w-full" size="sm">
<Download className="h-4 w-4 mr-2" />
Download CUDA Backend
</Button>
</div>
)}
{/* Downloaded but not active - show switch button */}
{cudaAvailable && platform.metadata.isTauri && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
CUDA backend is downloaded and ready. Restart the server to enable GPU
acceleration.
</p>
<Button onClick={handleRestart} className="w-full" size="sm">
<RotateCw className="h-4 w-4 mr-2" />
Switch to CUDA Backend
</Button>
</div>
)}
{/* Delete option when downloaded (and not active) */}
{cudaAvailable && (
<Button
onClick={handleDelete}
variant="ghost"
className="w-full text-muted-foreground "
size="sm"
>
<Trash2 className="h-4 w-4 mr-2" />
Remove CUDA Backend
</Button>
)}
</div>
)}
</>
)}
</CardContent>
@@ -0,0 +1,151 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Cloud, Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { SettingRow, SettingSection } from './SettingRow';
// "Log in with browser" device pairing. The backend opens the system browser
// and completes the code exchange; here we just kick it off and poll status
// until the link goes live. The API key never touches the frontend.
export function CloudSection() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [polling, setPolling] = useState(false);
const { data: status } = useQuery({
queryKey: ['cloud-status'],
queryFn: () => apiClient.getCloudStatus(),
refetchInterval: polling ? 2000 : false,
});
const connected = status?.connected ?? false;
// Once the browser flow completes, stop polling and celebrate.
useEffect(() => {
if (connected && polling) {
setPolling(false);
toast({
title: 'Connected to Voicebox Cloud',
description: `Linked as ${status?.device_name ?? 'this device'}.`,
});
}
}, [connected, polling, status?.device_name, toast]);
// Give up after two minutes so an abandoned browser flow doesn't leave the
// button stuck on "Waiting for browser…". The backend state stays valid for
// ten, so the user can simply start again.
useEffect(() => {
if (!polling) return;
const timeoutId = window.setTimeout(() => {
setPolling(false);
toast({
title: 'Sign-in timed out',
description: 'The browser sign-in was not completed. Try again.',
variant: 'destructive',
});
}, 120_000);
return () => window.clearTimeout(timeoutId);
}, [polling, toast]);
const startLogin = useMutation({
mutationFn: () => apiClient.startCloudLogin(),
onSuccess: () => {
setPolling(true);
toast({
title: 'Continue in your browser',
description: 'Authorize this device, then return here.',
});
},
onError: (error: Error) =>
toast({
title: 'Could not start sign-in',
description: error.message,
variant: 'destructive',
}),
});
const disconnect = useMutation({
mutationFn: () => apiClient.disconnectCloud(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cloud-status'] });
toast({
title: 'Disconnected',
description:
'This device is no longer linked. The key stays valid until revoked in your account.',
});
},
onError: (error: Error) =>
toast({ title: 'Could not disconnect', description: error.message, variant: 'destructive' }),
});
const busy = startLogin.isPending || polling;
return (
<SettingSection
title="Voicebox Cloud"
description="End-to-end encrypted backup & sync across your devices."
>
<SettingRow
title={connected ? 'Connected' : 'Account'}
description={
connected
? `Linked as ${status?.device_name ?? 'this device'}${
status?.key_prefix ? ` · ${status.key_prefix}…` : ''
}`
: 'Log in to back up and sync your captures and generations.'
}
action={
connected ? (
<Button
disabled={disconnect.isPending}
onClick={() => disconnect.mutate()}
size="sm"
variant="outline"
>
{disconnect.isPending ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
Disconnecting…
</>
) : (
'Disconnect'
)}
</Button>
) : (
<Button disabled={busy} onClick={() => startLogin.mutate()} size="sm">
{busy ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
{polling ? 'Waiting for browser…' : 'Opening…'}
</>
) : (
<>
<Cloud className="h-3.5 w-3.5 mr-1.5" />
Log in with browser
</>
)}
</Button>
)
}
/>
{connected && (
<SettingRow
title="Manage"
description="Revoke this device, add API keys, or manage billing from your account."
>
<a
className="text-sm text-accent hover:underline"
href={status?.dashboard_url ?? 'https://voicebox.sh/account'}
rel="noopener noreferrer"
target="_blank"
>
Open account dashboard ↗
</a>
</SettingRow>
)}
</SettingSection>
);
}
@@ -14,6 +14,7 @@ import { useAutoUpdater } from '@/hooks/useAutoUpdater';
import { useServerHealth } from '@/lib/hooks/useServer';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
import { CloudSection } from './CloudSection';
import { LanguageSelect } from './LanguageSelect';
import { SettingRow, SettingSection } from './SettingRow';
import { ThemeSelect } from './ThemeSelect';
@@ -207,6 +208,8 @@ export function GeneralPage() {
/>
</SettingSection>
<CloudSection />
<ApiReferenceCard serverUrl={serverUrl} />
{platform.metadata.isTauri && <UpdatesSection />}
+247 -30
View File
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { apiClient } from '@/lib/api/client';
import type { CudaDownloadProgress, HealthResponse } from '@/lib/api/types';
import type { CudaDownloadProgress, RocmDownloadProgress, HealthResponse } from '@/lib/api/types';
import { useServerHealth } from '@/lib/hooks/useServer';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
@@ -50,7 +50,10 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
: null;
const gpuBackend = hasGpu ? health.gpu_type!.replace(/\s*\(.+\)$/, '') : null;
const isApple = gpuBackend === 'MPS' || gpuBackend === 'Metal';
const showBackendVariant = health.backend_variant && health.backend_variant !== 'cpu';
const showBackendVariant =
health.backend_variant &&
health.backend_variant !== 'cpu' &&
health.backend_variant.toLowerCase() !== gpuBackend?.toLowerCase();
return (
<div className="rounded-lg border border-border/60 p-4">
@@ -115,10 +118,14 @@ export function GpuPage() {
const [restartPhase, setRestartPhase] = useState<RestartPhase>('idle');
const [error, setError] = useState<string | null>(null);
const [cudaStreaming, setCudaStreaming] = useState(false);
const [rocmStreaming, setRocmStreaming] = useState(false);
const [downloadProgress, setDownloadProgress] = useState<CudaDownloadProgress | null>(null);
const [rocmDownloadProgress, setRocmDownloadProgress] = useState<RocmDownloadProgress | null>(
null,
);
const healthPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Hold the latest `t` in a ref so the CUDA progress SSE effect below doesn't
// tear down and reconnect the EventSource every time the language changes.
const tRef = useRef(t);
useEffect(() => {
tRef.current = t;
@@ -136,9 +143,27 @@ export function GpuPage() {
enabled: !!health,
});
const {
data: rocmStatus,
isLoading: _rocmStatusLoading,
refetch: refetchRocmStatus,
} = useQuery({
queryKey: ['rocm-status', serverUrl],
queryFn: () => apiClient.getRocmStatus(),
refetchInterval: (query) => (query.state.status === 'pending' ? false : 10000),
retry: 1,
enabled: !!health,
});
const isCurrentlyCuda = health?.backend_variant === 'cuda';
const isCurrentlyRocm = health?.backend_variant === 'rocm';
const cudaAvailable = cudaStatus?.available ?? false;
const cudaDownloading = cudaStatus?.downloading ?? false;
const rocmAvailable = rocmStatus?.available ?? false;
const rocmDownloading = rocmStatus?.downloading ?? false;
// The ROCm backend only applies to AMD GPUs on Windows. Show the section when
// the backend detects applicable hardware, or it is already downloaded/active.
const supportsRocm = (health?.supports_rocm ?? false) || rocmAvailable || isCurrentlyRocm;
useEffect(() => {
return () => {
@@ -150,7 +175,7 @@ export function GpuPage() {
}, []);
useEffect(() => {
if (!cudaDownloading || !serverUrl) return;
if ((!cudaDownloading && !cudaStreaming) || !serverUrl) return;
const eventSource = new EventSource(`${serverUrl}/backend/cuda-progress`);
@@ -162,11 +187,13 @@ export function GpuPage() {
if (data.status === 'complete') {
eventSource.close();
setDownloadProgress(null);
setCudaStreaming(false);
refetchCudaStatus();
} else if (data.status === 'error') {
eventSource.close();
setError(data.error || tRef.current('settings.gpu.errors.downloadFailed'));
setDownloadProgress(null);
setCudaStreaming(false);
refetchCudaStatus();
}
} catch (e) {
@@ -176,12 +203,50 @@ export function GpuPage() {
eventSource.onerror = () => {
eventSource.close();
setCudaStreaming(false);
};
return () => {
eventSource.close();
};
}, [cudaDownloading, serverUrl, refetchCudaStatus]);
}, [cudaDownloading, cudaStreaming, serverUrl, refetchCudaStatus]);
useEffect(() => {
if ((!rocmDownloading && !rocmStreaming) || !serverUrl) return;
const eventSource = new EventSource(`${serverUrl}/backend/rocm-progress`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as RocmDownloadProgress;
setRocmDownloadProgress(data);
if (data.status === 'complete') {
eventSource.close();
setRocmDownloadProgress(null);
setRocmStreaming(false);
refetchRocmStatus();
} else if (data.status === 'error') {
eventSource.close();
setError(data.error || tRef.current('settings.gpu.errors.downloadFailed'));
setRocmDownloadProgress(null);
setRocmStreaming(false);
refetchRocmStatus();
}
} catch (e) {
console.error('Error parsing ROCm progress event:', e);
}
};
eventSource.onerror = () => {
eventSource.close();
setRocmStreaming(false);
};
return () => {
eventSource.close();
};
}, [rocmDownloading, rocmStreaming, serverUrl, refetchRocmStatus]);
const clearHealthPolling = useCallback(() => {
if (healthPollRef.current) {
@@ -224,10 +289,11 @@ export function GpuPage() {
[platform, startHealthPolling, clearHealthPolling],
);
const handleDownload = async () => {
const handleDownloadCuda = async () => {
setError(null);
try {
await apiClient.downloadCudaBackend();
setCudaStreaming(true);
refetchCudaStatus();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : t('settings.gpu.errors.downloadStart');
@@ -239,28 +305,64 @@ export function GpuPage() {
}
};
const handleRestart = async () => {
const handleDownloadRocm = async () => {
setError(null);
try {
await restartServerWithPolling(t('settings.gpu.errors.restartFailed'));
await apiClient.downloadRocmBackend();
setRocmStreaming(true);
refetchRocmStatus();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : t('settings.gpu.errors.restartFailed'));
const msg = e instanceof Error ? e.message : t('settings.gpu.errors.downloadStart');
if (msg.includes('already downloaded')) {
refetchRocmStatus();
} else {
setError(msg);
}
}
};
const handleSwitchToCpu = async () => {
setError(null);
setRestartPhase('stopping');
try {
await apiClient.deleteCudaBackend();
await platform.lifecycle.setBackendOverride('cpu');
await restartServerWithPolling(t('settings.gpu.errors.switchCpu'));
} catch (e: unknown) {
setRestartPhase('idle');
setError(e instanceof Error ? e.message : t('settings.gpu.errors.switchCpu'));
refetchCudaStatus();
refetchRocmStatus();
}
};
const handleSwitchToCuda = async () => {
setError(null);
setRestartPhase('stopping');
try {
await platform.lifecycle.setBackendOverride('cuda');
await restartServerWithPolling(t('settings.gpu.errors.restartFailed'));
} catch (e: unknown) {
setRestartPhase('idle');
setError(e instanceof Error ? e.message : t('settings.gpu.errors.restartFailed'));
refetchCudaStatus();
}
};
const handleDelete = async () => {
const handleSwitchToRocm = async () => {
setError(null);
setRestartPhase('stopping');
try {
await platform.lifecycle.setBackendOverride('rocm');
await restartServerWithPolling(t('settings.gpu.errors.restartFailed'));
} catch (e: unknown) {
setRestartPhase('idle');
setError(e instanceof Error ? e.message : t('settings.gpu.errors.restartFailed'));
refetchRocmStatus();
}
};
const handleDeleteCuda = async () => {
setError(null);
try {
await apiClient.deleteCudaBackend();
@@ -270,6 +372,16 @@ export function GpuPage() {
}
};
const handleDeleteRocm = async () => {
setError(null);
try {
await apiClient.deleteRocmBackend();
refetchRocmStatus();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : t('settings.gpu.errors.deleteRocm'));
}
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
@@ -283,6 +395,7 @@ export function GpuPage() {
const hasNativeGpu =
health.gpu_available &&
!isCurrentlyCuda &&
!isCurrentlyRocm &&
health.gpu_type &&
!health.gpu_type.includes('CUDA');
@@ -290,7 +403,8 @@ export function GpuPage() {
<div className="space-y-8 max-w-2xl">
<GpuInfoCard health={health} />
{!hasNativeGpu && !isCurrentlyCuda && (
{!hasNativeGpu && !isCurrentlyCuda && !isCurrentlyRocm && (
<>
<SettingSection
title={t('settings.gpu.cuda.title')}
description={t('settings.gpu.cuda.description')}
@@ -345,7 +459,7 @@ export function GpuPage() {
title={t('settings.gpu.download.title')}
description={t('settings.gpu.download.description')}
action={
<Button onClick={handleDownload} size="sm">
<Button onClick={handleDownloadCuda} size="sm">
<Download className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.download.button')}
</Button>
@@ -358,7 +472,7 @@ export function GpuPage() {
title={t('settings.gpu.switchToCuda.title')}
description={t('settings.gpu.switchToCuda.description')}
action={
<Button onClick={handleRestart} size="sm">
<Button onClick={handleSwitchToCuda} size="sm">
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.switchToCuda.button')}
</Button>
@@ -366,29 +480,16 @@ export function GpuPage() {
/>
)}
{isCurrentlyCuda && platform.metadata.isTauri && (
<SettingRow
title={t('settings.gpu.switchToCpu.title')}
description={t('settings.gpu.switchToCpu.description')}
action={
<Button onClick={handleSwitchToCpu} variant="outline" size="sm">
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.switchToCpu.button')}
</Button>
}
/>
)}
{cudaAvailable && !isCurrentlyCuda && (
<SettingRow
title={t('settings.gpu.remove.title')}
description={t('settings.gpu.remove.description')}
action={
<Button
onClick={handleDelete}
onClick={handleDeleteCuda}
variant="ghost"
size="sm"
className="text-muted-foreground "
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.remove.button')}
@@ -399,6 +500,122 @@ export function GpuPage() {
</>
)}
</SettingSection>
{supportsRocm && (
<SettingSection
title={t('settings.gpu.rocm.title')}
description={t('settings.gpu.rocm.description')}
>
{rocmDownloading && rocmDownloadProgress && (
<SettingRow title={t('settings.gpu.rocm.downloading')}>
<div className="space-y-1.5">
<Progress value={rocmDownloadProgress.progress} className="h-2" />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{rocmDownloadProgress.filename ||
(rocmAvailable
? t('settings.gpu.rocm.updating')
: t('settings.gpu.rocm.downloadingShort'))}
</span>
<span>
{rocmDownloadProgress.total > 0
? `${formatBytes(rocmDownloadProgress.current)} / ${formatBytes(rocmDownloadProgress.total)}`
: `${rocmDownloadProgress.progress.toFixed(1)}%`}
</span>
</div>
</div>
</SettingRow>
)}
{restartPhase === 'idle' && !rocmDownloading && (
<>
{!rocmAvailable && !isCurrentlyRocm && (
<SettingRow
title={t('settings.gpu.downloadRocm.title')}
description={t('settings.gpu.downloadRocm.description')}
action={
<Button onClick={handleDownloadRocm} size="sm">
<Download className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.downloadRocm.button')}
</Button>
}
/>
)}
{rocmAvailable && !isCurrentlyRocm && platform.metadata.isTauri && (
<SettingRow
title={t('settings.gpu.switchToRocm.title')}
description={t('settings.gpu.switchToRocm.description')}
action={
<Button onClick={handleSwitchToRocm} size="sm">
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.switchToRocm.button')}
</Button>
}
/>
)}
{rocmAvailable && !isCurrentlyRocm && (
<SettingRow
title={t('settings.gpu.removeRocm.title')}
description={t('settings.gpu.removeRocm.description')}
action={
<Button
onClick={handleDeleteRocm}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.removeRocm.button')}
</Button>
}
/>
)}
</>
)}
</SettingSection>
)}
</>
)}
{(isCurrentlyCuda || isCurrentlyRocm) && platform.metadata.isTauri && (
<SettingSection
title={isCurrentlyCuda ? t('settings.gpu.cuda.activeTitle') : t('settings.gpu.rocm.activeTitle')}
description={t('settings.gpu.activeBackend.description')}
>
{restartPhase !== 'idle' ? (
<SettingRow
title={
restartPhase === 'ready'
? t('settings.gpu.restart.ready')
: restartPhase === 'waiting'
? t('settings.gpu.restart.waiting')
: t('settings.gpu.restart.stopping')
}
action={<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
/>
) : (
<SettingRow
title={t('settings.gpu.switchToCpu.title')}
description={t('settings.gpu.switchToCpu.description')}
action={
<Button onClick={handleSwitchToCpu} variant="outline" size="sm">
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.switchToCpu.button')}
</Button>
}
/>
)}
{error && (
<SettingRow title={t('common.error')}>
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
</SettingRow>
)}
</SettingSection>
)}
<p className="text-xs text-muted-foreground/60 leading-relaxed">{t('settings.gpu.footer')}</p>
+15
View File
@@ -2,15 +2,25 @@ import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import { initReactI18next } from 'react-i18next';
import en from './locales/en/translation.json';
import es from './locales/es/translation.json';
import fr from './locales/fr/translation.json';
import it from './locales/it/translation.json';
import ja from './locales/ja/translation.json';
import ko from './locales/ko/translation.json';
import ptBR from './locales/pt-BR/translation.json';
import zhCN from './locales/zh-CN/translation.json';
import zhTW from './locales/zh-TW/translation.json';
export const SUPPORTED_LANGUAGES = [
{ code: 'en', label: 'English' },
{ code: 'es', label: 'Español' },
{ code: 'pt-BR', label: 'Português (Brasil)' },
{ code: 'ja', label: '日本語' },
{ code: 'ko', label: '한국어' },
{ code: 'zh-CN', label: '简体中文' },
{ code: 'zh-TW', label: '繁體中文' },
{ code: 'fr', label: 'Français' },
{ code: 'it', label: 'Italiano' },
] as const;
export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]['code'];
@@ -21,9 +31,14 @@ i18n
.init({
resources: {
en: { translation: en },
es: { translation: es },
'pt-BR': { translation: ptBR },
ja: { translation: ja },
ko: { translation: ko },
'zh-CN': { translation: zhCN },
'zh-TW': { translation: zhTW },
fr: { translation: fr },
it: { translation: it },
},
fallbackLng: 'en',
supportedLngs: SUPPORTED_LANGUAGES.map((l) => l.code),
+39 -7
View File
@@ -760,8 +760,13 @@
}
},
"general": {
"docs": { "title": "Read the Docs" },
"discord": { "title": "Join the Discord", "subtitle": "Get help & share voices" },
"docs": {
"title": "Read the Docs"
},
"discord": {
"title": "Join the Discord",
"subtitle": "Get help & share voices"
},
"serverUrl": {
"title": "Server URL",
"description": "The address of your voicebox backend server.",
@@ -1091,11 +1096,15 @@
"active": "Active",
"cuda": {
"title": "CUDA Backend",
"activeTitle": "CUDA Backend Active",
"description": "NVIDIA GPU acceleration via a downloadable CUDA backend.",
"downloading": "Downloading CUDA backend…",
"downloadingShort": "Downloading…",
"updating": "Updating…"
},
"activeBackend": {
"description": "GPU acceleration is currently enabled."
},
"restart": {
"ready": "Server restarted successfully",
"waiting": "Restarting server…",
@@ -1113,10 +1122,9 @@
},
"switchToCpu": {
"title": "Switch to CPU backend",
"description": "Disable GPU acceleration. You can re-download CUDA later.",
"description": "Disable GPU acceleration. You can re-download the GPU backend later.",
"button": "Switch"
},
"remove": {
}, "remove": {
"title": "Remove CUDA backend",
"description": "Delete the downloaded CUDA binary to free disk space.",
"button": "Remove"
@@ -1126,9 +1134,33 @@
"downloadStart": "Failed to start download",
"restartFailed": "Restart failed",
"switchCpu": "Failed to switch to CPU",
"deleteCuda": "Failed to delete CUDA backend"
"deleteCuda": "Failed to delete CUDA backend",
"deleteRocm": "Failed to delete ROCm backend"
},
"footer": "Voicebox automatically detects and uses the best available GPU on your system. On Apple Silicon Macs, the MLX backend runs natively on the Neural Engine and GPU via Metal Performance Shaders (MPS), with no additional setup required. On Windows and Linux with NVIDIA GPUs, you can download an optional CUDA backend for hardware-accelerated inference. AMD ROCm, Intel XPU, and DirectML are also supported where available through PyTorch. When no GPU is detected, Voicebox falls back to CPU — all engines still work, just slower."
"footer": "Voicebox automatically detects and uses the best available GPU on your system. On Apple Silicon Macs, the MLX backend runs natively on the Neural Engine and GPU via Metal Performance Shaders (MPS), with no additional setup required. On Windows, you can download optional CUDA (NVIDIA) or ROCm (AMD) backends for hardware-accelerated inference. Intel XPU and DirectML are also supported where available through PyTorch. When no GPU is detected, Voicebox falls back to CPU — all engines still work, just slower.",
"rocm": {
"title": "AMD ROCm Backend",
"activeTitle": "ROCm Backend Active",
"description": "AMD GPU acceleration via a downloadable ROCm backend.",
"downloading": "Downloading ROCm backend…",
"downloadingShort": "Downloading…",
"updating": "Updating…"
},
"downloadRocm": {
"title": "Download AMD ROCm backend",
"description": "~2-3 GB download. Requires an AMD Radeon GPU with ROCm support.",
"button": "Download"
},
"switchToRocm": {
"title": "Switch to ROCm backend",
"description": "ROCm backend is downloaded and ready. Restart to enable.",
"button": "Restart"
},
"removeRocm": {
"title": "Remove ROCm backend",
"description": "Delete the downloaded ROCm binary to free disk space.",
"button": "Remove"
}
},
"logs": {
"title": "Server Logs",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+35
View File
@@ -20,6 +20,7 @@ import type {
PresetVoice,
PersonalityTextResponse,
ProfileSampleResponse,
RocmStatus,
StoryCreate,
StoryDetailResponse,
StoryItemBatchUpdate,
@@ -50,6 +51,8 @@ import type {
MCPClientBinding,
MCPClientBindingListResponse,
MCPClientBindingUpsert,
CloudLoginStartResponse,
CloudStatus,
} from './types';
function formatErrorDetail(detail: unknown, fallback: string): string {
@@ -693,6 +696,23 @@ class ApiClient {
});
}
// ROCm Backend Management
async getRocmStatus(): Promise<RocmStatus> {
return this.request<RocmStatus>('/backend/rocm-status');
}
async downloadRocmBackend(): Promise<{ message: string; progress_key: string }> {
return this.request<{ message: string; progress_key: string }>('/backend/download-rocm', {
method: 'POST',
});
}
async deleteRocmBackend(): Promise<{ message: string }> {
return this.request<{ message: string }>('/backend/rocm', {
method: 'DELETE',
});
}
// Stories
async listStories(): Promise<StoryResponse[]> {
return this.request<StoryResponse[]>('/stories');
@@ -920,6 +940,21 @@ class ApiClient {
return response.blob();
}
// Cloud (backup & sync) — browser-based device login. startCloudLogin opens
// the system browser server-side; the UI then polls getCloudStatus until the
// backend completes the exchange and the link goes live.
async getCloudStatus(): Promise<CloudStatus> {
return this.request<CloudStatus>('/cloud/status');
}
async startCloudLogin(): Promise<CloudLoginStartResponse> {
return this.request<CloudLoginStartResponse>('/cloud/login/start', { method: 'POST' });
}
async disconnectCloud(): Promise<CloudStatus> {
return this.request<CloudStatus>('/cloud/disconnect', { method: 'POST' });
}
}
export const apiClient = new ApiClient();
+41 -2
View File
@@ -269,7 +269,8 @@ export interface HealthResponse {
gpu_type?: string;
vram_used_mb?: number;
backend_type?: string;
backend_variant?: string; // "cpu" or "cuda"
backend_variant?: string; // "cpu", "cuda", or "rocm"
supports_rocm?: boolean; // AMD GPU on Windows — the ROCm backend is applicable
}
export interface CudaDownloadProgress {
@@ -286,11 +287,34 @@ export interface CudaDownloadProgress {
export interface CudaStatus {
available: boolean; // CUDA binary exists on disk
active: boolean; // Currently running the CUDA binary
binary_path?: string;
binary_path: string | null;
cuda_libs_version: string | null;
download_supported: boolean; // Platform has a matching release asset
unsupported_reason: string | null;
downloading: boolean; // Download in progress
download_progress?: CudaDownloadProgress;
}
export interface RocmDownloadProgress {
model_name: string;
current: number;
total: number;
progress: number;
filename?: string;
status: 'downloading' | 'extracting' | 'complete' | 'error';
timestamp: string;
error?: string;
}
export interface RocmStatus {
available: boolean; // ROCm binary exists on disk
active: boolean; // Currently running the ROCm binary
binary_path?: string;
rocm_libs_version?: string;
downloading: boolean; // Download in progress
download_progress?: RocmDownloadProgress;
}
export interface ModelProgress {
model_name: string;
current: number;
@@ -521,3 +545,18 @@ export interface MCPClientBindingUpsert {
export interface MCPClientBindingListResponse {
items: MCPClientBinding[];
}
/* ─── Cloud (backup & sync) ───────────────────────────────────────────── */
export interface CloudLoginStartResponse {
authorize_url: string;
}
export interface CloudStatus {
connected: boolean;
device_name: string | null;
account_user_id: string | null;
key_prefix: string | null;
connected_at: string | null;
dashboard_url: string;
}
+8 -4
View File
@@ -47,12 +47,14 @@ export function useExportGeneration() {
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
const blob = await apiClient.exportGeneration(generationId);
// Create safe filename from text
// Create safe filename from text. Append a short id so exports of
// similarly-worded generations don't collide on the same filename
// (the first 30 chars are frequently identical).
const safeText = text
.substring(0, 30)
.replace(/[^a-z0-9]/gi, '-')
.toLowerCase();
const filename = `generation-${safeText}.voicebox.zip`;
const filename = `generation-${safeText}-${generationId.substring(0, 8)}.voicebox.zip`;
await platform.filesystem.saveFile(filename, blob, [
{
@@ -73,12 +75,14 @@ export function useExportGenerationAudio() {
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
const blob = await apiClient.exportGenerationAudio(generationId);
// Create safe filename from text
// Create safe filename from text. Append a short id so exports of
// similarly-worded generations don't collide on the same filename
// (the first 30 chars are frequently identical).
const safeText = text
.substring(0, 30)
.replace(/[^a-z0-9]/gi, '-')
.toLowerCase();
const filename = `${safeText}.wav`;
const filename = `${safeText}-${generationId.substring(0, 8)}.wav`;
await platform.filesystem.saveFile(filename, blob, [
{
+17 -12
View File
@@ -1,5 +1,5 @@
import { formatDistance } from 'date-fns';
import { ja, zhCN, zhTW } from 'date-fns/locale';
import { es, fr, ja, zhCN, zhTW } from 'date-fns/locale';
import i18n from '@/i18n';
export function formatDuration(seconds: number): string {
@@ -10,38 +10,43 @@ export function formatDuration(seconds: number): string {
function getDateLocale() {
switch (i18n.language) {
case 'es':
return es;
case 'ja':
return ja;
case 'zh-CN':
return zhCN;
case 'zh-TW':
return zhTW;
case 'fr':
return fr;
default:
return undefined;
}
}
export function formatDate(date: string | Date): string {
let dateObj: Date;
if (typeof date === 'string') {
// Backend timestamps are naive UTC — append `Z` so JS doesn't parse a
// timezone-less date-time string as local time.
function parseServerDate(date: string | Date): Date {
if (typeof date !== 'string') {
return date;
}
const dateStr = date.trim();
if (!dateStr.includes('Z') && !dateStr.match(/[+-]\d{2}:\d{2}$/)) {
dateObj = new Date(`${dateStr}Z`);
} else {
dateObj = new Date(dateStr);
}
} else {
dateObj = date;
return new Date(`${dateStr}Z`);
}
return new Date(dateStr);
}
return formatDistance(dateObj, new Date(), {
export function formatDate(date: string | Date): string {
return formatDistance(parseServerDate(date), new Date(), {
addSuffix: true,
locale: getDateLocale(),
}).replace(/^about /i, '');
}
export function formatAbsoluteDate(date: string | Date): string {
const dateObj = typeof date === 'string' ? new Date(date) : date;
const dateObj = parseServerDate(date);
return dateObj.toLocaleString(i18n.language, {
month: 'short',
day: 'numeric',
+1
View File
@@ -60,6 +60,7 @@ export interface PlatformLifecycle {
stopServer(): Promise<void>;
restartServer(modelsDir?: string | null): Promise<string>;
setKeepServerRunning(keep: boolean): Promise<void>;
setBackendOverride(backend?: string | null): Promise<void>;
setupWindowCloseHandler(): Promise<void>;
subscribeToServerLogs(callback: (entry: ServerLogEntry) => void): () => void;
onServerReady?: () => void;
+63 -1
View File
@@ -3,6 +3,8 @@
import asyncio
import logging
import os
import re
import subprocess
import sys
from contextlib import asynccontextmanager
from pathlib import Path
@@ -36,9 +38,67 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
# AMD GPU environment variables must be set before torch import
# 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
# and the override can cause suboptimal performance or errors.
if not os.environ.get("HSA_OVERRIDE_GFX_VERSION"):
try:
result = subprocess.run(
["rocminfo"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
# Collect all GPUs found in rocminfo output
gfx_versions = []
for line in result.stdout.splitlines():
line_lower = line.lower()
if "gfx" in line_lower:
match = re.search(r"(gfx\d+)", line_lower)
if match:
gfx_versions.append(match.group(1))
if gfx_versions:
# Check if any GPU needs the override (RDNA 2 and older)
# Use the oldest GPU (lowest gfx number) for the decision
try:
gfx_nums = []
for v in gfx_versions:
m = re.search(r"\d+", v)
if m:
gfx_nums.append(int(m.group()))
if gfx_nums:
oldest_num = min(gfx_nums)
oldest_gfx = gfx_versions[gfx_nums.index(oldest_num)]
if oldest_num < 1100:
os.environ["HSA_OVERRIDE_GFX_VERSION"] = "10.3.0"
logger.info(
"AMD GPU detected (%s), setting HSA_OVERRIDE_GFX_VERSION=10.3.0 for compatibility. All GPUs: %s",
oldest_gfx,
", ".join(gfx_versions),
)
else:
logger.info(
"AMD GPU detected (%s), native ROCm support available, skipping HSA_OVERRIDE_GFX_VERSION. All GPUs: %s",
oldest_gfx,
", ".join(gfx_versions),
)
except (ValueError, AttributeError) as e:
logger.info("Could not parse GPU version from rocminfo output: %s", e)
except (FileNotFoundError, subprocess.TimeoutExpired, Exception) as e:
logger.info(
"Could not detect AMD GPU via rocminfo, skipping automatic HSA_OVERRIDE_GFX_VERSION configuration: %s",
e,
)
if not os.environ.get("MIOPEN_LOG_LEVEL"):
os.environ["MIOPEN_LOG_LEVEL"] = "4"
@@ -273,8 +333,10 @@ async def _run_startup(application: FastAPI) -> None:
logger.warning("GPU COMPATIBILITY: %s", _cuda_warning)
from .services.cuda import check_and_update_cuda_binary
from .services.rocm import check_and_update_rocm_binary
create_background_task(check_and_update_cuda_binary())
create_background_task(check_and_update_rocm_binary())
try:
progress_manager = get_progress_manager()
+15
View File
@@ -56,6 +56,7 @@ class ModelConfig:
model_size: str = "default"
size_mb: int = 0
needs_trim: bool = False
retries_runaway: bool = False
supports_instruct: bool = False
languages: list[str] = field(default_factory=lambda: ["en"])
@@ -232,6 +233,10 @@ def _get_qwen_model_configs() -> list[ModelConfig]:
repo_1_7b = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
repo_0_6b = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
# mlx-audio can continue after an EOS miss with silence followed by
# codec noise. Retry only the affected text as smaller chunks.
retries_runaway = backend_type == "mlx"
return [
ModelConfig(
model_name="qwen-tts-1.7B",
@@ -240,6 +245,7 @@ def _get_qwen_model_configs() -> list[ModelConfig]:
hf_repo_id=repo_1_7b,
model_size="1.7B",
size_mb=3500,
retries_runaway=retries_runaway,
supports_instruct=False, # Base model drops instruct silently
languages=["zh", "en", "ja", "ko", "de", "fr", "ru", "pt", "es", "it"],
),
@@ -250,6 +256,7 @@ def _get_qwen_model_configs() -> list[ModelConfig]:
hf_repo_id=repo_0_6b,
model_size="0.6B",
size_mb=1200,
retries_runaway=retries_runaway,
supports_instruct=False,
languages=["zh", "en", "ja", "ko", "de", "fr", "ru", "pt", "es", "it"],
),
@@ -504,6 +511,14 @@ def engine_needs_trim(engine: str) -> bool:
return False
def engine_retries_runaway(engine: str) -> bool:
"""Whether unstable output should be retried in smaller chunks."""
for cfg in get_tts_model_configs():
if cfg.engine == engine:
return cfg.retries_runaway
return False
def engine_has_model_sizes(engine: str) -> bool:
"""Whether this engine supports multiple model sizes (only Qwen currently)."""
configs = [c for c in get_tts_model_configs() if c.engine == engine]
+5
View File
@@ -138,6 +138,11 @@ def check_cuda_compatibility() -> tuple[bool, str | None]:
if not torch.cuda.is_available():
return True, None
# ROCm/HIP uses the cuda frontend but has different architecture names (gfx*).
# Skip NVIDIA-specific compute capability checks on AMD hardware.
if hasattr(torch.version, "hip") and torch.version.hip:
return True, None
major, minor = torch.cuda.get_device_capability(0)
capability = f"{major}.{minor}"
device_name = torch.cuda.get_device_name(0)
+14 -2
View File
@@ -146,7 +146,15 @@ class HumeTadaBackend:
)
# Determine dtype — use bf16 on CUDA/XPU for ~50% memory savings
if device == "cuda" and torch.cuda.is_bf16_supported():
# On ROCm/AMD, torch.cuda.is_bf16_supported() works via the HIP abstraction,
# but we wrap it defensively in case an older build lacks the symbol.
_bf16_ok = False
if device == "cuda":
try:
_bf16_ok = torch.cuda.is_bf16_supported()
except Exception:
_bf16_ok = False
if _bf16_ok:
model_dtype = torch.bfloat16
elif device == "xpu":
# Intel Arc (Alchemist+) supports bf16 natively
@@ -240,8 +248,12 @@ class HumeTadaBackend:
audio = audio.T # (samples, channels) -> (channels, samples)
audio = audio.to(device)
# Encode with forced alignment
# Encode with forced alignment.
# Must run under inference_mode: encoder params still require
# grad by default, and an autograd graph across the DAC/Snake
# stack can balloon VRAM far past the model footprint (#890).
text_arg = [reference_text] if reference_text else None
with torch.inference_mode():
prompt = self.encoder(audio, text=text_arg, sample_rate=sr)
# Serialize EncoderOutput to a dict of CPU tensors for caching
+6 -1
View File
@@ -96,11 +96,16 @@ KOKORO_VOICES = [
("pf_dora", "Dora", "female", "pt"),
("pm_alex", "Alex", "male", "pt"),
("pm_santa", "Santa", "male", "pt"),
# Chinese
# Chinese female
("zf_xiaobei", "Xiaobei", "female", "zh"),
("zf_xiaoni", "Xiaoni", "female", "zh"),
("zf_xiaoxiao", "Xiaoxiao", "female", "zh"),
("zf_xiaoyi", "Xiaoyi", "female", "zh"),
# Chinese male
("zm_yunjian", "Yunjian", "male", "zh"),
("zm_yunxi", "Yunxi", "male", "zh"),
("zm_yunxia", "Yunxia", "male", "zh"),
("zm_yunyang", "Yunyang", "male", "zh"),
]
# Map our ISO language codes to Kokoro lang_code characters
+6 -3
View File
@@ -19,7 +19,6 @@ from .base import (
manual_seed,
model_load_progress,
)
from ..utils.hf_offline_patch import force_offline_if_cached
logger = logging.getLogger(__name__)
@@ -103,7 +102,11 @@ class PyTorchQwenLLMBackend:
with model_load_progress(progress_model_name, is_cached):
logger.info("Loading Qwen3 %s on %s...", model_size, self.device)
with force_offline_if_cached(is_cached, progress_model_name):
# Loads run with the process's default HF_HUB_OFFLINE state.
# Forcing offline for cached models flips process-global state
# and silently switches every concurrent download/load on other
# threads to offline mode (issue #841) — the same regression
# removed app-wide in #524/#530.
self.tokenizer = AutoTokenizer.from_pretrained(repo)
dtype = torch.float16 if self.device in ("cuda", "mps") else torch.float32
self.model = AutoModelForCausalLM.from_pretrained(
@@ -223,7 +226,7 @@ class MLXQwenLLMBackend:
with model_load_progress(progress_model_name, is_cached):
logger.info("Loading Qwen3 %s via MLX...", model_size)
with force_offline_if_cached(is_cached, progress_model_name):
# See the PyTorch loader comment — no offline forcing (issue #841).
loaded = mlx_load(repo)
# mlx_lm.load returns (model, tokenizer) by default and
+228 -30
View File
@@ -22,24 +22,34 @@ def is_apple_silicon():
return platform.system() == "Darwin" and platform.machine() == "arm64"
def build_server(cuda=False):
def build_server(cuda=False, rocm=False):
"""Build Python server as standalone binary.
Args:
cuda: If True, build with CUDA support and name the binary
voicebox-server-cuda instead of voicebox-server.
rocm: If True, build with ROCm support and name the binary
voicebox-server-rocm instead of voicebox-server.
"""
if cuda and rocm:
raise ValueError("Cannot build with both CUDA and ROCm support")
backend_dir = Path(__file__).parent
binary_name = "voicebox-server-cuda" if cuda else "voicebox-server"
if rocm:
binary_name = "voicebox-server-rocm"
elif cuda:
binary_name = "voicebox-server-cuda"
else:
binary_name = "voicebox-server"
# PyInstaller arguments
# CUDA builds use --onedir so we can split the output into two archives:
# CUDA and ROCm builds use --onedir so we can split the output into two archives:
# 1. Server core (~200-400MB) — versioned with the app
# 2. CUDA libs (~2GB) — versioned independently (only redownloaded on
# CUDA toolkit / torch major version changes)
# 2. GPU libs (~2GB) — versioned independently (only redownloaded on
# GPU toolkit / torch major version changes)
# CPU builds remain --onefile for simplicity.
pack_mode = "--onedir" if cuda else "--onefile"
pack_mode = "--onedir" if (cuda or rocm) else "--onefile"
args = [
"server.py", # Use server.py as entry point instead of main.py
pack_mode,
@@ -320,22 +330,77 @@ def build_server(cuda=False):
]
)
# Add CUDA-specific hidden imports
if cuda:
logger.info("Building with CUDA support")
args.extend(
[
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"
logger.info("Building with %s support", variant)
gpu_hidden = [
"--hidden-import",
"torch.cuda",
]
# cudnn is NVIDIA-specific; ROCm uses MIOpen under the abstraction layer
if cuda:
gpu_hidden.extend(
[
"--hidden-import",
"torch.backends.cudnn",
]
)
else:
# Exclude NVIDIA CUDA packages from CPU-only builds to keep binary small.
args.extend(gpu_hidden)
if rocm:
# rocm_sdk imports its backend packages dynamically via
# importlib.import_module(py_package_name), which PyInstaller's
# static analyzer cannot see. We must collect them explicitly —
# otherwise only the pure-python rocm_sdk wrapper ships and
# rocm_sdk.find_libraries crashes with UnboundLocalError at boot.
#
# The backend packages also contain the HIP/MIOpen/hipBLAS DLLs
# under bin/ (plus ~750 MB of tensile kernel files under
# bin/rocblas/library and bin/hipblaslt/library) — collect-all
# walks the tree recursively so both DLLs and kernel data are
# bundled. See rocm_sdk/_dist_info.py for the package mapping.
args.extend(
[
"--collect-all",
"rocm_sdk",
"--collect-all",
"_rocm_sdk_core",
"--collect-all",
"_rocm_sdk_libraries_custom",
"--collect-all",
"rocm_sdk_core",
"--collect-all",
"rocm_sdk_libraries_custom",
"--hidden-import",
"_rocm_sdk_core",
"--hidden-import",
"_rocm_sdk_libraries_custom",
"--hidden-import",
"rocm_sdk_core",
"--hidden-import",
"rocm_sdk_libraries_custom",
"--copy-metadata",
"rocm",
"--copy-metadata",
"rocm-sdk-core",
"--copy-metadata",
"rocm-sdk-libraries-custom",
# Repair rocm_sdk.find_libraries (masks UnboundLocalError
# with a readable ModuleNotFoundError on missing backends).
"--runtime-hook",
"pyi_rth_rocm_sdk.py",
]
)
# Exclude NVIDIA CUDA packages from non-CUDA builds to keep binary small.
# When building from a venv with CUDA torch installed, PyInstaller would
# bundle ~3GB of NVIDIA shared libraries. We exclude both the Python
# modules and the binary DLLs.
# modules and the binary DLLs. This applies to CPU and ROCm builds.
if not cuda:
nvidia_packages = [
"nvidia",
"nvidia.cublas",
@@ -354,8 +419,8 @@ def build_server(cuda=False):
for pkg in nvidia_packages:
args.extend(["--exclude-module", pkg])
# Add MLX-specific imports if building on Apple Silicon (never for CUDA builds)
if is_apple_silicon() and not cuda:
# Add MLX-specific imports if building on Apple Silicon (never for GPU builds)
if is_apple_silicon() and not cuda and not rocm:
logger.info("Building for Apple Silicon - including MLX dependencies")
args.extend(
[
@@ -399,7 +464,7 @@ def build_server(cuda=False):
"mlx_lm",
]
)
elif not cuda:
elif not cuda and not rocm:
logger.info("Building for non-Apple Silicon platform - PyTorch only")
dist_dir = str(backend_dir / "dist")
@@ -420,18 +485,23 @@ def build_server(cuda=False):
os.chdir(backend_dir)
# For CPU builds on Windows, ensure we're using CPU-only torch.
# If CUDA torch is installed (local dev), swap to CPU torch before building,
# then restore CUDA torch after. This prevents PyInstaller from bundling
# ~3GB of CUDA DLLs into the CPU binary.
restore_cuda = False
if not cuda and platform.system() == "Windows":
# If CUDA or ROCm torch is installed (local dev), swap to CPU torch before
# building, then restore afterwards. This prevents PyInstaller from bundling
# GPU libraries into the CPU binary.
restore_torch = None
try:
if not cuda and not rocm and platform.system() == "Windows":
import subprocess
result = subprocess.run(
cuda_result = subprocess.run(
[sys.executable, "-c", "import torch; print(torch.version.cuda or '')"], capture_output=True, text=True
)
has_cuda_torch = bool(result.stdout.strip())
if has_cuda_torch:
rocm_result = subprocess.run(
[sys.executable, "-c", "import torch; print(torch.version.hip or '')"], capture_output=True, text=True
)
if cuda_result.stdout.strip():
restore_torch = "cuda"
logger.info("CUDA torch detected — installing CPU torch for CPU build...")
subprocess.run(
[
@@ -445,18 +515,98 @@ def build_server(cuda=False):
"--index-url",
"https://download.pytorch.org/whl/cpu",
"--force-reinstall",
"--no-deps",
"-q",
],
check=True,
)
elif rocm_result.stdout.strip():
restore_torch = "rocm"
logger.info("ROCm torch detected — installing CPU torch for CPU build...")
subprocess.run(
[
sys.executable,
"-m",
"pip",
"install",
"torch",
"torchvision",
"torchaudio",
"--index-url",
"https://download.pytorch.org/whl/cpu",
"--force-reinstall",
"--no-deps",
"-q",
],
check=True,
)
# For ROCm builds on Windows, ensure ROCm torch is installed.
if rocm and platform.system() == "Windows":
import subprocess
if sys.implementation.name != "cpython" or sys.version_info[:2] != (3, 12):
raise RuntimeError(
"ROCm wheels are cp312-cp312-specific; "
f"got {sys.implementation.name} {sys.version.split()[0]}. "
"Use CPython 3.12 to build the ROCm binary."
)
result = subprocess.run(
[sys.executable, "-c", "import torch; print(torch.version.hip or '')"], capture_output=True, text=True
)
has_rocm_torch = bool(result.stdout.strip())
if not has_rocm_torch:
logger.info("ROCm torch not detected — installing ROCm torch for ROCm build...")
# Determine what to restore BEFORE overwriting the environment
cuda_result = subprocess.run(
[sys.executable, "-c", "import torch; print(torch.version.cuda or '')"],
capture_output=True,
text=True,
)
if cuda_result.stdout.strip():
restore_torch = "cuda"
else:
restore_torch = "cpu"
# Now overwrite the environment safely
subprocess.run(
[
sys.executable,
"-m",
"pip",
"install",
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/rocm_sdk_core-7.2.1-py3-none-win_amd64.whl",
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/rocm_sdk_devel-7.2.1-py3-none-win_amd64.whl",
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/rocm_sdk_libraries_custom-7.2.1-py3-none-win_amd64.whl",
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/rocm-7.2.1.tar.gz",
"--no-deps",
"-q",
],
check=True,
)
subprocess.run(
[
sys.executable,
"-m",
"pip",
"install",
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/torch-2.9.1%2Brocm7.2.1-cp312-cp312-win_amd64.whl",
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/torchaudio-2.9.1%2Brocm7.2.1-cp312-cp312-win_amd64.whl",
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/torchvision-0.24.1%2Brocm7.2.1-cp312-cp312-win_amd64.whl",
"--force-reinstall",
"--no-deps",
"-q",
],
check=True,
)
restore_cuda = True
# Run PyInstaller
try:
PyInstaller.__main__.run(args)
finally:
# Restore CUDA torch if we swapped it out (even on build failure)
if restore_cuda:
# Restore torch if we swapped it out (even on build failure)
if restore_torch == "cuda":
logger.info("Restoring CUDA torch...")
import subprocess
@@ -472,10 +622,52 @@ def build_server(cuda=False):
"--index-url",
"https://download.pytorch.org/whl/cu128",
"--force-reinstall",
"--no-deps",
"-q",
],
check=True,
)
elif restore_torch == "rocm":
logger.info("Restoring ROCm torch...")
import subprocess
subprocess.run(
[
sys.executable,
"-m",
"pip",
"install",
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/torch-2.9.1%2Brocm7.2.1-cp312-cp312-win_amd64.whl",
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/torchaudio-2.9.1%2Brocm7.2.1-cp312-cp312-win_amd64.whl",
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/torchvision-0.24.1%2Brocm7.2.1-cp312-cp312-win_amd64.whl",
"--force-reinstall",
"--no-deps",
"-q",
],
check=True,
)
elif restore_torch == "cpu":
logger.info("Restoring CPU torch...")
import subprocess
subprocess.run(
[
sys.executable,
"-m",
"pip",
"install",
"torch",
"torchvision",
"torchaudio",
"--index-url",
"https://download.pytorch.org/whl/cpu",
"--force-reinstall",
"--no-deps",
"-q",
],
check=True,
)
logger.info("Binary built in %s", backend_dir / "dist" / binary_name)
@@ -577,6 +769,11 @@ if __name__ == "__main__":
action="store_true",
help="Build CUDA-enabled binary (voicebox-server-cuda)",
)
parser.add_argument(
"--rocm",
action="store_true",
help="Build ROCm-enabled binary (voicebox-server-rocm) for AMD GPUs",
)
parser.add_argument(
"--shim",
action="store_true",
@@ -586,4 +783,5 @@ if __name__ == "__main__":
if cli_args.shim:
build_shim()
else:
build_server(cuda=cli_args.cuda)
build_server(cuda=cli_args.cuda, rocm=cli_args.rocm)
+19
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:
@@ -138,3 +143,17 @@ def get_models_dir() -> Path:
path = _data_dir / "models"
path.mkdir(parents=True, exist_ok=True)
return path
# Voicebox Cloud (backup & sync). Two hosts: the web app owns auth + device
# pairing (voicebox.sh), the API owns sync + account endpoints
# (api.voicebox.sh). Override both for local development, e.g.
# VOICEBOX_CLOUD_URL=http://localhost:17592 VOICEBOX_CLOUD_API_URL=http://localhost:17593
def get_cloud_web_url() -> str:
"""Base URL of the Voicebox Cloud web app (auth + /connect + exchange)."""
return os.environ.get("VOICEBOX_CLOUD_URL", "https://voicebox.sh").rstrip("/")
def get_cloud_api_url() -> str:
"""Base URL of the Voicebox Cloud API (bearer-authenticated sync/account)."""
return os.environ.get("VOICEBOX_CLOUD_API_URL", "https://api.voicebox.sh").rstrip("/")
+2
View File
@@ -11,6 +11,7 @@ from .models import (
Capture,
CaptureSettings,
ChannelDeviceMapping,
CloudSettings,
EffectPreset,
Generation,
GenerationSettings,
@@ -32,6 +33,7 @@ __all__ = [
"Capture",
"CaptureSettings",
"ChannelDeviceMapping",
"CloudSettings",
"EffectPreset",
"Generation",
"GenerationSettings",
+22
View File
@@ -234,6 +234,28 @@ class GenerationSettings(Base):
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class CloudSettings(Base):
"""Singleton row holding the link to a Voicebox Cloud account.
Populated by the "Log in with browser" pairing flow (see services/cloud.py):
the browser hands back a one-time code, which the backend exchanges for an
``api_key`` it stores here. The key is a bearer credential for
api.voicebox.sh — auth only, never an encryption key (E2E key material lives
elsewhere). Stored in the local app database alongside the user's other data;
moving it to the OS keychain is a future hardening step. The ``id`` is
always 1; a null ``api_key`` means "not connected".
"""
__tablename__ = "cloud_settings"
id = Column(Integer, primary_key=True, default=1)
api_key = Column(String, nullable=True)
device_name = Column(String, nullable=True)
account_user_id = Column(String, nullable=True)
connected_at = Column(DateTime, nullable=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class MCPClientBinding(Base):
"""Per-MCP-client settings (voice profile, engine, personality default).
+14 -1
View File
@@ -12,7 +12,7 @@ import base64 as b64
import logging
import tempfile
from pathlib import Path
from typing import Any
from typing import Any, Literal
from fastmcp import FastMCP
@@ -49,6 +49,7 @@ def register_tools(mcp: FastMCP) -> None:
engine: str | None = None,
personality: bool | None = None,
language: str | None = None,
model_size: Literal["1.7B", "0.6B", "1B", "3B"] | None = None,
) -> dict[str, Any]:
"""Speak ``text`` in a voice profile.
@@ -61,6 +62,12 @@ def register_tools(mcp: FastMCP) -> None:
LLM before TTS. When omitted, the per-client binding's
``default_personality`` flag decides; when that is unset, the
default is plain TTS.
``model_size`` selects a model variant for engines that ship more
than one — ``qwen`` and ``qwen_custom_voice`` accept "1.7B" (default)
or "0.6B"; ``tada`` accepts "1B" or "3B". Other engines ignore it.
Omit to use the engine default. Requesting a smaller variant (e.g.
"0.6B") is faster and avoids reloading a heavier model between calls.
"""
from ..database.models import MCPClientBinding
@@ -99,6 +106,7 @@ def register_tools(mcp: FastMCP) -> None:
engine=resolved_engine,
language=language,
personality=use_persona,
model_size=model_size,
db=db,
)
finally:
@@ -228,18 +236,23 @@ async def _speak(
engine: str | None,
language: str | None,
personality: bool,
model_size: str | None = None,
db,
) -> dict[str, Any]:
"""Delegate to POST /generate — the route handles personality-rewrite
internally when ``personality=true`` and the profile has a prompt."""
from ..routes.generations import generate_speech
# model_size=None is intentional: generate_speech normalizes it to the
# engine default (see routes/generations.py), so an omitted size behaves
# exactly like the REST /generate endpoint with no model_size in the body.
req = models.GenerationRequest(
profile_id=profile_id,
text=text,
language=language or "en",
engine=engine,
personality=personality,
model_size=model_size,
)
generation = await generate_speech(req, db)
return _speak_response(generation, profile_name, source="mcp")
+23 -1
View File
@@ -442,7 +442,8 @@ class HealthResponse(BaseModel):
gpu_type: Optional[str] = None # GPU type (CUDA, MPS, or None)
vram_used_mb: Optional[float] = None
backend_type: Optional[str] = None # Backend type (mlx or pytorch)
backend_variant: Optional[str] = None # Binary variant (cpu or cuda)
backend_variant: Optional[str] = None # Binary variant (cpu, cuda, or rocm)
supports_rocm: bool = False # AMD GPU on Windows — the ROCm backend is applicable
gpu_compatibility_warning: Optional[str] = None # Warning if GPU arch unsupported
@@ -793,3 +794,24 @@ class AvailableEffectsResponse(BaseModel):
"""Response listing all available effect types."""
effects: List[AvailableEffect]
# ─── Cloud (backup & sync) ──────────────────────────────────────────────
class CloudLoginStartResponse(BaseModel):
"""Returned when the desktop kicks off browser login. The backend has
already opened the browser; the URL is included for fallback/debugging."""
authorize_url: str
class CloudStatusResponse(BaseModel):
"""Current link between this device and a Voicebox Cloud account."""
connected: bool
device_name: Optional[str] = None
account_user_id: Optional[str] = None
key_prefix: Optional[str] = None
connected_at: Optional[datetime] = None
dashboard_url: str
+85
View File
@@ -0,0 +1,85 @@
"""
Runtime hook: repair rocm_sdk.find_libraries under PyInstaller.
rocm_sdk 7.2.x ships a find_libraries() with a latent bug: when the
backend package (_rocm_sdk_core / _rocm_sdk_libraries_{target}) cannot
be imported, the except clause records the miss but falls through to
`py_root = Path(py_module.__file__).parent`, where py_module was never
assigned. This surfaces as UnboundLocalError instead of the intended
ModuleNotFoundError, masking the real cause.
Frozen apps trip this because rocm_sdk imports the backend packages
dynamically via importlib, which PyInstaller's static analyzer cannot
see. We re-collect those packages in build_binary.py; this hook is
defense-in-depth: it replaces find_libraries with a corrected version
so any future missing-package case surfaces a readable error.
"""
def _patch_rocm_sdk():
try:
import rocm_sdk
from rocm_sdk import _dist_info
except ModuleNotFoundError as e:
if e.name not in {"rocm_sdk", "rocm_sdk._dist_info"}:
raise
return
import importlib
import platform
from pathlib import Path
def find_libraries(*shortnames):
paths = []
missing_extras = set()
is_windows = platform.system() == "Windows"
for shortname in shortnames:
try:
lib_entry = _dist_info.ALL_LIBRARIES[shortname]
except KeyError:
raise ModuleNotFoundError(f"Unknown rocm library '{shortname}'") from None
if is_windows and not lib_entry.dll_pattern:
continue
package = lib_entry.package
target_family = None
if package.is_target_specific:
target_family = _dist_info.determine_target_family()
py_package_name = package.get_py_package_name(target_family)
try:
py_module = importlib.import_module(py_package_name)
except ModuleNotFoundError as e:
if e.name != py_package_name:
raise
missing_extras.add(package.logical_name)
continue
py_root = Path(py_module.__file__).parent
if is_windows:
relpath = py_root / lib_entry.windows_relpath
entry_pattern = lib_entry.dll_pattern
else:
relpath = py_root / lib_entry.posix_relpath
entry_pattern = lib_entry.so_pattern
matching_paths = sorted(relpath.glob(entry_pattern))
if len(matching_paths) == 0:
raise FileNotFoundError(
f"Could not find rocm library '{shortname}' at path "
f"'{relpath},' no match for pattern '{entry_pattern}'"
)
paths.append(matching_paths[0])
if missing_extras:
raise ModuleNotFoundError(
f"Missing required rocm backend packages: "
f"{', '.join(sorted(missing_extras))}. The frozen build did "
f"not bundle _rocm_sdk_core / _rocm_sdk_libraries_<target>. "
f"Check build_binary.py --collect-all flags."
)
return paths
rocm_sdk.find_libraries = find_libraries
_patch_rocm_sdk()
+2 -1
View File
@@ -16,7 +16,8 @@ miniaudio>=1.59
# mlx_audio.stt.load) works fine on transformers 4.57.x in practice.
#
# Install it via `pip install --no-deps mlx-audio==0.4.1` after this file
# (see .github/workflows/release.yml). Most other mlx-audio runtime deps
# (see .github/workflows/release.yml and the setup-python recipe in the
# justfile). Most other mlx-audio runtime deps
# (huggingface_hub, librosa, mlx-lm, numba, numpy, protobuf, pyloudnorm,
# sounddevice, tqdm) are already in requirements.txt or pulled in by
# other engines.
+4
View File
@@ -0,0 +1,4 @@
--extra-index-url https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/
torch==2.9.1+rocm7.2.1
torchaudio==2.9.1+rocm7.2.1
torchvision==0.24.1+rocm7.2.1
+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
+4
View File
@@ -20,9 +20,11 @@ def register_routers(app: FastAPI) -> None:
from .settings import router as settings_router
from .tasks import router as tasks_router
from .cuda import router as cuda_router
from .rocm import router as rocm_router
from .speak import router as speak_router
from .mcp_bindings import router as mcp_bindings_router
from .events import router as events_router
from .cloud import router as cloud_router
app.include_router(health_router)
app.include_router(profiles_router)
@@ -39,6 +41,8 @@ def register_routers(app: FastAPI) -> None:
app.include_router(settings_router)
app.include_router(tasks_router)
app.include_router(cuda_router)
app.include_router(rocm_router)
app.include_router(speak_router)
app.include_router(mcp_bindings_router)
app.include_router(events_router)
app.include_router(cloud_router)
+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(
+75
View File
@@ -0,0 +1,75 @@
"""Voicebox Cloud device login routes.
The browser-based pairing flow:
1. POST /cloud/login/start — opens the browser to the cloud authorize page.
2. GET /cloud/callback — the browser lands here with a one-time code;
the backend exchanges it for an API key.
3. GET /cloud/status — the UI polls this to learn when it connected.
4. POST /cloud/disconnect — forget the local credential.
"""
import socket
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from .. import models
from ..database import get_db
from ..services import cloud as cloud_service
router = APIRouter(prefix="/cloud", tags=["cloud"])
def _callback_url(request: Request) -> str:
# Always loopback — the cloud only redirects codes to 127.0.0.1/localhost.
port = request.url.port or 17493
return f"http://127.0.0.1:{port}/cloud/callback"
@router.post("/login/start", response_model=models.CloudLoginStartResponse)
async def start_cloud_login(request: Request):
device_name = socket.gethostname() or "Desktop"
authorize_url = cloud_service.start_login(_callback_url(request), device_name)
return models.CloudLoginStartResponse(authorize_url=authorize_url)
@router.get("/callback", response_class=HTMLResponse)
async def cloud_callback(
request: Request,
code: str = "",
state: str = "",
db: Session = Depends(get_db),
):
ok, message = await cloud_service.handle_callback(db, code=code, state=state)
heading = "You're connected" if ok else "Couldn't connect"
accent = "#16a34a" if ok else "#dc2626"
sub = (
"Voicebox is now linked to your account. You can close this tab and return to the app."
if ok
else message
)
html = f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Voicebox Cloud</title>
<style>
body {{ margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; background:#0b0b0d; color:#e7e7ea; }}
.card {{ max-width:28rem; padding:2.5rem; text-align:center; }}
h1 {{ font-size:1.5rem; margin:0 0 .5rem; color:{accent}; }}
p {{ color:#a1a1aa; line-height:1.5; }}
</style></head>
<body><div class="card"><h1>{heading}</h1><p>{sub}</p></div></body></html>"""
return HTMLResponse(content=html, status_code=200 if ok else 400)
@router.get("/status", response_model=models.CloudStatusResponse)
async def cloud_status(db: Session = Depends(get_db)):
return models.CloudStatusResponse(**cloud_service.get_status(db))
@router.post("/disconnect", response_model=models.CloudStatusResponse)
async def cloud_disconnect(db: Session = Depends(get_db)):
cloud_service.disconnect(db)
return models.CloudStatusResponse(**cloud_service.get_status(db))
+4
View File
@@ -26,6 +26,10 @@ async def download_cuda_backend():
"""Download the CUDA backend binary."""
from ..services import cuda
unsupported_reason = cuda.get_cuda_download_unsupported_reason()
if unsupported_reason:
raise HTTPException(status_code=409, detail=unsupported_reason)
if cuda.get_cuda_binary_path() is not None:
raise HTTPException(status_code=409, detail="CUDA backend already downloaded")
+13 -1
View File
@@ -321,7 +321,13 @@ async def stream_speech(
db: Session = Depends(get_db),
):
"""Generate speech and stream the WAV audio directly without saving to disk."""
from ..backends import get_tts_backend_for_engine, ensure_model_cached_or_raise, load_engine_model, engine_needs_trim
from ..backends import (
engine_needs_trim,
engine_retries_runaway,
ensure_model_cached_or_raise,
get_tts_backend_for_engine,
load_engine_model,
)
profile = await profiles.get_profile(data.profile_id, db)
if not profile:
@@ -347,10 +353,15 @@ async def stream_speech(
from ..utils.chunked_tts import generate_chunked
trim_fn = None
runaway_detector = None
if engine_needs_trim(engine):
from ..utils.audio import trim_tts_output
trim_fn = trim_tts_output
if engine_retries_runaway(engine):
from ..utils.audio import has_tts_runaway
runaway_detector = has_tts_runaway
audio, sample_rate = await generate_chunked(
tts_model,
@@ -362,6 +373,7 @@ async def stream_speech(
max_chunk_chars=data.max_chunk_chars,
crossfade_ms=data.crossfade_ms,
trim_fn=trim_fn,
runaway_detector=runaway_detector,
)
effects_chain_config = None
+15 -5
View File
@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
from .. import config, models
from ..services import tts
from ..database import get_db
from ..utils.platform_detect import get_backend_type
from ..utils.platform_detect import get_backend_type, is_amd_gpu_windows
router = APIRouter()
@@ -103,6 +103,9 @@ async def health():
gpu_type = None
if has_cuda:
if hasattr(torch.version, "hip") and torch.version.hip:
gpu_type = f"ROCm ({torch.cuda.get_device_name(0)})"
else:
gpu_type = f"CUDA ({torch.cuda.get_device_name(0)})"
elif has_mps:
gpu_type = "MPS (Apple Silicon)"
@@ -164,6 +167,15 @@ async def health():
except Exception:
pass
default_variant = "cpu"
if has_cuda:
if hasattr(torch.version, "hip") and torch.version.hip:
default_variant = "rocm"
else:
default_variant = "cuda"
elif has_xpu:
default_variant = "xpu"
return models.HealthResponse(
status="healthy",
model_loaded=model_loaded,
@@ -173,10 +185,8 @@ async def health():
gpu_type=gpu_type,
vram_used_mb=vram_used,
backend_type=backend_type,
backend_variant=os.environ.get(
"VOICEBOX_BACKEND_VARIANT",
"cuda" if torch.cuda.is_available() else ("xpu" if has_xpu else "cpu"),
),
backend_variant=os.environ.get("VOICEBOX_BACKEND_VARIANT", default_variant),
supports_rocm=is_amd_gpu_windows(),
gpu_compatibility_warning=gpu_compat_warning,
)
+6 -2
View File
@@ -151,7 +151,9 @@ async def export_generation(
safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (" ", "-", "_")).strip()
if not safe_text:
safe_text = "generation"
filename = f"generation-{safe_text}.voicebox.zip"
# Append a short id so exports of similarly-worded generations don't collide
# on the same filename (the first 30 chars are frequently identical).
filename = f"generation-{safe_text}-{generation_id[:8]}.voicebox.zip"
return StreamingResponse(
io.BytesIO(zip_bytes),
@@ -180,7 +182,9 @@ async def export_generation_audio(
safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (" ", "-", "_")).strip()
if not safe_text:
safe_text = "generation"
filename = f"{safe_text}.wav"
# Append a short id so exports of similarly-worded generations don't collide
# on the same filename (the first 30 chars are frequently identical).
filename = f"{safe_text}-{generation_id[:8]}.wav"
return FileResponse(
audio_path,
+4 -1
View File
@@ -231,7 +231,10 @@ async def get_model_status():
backend_type = get_backend_type()
task_manager = get_task_manager()
active_download_names = {task.model_name for task in task_manager.get_active_downloads()}
# Pending only — an errored task stays in the active list for the
# error/retry UI, but reporting it as "downloading" here would mask
# the model's real cache state until the app restarts (issue #925).
active_download_names = {task.model_name for task in task_manager.get_pending_downloads()}
try:
from huggingface_hub import scan_cache_dir
+1 -1
View File
@@ -232,7 +232,7 @@ async def upload_profile_avatar(
db: Session = Depends(get_db),
):
"""Upload or update avatar image for a profile."""
with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename).suffix) as tmp:
with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename or "").suffix) as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name
+79
View File
@@ -0,0 +1,79 @@
"""ROCm backend management endpoints."""
import logging
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from ..services.task_queue import create_background_task
from ..utils.progress import get_progress_manager
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/backend/rocm-status")
async def get_rocm_status():
"""Get ROCm backend download/availability status."""
from ..services import rocm
return rocm.get_rocm_status()
@router.post("/backend/download-rocm")
async def download_rocm_backend():
"""Download the ROCm backend binary."""
from ..services import rocm
progress_manager = get_progress_manager()
existing = progress_manager.get_progress(rocm.PROGRESS_KEY)
if existing and existing.get("status") in {"downloading", "extracting"}:
raise HTTPException(status_code=409, detail="ROCm backend download already in progress")
async def _download():
try:
await rocm.download_rocm_binary()
except Exception as e:
logger.error("ROCm download failed: %s", e)
create_background_task(_download())
return {"message": "ROCm backend download started", "progress_key": rocm.PROGRESS_KEY}
@router.delete("/backend/rocm")
async def delete_rocm_backend():
"""Delete the downloaded ROCm backend binary."""
from ..services import rocm
if rocm.is_rocm_active():
raise HTTPException(
status_code=409,
detail="Cannot delete ROCm backend while it is active. Switch to CPU first.",
)
deleted = await rocm.delete_rocm_binary()
if not deleted:
raise HTTPException(status_code=404, detail="No ROCm backend found to delete")
return {"message": "ROCm backend deleted"}
@router.get("/backend/rocm-progress")
async def get_rocm_download_progress():
"""Get ROCm backend download progress via Server-Sent Events."""
progress_manager = get_progress_manager()
async def event_generator():
async for event in progress_manager.subscribe("rocm-backend"):
yield event
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
+24 -3
View File
@@ -15,6 +15,10 @@ router = APIRouter()
UPLOAD_CHUNK_SIZE = 1024 * 1024 # 1MB
# Same set profiles.py accepts for voice samples. librosa picks its decoder from the
# file extension, so the temp file has to keep the uploaded one.
ALLOWED_AUDIO_EXTS = {".wav", ".mp3", ".m4a", ".ogg", ".flac", ".aac", ".webm", ".opus"}
@router.post("/transcribe", response_model=models.TranscriptionResponse)
async def transcribe_audio(
@@ -23,18 +27,33 @@ async def transcribe_audio(
model: str | None = Form(None),
):
"""Transcribe audio file to text."""
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
uploaded_ext = Path(file.filename or "").suffix.lower()
file_suffix = uploaded_ext if uploaded_ext in ALLOWED_AUDIO_EXTS else ".wav"
with tempfile.NamedTemporaryFile(suffix=file_suffix, delete=False) as tmp:
while chunk := await file.read(UPLOAD_CHUNK_SIZE):
tmp.write(chunk)
tmp_path = tmp.name
stt_path = tmp_path
try:
from ..utils.audio import load_audio
from ..utils.audio import load_audio, save_audio
from ..backends import WHISPER_HF_REPOS
audio, sr = await asyncio.to_thread(load_audio, tmp_path)
duration = len(audio) / sr
# The STT backend (mlx_audio.stt -> miniaudio) only decodes
# WAV/FLAC/MP3/Vorbis, so browser recordings uploaded as WebM/Opus
# fail with "unsupported file format" (issue: web-mode dictation).
# librosa already decoded the file above (it falls back to
# audioread/ffmpeg for exotic containers), so re-encode that PCM to a
# temp WAV and hand *that* to Whisper. WAV inputs pass through
# unchanged.
if file_suffix != ".wav":
stt_path = f"{tmp_path}.stt.wav"
await asyncio.to_thread(save_audio, audio, stt_path, sr)
whisper_model = transcribe.get_whisper_model()
model_size = model if model else whisper_model.model_size
@@ -69,7 +88,7 @@ async def transcribe_audio(
},
)
text = await whisper_model.transcribe(tmp_path, language, model_size)
text = await whisper_model.transcribe(stt_path, language, model_size)
return models.TranscriptionResponse(
text=text,
@@ -82,3 +101,5 @@ async def transcribe_audio(
raise HTTPException(status_code=500, detail=str(e))
finally:
Path(tmp_path).unlink(missing_ok=True)
if stt_path != tmp_path:
Path(stt_path).unlink(missing_ok=True)
+13 -10
View File
@@ -7,6 +7,7 @@ absolute imports instead of relative imports.
import sys
import os
import re
# On Windows with --noconsole (PyInstaller), sys.stdout/stderr are None.
# They can also be broken file objects in some edge cases.
@@ -47,6 +48,17 @@ if "--version" in sys.argv:
print(f"voicebox-server {__version__}")
sys.exit(0)
# Detect backend variant from binary name BEFORE importing backend modules
# so that env-var guards in app.py (e.g. HSA_OVERRIDE_GFX_VERSION) fire at import time.
_binary_name = os.path.basename(sys.executable).lower()
if re.search(r"voicebox-server-rocm(\.exe)?$", _binary_name):
os.environ["VOICEBOX_BACKEND_VARIANT"] = "rocm"
elif re.search(r"voicebox-server-cuda(\.exe)?$", _binary_name):
os.environ["VOICEBOX_BACKEND_VARIANT"] = "cuda"
else:
os.environ.setdefault("VOICEBOX_BACKEND_VARIANT", "cpu")
import logging
# Set up logging FIRST, before any imports that might fail
@@ -260,16 +272,7 @@ if __name__ == "__main__":
if args.parent_pid is not None and args.parent_pid <= 0:
parser.error("--parent-pid must be a positive integer")
# Detect backend variant from binary name
# voicebox-server-cuda → sets VOICEBOX_BACKEND_VARIANT=cuda
import os
binary_name = os.path.basename(sys.executable).lower()
if "cuda" in binary_name:
os.environ["VOICEBOX_BACKEND_VARIANT"] = "cuda"
logger.info("Backend variant: CUDA")
else:
os.environ["VOICEBOX_BACKEND_VARIANT"] = "cpu"
logger.info("Backend variant: CPU")
logger.info(f"Backend variant: {os.environ.get('VOICEBOX_BACKEND_VARIANT', 'cpu').upper()}")
# Register parent watchdog to start after server is fully ready
if args.parent_pid is not None:
+183
View File
@@ -0,0 +1,183 @@
"""
Voicebox Cloud device login — the "Log in with browser" flow.
The desktop opens the browser to ``{web}/connect``; the user authorizes while
signed in; the cloud redirects a single-use code back to this backend's loopback
callback. We exchange that code (server-to-server, over TLS) for a ``voicebox_…``
API key, verify the key against the API, and store it locally. The key never
travels through a browser URL, and an unfinished flow leaves nothing behind.
The ``state`` we mint and round-trip prevents login-CSRF: a callback whose state
we didn't issue (e.g. an attacker tricking the user into hitting the loopback
callback with their own code) is rejected.
"""
import logging
import secrets
import time
import webbrowser
from urllib.parse import urlencode
import httpx
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .. import config
from ..database import CloudSettings as DBCloudSettings
logger = logging.getLogger(__name__)
SINGLETON_ID = 1
PENDING_TTL_SECONDS = 600 # the whole browser flow must finish within 10 min
# state -> expiry epoch. In-memory: a single backend process owns the flow, and a
# dropped pairing should simply be restarted.
_pending: dict[str, float] = {}
def _prune() -> None:
now = time.time()
for state, expiry in list(_pending.items()):
if expiry < now:
_pending.pop(state, None)
def _json_dict(response: httpx.Response) -> dict | None:
"""Parsed JSON body, or None when it isn't a JSON object."""
try:
payload = response.json()
except ValueError:
return None
return payload if isinstance(payload, dict) else None
def _consume_state(state: str) -> bool:
"""Validate and single-use-consume a pending state."""
_prune()
expiry = _pending.pop(state, None)
return expiry is not None and expiry >= time.time()
def start_login(callback_url: str, device_name: str) -> str:
"""Mint a state, build the authorize URL, and open the browser.
Returns the authorize URL (also opened here) so the caller can surface it as
a fallback if the browser didn't open.
"""
state = secrets.token_urlsafe(24)
_prune()
_pending[state] = time.time() + PENDING_TTL_SECONDS
params = urlencode({"redirect_uri": callback_url, "state": state, "name": device_name})
authorize_url = f"{config.get_cloud_web_url()}/connect?{params}"
try:
webbrowser.open(authorize_url)
except Exception: # pragma: no cover - platform dependent
logger.exception("failed to open browser for cloud login")
return authorize_url
async def handle_callback(db: Session, code: str, state: str) -> tuple[bool, str]:
"""Exchange the code for an API key and store it. Returns (ok, message)."""
if not _consume_state(state):
return False, "This sign-in link is invalid or has expired. Start again from the app."
if not code:
return False, "Missing authorization code."
web = config.get_cloud_web_url()
api = config.get_cloud_api_url()
try:
async with httpx.AsyncClient(timeout=15.0) as client:
exchanged = await client.post(f"{web}/api/connect/exchange", json={"code": code})
if exchanged.status_code != 200:
logger.warning("cloud exchange rejected code: %s", exchanged.status_code)
return False, "Could not complete sign-in — the code was rejected."
payload = _json_dict(exchanged)
if payload is None:
logger.warning("cloud exchange returned a non-JSON payload")
return False, "Voicebox Cloud returned an unexpected response."
api_key = payload.get("key")
device_name = payload.get("label")
if not api_key:
return False, "Voicebox Cloud did not return a key."
# Confirm the freshly minted key actually authenticates the API.
me = await client.get(
f"{api}/v1/account/me",
headers={"Authorization": f"Bearer {api_key}"},
)
if me.status_code != 200:
logger.warning("minted key failed verification: %s", me.status_code)
return False, "Sign-in succeeded but the key could not be verified."
# The 200 above proves the key works; the user id is best-effort.
data = (_json_dict(me) or {}).get("data")
account_user_id = data.get("userId") if isinstance(data, dict) else None
except httpx.HTTPError:
logger.exception("network error during cloud exchange")
return False, "Could not reach Voicebox Cloud. Check your connection and try again."
_store_key(db, api_key=api_key, device_name=device_name, account_user_id=account_user_id)
logger.info("connected to Voicebox Cloud as device %r", device_name)
return True, "Connected"
def _get_or_create_row(db: Session) -> DBCloudSettings:
row = db.query(DBCloudSettings).filter(DBCloudSettings.id == SINGLETON_ID).first()
if row is None:
row = DBCloudSettings(id=SINGLETON_ID)
db.add(row)
try:
db.commit()
except IntegrityError:
# Another request created the singleton concurrently.
db.rollback()
row = db.query(DBCloudSettings).filter(DBCloudSettings.id == SINGLETON_ID).one()
else:
db.refresh(row)
return row
def _store_key(db: Session, *, api_key: str, device_name: str | None, account_user_id: str | None):
from datetime import datetime
row = _get_or_create_row(db)
row.api_key = api_key
row.device_name = device_name
row.account_user_id = account_user_id
row.connected_at = datetime.utcnow()
db.commit()
def get_status(db: Session) -> dict:
"""Local view of the cloud link — never returns the full key."""
row = _get_or_create_row(db)
connected = bool(row.api_key)
# Prefix only: "voicebox_" (9) + 8 chars, matching the cloud's key_prefix.
key_prefix = row.api_key[:17] if row.api_key else None
return {
"connected": connected,
"device_name": row.device_name if connected else None,
"account_user_id": row.account_user_id if connected else None,
"key_prefix": key_prefix,
"connected_at": row.connected_at if connected else None,
"dashboard_url": f"{config.get_cloud_web_url()}/account",
}
def disconnect(db: Session) -> None:
"""Forget the local credential. The key remains valid on the server until
revoked from the account dashboard — surface that in the UI."""
row = _get_or_create_row(db)
row.api_key = None
row.device_name = None
row.account_user_id = None
row.connected_at = None
db.commit()
def get_api_key(db: Session) -> str | None:
"""The stored bearer key, for the (future) sync client. None if not linked."""
row = _get_or_create_row(db)
return row.api_key
+32 -1
View File
@@ -21,9 +21,9 @@ import tarfile
from pathlib import Path
from typing import Optional
from .. import __version__
from ..config import get_data_dir
from ..utils.progress import get_progress_manager
from .. import __version__
logger = logging.getLogger(__name__)
@@ -31,6 +31,8 @@ GITHUB_RELEASES_URL = "https://github.com/jamiepine/voicebox/releases/download"
PROGRESS_KEY = "cuda-backend"
CUDA_DOWNLOAD_UNSUPPORTED_REASON = "Downloadable CUDA backend releases are currently only published for Windows."
# The current expected CUDA libs version. Bump this when we change the
# CUDA toolkit version or torch's CUDA dependency changes (e.g. cu126 -> cu128).
CUDA_LIBS_VERSION = "cu128-v1"
@@ -63,6 +65,25 @@ def get_cuda_exe_name() -> str:
return "voicebox-server-cuda"
def is_cuda_download_supported() -> bool:
"""Return whether this platform has a matching CUDA release asset."""
return sys.platform == "win32"
def get_cuda_download_unsupported_reason() -> str | None:
"""Explain why this platform cannot use the release-download flow."""
if is_cuda_download_supported():
return None
return CUDA_DOWNLOAD_UNSUPPORTED_REASON
def ensure_cuda_download_supported() -> None:
"""Raise if downloading would fetch an asset built for another platform."""
reason = get_cuda_download_unsupported_reason()
if reason:
raise RuntimeError(reason)
def get_cuda_binary_path() -> Optional[Path]:
"""Return path to the CUDA executable if it exists inside the onedir."""
p = get_cuda_dir() / get_cuda_exe_name()
@@ -103,12 +124,15 @@ def get_cuda_status() -> dict:
cuda_path = get_cuda_binary_path()
progress = progress_manager.get_progress(PROGRESS_KEY)
cuda_libs_version = get_installed_cuda_libs_version()
unsupported_reason = get_cuda_download_unsupported_reason()
return {
"available": cuda_path is not None,
"active": is_cuda_active(),
"binary_path": str(cuda_path) if cuda_path else None,
"cuda_libs_version": cuda_libs_version,
"download_supported": unsupported_reason is None,
"unsupported_reason": unsupported_reason,
"downloading": progress is not None and progress.get("status") == "downloading",
"download_progress": progress,
}
@@ -257,6 +281,8 @@ async def download_cuda_binary(version: Optional[str] = None):
async def _download_cuda_binary_locked(version: Optional[str] = None):
"""Inner implementation of download_cuda_binary, called under _download_lock."""
ensure_cuda_download_supported()
import httpx
if version is None:
@@ -387,6 +413,11 @@ async def check_and_update_cuda_binary():
if not cuda_path:
return # No CUDA binary installed, nothing to update
unsupported_reason = get_cuda_download_unsupported_reason()
if unsupported_reason:
logger.info("Skipping CUDA backend auto-update: %s", unsupported_reason)
return
need_server = _needs_server_download()
need_libs = _needs_cuda_libs_download()
+18 -4
View File
@@ -48,9 +48,14 @@ async def run_generation(
This is the single entry point for all background generation work.
It is designed to be enqueued via ``services.task_queue.enqueue_generation``.
"""
from ..backends import load_engine_model, get_tts_backend_for_engine, engine_needs_trim
from ..backends import (
engine_needs_trim,
engine_retries_runaway,
get_tts_backend_for_engine,
load_engine_model,
)
from ..utils.chunked_tts import generate_chunked
from ..utils.audio import normalize_audio, save_audio, trim_tts_output
from ..utils.audio import has_tts_runaway, normalize_audio, save_audio, trim_tts_output
task_manager = get_task_manager()
bg_db = next(get_db())
@@ -72,12 +77,14 @@ async def run_generation(
await history.update_generation_status(generation_id, "generating", bg_db)
trim_fn = trim_tts_output if engine_needs_trim(engine) else None
runaway_detector = has_tts_runaway if engine_retries_runaway(engine) else None
gen_kwargs: dict = dict(
language=language,
seed=seed if mode != "regenerate" else None,
instruct=instruct,
trim_fn=trim_fn,
runaway_detector=runaway_detector,
)
if max_chunk_chars is not None:
gen_kwargs["max_chunk_chars"] = max_chunk_chars
@@ -267,9 +274,14 @@ async def generate_audio_sync(
normalize, then encodes in-memory via :func:`tts.audio_to_wav_bytes`
(same helper ``/generate/stream`` uses).
"""
from ..backends import load_engine_model, get_tts_backend_for_engine, engine_needs_trim
from ..backends import (
engine_needs_trim,
engine_retries_runaway,
get_tts_backend_for_engine,
load_engine_model,
)
from ..utils.chunked_tts import generate_chunked
from ..utils.audio import normalize_audio, trim_tts_output
from ..utils.audio import has_tts_runaway, normalize_audio, trim_tts_output
from . import tts
bg_db = next(get_db())
@@ -287,12 +299,14 @@ async def generate_audio_sync(
bg_db.close()
trim_fn = trim_tts_output if engine_needs_trim(engine) else None
runaway_detector = has_tts_runaway if engine_retries_runaway(engine) else None
gen_kwargs: dict = dict(
language=language,
seed=seed,
instruct=instruct,
trim_fn=trim_fn,
runaway_detector=runaway_detector,
)
if max_chunk_chars is not None:
gen_kwargs["max_chunk_chars"] = max_chunk_chars
+467
View File
@@ -0,0 +1,467 @@
"""
ROCm backend download, assembly, and verification.
Downloads two archives from GitHub Releases:
1. Server core (voicebox-server-rocm.tar.gz) — the exe + non-AMD deps,
versioned with the app.
2. ROCm libs (rocm-libs-{version}.tar.gz) — AMD runtime libraries,
versioned independently (only redownloaded on ROCm toolkit bump).
Both archives are extracted into {data_dir}/backends/rocm/ which forms the
complete PyInstaller --onedir directory structure that torch expects.
"""
import asyncio
import hashlib
import json
import logging
import os
import shutil
import sys
import tarfile
from pathlib import Path
from typing import Optional
from ..config import get_data_dir
from ..utils.progress import get_progress_manager
from .. import __version__
logger = logging.getLogger(__name__)
GITHUB_RELEASES_URL = "https://github.com/jamiepine/voicebox/releases/download"
PROGRESS_KEY = "rocm-backend"
# The current expected ROCm libs version. Bump this when we change the
# ROCm toolkit version or torch's ROCm dependency changes (e.g. rocm7.2 -> rocm7.4).
ROCM_LIBS_VERSION = "rocm7.2-v1"
# Prevents concurrent download_rocm_binary() calls from racing on the same
# temp file. The auto-update background task and the manual HTTP endpoint
# can both invoke download_rocm_binary(); without this lock the progress-
# manager status check is a TOCTOU race.
_download_lock = asyncio.Lock()
def get_backends_dir() -> Path:
"""Directory where downloaded backend binaries are stored."""
d = get_data_dir() / "backends"
d.mkdir(parents=True, exist_ok=True)
return d
def get_rocm_dir() -> Path:
"""Directory where the ROCm backend (onedir) is extracted."""
d = get_backends_dir() / "rocm"
d.mkdir(parents=True, exist_ok=True)
return d
def get_rocm_exe_name() -> str:
"""Platform-specific ROCm executable filename."""
if sys.platform == "win32":
return "voicebox-server-rocm.exe"
return "voicebox-server-rocm"
def get_rocm_binary_path() -> Optional[Path]:
"""Return path to the ROCm executable if it exists inside the onedir."""
p = get_rocm_dir() / get_rocm_exe_name()
if p.exists():
return p
return None
def get_rocm_libs_manifest_path() -> Path:
"""Path to the rocm-libs.json manifest inside the ROCm dir."""
return get_rocm_dir() / "rocm-libs.json"
def get_installed_rocm_libs_version() -> Optional[str]:
"""Read the installed ROCm libs version from rocm-libs.json, or None."""
manifest_path = get_rocm_libs_manifest_path()
if not manifest_path.exists():
return None
try:
data = json.loads(manifest_path.read_text())
return data.get("version")
except Exception as e:
logger.warning(f"Could not read rocm-libs.json: {e}")
return None
def is_rocm_active() -> bool:
"""Check if the current process is the ROCm binary.
The ROCm binary sets this env var on startup (see server.py).
"""
return os.environ.get("VOICEBOX_BACKEND_VARIANT") == "rocm"
def get_rocm_status() -> dict:
"""Get current ROCm backend status for the API."""
progress_manager = get_progress_manager()
rocm_path = get_rocm_binary_path()
progress = progress_manager.get_progress(PROGRESS_KEY)
rocm_libs_version = get_installed_rocm_libs_version()
return {
"available": rocm_path is not None,
"active": is_rocm_active(),
"binary_path": str(rocm_path) if rocm_path else None,
"rocm_libs_version": rocm_libs_version,
"downloading": progress is not None and progress.get("status") == "downloading",
"download_progress": progress,
}
def _needs_server_download(version: Optional[str] = None) -> bool:
"""Check if the server core archive needs to be (re)downloaded."""
rocm_path = get_rocm_binary_path()
if not rocm_path:
return True
# Check if the binary version matches the expected app version
installed = get_rocm_binary_version()
expected = version or __version__
if expected.startswith("v"):
expected = expected[1:]
return installed != expected
def _needs_rocm_libs_download() -> bool:
"""Check if the ROCm libs archive needs to be (re)downloaded."""
installed = get_installed_rocm_libs_version()
if installed is None:
return True
return installed != ROCM_LIBS_VERSION
async def _download_and_extract_archive(
client,
url: str,
sha256_url: Optional[str],
dest_dir: Path,
label: str,
progress_offset: int,
total_size: int,
):
"""Download a .tar.gz archive and extract it into dest_dir.
Args:
client: httpx.AsyncClient
url: URL of the .tar.gz archive
sha256_url: URL of the .sha256 checksum file (optional)
dest_dir: Directory to extract into
label: Human-readable label for progress updates
progress_offset: Byte offset for progress reporting (when downloading
multiple archives sequentially)
total_size: Total bytes across all downloads (for progress bar)
"""
progress = get_progress_manager()
temp_path = dest_dir / f".download-{label.replace(' ', '-')}.tmp"
# Clean up leftover partial download
if temp_path.exists():
temp_path.unlink()
# Fetch expected checksum (fail-fast: never extract an unverified archive)
expected_sha = None
if sha256_url:
try:
sha_resp = await client.get(sha256_url)
sha_resp.raise_for_status()
expected_sha = sha_resp.text.strip().split()[0]
logger.info(f"{label}: expected SHA-256: {expected_sha[:16]}...")
except Exception as e:
raise RuntimeError(f"{label}: failed to fetch checksum from {sha256_url}") from e
# Stream download, verify, and extract — always clean up temp file
downloaded = 0
try:
async with client.stream("GET", url) as response:
response.raise_for_status()
with open(temp_path, "wb") as f:
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
f.write(chunk)
downloaded += len(chunk)
progress.update_progress(
PROGRESS_KEY,
current=progress_offset + downloaded,
total=total_size,
filename=f"Downloading {label}",
status="downloading",
)
# Verify integrity
if expected_sha:
progress.update_progress(
PROGRESS_KEY,
current=progress_offset + downloaded,
total=total_size,
filename=f"Verifying {label}...",
status="downloading",
)
sha256 = hashlib.sha256()
with open(temp_path, "rb") as f:
while True:
data = f.read(1024 * 1024)
if not data:
break
sha256.update(data)
actual = sha256.hexdigest()
if actual != expected_sha:
raise ValueError(
f"{label} integrity check failed: expected {expected_sha[:16]}..., got {actual[:16]}..."
)
logger.info(f"{label}: integrity verified")
# Extract (use data filter for path traversal protection on Python 3.12+)
progress.update_progress(
PROGRESS_KEY,
current=progress_offset + downloaded,
total=total_size,
filename=f"Extracting {label}...",
status="downloading",
)
with tarfile.open(temp_path, "r:gz") as tar:
tar.extractall(path=dest_dir, filter="data")
logger.info(f"{label}: extracted to {dest_dir}")
finally:
if temp_path.exists():
temp_path.unlink()
return downloaded
async def download_rocm_binary(version: Optional[str] = None):
"""Download the ROCm backend (server core + ROCm libs if needed).
Downloads both archives from GitHub Releases, extracts them into
{data_dir}/backends/rocm/, and writes the rocm-libs.json manifest.
Only downloads what's needed:
- Server core: always redownloaded (versioned with app)
- ROCm libs: only if missing or version mismatch
Args:
version: Version tag (e.g. "v0.3.0"). Defaults to current app version.
"""
if _download_lock.locked():
logger.info("ROCm download already in progress, skipping duplicate request")
return
async with _download_lock:
await _download_rocm_binary_locked(version)
async def _download_rocm_binary_locked(version: Optional[str] = None):
"""Inner implementation of download_rocm_binary, called under _download_lock."""
import httpx
if version is None:
version = f"v{__version__}"
progress = get_progress_manager()
rocm_dir = get_rocm_dir()
need_server = _needs_server_download(version)
need_libs = _needs_rocm_libs_download()
if not need_server and not need_libs:
logger.info("ROCm backend is up to date, nothing to download")
return
logger.info(
f"Starting ROCm backend download for {version} "
f"(server={'yes' if need_server else 'cached'}, "
f"libs={'yes' if need_libs else 'cached'})"
)
progress.update_progress(
PROGRESS_KEY,
current=0,
total=0,
filename="Preparing download...",
status="downloading",
)
# Server core and libs archive are both published under the app-version
# release tag; the libs content version is encoded in the filename only.
server_base_url = f"{GITHUB_RELEASES_URL}/{version}"
libs_base_url = server_base_url
server_archive = "voicebox-server-rocm.tar.gz"
libs_archive = f"rocm-libs-{ROCM_LIBS_VERSION}.tar.gz"
# Always stage when any download is needed, then atomically rename over
# rocm_dir on success. This prevents a failed mid-extraction from leaving
# rocm_dir in a partially-installed state that still passes the
# get_rocm_binary_path() existence check. Existing files are pre-copied
# into staging so partial updates (e.g. libs-only or server-only) preserve
# whatever isn't being re-downloaded.
use_staging = need_server or need_libs
staging_dir = get_backends_dir() / "rocm-staging"
if use_staging:
if staging_dir.exists():
shutil.rmtree(staging_dir)
staging_dir.mkdir(parents=True, exist_ok=True)
# Preserve existing files (server or libs) that don't need re-downloading.
# Extracted archives will overwrite only what we actually download.
if rocm_dir.exists():
shutil.copytree(rocm_dir, staging_dir, dirs_exist_ok=True)
extract_dir = staging_dir
else:
extract_dir = rocm_dir
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=30.0) as client:
# Estimate total download size
total_size = 0
if need_server:
try:
head = await client.head(f"{server_base_url}/{server_archive}")
total_size += int(head.headers.get("content-length", 0))
except Exception:
pass
if need_libs:
try:
head = await client.head(f"{libs_base_url}/{libs_archive}")
total_size += int(head.headers.get("content-length", 0))
except Exception:
pass
logger.info(f"Total download size: {total_size / 1024 / 1024:.1f} MB")
offset = 0
# Download server core
if need_server:
server_downloaded = await _download_and_extract_archive(
client,
url=f"{server_base_url}/{server_archive}",
sha256_url=f"{server_base_url}/{server_archive}.sha256",
dest_dir=extract_dir,
label="ROCm server",
progress_offset=offset,
total_size=total_size,
)
offset += server_downloaded
# Make executable on Unix
exe_path = extract_dir / get_rocm_exe_name()
if sys.platform != "win32" and exe_path.exists():
exe_path.chmod(0o755)
# Download ROCm libs
if need_libs:
await _download_and_extract_archive(
client,
url=f"{libs_base_url}/{libs_archive}",
sha256_url=f"{libs_base_url}/{libs_archive}.sha256",
dest_dir=extract_dir,
label="ROCm libraries",
progress_offset=offset,
total_size=total_size,
)
# Write local rocm-libs.json manifest
manifest = {"version": ROCM_LIBS_VERSION}
(extract_dir / "rocm-libs.json").write_text(json.dumps(manifest, indent=2) + "\n")
# Atomic swap: replace rocm_dir with the fully-extracted staging dir
if use_staging:
backup_dir = get_backends_dir() / "rocm-backup"
if backup_dir.exists():
shutil.rmtree(backup_dir)
if rocm_dir.exists():
rocm_dir.rename(backup_dir)
try:
staging_dir.rename(rocm_dir)
except Exception:
if backup_dir.exists() and not rocm_dir.exists():
backup_dir.rename(rocm_dir)
raise
else:
if backup_dir.exists():
shutil.rmtree(backup_dir)
logger.info(f"ROCm backend ready at {rocm_dir}")
progress.mark_complete(PROGRESS_KEY)
except Exception as e:
if use_staging and staging_dir.exists():
shutil.rmtree(staging_dir)
logger.error(f"ROCm backend download failed: {e}")
progress.mark_error(PROGRESS_KEY, str(e))
raise
def get_rocm_binary_version() -> Optional[str]:
"""Get the version of the installed ROCm binary, or None if not installed."""
import subprocess
rocm_path = get_rocm_binary_path()
if not rocm_path:
return None
try:
result = subprocess.run(
[str(rocm_path), "--version"],
capture_output=True,
text=True,
timeout=30,
cwd=str(rocm_path.parent), # Run from the onedir directory
)
# Output format: "voicebox-server 0.3.0"
for line in result.stdout.strip().splitlines():
if "voicebox-server" in line:
return line.split()[-1]
except Exception as e:
logger.warning(f"Could not get ROCm binary version: {e}")
return None
async def check_and_update_rocm_binary():
"""Check if the ROCm binary is outdated and auto-download if so.
Called on server startup. Checks both server version and ROCm libs
version. Downloads only what's needed.
"""
rocm_path = get_rocm_binary_path()
if not rocm_path:
return # No ROCm binary installed, nothing to update
if is_rocm_active():
logger.info("ROCm backend is active; skipping auto-update to avoid replacing the running backend")
return
need_server = _needs_server_download()
need_libs = _needs_rocm_libs_download()
if not need_server and not need_libs:
logger.info(f"ROCm binary is up to date (server=v{__version__}, libs={get_installed_rocm_libs_version()})")
return
reasons = []
if need_server:
rocm_version = get_rocm_binary_version()
reasons.append(f"server v{rocm_version} != v{__version__}")
if need_libs:
installed_libs = get_installed_rocm_libs_version()
reasons.append(f"libs {installed_libs} != {ROCM_LIBS_VERSION}")
logger.info(f"ROCm backend needs update ({', '.join(reasons)}). Auto-downloading...")
try:
await download_rocm_binary()
except Exception as e:
logger.error(f"Auto-update of ROCm binary failed: {e}")
async def delete_rocm_binary() -> bool:
"""Delete the downloaded ROCm backend directory. Returns True if deleted."""
import shutil
rocm_dir = get_rocm_dir()
if rocm_dir.exists() and any(rocm_dir.iterdir()):
shutil.rmtree(rocm_dir)
logger.info(f"Deleted ROCm backend directory: {rocm_dir}")
return True
return False
+15 -3
View File
@@ -125,12 +125,24 @@ async def list_stories(
"""
stories = db.query(DBStory).order_by(DBStory.updated_at.desc()).all()
if not stories:
return []
# Batch-fetch all story item counts in one query to avoid an N+1 pattern
# (previously there was one COUNT query per story in the loop below).
story_ids = [s.id for s in stories]
count_rows = (
db.query(DBStoryItem.story_id, func.count(DBStoryItem.id).label("cnt"))
.filter(DBStoryItem.story_id.in_(story_ids))
.group_by(DBStoryItem.story_id)
.all()
)
item_counts = {row.story_id: row.cnt for row in count_rows}
result = []
for story in stories:
item_count = db.query(func.count(DBStoryItem.id)).filter(DBStoryItem.story_id == story.id).scalar()
response = StoryResponse.model_validate(story)
response.item_count = item_count
response.item_count = item_counts.get(story.id, 0)
result.append(response)
return result
+96
View File
@@ -0,0 +1,96 @@
"""
Phase 2.1 Test: AMD GPU detection on Windows.
Validates is_amd_gpu_windows() via mocked WMI and torch queries.
Usage:
python -m pytest backend/tests/test_amd_gpu_detect.py -v
"""
from unittest.mock import MagicMock, patch
import pytest
from backend.utils.platform_detect import is_amd_gpu_windows
class TestAmdGpuWindows:
"""Unit tests for is_amd_gpu_windows with mocks."""
@pytest.fixture(autouse=True)
def _clear_detection_cache(self):
# is_amd_gpu_windows is memoized; reset between cases so each mock takes effect.
is_amd_gpu_windows.cache_clear()
yield
is_amd_gpu_windows.cache_clear()
@patch("backend.utils.platform_detect.platform.system", return_value="Linux")
def test_returns_false_on_linux(self, _mock_system):
"""Non-Windows platforms should always return False."""
assert is_amd_gpu_windows() is False
@patch("backend.utils.platform_detect.platform.system", return_value="Windows")
@patch(
"backend.utils.platform_detect.subprocess.run",
return_value=MagicMock(stdout="1\n", returncode=0),
)
def test_detects_amd_via_wmi(self, _mock_run, _mock_system):
"""WMI reporting an AMD adapter should return True."""
assert is_amd_gpu_windows() is True
@patch("backend.utils.platform_detect.platform.system", return_value="Windows")
@patch(
"backend.utils.platform_detect.subprocess.run",
return_value=MagicMock(stdout="0\n", returncode=0),
)
def test_no_amd_via_wmi(self, _mock_run, _mock_system):
"""WMI reporting zero AMD adapters should return False."""
assert is_amd_gpu_windows() is False
@patch("backend.utils.platform_detect.platform.system", return_value="Windows")
@patch(
"backend.utils.platform_detect.subprocess.run",
side_effect=Exception("WMI not available"),
)
@patch("torch.cuda.is_available", return_value=True)
@patch(
"torch.cuda.get_device_name",
return_value="AMD Radeon RX 7800 XT",
)
def test_fallback_to_torch_radeon(self, _mock_name, _mock_avail, _mock_run, _mock_system):
"""When WMI fails, torch.cuda.get_device_name('Radeon') should return True."""
assert is_amd_gpu_windows() is True
@patch("backend.utils.platform_detect.platform.system", return_value="Windows")
@patch(
"backend.utils.platform_detect.subprocess.run",
side_effect=Exception("WMI not available"),
)
@patch("torch.cuda.is_available", return_value=True)
@patch(
"torch.cuda.get_device_name",
return_value="NVIDIA GeForce RTX 4090",
)
def test_fallback_to_torch_nvidia(self, _mock_name, _mock_avail, _mock_run, _mock_system):
"""When WMI fails, torch.cuda.get_device_name('NVIDIA') should return False."""
assert is_amd_gpu_windows() is False
@patch("backend.utils.platform_detect.platform.system", return_value="Windows")
@patch(
"backend.utils.platform_detect.subprocess.run",
side_effect=Exception("WMI not available"),
)
@patch("torch.cuda.is_available", return_value=False)
def test_no_torch_cuda(self, _mock_avail, _mock_run, _mock_system):
"""When WMI fails and torch.cuda is unavailable, should return False."""
assert is_amd_gpu_windows() is False
@patch("backend.utils.platform_detect.platform.system", return_value="Windows")
@patch(
"backend.utils.platform_detect.subprocess.run",
side_effect=Exception("WMI not available"),
)
def test_torch_not_installed(self, _mock_run, _mock_system):
"""When torch is not installed, should return False without crashing."""
with patch.dict("sys.modules", {"torch": None}):
assert is_amd_gpu_windows() is False
@@ -0,0 +1,164 @@
"""
Regression tests for GET /audio/{generation_id} on failed generations.
A failed generation stores an empty ``audio_path``. Previously,
``config.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`` — a 500 instead of
a clean 404.
Usage:
python -m pytest backend/tests/test_audio_failed_generation.py -v
"""
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from starlette.testclient import TestClient
# Repo root on sys.path so ``backend`` imports as a package (the audio
# routes use package-relative imports).
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from backend import config
from backend.database import (
Base,
Generation,
GenerationVersion,
ProfileSample,
VoiceProfile,
get_db,
)
from backend.routes.audio import router as audio_router
def test_resolve_storage_path_empty_returns_none():
"""An empty stored path must not resolve to the data dir itself."""
assert config.resolve_storage_path("") is None
assert config.resolve_storage_path(None) is None
# Path("") is truthy, so it must be rejected via its (empty) parts.
assert config.resolve_storage_path(Path("")) is None
@pytest.fixture
def client(tmp_path, monkeypatch):
"""Minimal app with only the audio routes and a temp sqlite DB."""
monkeypatch.setattr(config, "_data_dir", tmp_path)
# An existing directory that a stored audio_path may wrongly point to.
(tmp_path / "somedir").mkdir()
engine = create_engine(
f"sqlite:///{tmp_path / 'test.db'}",
connect_args={"check_same_thread": False},
)
Base.metadata.create_all(bind=engine)
testing_session_local = sessionmaker(autocommit=False, autoflush=False, bind=engine)
session = testing_session_local()
profile = VoiceProfile(id="profile-1", name="Test Profile")
session.add(profile)
session.add_all(
[
Generation(
id="gen-failed-empty",
profile_id="profile-1",
text="failed generation",
audio_path="",
status="failed",
error="engine exploded",
),
Generation(
id="gen-failed-null",
profile_id="profile-1",
text="failed generation",
audio_path=None,
status="failed",
),
Generation(
id="gen-missing-file",
profile_id="profile-1",
text="completed but file deleted",
audio_path="generations/does-not-exist.wav",
status="completed",
),
Generation(
id="gen-with-version",
profile_id="profile-1",
text="generation with a broken version",
audio_path="somedir",
status="completed",
),
GenerationVersion(
id="version-dir",
generation_id="gen-with-version",
label="original",
audio_path="somedir",
),
ProfileSample(
id="sample-dir",
profile_id="profile-1",
audio_path="somedir",
reference_text="sample pointing at a directory",
),
]
)
session.commit()
session.close()
app = FastAPI()
app.include_router(audio_router)
def override_get_db():
db = testing_session_local()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
return TestClient(app)
@pytest.mark.parametrize("generation_id", ["gen-failed-empty", "gen-failed-null"])
def test_failed_generation_returns_404(client, generation_id):
"""Failed generations (empty/null audio_path) get a clean 404, not a 500."""
response = client.get(f"/audio/{generation_id}")
assert response.status_code == 404
assert response.json()["detail"] == "Generation failed; no audio available"
def test_missing_audio_file_returns_404(client):
"""A completed generation whose file vanished still 404s."""
response = client.get("/audio/gen-missing-file")
assert response.status_code == 404
assert response.json()["detail"] == "Audio file not found"
def test_unknown_generation_returns_404(client):
response = client.get("/audio/no-such-generation")
assert response.status_code == 404
assert response.json()["detail"] == "Generation not found"
@pytest.mark.parametrize(
"url",
[
"/audio/gen-with-version",
"/audio/version/version-dir",
"/samples/sample-dir",
],
)
def test_audio_path_pointing_at_directory_returns_404(client, url):
"""A stored path resolving to an existing directory must 404, not 500.
Guards the is_file() checks: a directory passes exists() and would
crash FileResponse.
"""
response = client.get(url)
assert response.status_code == 404
assert response.json()["detail"] == "Audio file not found"
+123
View File
@@ -0,0 +1,123 @@
"""
Regression tests for issue #852: audioop removed from Python 3.13 stdlib.
Voice sample validation imports audioop transitively (librosa → audioread).
The audioop-lts backport must be declared in requirements and bundled in
PyInstaller builds on 3.13+.
"""
import re
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from build_binary import build_server
@pytest.fixture
def backend_dir():
return Path(__file__).parent.parent
class TestAudioopRequirements:
def test_requirements_declare_audioop_lts_for_python_313(self, backend_dir):
content = (backend_dir / "requirements.txt").read_text()
assert re.search(
r"^audioop-lts.*python_version\s*>=\s*['\"]3\.13['\"]",
content,
re.MULTILINE,
), "requirements.txt must pin audioop-lts for Python 3.13+"
@pytest.mark.skipif(sys.version_info < (3, 13), reason="Python 3.13+ only")
class TestAudioopRuntime:
def test_audioop_importable(self):
import audioop # noqa: F401
def test_validate_reference_wav_does_not_fail_on_missing_audioop(self, tmp_path):
import numpy as np
import soundfile as sf
from utils.audio import validate_and_load_reference_audio
sr = 24000
t = np.arange(int(sr * 3), dtype=np.float32) / sr
audio = (0.3 * np.sin(2 * np.pi * 220 * t)).astype(np.float32)
path = tmp_path / "reference.wav"
sf.write(str(path), audio, sr)
ok, err, out_audio, out_sr = validate_and_load_reference_audio(str(path))
assert ok, err
assert out_audio is not None
assert out_sr == sr
assert "audioop" not in (err or "").lower()
class TestAudioopBuildArgs:
@staticmethod
def _hidden_imports(args):
imports = []
for i, arg in enumerate(args):
if arg == "--hidden-import" and i + 1 < len(args):
imports.append(args[i + 1])
return imports
def test_pyinstaller_includes_audioop_on_python_313(self):
class FakeVersionInfo(tuple):
@property
def major(self):
return self[0]
@property
def minor(self):
return self[1]
@property
def micro(self):
return self[2]
fake_313 = FakeVersionInfo((3, 13, 0, "final", 0))
with (
patch("build_binary.PyInstaller.__main__.run") as mock_run,
patch("build_binary.platform.system", return_value="Linux"),
patch("build_binary.is_apple_silicon", return_value=False),
patch("build_binary.os.chdir"),
patch("build_binary.sys.version_info", fake_313),
):
build_server()
args = mock_run.call_args[0][0]
assert "audioop" in self._hidden_imports(args)
def test_pyinstaller_omits_audioop_on_python_312(self):
class FakeVersionInfo(tuple):
@property
def major(self):
return self[0]
@property
def minor(self):
return self[1]
@property
def micro(self):
return self[2]
fake_312 = FakeVersionInfo((3, 12, 0, "final", 0))
with (
patch("build_binary.PyInstaller.__main__.run") as mock_run,
patch("build_binary.platform.system", return_value="Linux"),
patch("build_binary.is_apple_silicon", return_value=False),
patch("build_binary.os.chdir"),
patch("build_binary.sys.version_info", fake_312),
):
build_server()
args = mock_run.call_args[0][0]
assert "audioop" not in self._hidden_imports(args)
+32
View File
@@ -0,0 +1,32 @@
import sys as py_sys
import types
import pytest
from backend.services import cuda
def test_cuda_status_reports_unsupported_linux_download(monkeypatch, tmp_path):
monkeypatch.setattr(cuda.sys, "platform", "linux")
monkeypatch.setattr(cuda, "get_data_dir", lambda: tmp_path)
status = cuda.get_cuda_status()
assert status["available"] is False
assert status["download_supported"] is False
assert status["unsupported_reason"] == cuda.CUDA_DOWNLOAD_UNSUPPORTED_REASON
@pytest.mark.asyncio
async def test_cuda_download_rejects_linux_before_network(monkeypatch, tmp_path):
monkeypatch.setattr(cuda.sys, "platform", "linux")
monkeypatch.setattr(cuda, "get_data_dir", lambda: tmp_path)
class UnexpectedClient:
def __init__(self, *args, **kwargs):
raise AssertionError("unsupported platforms should not start a release download")
monkeypatch.setitem(py_sys.modules, "httpx", types.SimpleNamespace(AsyncClient=UnexpectedClient))
with pytest.raises(RuntimeError, match="currently only published for Windows"):
await cuda._download_cuda_binary_locked("v0.5.0")
@@ -0,0 +1,68 @@
"""Ensure TADA voice-prompt encoding disables autograd (#890)."""
from __future__ import annotations
from dataclasses import dataclass
from unittest.mock import AsyncMock
import numpy as np
import pytest
import soundfile as sf
import torch
from backend.backends.hume_backend import HumeTadaBackend
@dataclass
class _FakeEncoderOutput:
emb: torch.Tensor
class _GradTrackingEncoder:
"""Raises unless called under torch.inference_mode()."""
def __init__(self) -> None:
self.called_under_inference_mode = False
def __call__(self, audio, text=None, sample_rate=None):
self.called_under_inference_mode = torch.is_inference_mode_enabled()
if not self.called_under_inference_mode:
raise AssertionError("encoder forward must run under inference_mode")
# Touch a requires_grad tensor the way Snake1d alpha would.
alpha = torch.nn.Parameter(torch.ones(1, device=audio.device))
_ = audio.mean() * alpha
return _FakeEncoderOutput(emb=torch.zeros(1, 4, device=audio.device))
@pytest.mark.asyncio
async def test_create_voice_prompt_runs_encoder_under_inference_mode(tmp_path, monkeypatch):
wav = tmp_path / "ref.wav"
sf.write(str(wav), np.zeros(24000, dtype=np.float32), 24000)
backend = HumeTadaBackend()
backend.model = object() # mark loaded
backend.model_size = "1B"
backend._device = "cpu"
encoder = _GradTrackingEncoder()
backend.encoder = encoder
monkeypatch.setattr(backend, "load_model", AsyncMock(return_value=None))
monkeypatch.setattr(
"backend.backends.hume_backend.get_cached_voice_prompt",
lambda key: None,
)
monkeypatch.setattr(
"backend.backends.hume_backend.cache_voice_prompt",
lambda key, value: None,
)
prompt, from_cache = await backend.create_voice_prompt(
str(wav),
reference_text="hello world",
use_cache=False,
)
assert from_cache is False
assert encoder.called_under_inference_mode is True
assert isinstance(prompt["emb"], torch.Tensor)
assert prompt["emb"].device.type == "cpu"
+91
View File
@@ -0,0 +1,91 @@
"""Tests for the voicebox.speak MCP tool's ``model_size`` plumbing (issue #884).
The MCP speak path used to build its ``GenerationRequest`` without a
``model_size``, so every agent-triggered generation silently fell back to the
schema default ("1.7B") — there was no way to reach 0.6B (or TADA's 1B/3B)
through MCP. These tests pin the fix: ``_speak`` now forwards ``model_size``
straight into the request, matching the REST ``/generate`` surface.
"""
import pytest
from pydantic import ValidationError
import backend.routes.generations as generations
from backend.mcp_server import tools
class _FakeGeneration:
"""Minimal stand-in for GenerationResponse consumed by ``_speak_response``."""
def model_dump(self, mode="json"):
return {"id": "gen-test", "status": "generating"}
@pytest.fixture
def captured_request(monkeypatch):
"""Replace the real (torch-backed) generate_speech with a capturing stub.
``_speak`` imports ``generate_speech`` lazily from ``routes.generations``,
so patching the attribute on that module intercepts the call and lets us
inspect the ``GenerationRequest`` it would have run.
"""
captured = {}
async def fake_generate_speech(req, db):
captured["req"] = req
return _FakeGeneration()
monkeypatch.setattr(generations, "generate_speech", fake_generate_speech)
# Isolate the unit from the MCP event bus — _speak_response fires a
# speak-start event we don't care about here.
monkeypatch.setattr(tools.mcp_events, "publish", lambda *a, **k: None)
return captured
@pytest.mark.asyncio
async def test_speak_forwards_explicit_model_size(captured_request):
await tools._speak(
profile_id="p1",
profile_name="Morgan",
text="hello",
engine="qwen",
language="en",
personality=False,
model_size="0.6B",
db=None,
)
assert captured_request["req"].model_size == "0.6B"
@pytest.mark.asyncio
async def test_speak_omitted_model_size_is_none(captured_request):
# Omitted → None; generate_speech normalizes None to the engine default,
# so this reproduces the pre-fix behaviour for callers that don't ask.
await tools._speak(
profile_id="p1",
profile_name="Morgan",
text="hello",
engine="qwen",
language="en",
personality=False,
db=None,
)
assert captured_request["req"].model_size is None
@pytest.mark.asyncio
async def test_speak_rejects_invalid_model_size(captured_request):
# The GenerationRequest schema pattern is the single source of truth for
# valid sizes; a bad value is rejected before any generation runs.
with pytest.raises(ValidationError):
await tools._speak(
profile_id="p1",
profile_name="Morgan",
text="hello",
engine="qwen",
language="en",
personality=False,
model_size="9B",
db=None,
)
assert "req" not in captured_request
+55
View File
@@ -0,0 +1,55 @@
"""
Smoke test for the MLX backend dependencies on Apple Silicon.
Guards the `--no-deps` install of mlx-audio/mlx-lm done by `just setup-python`
and release.yml: those packages skip their declared dependencies (transformers
>=5.x conflict), so a missing transitive dep only surfaces at import time.
This test fails fast if the MLX STT/TTS entry points the backend uses stop
importing (e.g. the `miniaudio` regression from issue #505).
Usage:
python -m pytest backend/tests/test_mlx_smoke.py -v
"""
import platform
import sys
import pytest
pytestmark = pytest.mark.skipif(
not (sys.platform == "darwin" and platform.machine() == "arm64"),
reason="MLX packages are only installed on Apple Silicon macOS",
)
def test_mlx_core_runs():
"""The MLX runtime itself works (Metal array op)."""
import mlx.core as mx
assert mx.array([1, 2]).sum().item() == 3
def test_mlx_audio_tts_entry_point():
"""`from mlx_audio.tts import load` — used by MLXBackend.load_model_async."""
from mlx_audio.tts import load
assert callable(load)
def test_mlx_audio_stt_entry_point():
"""`from mlx_audio.stt import load` — used by the Whisper MLX STT path.
Importing mlx_audio.stt also pulls in miniaudio, so this catches the
ModuleNotFoundError from issue #505 on fresh installs.
"""
from mlx_audio.stt import load
assert callable(load)
def test_mlx_lm_entry_points():
"""`mlx_lm.load` / `mlx_lm.generate` — used by qwen_llm_backend."""
from mlx_lm import generate, load
assert callable(load)
assert callable(generate)
@@ -0,0 +1,51 @@
"""Errored downloads must not be reported as still downloading.
A failed download intentionally stays in the TaskManager with
``status="error"`` so ``/tasks/active`` can surface the error and retry
UI — but ``/models/status`` derives its ``downloading`` flag from the
same list. Without a status filter, one failed download shows the model
as "downloading" forever and masks its real cache state until the app
restarts (issue #925, symptom reports like #181).
"""
from backend.utils.tasks import TaskManager
def test_errored_download_is_not_pending():
tm = TaskManager()
tm.start_download("whisper-turbo")
assert [t.model_name for t in tm.get_pending_downloads()] == ["whisper-turbo"]
tm.error_download("whisper-turbo", "boom")
assert tm.get_pending_downloads() == []
# Still visible to /tasks/active for the error/retry UI.
active = tm.get_active_downloads()
assert [t.model_name for t in active] == ["whisper-turbo"]
assert active[0].status == "error"
assert active[0].error == "boom"
def test_retry_after_error_is_pending_again():
tm = TaskManager()
tm.start_download("qwen3-4b")
tm.error_download("qwen3-4b", "boom")
tm.start_download("qwen3-4b")
assert [t.model_name for t in tm.get_pending_downloads()] == ["qwen3-4b"]
def test_completed_download_is_removed_everywhere():
tm = TaskManager()
tm.start_download("whisper-turbo")
tm.complete_download("whisper-turbo")
assert tm.get_pending_downloads() == []
assert tm.get_active_downloads() == []
def test_cancel_dismisses_errored_download():
tm = TaskManager()
tm.start_download("whisper-turbo")
tm.error_download("whisper-turbo", "boom")
assert tm.cancel_download("whisper-turbo") is True
assert tm.get_active_downloads() == []
assert tm.get_pending_downloads() == []
+121
View File
@@ -0,0 +1,121 @@
"""
Tests for scripts/package_rocm.py — the ROCm onedir → server + libs splitter.
The classifier can't be validated against a real AMD build on CI hardware, so
these tests pin the file-classification rules against a synthetic onedir layout
that mirrors the PyInstaller --rocm output (torch/lib HIP DLLs + bundled
rocm_sdk runtime packages).
Usage:
python -m pytest backend/tests/test_package_rocm.py -v
"""
import importlib.util
import tarfile
from pathlib import Path
import pytest
_PACKAGE_ROCM = Path(__file__).resolve().parents[2] / "scripts" / "package_rocm.py"
_spec = importlib.util.spec_from_file_location("package_rocm", _PACKAGE_ROCM)
package_rocm = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(package_rocm)
class TestIsRocmFile:
"""Classification of individual files into core vs ROCm libs."""
@pytest.mark.parametrize(
"rel_path",
[
"_internal/torch/lib/amdhip64.dll",
"_internal/torch/lib/rocblas.dll",
"_internal/torch/lib/hipblaslt.dll",
"_internal/torch/lib/miopen.dll",
"_internal/_rocm_sdk_core/amd_comgr.dll",
"_internal/_rocm_sdk_libraries_custom/lib/rocblas/library/TensileLibrary.dat",
"_internal/_rocm_sdk_libraries_custom/lib/miopen/db/kernels.kdb",
# Windows path separators must be handled too.
"_internal\\torch\\lib\\rccl.dll",
],
)
def test_runtime_files_are_rocm(self, rel_path):
assert package_rocm.is_rocm_file(rel_path) is True
@pytest.mark.parametrize(
"rel_path",
[
"voicebox-server-rocm.exe",
"_internal/python312.dll",
"_internal/torch/lib/torch_cpu.dll",
"_internal/torch/lib/c10.dll",
# Pure-python rocm_sdk glue stays in the core, even under an SDK dir.
"_internal/rocm_sdk/__init__.py",
"_internal/_rocm_sdk_core/_dist_info.py",
"_internal/torch/_inductor/codegen/something.py",
],
)
def test_core_files_are_not_rocm(self, rel_path):
assert package_rocm.is_rocm_file(rel_path) is False
def _write(path: Path, content: bytes = b"x"):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
class TestPackage:
"""End-to-end split of a synthetic onedir into the two archives."""
def test_split_and_manifest(self, tmp_path):
onedir = tmp_path / "voicebox-server-rocm"
_write(onedir / "voicebox-server-rocm.exe")
_write(onedir / "_internal" / "python312.dll")
_write(onedir / "_internal" / "rocm_sdk" / "__init__.py")
_write(onedir / "_internal" / "torch" / "lib" / "torch_cpu.dll")
_write(onedir / "_internal" / "torch" / "lib" / "amdhip64.dll")
_write(onedir / "_internal" / "_rocm_sdk_core" / "miopen.dll")
_write(
onedir
/ "_internal"
/ "_rocm_sdk_libraries_custom"
/ "lib"
/ "rocblas"
/ "library"
/ "TensileLibrary.dat"
)
out = tmp_path / "release-assets"
package_rocm.package(onedir, out, "rocm7.2-v1", ">=2.9.0,<2.10.0")
server = out / "voicebox-server-rocm.tar.gz"
libs = out / "rocm-libs-rocm7.2-v1.tar.gz"
assert server.exists()
assert libs.exists()
assert (out / "voicebox-server-rocm.tar.gz.sha256").exists()
assert (out / "rocm-libs-rocm7.2-v1.tar.gz.sha256").exists()
with tarfile.open(libs) as tar:
lib_names = set(tar.getnames())
with tarfile.open(server) as tar:
core_names = set(tar.getnames())
assert "_internal/torch/lib/amdhip64.dll" in lib_names
assert "_internal/_rocm_sdk_core/miopen.dll" in lib_names
assert (
"_internal/_rocm_sdk_libraries_custom/lib/rocblas/library/TensileLibrary.dat"
in lib_names
)
assert "voicebox-server-rocm.exe" in core_names
assert "_internal/torch/lib/torch_cpu.dll" in core_names
assert "_internal/rocm_sdk/__init__.py" in core_names
# Archives must be disjoint.
assert lib_names.isdisjoint(core_names)
def test_empty_rocm_set_exits(self, tmp_path):
onedir = tmp_path / "voicebox-server-rocm"
_write(onedir / "voicebox-server-rocm.exe")
_write(onedir / "_internal" / "torch" / "lib" / "torch_cpu.dll")
with pytest.raises(SystemExit):
package_rocm.package(onedir, tmp_path / "out", "rocm7.2-v1", ">=2.9.0,<2.10.0")
+117
View File
@@ -0,0 +1,117 @@
"""Regression coverage for runaway MLX Qwen TTS output."""
from unittest.mock import patch
import numpy as np
import pytest
from backend.backends import engine_needs_trim, engine_retries_runaway
from backend.utils.audio import has_tts_runaway
from backend.utils.chunked_tts import generate_chunked
SAMPLE_RATE = 1000
def test_mlx_qwen_enables_runaway_retry_without_aggressive_trim():
with patch("backend.backends.get_backend_type", return_value="mlx"):
assert engine_needs_trim("qwen") is False
assert engine_retries_runaway("qwen") is True
def test_pytorch_qwen_keeps_runaway_retry_disabled():
with patch("backend.backends.get_backend_type", return_value="pytorch"):
assert engine_needs_trim("qwen") is False
assert engine_retries_runaway("qwen") is False
def test_detector_flags_long_internal_silence():
speech = np.full(2 * SAMPLE_RATE, 0.2, dtype=np.float32)
runaway_gap = np.zeros(2500, dtype=np.float32)
hallucinated_noise = np.full(2 * SAMPLE_RATE, 0.8, dtype=np.float32)
audio = np.concatenate([speech, runaway_gap, hallucinated_noise])
assert has_tts_runaway(audio, SAMPLE_RATE) is True
def test_detector_ignores_normal_internal_pause():
speech = np.full(SAMPLE_RATE, 0.2, dtype=np.float32)
normal_pause = np.zeros(1200, dtype=np.float32)
audio = np.concatenate([speech, normal_pause, speech])
assert has_tts_runaway(audio, SAMPLE_RATE) is False
def test_trailing_silence_is_not_a_runaway():
speech = np.full(SAMPLE_RATE, 0.2, dtype=np.float32)
trailing_silence = np.zeros(2 * SAMPLE_RATE, dtype=np.float32)
assert (
has_tts_runaway(
np.concatenate([speech, trailing_silence]),
SAMPLE_RATE,
)
is False
)
@pytest.mark.asyncio
async def test_runaway_chunk_is_retried_as_smaller_chunks():
class FakeBackend:
def __init__(self):
self.calls = []
async def generate(self, text, *_args):
self.calls.append(text)
if len(text) > 200:
speech = np.full(SAMPLE_RATE, 0.2, dtype=np.float32)
silence = np.zeros(2500, dtype=np.float32)
noise = np.full(SAMPLE_RATE, 0.8, dtype=np.float32)
return np.concatenate([speech, silence, noise]), SAMPLE_RATE
return np.full(SAMPLE_RATE, 0.2, dtype=np.float32), SAMPLE_RATE
backend = FakeBackend()
text = f"{'A' * 119}. {'B' * 119}."
audio, sample_rate = await generate_chunked(
backend,
text,
{},
max_chunk_chars=800,
crossfade_ms=50,
runaway_detector=has_tts_runaway,
)
assert sample_rate == SAMPLE_RATE
assert backend.calls == [text, f"{'A' * 119}.", f"{'B' * 119}."]
assert len(audio) == 1950
@pytest.mark.asyncio
async def test_persistent_runaway_fails_instead_of_returning_corrupt_audio():
class AlwaysRunawayBackend:
def __init__(self):
self.calls = []
async def generate(self, text, *_args):
self.calls.append(text)
speech = np.full(SAMPLE_RATE, 0.2, dtype=np.float32)
silence = np.zeros(2500, dtype=np.float32)
noise = np.full(SAMPLE_RATE, 0.8, dtype=np.float32)
return np.concatenate([speech, silence, noise]), SAMPLE_RATE
backend = AlwaysRunawayBackend()
text = f"{'A' * 119}. {'B' * 119}."
with pytest.raises(
RuntimeError,
match="remained unstable after retrying smaller text chunks",
):
await generate_chunked(
backend,
text,
{},
max_chunk_chars=800,
runaway_detector=has_tts_runaway,
)
assert [len(call) for call in backend.calls] == [241, 120, 100]
+68
View File
@@ -0,0 +1,68 @@
"""
Phase 2.2 Test: Backend ROCm compatibility.
Validates that check_cuda_compatibility() and other backend utilities
behave correctly on ROCm/AMD hardware.
Usage:
python -m pytest backend/tests/test_rocm_backends.py -v
"""
from unittest.mock import patch
import pytest
class TestCheckCudaCompatibility:
"""Unit tests for check_cuda_compatibility with ROCm awareness."""
def test_no_gpu_returns_compatible(self):
from backend.backends.base import check_cuda_compatibility
with patch("torch.cuda.is_available", return_value=False):
compatible, warning = check_cuda_compatibility()
assert compatible is True
assert warning is None
def test_rocm_skips_compute_check(self):
"""On ROCm, the NVIDIA compute-capability check should be skipped."""
from backend.backends.base import check_cuda_compatibility
with patch("torch.cuda.is_available", return_value=True):
with patch("torch.version.hip", "6.2.41133"):
compatible, warning = check_cuda_compatibility()
assert compatible is True
assert warning is None
def test_cuda_compatible_arch(self):
from backend.backends.base import check_cuda_compatibility
with patch("torch.cuda.is_available", return_value=True):
with patch("torch.version.hip", None):
with patch("torch.cuda.get_device_capability", return_value=(8, 6)):
with patch("torch.cuda.get_device_name", return_value="NVIDIA GeForce RTX 3060"):
with patch.object(
__import__("torch").cuda, "_get_arch_list",
return_value=["sm_80", "sm_86", "sm_89"],
create=True,
):
compatible, warning = check_cuda_compatibility()
assert compatible is True
assert warning is None
def test_cuda_incompatible_arch(self):
from backend.backends.base import check_cuda_compatibility
with patch("torch.cuda.is_available", return_value=True):
with patch("torch.version.hip", None):
with patch("torch.cuda.get_device_capability", return_value=(9, 0)):
with patch("torch.cuda.get_device_name", return_value="NVIDIA GeForce RTX 4090"):
with patch.object(
__import__("torch").cuda, "_get_arch_list",
return_value=["sm_80", "sm_86"],
create=True,
):
compatible, warning = check_cuda_compatibility()
assert compatible is False
assert warning is not None
assert "not supported" in warning
+129
View File
@@ -0,0 +1,129 @@
"""
Phase 1.2 Test: ROCm build script configuration.
Validates that build_binary.py --rocm generates the correct PyInstaller
arguments and optionally performs a true E2E build.
Usage:
python -m pytest backend/tests/test_rocm_build.py -v
python -m pytest backend/tests/test_rocm_build.py -v -m "slow" # include E2E
"""
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
from build_binary import build_server
class TestRocmBuildArgs:
"""Validate PyInstaller arguments for ROCm builds."""
@pytest.fixture
def captured_args(self):
"""Run build_server(rocm=True) with mocked PyInstaller and return args."""
with (
patch("build_binary.PyInstaller.__main__.run") as mock_run,
patch("build_binary.platform.system", return_value="Linux"),
patch("build_binary.os.chdir"),
):
build_server(rocm=True)
return mock_run.call_args[0][0]
def test_binary_name(self, captured_args):
idx = captured_args.index("--name")
assert captured_args[idx + 1] == "voicebox-server-rocm"
def test_pack_mode_is_onedir(self, captured_args):
assert "--onedir" in captured_args
assert "--onefile" not in captured_args
def test_hidden_imports_cuda(self, captured_args):
"""ROCm builds must include torch.cuda hidden imports."""
assert "torch.cuda" in captured_args
def test_no_cudnn_hidden_import_for_rocm(self, captured_args):
"""ROCm builds must NOT include NVIDIA-specific cudnn hidden imports."""
assert "torch.backends.cudnn" not in captured_args
def test_nvidia_excludes_present(self, captured_args):
"""ROCm builds must exclude nvidia packages to avoid bundling ~3GB of bloat."""
excludes = []
for i, arg in enumerate(captured_args):
if arg == "--exclude-module":
excludes.append(captured_args[i + 1])
assert "nvidia" in excludes
assert "nvidia.cudnn" in excludes
class TestRocmBuildCli:
"""Validate CLI argument parsing for --rocm."""
def test_rocm_flag_parses(self):
build_script = Path(__file__).parent.parent / "build_binary.py"
result = subprocess.run(
[sys.executable, str(build_script), "--rocm", "--help"],
capture_output=True,
text=True,
)
assert result.returncode == 0
assert "--rocm" in result.stdout
def test_cannot_combine_cuda_and_rocm(self):
"""Building with both CUDA and ROCm should raise ValueError."""
with pytest.raises(ValueError, match="Cannot build with both CUDA and ROCm"):
build_server(cuda=True, rocm=True)
@pytest.mark.slow()
@pytest.mark.skipif(sys.platform != "win32", reason="ROCm build E2E only runs on Windows")
class TestRocmBuildE2E:
"""
True end-to-end build test.
Executes build_binary.py --rocm, verifies the binary exists, and runs it
with --help to confirm it boots without import errors.
"""
def test_rocm_binary_compiles_and_runs(self, tmp_path):
backend_dir = Path(__file__).parent.parent
build_script = backend_dir / "build_binary.py"
dist_dir = backend_dir / "dist"
binary_dir = dist_dir / "voicebox-server-rocm"
binary_exe = binary_dir / "voicebox-server-rocm.exe"
# Clean previous dist if it exists to ensure a fresh build
if binary_dir.exists():
import shutil
shutil.rmtree(binary_dir)
# Run the full build (this can take several minutes)
result = subprocess.run(
[sys.executable, str(build_script), "--rocm"],
capture_output=True,
text=True,
cwd=str(backend_dir),
timeout=900,
)
assert result.returncode == 0, (
f"Build failed with stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
assert binary_exe.exists(), (
f"Expected binary not found at {binary_exe}"
)
# Run the binary with --help to ensure it boots without import errors
run_result = subprocess.run(
[str(binary_exe), "--help"],
capture_output=True,
text=True,
timeout=60,
)
# A frozen binary may not have argparse help, but it should not crash
# with a ModuleNotFoundError or similar import error.
assert "ModuleNotFoundError" not in run_result.stderr
assert "ImportError" not in run_result.stderr
+203
View File
@@ -0,0 +1,203 @@
"""
Tests for the ROCm backend download service.
Mocks httpx to verify download, extraction, and progress reporting
without hitting the network.
"""
import json
import tarfile
import tempfile
from io import BytesIO
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.services import rocm
from backend.utils.progress import get_progress_manager
@pytest.fixture(autouse=True)
def reset_progress_manager():
"""Reset the global progress manager before each test."""
import backend.utils.progress
backend.utils.progress._progress_manager = None
yield
backend.utils.progress._progress_manager = None
@pytest.fixture
def mock_backends_dir(tmp_path: Path, monkeypatch):
"""Patch get_data_dir so downloads land in a temp directory."""
monkeypatch.setattr(rocm, "get_backends_dir", lambda: tmp_path / "backends")
return tmp_path / "backends"
@pytest.fixture
def fake_tar_gz():
"""Create an in-memory .tar.gz archive containing a dummy file."""
buf = BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
data = b"fake binary content"
info = tarfile.TarInfo(name="voicebox-server-rocm.exe")
info.size = len(data)
tar.addfile(info, BytesIO(data))
buf.seek(0)
return buf.read()
@pytest.fixture
def fake_sha256():
"""Return a dummy SHA-256 hex string."""
return "a" * 64
class FakeResponse:
"""Minimal fake for httpx.Response."""
def __init__(self, content: bytes = b"", status_code: int = 200, headers: dict | None = None):
self.content = content
self.status_code = status_code
self.headers = headers or {}
def raise_for_status(self):
if self.status_code >= 400:
raise Exception(f"HTTP {self.status_code}")
def iter_bytes(self, chunk_size: int = 1024):
for i in range(0, len(self.content), chunk_size):
yield self.content[i : i + chunk_size]
async def aiter_bytes(self, chunk_size: int = 1024):
for i in range(0, len(self.content), chunk_size):
yield self.content[i : i + chunk_size]
@property
def text(self):
return self.content.decode()
class FakeHttpxClient:
"""Minimal fake for httpx.AsyncClient."""
def __init__(self, responses: dict[str, FakeResponse]):
self._responses = responses
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
async def head(self, url: str):
return self._responses.get(url, FakeResponse(status_code=404))
async def get(self, url: str):
return self._responses.get(url, FakeResponse(status_code=404))
def stream(self, method: str, url: str):
resp = self._responses.get(url, FakeResponse(status_code=404))
resp.raise_for_status()
class _Streamer:
async def __aenter__(self):
return resp
async def __aexit__(self, *args):
return False
async def aiter_bytes(self, chunk_size: int = 1024):
for i in range(0, len(resp.content), chunk_size):
yield resp.content[i : i + chunk_size]
return _Streamer()
@pytest.mark.asyncio
async def test_get_rocm_status_not_installed(mock_backends_dir):
status = rocm.get_rocm_status()
assert status["available"] is False
assert status["active"] is False
assert status["binary_path"] is None
assert status["downloading"] is False
@pytest.mark.asyncio
async def test_download_rocm_binary_progress_reporting(mock_backends_dir, fake_tar_gz, fake_sha256):
"""
Verify that download_rocm_binary():
1. Downloads the server archive and ROCm libs archive.
2. Extracts them into the backends/rocm directory.
3. Reports progress via the progress_manager.
"""
import hashlib
server_sha = hashlib.sha256(fake_tar_gz).hexdigest()
libs_sha = hashlib.sha256(fake_tar_gz).hexdigest()
responses = {
"https://github.com/jamiepine/voicebox/releases/download/v0.2.3/voicebox-server-rocm.tar.gz": FakeResponse(
content=fake_tar_gz,
headers={"content-length": str(len(fake_tar_gz))},
),
"https://github.com/jamiepine/voicebox/releases/download/v0.2.3/voicebox-server-rocm.tar.gz.sha256": FakeResponse(
content=f"{server_sha} voicebox-server-rocm.tar.gz\n".encode(),
),
f"https://github.com/jamiepine/voicebox/releases/download/v0.2.3/rocm-libs-{rocm.ROCM_LIBS_VERSION}.tar.gz": FakeResponse(
content=fake_tar_gz,
headers={"content-length": str(len(fake_tar_gz))},
),
f"https://github.com/jamiepine/voicebox/releases/download/v0.2.3/rocm-libs-{rocm.ROCM_LIBS_VERSION}.tar.gz.sha256": FakeResponse(
content=f"{libs_sha} rocm-libs.tar.gz\n".encode(),
),
}
fake_client = FakeHttpxClient(responses)
with patch("httpx.AsyncClient", return_value=fake_client):
await rocm.download_rocm_binary(version="v0.2.3")
# Verify extraction
rocm_dir = rocm.get_rocm_dir()
assert (rocm_dir / "voicebox-server-rocm.exe").exists()
# Verify manifest written
manifest_path = rocm.get_rocm_libs_manifest_path()
assert manifest_path.exists()
data = json.loads(manifest_path.read_text())
assert data["version"] == rocm.ROCM_LIBS_VERSION
# Verify progress was reported
progress = get_progress_manager().get_progress("rocm-backend")
assert progress is not None
assert progress["status"] == "complete"
assert progress["progress"] == 100.0
@pytest.mark.asyncio
async def test_is_rocm_active(mock_backends_dir, monkeypatch):
monkeypatch.setenv("VOICEBOX_BACKEND_VARIANT", "rocm")
assert rocm.is_rocm_active() is True
monkeypatch.setenv("VOICEBOX_BACKEND_VARIANT", "cpu")
assert rocm.is_rocm_active() is False
monkeypatch.delenv("VOICEBOX_BACKEND_VARIANT", raising=False)
assert rocm.is_rocm_active() is False
@pytest.mark.asyncio
async def test_delete_rocm_binary(mock_backends_dir, fake_tar_gz):
"""Test deleting the ROCm backend directory."""
rocm_dir = rocm.get_rocm_dir()
rocm_dir.mkdir(parents=True, exist_ok=True)
(rocm_dir / "dummy.txt").write_text("hello")
result = await rocm.delete_rocm_binary()
assert result is True
assert not rocm_dir.exists()
# Deleting again should return False
result = await rocm.delete_rocm_binary()
assert result is False
+130
View File
@@ -0,0 +1,130 @@
"""
Phase 1.1 Test: ROCm requirements installation.
Validates that requirements-rocm.txt correctly installs ROCm-enabled PyTorch
and that torch.cuda.is_available() returns True on AMD hardware.
Usage:
python -m pytest backend/tests/test_rocm_requirements.py -v
"""
import os
import platform
import subprocess
import sys
import tempfile
from pathlib import Path
import pytest
def _has_amd_hardware():
"""Check if AMD GPU hardware is present on Windows."""
if platform.system() != "Windows":
return False
try:
result = subprocess.run(
[
"powershell",
"-Command",
"Get-WmiObject Win32_VideoController | "
"Where-Object {$_.AdapterCompatibility -like '*AMD*'} | "
"Measure-Object | Select-Object -ExpandProperty Count",
],
capture_output=True,
text=True,
check=True,
)
return int(result.stdout.strip()) > 0
except Exception:
return False
@pytest.fixture()
def backend_dir():
return Path(__file__).parent.parent
class TestRocmRequirements:
"""Validate requirements-rocm.txt content and installation."""
def test_requirements_file_exists(self, backend_dir):
req_file = backend_dir / "requirements-rocm.txt"
assert req_file.exists(), "requirements-rocm.txt must exist"
def test_requirements_file_content(self, backend_dir):
import re
req_file = backend_dir / "requirements-rocm.txt"
content = req_file.read_text()
assert "rocm7.2" in content, "Must point to ROCm 7.2 extra index"
# Parse exact package names to avoid false positives from URL substrings
package_names = re.findall(r"^([A-Za-z][A-Za-z0-9_-]*)", content, re.MULTILINE)
assert "torch" in package_names, "Must include torch package"
assert "torchaudio" in package_names, "Must include torchaudio package"
assert "torchvision" in package_names, "Must include torchvision package"
@pytest.mark.timeout(900)
@pytest.mark.skipif(
not os.environ.get("VOICEBOX_TEST_ROCM_INSTALL"),
reason="Set VOICEBOX_TEST_ROCM_INSTALL=1 to run the heavy install test",
)
def test_rocm_torch_installs_and_detects_amd(self, backend_dir):
"""
Create a temporary venv, install requirements-rocm.txt, and verify
torch.cuda.is_available() returns True on AMD hardware.
"""
req_file = backend_dir / "requirements-rocm.txt"
has_amd = _has_amd_hardware()
with tempfile.TemporaryDirectory() as tmpdir:
venv_dir = Path(tmpdir) / "venv"
subprocess.run(
[sys.executable, "-m", "venv", str(venv_dir)],
check=True,
)
if sys.platform == "win32":
venv_python = venv_dir / "Scripts" / "python.exe"
else:
venv_python = venv_dir / "bin" / "python"
# Upgrade pip to avoid resolver issues
subprocess.run(
[str(venv_python), "-m", "pip", "install", "--upgrade", "pip"],
check=True,
)
# Install ROCm requirements
subprocess.run(
[str(venv_python), "-m", "pip", "install", "-r", str(req_file)],
check=True,
)
# Verify torch imports and cuda availability
result = subprocess.run(
[
str(venv_python),
"-c",
"import torch; print(torch.__version__); print(torch.cuda.is_available())",
],
capture_output=True,
text=True,
check=True,
)
lines = result.stdout.strip().splitlines()
assert len(lines) >= 2, f"Unexpected output: {result.stdout}"
torch_version = lines[0]
cuda_available = lines[1] == "True"
# The honest test: on AMD hardware ROCm torch should report cuda available
if has_amd:
assert cuda_available, (
f"AMD hardware detected but torch.cuda.is_available() returned False. "
f"torch version: {torch_version}, stderr: {result.stderr}"
)
else:
assert not cuda_available, (
f"No AMD hardware detected but torch.cuda.is_available() returned True. "
f"torch version: {torch_version}"
)
+37
View File
@@ -110,6 +110,43 @@ def save_audio(
raise OSError(f"Failed to save audio to {path}: {e}") from e
def has_tts_runaway(
audio: np.ndarray,
sample_rate: int = 24000,
frame_ms: int = 20,
silence_threshold_db: float = -40.0,
max_internal_silence_ms: int = 2000,
) -> bool:
"""Detect speech followed by a long silence and then more output.
This shape is a reliable signal that a TTS model missed EOS and resumed
with hallucinated speech or codec noise. Leading and trailing silence do
not count because they are not bounded by non-silent audio.
"""
frame_len = int(sample_rate * frame_ms / 1000)
if frame_len == 0 or len(audio) < frame_len:
return False
n_frames = len(audio) // frame_len
threshold_linear = 10 ** (silence_threshold_db / 20)
max_silence_frames = int(max_internal_silence_ms / frame_ms)
seen_speech = False
consecutive_silence = 0
for i in range(n_frames):
frame = audio[i * frame_len : (i + 1) * frame_len]
is_speech = np.sqrt(np.mean(frame**2)) >= threshold_linear
if is_speech:
if seen_speech and consecutive_silence >= max_silence_frames:
return True
seen_speech = True
consecutive_silence = 0
elif seen_speech:
consecutive_silence += 1
return False
def trim_tts_output(
audio: np.ndarray,
sample_rate: int = 24000,
+65 -17
View File
@@ -20,6 +20,8 @@ logger = logging.getLogger("voicebox.chunked-tts")
# Default chunk size in characters. Can be overridden per-request via
# the ``max_chunk_chars`` field on GenerationRequest.
DEFAULT_MAX_CHUNK_CHARS = 800
MAX_RUNAWAY_RETRIES = 2
MIN_RUNAWAY_RETRY_CHARS = 100
# Common abbreviations that should NOT be treated as sentence endings.
# Lowercase for case-insensitive matching.
@@ -211,6 +213,7 @@ async def generate_chunked(
max_chunk_chars: int = DEFAULT_MAX_CHUNK_CHARS,
crossfade_ms: int = 50,
trim_fn=None,
runaway_detector=None,
) -> Tuple[np.ndarray, int]:
"""Generate audio with automatic chunking for long text.
@@ -239,25 +242,75 @@ async def generate_chunked(
Optional ``(audio, sample_rate) -> audio`` post-processing
function applied to each chunk before concatenation (e.g.
``trim_tts_output`` for Chatterbox engines).
runaway_detector : callable | None
Optional ``(audio, sample_rate) -> bool`` detector. When it flags
unstable output, the affected text is split in half and retried.
Returns
-------
(audio, sample_rate) : Tuple[np.ndarray, int]
"""
async def generate_one(
chunk_text: str,
chunk_seed: int | None,
retry_depth: int = 0,
) -> tuple[np.ndarray, int]:
chunk_audio, chunk_sr = await backend.generate(
chunk_text,
voice_prompt,
language,
chunk_seed,
instruct,
)
if runaway_detector is not None and runaway_detector(chunk_audio, chunk_sr):
if retry_depth >= MAX_RUNAWAY_RETRIES or len(chunk_text) <= MIN_RUNAWAY_RETRY_CHARS:
raise RuntimeError(
"TTS output remained unstable after retrying smaller text chunks"
)
retry_max_chars = max(MIN_RUNAWAY_RETRY_CHARS, len(chunk_text) // 2)
retry_chunks = split_text_into_chunks(chunk_text, retry_max_chars)
if len(retry_chunks) <= 1:
raise RuntimeError("Unable to split unstable TTS output for retry")
logger.warning(
"Detected unstable TTS output for %d chars; retrying as %d smaller chunks",
len(chunk_text),
len(retry_chunks),
)
retry_audio: list[np.ndarray] = []
for i, retry_text in enumerate(retry_chunks):
retry_seed = (
chunk_seed + ((retry_depth + 1) * 1000) + i
if chunk_seed is not None
else None
)
audio, sample_rate = await generate_one(
retry_text,
retry_seed,
retry_depth + 1,
)
retry_audio.append(np.asarray(audio, dtype=np.float32))
return (
concatenate_audio_chunks(
retry_audio,
sample_rate,
crossfade_ms=crossfade_ms,
),
sample_rate,
)
if trim_fn is not None:
chunk_audio = trim_fn(chunk_audio, chunk_sr)
return np.asarray(chunk_audio, dtype=np.float32), chunk_sr
chunks = split_text_into_chunks(text, max_chunk_chars)
if len(chunks) <= 1:
# Short text — single-shot fast path
audio, sample_rate = await backend.generate(
text,
voice_prompt,
language,
seed,
instruct,
)
if trim_fn is not None:
audio = trim_fn(audio, sample_rate)
return audio, sample_rate
return await generate_one(text, seed)
# Long text — chunked generation
logger.info(
@@ -281,17 +334,12 @@ async def generate_chunked(
# always produces the same output.
chunk_seed = (seed + i) if seed is not None else None
chunk_audio, chunk_sr = await backend.generate(
chunk_audio, chunk_sr = await generate_one(
chunk_text,
voice_prompt,
language,
chunk_seed,
instruct,
)
if trim_fn is not None:
chunk_audio = trim_fn(chunk_audio, chunk_sr)
audio_chunks.append(np.asarray(chunk_audio, dtype=np.float32))
audio_chunks.append(chunk_audio)
if sample_rate is None:
sample_rate = chunk_sr
+53
View File
@@ -3,6 +3,8 @@ Platform detection for backend selection.
"""
import platform
import subprocess
from functools import lru_cache
from typing import Literal
@@ -16,6 +18,57 @@ def is_apple_silicon() -> bool:
return platform.system() == "Darwin" and platform.machine() == "arm64"
@lru_cache(maxsize=1)
def is_amd_gpu_windows() -> bool:
"""
Check if the primary GPU on Windows is an AMD Radeon card.
Uses WMI to query Win32_VideoController, with a fallback to
torch.cuda.get_device_name(0) if WMI is unavailable. This is
useful for deciding whether the ROCm backend is appropriate.
Result is cached since it shells out to PowerShell and the GPU
does not change at runtime — safe to call from the health path.
Returns:
True if an AMD GPU is detected on Windows, False otherwise.
"""
if platform.system() != "Windows":
return False
# Primary method: WMI query for AMD adapters
try:
result = subprocess.run(
[
"powershell",
"-Command",
"Get-CimInstance Win32_VideoController | "
"Where-Object {$_.AdapterCompatibility -like '*AMD*'} | "
"Measure-Object | Select-Object -ExpandProperty Count",
],
capture_output=True,
text=True,
check=True,
)
if int(result.stdout.strip()) > 0:
return True
except Exception:
pass
# Fallback: torch.cuda.get_device_name(0) (works for ROCm/HIP too)
try:
import torch
if torch.cuda.is_available():
name = torch.cuda.get_device_name(0)
if "Radeon" in name or "AMD" in name:
return True
except Exception:
pass
return False
def get_backend_type() -> Literal["mlx", "pytorch"]:
"""
Detect the best backend for the current platform.
+13
View File
@@ -68,6 +68,19 @@ class TaskManager:
"""Get all active downloads."""
return list(self._active_downloads.values())
def get_pending_downloads(self) -> List[DownloadTask]:
"""Get downloads that are still in flight.
Excludes errored tasks, which stay in the active list so the
error/retry UI can show them but must not be reported as
"downloading" by /models/status.
"""
return [
task
for task in self._active_downloads.values()
if task.status in ("downloading", "extracting")
]
def get_active_generations(self) -> List[GenerationTask]:
"""Get all active generations."""
return list(self._active_generations.values())
-5
View File
@@ -57,7 +57,6 @@
"react-dom": "^18.3.0",
"react-hook-form": "^7.53.0",
"react-i18next": "^17.0.4",
"react-qr-code": "^2.0.18",
"react-sound-visualizer": "^1.4.0",
"tailwind-merge": "^2.5.4",
"wavesurfer.js": "^7.0.0",
@@ -1006,8 +1005,6 @@
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qr.js": ["[email protected]", "", {}, "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ=="],
"queue-microtask": ["[email protected]", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
@@ -1022,8 +1019,6 @@
"react-loaders": ["[email protected]", "", { "dependencies": { "classnames": "^2.2.3" }, "peerDependencies": { "prop-types": ">=15.6.0", "react": ">=15" } }, "sha512-4igMNqs9Fb3d4Z+0UHIGQNJsw/37gX0nUO8QxupnEKRn1dtyYC1LGwk5GuaoDciMQCQc/MmPwb4Fn6ZfdoX1FQ=="],
"react-qr-code": ["[email protected]", "", { "dependencies": { "prop-types": "^15.8.1", "qr.js": "0.0.0" }, "peerDependencies": { "react": "*" } }, "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg=="],
"react-refresh": ["[email protected]", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-remove-scroll": ["[email protected]", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
+48
View File
@@ -0,0 +1,48 @@
---
# ROCm (AMD GPU) overlay for Voicebox
#
# docker compose -f docker-compose.yml -f docker-compose.rocm.yml up --build
#
# Requires ROCm drivers on the host:
# https://rocm.docs.amd.com/projects/install-on-linux
# RDNA4 (RX 9000): export ROCM_VERSION=7.2 (default 6.3 covers RDNA1-3).
services:
voicebox:
build:
context: .
args:
PYTORCH_VARIANT: rocm
ROCM_VERSION: ${ROCM_VERSION:-6.3}
devices:
- /dev/kfd
- /dev/dri
environment:
# HSA_OVERRIDE_GFX_VERSION forces the ROCm runtime to treat the GPU as a
# specific GFX version when auto-detection fails or the GPU is newer than
# the ROCm release. app.py sets 10.3.0 (RDNA2) by default; override here
# for your GPU family:
# RDNA4 / RX 9000 series: 12.0.0
# (requires ROCM_VERSION=7.2)
# RDNA3 / RX 7000 series / Strix Halo: 11.0.0
# RDNA2 / RX 6000 series: 10.3.0
# RDNA1 / RX 5000 series: 10.1.0
# Vega / GCN5: 9.0.0
- HSA_OVERRIDE_GFX_VERSION=${HSA_OVERRIDE_GFX_VERSION:-}
# Tune the ROCm memory allocator
- PYTORCH_HIP_ALLOC_CONF=garbage_collection_threshold:0.8,max_split_size_mb:512
# Redirect MIOpen kernel cache to a writable, persistent directory.
# Without this, MIOpen may fail to write its cache and throw
# miopenStatusUnknownError on fresh containers.
- MIOPEN_USER_DB_PATH=/app/data/cache/miopen_db
- MIOPEN_CUSTOM_CACHE_DIR=/app/data/cache/miopen_cache
# Use fast heuristics for kernel selection instead of exhaustive
# benchmarking. On RDNA4, exhaustive mode tries kernels that fail to
# allocate workspace memory (ptr: 0 size: 0), causing system stuttering
# on every generation even when the cache is present.
- MIOPEN_FIND_MODE=FAST
+4
View File
@@ -1,3 +1,7 @@
# Voicebox — CPU build (default)
# For AMD ROCm GPU acceleration use the overlay:
# docker compose -f docker-compose.yml -f docker-compose.rocm.yml up --build
services:
voicebox:
build: .
+74 -3
View File
@@ -1,6 +1,6 @@
# Voicebox Project Status & Roadmap
> Last updated: 2026-06-27 | Current version: **v0.5.0** | 402 open issues | 88 open PRs | 1.3M downloads · 34.8k stars
> Last updated: 2026-07-02 | Current version: **v0.5.0** | 402 open issues | 88 open PRs | 1.3M downloads · 34.8k stars
---
@@ -218,6 +218,19 @@ Shipped 2026-04-25 (PR #544). Voicebox went from a voice-cloning studio to a ful
**Integration shape if we revive it:** Zero-shot cloning maps naturally to the Chatterbox-style backend (store `ref_audio` + `ref_text` paths in the voice prompt dict, process at generate time). Est. ~250 lines for `voxcpm_backend.py` + one `ModelConfig` entry + engine registration in `backends/__init__.py`. Frontend UI gating is the bigger lift.
### Funded Roadmap (2026-H2)
`$VOICEBOX` funded ~2–3 months of full-time work; cadence resumes the week of 2026-06-27. Direction committed publicly in #806:
| Item | Notes |
|------|-------|
| **Resume merge/release cadence** | Clear the 88-PR backlog, regular commits + releases — this is the immediate focus (see Tier 1) |
| **Mobile companion app** | New surface; already drawing issues (#773 iPhone logout) |
| **Encrypted cloud backup/sync** | For voice profiles + generations — first cloud feature; stays opt-in, local-first remains default |
| **More TTS models** | Engine candidates in the Landscape section below; community PRs #507/#766/#777 in queue |
| **Better GPU support** | Blackwell/sm_120, ROCm, DirectML, Intel — incl. paying testers for hardware the dev lacks |
| **Bug fixes** | 0.5.0 regression cluster first (macOS load crash, capture cutoffs, MCP, refinement) |
### What's In-Flight
| Feature | Branch/PR | Status |
@@ -226,7 +239,7 @@ Shipped 2026-04-25 (PR #544). Voicebox went from a voice-cloning studio to a ful
| Engine sprawl cleanup | issue #419 | First-class vs experimental TTS backends distinction |
| Frontend tech-debt burn-down | issue #421 | Biome + a11y debt before gating CI |
| Docker registry auto-publish | PR #463, issue #453 | ghcr.io image on tag push |
| New model research | `voicebox-new-models` branch | Evaluating Fish Speech, XTTS-v2, Pocket TTS, VibeVoice, Fish Audio S2, index-tts2 |
| New model research | `voicebox-new-models` branch | Evaluating Fish Speech, XTTS-v2, Pocket TTS, VibeVoice, Fish Audio S2, index-tts2. **2026-06-27 sweep** added dots.tts, LongCat-AudioDiT, SoproTTS, NeuTTS, Nemotron/Cohere STT — see Landscape → New Candidate Sweep |
### TTS Engine Comparison
@@ -592,6 +605,43 @@ Notable:
4. **Instruct support fills a real gap** (#173, #224, #303). Qwen CustomVoice partially addresses it with preset speakers; zero-shot clone-with-instruct is still unmet.
5. **Long-form + streaming are user-requested** (#363, #365, #464). Candidates with native streaming (Pocket TTS, Fish Speech) get extra weight.
### New Candidate Sweep (2026-06-27)
A follow-up deep-research pass, filtered against everything already tracked — the shipped engines plus MOSS-TTS-Nano, Pocket TTS, IndicF5, VibeVoice, Voxtral, Fish/Fish Audio, XTTS-v2, index-tts2, VoxCPM2, OmniVoice, MioTTS, Oolel, Faster-Qwen, Orpheus/Sesame, MiniMax, RVC, Parakeet, Qwen3-ASR, Moshi, GLM-4-Voice, Qwen2.5-Omni — kept only where a **newer sibling/variant** changes the evaluation. Same criteria as the 04-18 cycle: cross-platform, PyPI/clean packaging, permissive license, quality, instruct/style control, long-form, streaming.
**Top new TTS candidates**
| Candidate | Add as | Why it matters | Caveat |
|-----------|--------|----------------|--------|
| **[dots.tts](https://github.com/rednote-hilab/dots.tts)** (soar / mf) | **Top new TTS candidate** | 2B fully-continuous end-to-end autoregressive TTS, 48 kHz AudioVAE output, zero-shot cloning via prompt audio/text, Apache-2.0 code+checkpoints, MeanFlow-distilled variant for low latency. Freshest "serious clone engine" not yet on the roadmap. | Git-source install with constraints, not clean PyPI. Needs Windows/macOS packaging + VRAM/CPU smoke test; probably experimental until platform gating exists. |
| **[MOSS-TTS family](https://github.com/OpenMOSS/MOSS-TTS)** / v1.5 / Local-Transformer-v1.5 | **Upgrade the MOSS-Nano entry into a MOSS family epic** | We track only Nano, but MOSS now spans MOSS-TTS, TTSD (long multi-speaker dialogue), VoiceGenerator (text-prompt voice design), TTS-Realtime, SoundEffect. v1.5 adds broader languages, long-reference cloning, pause control, 48 kHz stereo, MLX/vLLM support, Apache-2.0. | Full 4B/8B variants aren't the lightweight Nano win. Treat as several engines/features, not one checkbox. |
| **[LongCat-AudioDiT](https://arxiv.org/html/2603.29339v1)** | **High-priority Apple Silicon candidate** | 3.5B non-autoregressive diffusion TTS in waveform latent space, zero-shot cloning, already has an MLX conversion usable via `mlx_audio` — unusually aligned with our Apple Silicon base. | zh/en only, not realtime. Quality play, not low-latency agent speech. |
| **[SoproTTS](https://github.com/samuel-vitorino/sopro)** | **Lightweight CPU/streaming cloned TTS** | 135M zero-shot cloning, `pip install -U sopro`, streaming + non-streaming APIs, 3–12s reference, claimed 250 ms TTFA / 0.05 RTF on M3 CPU. Strong local-first/low-maintenance fit. | English-focused, self-described as inconsistent — quality-test before promoting past experimental. |
| **[NeuTTS Air / Nano](https://github.com/neuphonic/neutts)** | **GGUF/on-device cloned TTS** | On-device instant cloning, GGUF-ready, ~3s reference, laptop/phone/Pi targets. Air is Apache-2.0. | Needs a GGUF/llama.cpp-style wrapper, not a normal PyTorch backend. Nano has a separate NeuTTS Open License — split needs review. |
| **[X-Voice](https://github.com/sunnyxrxrx/X-Voice)** | **Small multilingual clone** | 0.4B multilingual zero-shot cloning, 30 languages, IPA-style unified rep, claims no prompt-transcript requirement — targets a real cloning-UX pain point. | Verify license, packaging, production-readiness of weights/code. |
| **[FireRedTTS-2](https://huggingface.co/FireRedTeam/FireRedTTS2)** | **Stories / podcast / multi-speaker** | Apache-2.0 long-form streaming, 3-min / 4-speaker dialogue, cross-lingual code-switching cloning, low first-packet latency. | Stories-editor engine more than a general default. Needs platform/VRAM testing. |
| **[Maya1](https://huggingface.co/maya-research/maya1)** | **Expressive English voice-design** | 3B Apache-2.0, voice design, streaming, emotion/style tags, vLLM-compatible, 24 kHz, single-GPU. Good "voice personalities" / game-dialogue fit. | English-only, 16 GB+ VRAM — platform gating required. |
**MOSS is now a family, not one checkbox.** The single `MOSS-TTS-Nano` row above should become an epic: keep Nano as the CPU-friendly model, and track v1.5 / Local-Transformer-v1.5, Realtime, TTSD, VoiceGenerator, and SoundEffect as siblings under it.
**STT / capture candidates** (feed the planned streaming-transcription roadmap)
| Candidate | Add as | Why it matters | Caveat |
|-----------|--------|----------------|--------|
| **[Nemotron 3.5 ASR Streaming 0.6B](https://huggingface.co/mlx-community/nemotron-3.5-asr-streaming-0.6b)** | **Top new STT candidate** | Cache-aware streaming FastConformer-RNNT, 40 language-locales, punctuation/caps, language-ID conditioning, MLX conversion path — strongest fit for planned streaming transcription. | NVIDIA-origin; verify license + non-CUDA (MLX/CPU) performance. |
| **[Cohere Transcribe 03-2026](https://huggingface.co/blog/CohereLabs/cohere-transcribe-03-2026-release)** | **High-quality offline STT** | 2B Apache-2.0, 14 languages, ONNX/INT8 exports across CPU / Apple Silicon / GPU. Cleanest-looking offline `/transcribe` + captures candidate. | Less clearly a streaming dictation model than Nemotron. |
| **[ARK-ASR 3B / 0.6B](https://huggingface.co/AutoArk-AI/ARK-ASR-3B)** | **Multilingual STT watch** | New family, broad European/Asian coverage, strong leaderboard claims, INT8 ONNX for edge. | Very new; likely `trust_remote_code`. Validate stability first. |
| **[IBM Granite Speech 4.1 2B / NAR](https://huggingface.co/ibm-granite/granite-speech-4.1-2b)** | **ASR + speech translation** | Compact multilingual ASR + bidirectional speech translation (en/fr/de/es/pt/ja); NAR variant for latency-sensitive work. | More compelling if we expand into translation, not just dictation. |
**Watch-list / blocked** (license or platform work must land first): LEMAS-TTS, Supertonic 3, KugelAudio, GLM-TTS, KittenTTS, TinyTTS (preset/on-device, not cloning); Sarashina2.2, Higgs Audio v3, T5Gemma-TTS, Step-Audio-EditX, MisoTTS (non-commercial terms or CUDA-heavy); MegaTTS3 (incomplete WaveVAE encoder distribution); PFluxTTS, LongCat-Next (paper-only / too broad). **Low-hanging Qwen-family variants:** `Qwen3-TTS-VoiceDesign` (fills text-to-voice-design with minimal churn) and ZipVoice/ZipVoice-Dialog (only if it brings zh-en/dialogue behavior our shipped LuxTTS doesn't already expose).
**Roadmap patch from this sweep** (reflected in Tier 3 below):
1. Replace the `MOSS-TTS-Nano` checkbox with a **MOSS-TTS family** epic (Nano tracked separately as the CPU model).
2. New Tier-3 TTS candidates, in order: **dots.tts → LongCat-AudioDiT → SoproTTS → NeuTTS → X-Voice → FireRedTTS-2 → Maya1**.
3. New STT expansion candidates, in order: **Nemotron 3.5 → Cohere Transcribe → ARK-ASR → Granite Speech**.
4. Keep Sarashina2.2, Higgs v3, T5Gemma, Step-Audio-EditX, MisoTTS, MegaTTS3, PFluxTTS blocked/watch-only.
5. **Do platform gating (bottleneck #6 / `ModelConfig.requires`) before shipping GPU-only engines** — Maya1, Step-Audio-EditX, MisoTTS, and probably dots.tts stay experimental until it exists.
### Adding a New Engine (Now Straightforward)
With the model config registry and shared `EngineModelSelector` component, adding a new TTS engine requires:
@@ -675,9 +725,11 @@ The two-month gap means the highest-leverage work isn't new code — it's review
### Tier 3 — Future Engines (cross-platform preferred)
Committed ordering (04-18 cycle), then the 2026-06-27 sweep additions. See Landscape → New Candidate Sweep for full rationale.
| Priority | Item | Notes |
|----------|------|-------|
| 1 | **MOSS-TTS-Nano** | 0.1B, Apache 2.0, 4-core CPU realtime, 48 kHz stereo, streaming, 20 langs, released 2026-04-13. Best alignment with our criteria. Verify install ergonomics before committing. |
| 1 | **MOSS-TTS family** (was MOSS-TTS-Nano) | Nano first: 0.1B, Apache 2.0, 4-core CPU realtime, 48 kHz stereo, streaming, 20 langs. Best alignment with our criteria. Then track v1.5 / Realtime / TTSD / VoiceGenerator / SoundEffect as siblings under one epic. |
| 2 | **Pocket TTS** (Kyutai) | CPU-first 100M model. MIT. Fills streaming gap without CUDA dependency. Several European langs added by Feb 2026. |
| 3 | **IndicF5** | Fills Indian-language gap (#339). Closes many language-request issues. |
| 4 | **VibeVoice** (Microsoft, #172) | 1.5B, long-form multi-speaker (up to 90 min, 4 speakers). Strong Stories-editor fit. |
@@ -686,6 +738,25 @@ The two-month gap means the highest-leverage work isn't new code — it's review
| 7 | **XTTS-v2** | 17+ langs, mature pip. CPML likely kills commercial use — verify. |
| 8 | **index-tts2** (#370) | Unvetted. |
| — | ~~**VoxCPM2**~~ | **Backlogged** — CUDA-only upstream. Revisit when tier system ships or MPS bugs are fixed upstream. |
| — | *New (06-27 sweep), in order* → | |
| 9 | **dots.tts** | 2B end-to-end AR, 48 kHz, Apache-2.0 + fast MeanFlow variant. Top new candidate. Git-source install — smoke-test packaging + VRAM; likely experimental until platform gating exists. |
| 10 | **LongCat-AudioDiT** | 3.5B diffusion, has an MLX/`mlx_audio` path — best Apple Silicon fit. zh/en only, not realtime. |
| 11 | **SoproTTS** | 135M, `pip install sopro`, streaming, ~250 ms TTFA / 0.05 RTF on M3 CPU. Quality-test first. |
| 12 | **NeuTTS Air/Nano** | On-device GGUF cloning, ~3s reference. Needs a GGUF wrapper; Air is Apache-2.0, Nano license split needs review. |
| 13 | **X-Voice** | 0.4B, 30 langs, no prompt-transcript required. Verify license/packaging. |
| 14 | **FireRedTTS-2** | Apache-2.0 long-form multi-speaker/podcast streaming. Stories-editor engine; needs VRAM testing. |
| 15 | **Maya1** | 3B Apache-2.0 expressive voice-design, emotion tags. English-only, 16 GB+ VRAM — gate behind platform tiers. |
### Tier 3b — STT / Capture Candidates (06-27 sweep)
Feeds the planned streaming-transcription roadmap; Whisper alternatives.
| Priority | Item | Notes |
|----------|------|-------|
| 1 | **Nemotron 3.5 ASR Streaming 0.6B** | Cache-aware streaming FastConformer-RNNT, 40 locales, MLX path. Strongest streaming-dictation fit. Verify license + non-CUDA perf. |
| 2 | **Cohere Transcribe 03-2026** | 2B Apache-2.0, 14 langs, ONNX/INT8 across CPU/Apple Silicon/GPU. Cleanest offline `/transcribe` candidate. |
| 3 | **ARK-ASR 3B / 0.6B** | Broad multilingual, INT8 ONNX for edge. Very new; likely `trust_remote_code` — validate stability. |
| 4 | **IBM Granite Speech 4.1 2B / NAR** | ASR + speech translation (en/fr/de/es/pt/ja). Compelling if we expand into translation. |
### ~~Previously Prioritized — Now Done~~
@@ -49,6 +49,7 @@ class ModelConfig:
model_size: str = "default"
size_mb: int = 0
needs_trim: bool = False
retries_runaway: bool = False
supports_instruct: bool = False
languages: list[str] = field(default_factory=lambda: ["en"])
```
@@ -59,6 +60,7 @@ Registry helpers in `backends/__init__.py` replace what used to be per-engine `i
- `get_tts_model_configs()` — only TTS variants
- `get_model_config(model_name)` — lookup by name
- `engine_needs_trim(engine)` — whether output should run through `trim_tts_output()`
- `engine_retries_runaway(engine)` — whether unstable output should be retried as smaller chunks
- `load_engine_model(engine, model_size)` — downloads + loads, handles engines with multiple sizes
- `get_tts_backend_for_engine(engine)` — thread-safe backend factory with double-checked locking
@@ -152,7 +154,7 @@ The request path from frontend to audio file:
6. **Inference** — the engine's `generate()` returns `(audio_array, sample_rate)`.
7. **Post-process** — if `engine_needs_trim(engine)` is True, `trim_tts_output()` strips trailing silence. Effects chains (if any) are applied per generation version, not the clean version.
7. **Validate and post-process** — engines with `retries_runaway=True` retry unstable output as smaller chunks. If `engine_needs_trim(engine)` is True, `trim_tts_output()` strips trailing silence. Effects chains (if any) are applied per generation version, not the clean version.
8. **Persist** — audio is written to the generations directory, a row is inserted into the `generations` table, and the response includes the generation metadata.
@@ -23,7 +23,7 @@ This page is for the cases where it doesn't:
| **Windows + NVIDIA** | PyTorch CUDA (cu128) | Auto-downloads the CUDA backend binary on first use |
| **Windows + Intel Arc** | PyTorch XPU (IPEX) | New in 0.4 — works with Arc A-series and B-series |
| **Windows generic GPU** | DirectML | Universal Windows GPU support; slower than CUDA |
| **Linux + NVIDIA** | PyTorch CUDA (cu128) | Same auto-download flow as Windows |
| **Linux + NVIDIA** | PyTorch CUDA (cu128) | Use a local/remote Python backend with CUDA PyTorch |
| **Linux + AMD** | PyTorch ROCm | Auto-configures `HSA_OVERRIDE_GFX_VERSION` |
| **Linux + Intel Arc** | PyTorch XPU (IPEX) | |
| **Any (no GPU)** | PyTorch CPU | Works everywhere; expect 5-50x slower than GPU |
@@ -46,7 +46,7 @@ On M-series Macs, Voicebox ships an MLX-optimized backend that uses the Apple Ne
The Whisper Turbo + MLX combo dropped transcription latency from ~20s to ~2-3s on M-series chips (see CHANGELOG entry for v0.1.10).
## Windows / Linux + NVIDIA — The CUDA Backend Swap
## Windows + NVIDIA — The CUDA Backend Swap
Voicebox doesn't bundle CUDA into the main installer (it would balloon downloads to multi-gigabyte territory for users who don't have an NVIDIA GPU). Instead, when you first need it, the app downloads a separate **CUDA backend binary** that contains the PyTorch + CUDA runtime.
+2 -1
View File
@@ -75,7 +75,8 @@ No cloud fallback, no bring-your-own-API-key. Local is the product.
| Platform | Backend | Notes |
|----------|---------|-------|
| macOS (Apple Silicon) | MLX (Metal) | 4-5x faster via Neural Engine |
| Windows / Linux (NVIDIA) | PyTorch (CUDA) | Auto-downloads CUDA binary from within the app |
| Windows (NVIDIA) | PyTorch (CUDA) | Auto-downloads CUDA binary from within the app |
| Linux (NVIDIA) | PyTorch (CUDA) | Use a local/remote Python backend with CUDA PyTorch |
| Linux (AMD) | PyTorch (ROCm) | Auto-configures HSA_OVERRIDE_GFX_VERSION |
| Windows (any GPU) | DirectML | Universal Windows GPU support |
| Intel Arc | IPEX/XPU | Intel discrete GPU acceleration |
+4 -4
View File
@@ -14,12 +14,12 @@ Make sure you have [installed Voicebox](/overview/installation) and launched the
Voice profiles are the foundation of Voicebox. Each profile contains voice samples that the AI uses to clone the voice.
<Steps>
<Step title="Navigate to Profiles">
Click the **Profiles** tab in the sidebar
<Step title="Navigate to Voices">
Click the **Voices** tab in the sidebar
</Step>
<Step title="Create New Profile">
Click the **+ New Profile** button
<Step title="Create New Voice">
Click the **+ New Voice** button
Fill in the details:
- **Name:** A descriptive name (e.g., "John Smith")
+37 -7
View File
@@ -43,6 +43,26 @@ setup-python:
fi
echo "Installing Python dependencies..."
{{ pip }} install --upgrade pip -q
if [ "$(uname)" = "Linux" ]; then
torch_index=""
if [ -e /proc/driver/nvidia/version ] || [ -d /sys/module/nvidia ]; then
echo "Detected NVIDIA GPU — installing CUDA PyTorch..."
torch_index="https://download.pytorch.org/whl/cu128"
elif [ -e /dev/kfd ]; then
if [ -n "${VOICEBOX_ROCM_VERSION:-}" ]; then
rocm_ver="$VOICEBOX_ROCM_VERSION"
elif lspci 2>/dev/null | grep -qi "Navi 4"; then
rocm_ver=7.2
else
rocm_ver=6.3
fi
echo "Detected AMD GPU — installing ROCm PyTorch (rocm${rocm_ver})..."
torch_index="https://download.pytorch.org/whl/rocm${rocm_ver}"
fi
if [ -n "$torch_index" ]; then
{{ pip }} install torch torchaudio --index-url "$torch_index"
fi
fi
{{ pip }} install -r {{ backend_dir }}/requirements.txt
# Chatterbox pins numpy<1.26 / torch==2.6 which break on Python 3.12+
{{ pip }} install --no-deps chatterbox-tts
@@ -52,6 +72,12 @@ setup-python:
if [ "$(uname -m)" = "arm64" ] && [ "$(uname)" = "Darwin" ]; then
echo "Detected Apple Silicon — installing MLX dependencies..."
{{ pip }} install -r {{ backend_dir }}/requirements-mlx.txt
# mlx-lm and mlx-audio declare transformers>=5.x, which conflicts with
# our transformers<=4.57.x cap, so install them --no-deps (their other
# runtime deps are covered by requirements.txt / requirements-mlx.txt —
# see the note in requirements-mlx.txt and .github/workflows/release.yml)
{{ pip }} install --no-deps mlx-lm==0.31.1
{{ pip }} install --no-deps mlx-audio==0.4.1
fi
{{ pip }} install git+https://github.com/QwenLM/Qwen3-TTS.git
{{ pip }} install pyinstaller ruff pytest pytest-asyncio -q
@@ -69,10 +95,10 @@ setup-python:
}
Write-Host "Installing Python dependencies..."
& "{{ python }}" -m pip install --upgrade pip -q
$gpus = Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name
Write-Host "Detected GPUs: $($gpus -join ', ')"
$hasNvidia = ($gpus | Where-Object { $_ -match 'NVIDIA' }).Count -gt 0
$hasIntelArc = ($gpus | Where-Object { $_ -match 'Arc' }).Count -gt 0
$gpus = Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name; \
Write-Host "Detected GPUs: $($gpus -join ', ')"; \
$hasNvidia = ($gpus | Where-Object { $_ -match 'NVIDIA' }).Count -gt 0; \
$hasIntelArc = ($gpus | Where-Object { $_ -match 'Arc' }).Count -gt 0; \
if ($hasNvidia) { \
Write-Host "NVIDIA GPU detected — installing PyTorch with CUDA support..."; \
& "{{ pip }}" install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128; \
@@ -206,12 +232,16 @@ build-server: _ensure-venv
build-server: _ensure-venv
$ErrorActionPreference = "Stop"; \
$env:PATH = "{{ venv_bin }};$env:PATH"; \
& "{{ python }}" backend/build_binary.py; \
if ($LASTEXITCODE -ne 0) { throw "build_binary.py failed with exit code $LASTEXITCODE" }; \
$triple = (rustc --print host-tuple); \
New-Item -ItemType Directory -Path "{{ tauri_dir }}/src-tauri/binaries" -Force | Out-Null; \
& "{{ python }}" backend/build_binary.py; \
if ($LASTEXITCODE -ne 0) { throw "build_binary.py failed with exit code $LASTEXITCODE" }; \
Copy-Item "backend/dist/voicebox-server.exe" "{{ tauri_dir }}/src-tauri/binaries/voicebox-server-$triple.exe" -Force; \
Write-Host "Copied sidecar: voicebox-server-$triple.exe"
Write-Host "Copied sidecar: voicebox-server-$triple.exe"; \
& "{{ python }}" backend/build_binary.py --shim; \
if ($LASTEXITCODE -ne 0) { throw "build_binary.py --shim failed with exit code $LASTEXITCODE" }; \
Copy-Item "backend/dist/voicebox-mcp.exe" "{{ tauri_dir }}/src-tauri/binaries/voicebox-mcp-$triple.exe" -Force; \
Write-Host "Copied sidecar: voicebox-mcp-$triple.exe"
# Build CUDA server binary and place in app data dir for local testing
[windows]
+8 -66
View File
@@ -12,6 +12,7 @@ import type {Metadata} from "next";
import {Footer} from "@/components/Footer";
import {Navbar} from "@/components/Navbar";
import {TokenSection} from "@/components/TokenSection";
import {TokenStatsSection} from "@/components/TokenStats";
import {
TOKEN_PROOFS,
TOKEN_SOLSCAN_URL,
@@ -30,6 +31,10 @@ export const metadata: Metadata = {
},
};
// Re-fetch live on-chain stats at most every 10 minutes (matches the server
// cache in token-stats.ts). Keeps the page static-fast while staying fresh.
export const revalidate = 600;
const USE_OF_FUNDS = [
{
icon: Rocket,
@@ -77,6 +82,9 @@ export default function TokenPage() {
<main className="pt-16">
<TokenSection />
{/* ── Live on-chain stats ──────────────────────────────────── */}
<TokenStatsSection />
{/* ── Why a token ──────────────────────────────────────────── */}
<section className="border-t border-border py-20">
<div className="mx-auto max-w-3xl px-6">
@@ -144,72 +152,6 @@ export default function TokenPage() {
</div>
</section>
{/* ── On-chain transparency ────────────────────────────────── */}
<section className="border-t border-border py-20">
<div className="mx-auto max-w-4xl px-6">
<div className="text-center mb-12">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
On-chain transparency
</div>
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
Don't trust — verify.
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto mt-4">
Liquidity is locked and supply is reduced through ongoing
buyback &amp; burns. Every action is on-chain and linked here, so
you never have to take my word for it.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-3">
{TOKEN_PROOFS.map((proof, i) => {
const Icon = proof.kind === "lock" ? Lock : Flame;
return (
<div
key={`${proof.label}-${i}`}
className="flex flex-col rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
>
<Icon className="h-5 w-5 text-accent mb-3" />
<h3 className="text-[15px] font-semibold text-foreground mb-2">
{proof.label}
</h3>
<p className="text-sm leading-relaxed text-muted-foreground flex-1">
{proof.detail}
</p>
{proof.txUrl ? (
<a
href={proof.txUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-foreground/80 hover:text-foreground transition-colors"
>
View on Solscan
<ArrowUpRight className="h-3.5 w-3.5" />
</a>
) : (
<span className="mt-4 inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground/60">
Proof link pending
</span>
)}
</div>
);
})}
</div>
<div className="mt-6 text-center">
<a
href={TOKEN_SOLSCAN_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Inspect supply &amp; holders on Solscan
<ArrowUpRight className="h-4 w-4" />
</a>
</div>
</div>
</section>
{/* ── Official vs community ────────────────────────────────── */}
<section className="border-t border-border py-20">
<div className="mx-auto max-w-3xl px-6">
+309
View File
@@ -0,0 +1,309 @@
import {ArrowUpRight, Coins, Flame, Lock, Users, Wallet} from "lucide-react";
import {
TOKEN_CONTRACT_ADDRESS,
TOKEN_CREATOR_ADDRESS,
TOKEN_SOLSCAN_URL,
TOKEN_TICKER,
} from "@/lib/constants";
import {getTokenStats, type TokenStats} from "@/lib/token-stats";
// ── formatters ───────────────────────────────────────────────────────────────
function compact(n: number | null): string {
if (n == null) return "—";
const abs = Math.abs(n);
if (abs >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B`;
if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
if (abs >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return n.toLocaleString("en-US", {maximumFractionDigits: 0});
}
function pct(n: number | null): string {
if (n == null) return "—";
if (n > 0 && n < 0.01) return "<0.01%";
return `${n.toFixed(2)}%`;
}
function usdPrice(n: number | null): string {
if (n == null) return "—";
if (n < 0.000001) return `$${n.toExponential(2)}`;
if (n < 1) return `$${n.toPrecision(3)}`;
return `$${n.toLocaleString("en-US", {maximumFractionDigits: 2})}`;
}
function usdBig(n: number | null): string {
if (n == null) return "—";
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(2)}M`;
if (n >= 1_000) return `$${(n / 1_000).toFixed(1)}K`;
return `$${n.toLocaleString("en-US", {maximumFractionDigits: 0})}`;
}
function sol(n: number | null): string {
if (n == null) return "—";
return `${n.toLocaleString("en-US", {maximumFractionDigits: 2})} SOL`;
}
function shortAddr(a: string): string {
return a.length > 12 ? `${a.slice(0, 4)}…${a.slice(-4)}` : a;
}
function solscanAccount(a: string): string {
return `https://solscan.io/account/${a}`;
}
function timeAgo(ts: number): string {
const secs = Math.max(0, Math.round((Date.now() - ts) / 1000));
if (secs < 60) return "just now";
const mins = Math.round(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.round(mins / 60);
return `${hrs}h ago`;
}
export async function TokenStatsSection() {
const stats = await getTokenStats();
return <TokenStatsView stats={stats} />;
}
// Hidden for now — flip to true to bring the "fees earned for development"
// card back. The data is still fetched; it's just not rendered.
const SHOW_CREATOR_REWARDS = false;
function TokenStatsView({stats}: {stats: TokenStats}) {
const cards = [
{
icon: Flame,
label: "Burned",
value: compact(stats.burned),
sub: stats.burnedPct != null ? `${pct(stats.burnedPct)} of initial supply` : "Removed from supply forever",
},
{
icon: Lock,
label: "Locked",
value: stats.locked != null ? compact(stats.locked) : "Not configured",
sub: stats.lockedPct != null ? `${pct(stats.lockedPct)} of supply` : "Liquidity & vesting locks",
},
{
icon: Wallet,
label: "Dev / treasury",
value: stats.devBalance != null ? compact(stats.devBalance) : "Not configured",
sub: stats.devPct != null ? `${pct(stats.devPct)} of supply` : "Team-held tokens",
},
{
icon: Users,
label: "Holders",
value: stats.holders != null ? `${stats.holdersCapped ? "" : ""}${stats.holders.toLocaleString("en-US")}` : "—",
sub: stats.holdersCapped ? "counted (capped)" : "unique wallets",
},
];
return (
<section className="border-t border-border py-20">
<div className="mx-auto max-w-5xl px-6">
{/* Header */}
<div className="text-center mb-12">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
Live on-chain stats
</div>
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
Every number, straight from the chain.
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto mt-4">
Supply, holders, burns, locks and team holdings for {TOKEN_TICKER},
read live from Solana.
</p>
</div>
{/* Supply + market headline */}
<div className="grid gap-4 sm:grid-cols-3 mb-4">
<HeadlineStat
label="Circulating supply"
value={compact(stats.circulating)}
sub={
stats.totalSupply != null
? `of ${compact(stats.totalSupply)} total`
: undefined
}
/>
<HeadlineStat label="Price" value={usdPrice(stats.priceUsd)} sub="via Jupiter" />
<HeadlineStat label="Market cap" value={usdBig(stats.marketCapUsd)} sub="price × supply" />
</div>
{/* Stat cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{cards.map((c) => {
const Icon = c.icon;
return (
<div
key={c.label}
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
>
<Icon className="h-5 w-5 text-accent mb-3" />
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground mb-1">
{c.label}
</div>
<div className="text-2xl font-semibold tracking-tight text-foreground tabular-nums">
{c.value}
</div>
<div className="text-xs text-muted-foreground mt-1">{c.sub}</div>
</div>
);
})}
</div>
{/* Creator rewards — pump.fun creator fees, the funding story */}
{SHOW_CREATOR_REWARDS && stats.creatorRewardsSol != null && (
<div className="mt-4 rounded-2xl border border-accent/30 bg-gradient-to-b from-accent/[0.08] to-transparent p-8 text-center">
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full border border-accent/30 bg-accent/10">
<Coins className="h-5 w-5 text-accent" />
</div>
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
Fees earned for development
</div>
<div className="mt-2 text-4xl font-semibold tracking-tight text-foreground tabular-nums">
{sol(stats.creatorRewardsSol)}
</div>
{stats.creatorRewardsUsd != null && (
<div className="mt-1 text-sm text-muted-foreground tabular-nums">
≈ {usdBig(stats.creatorRewardsUsd)}
</div>
)}
<p className="mx-auto mt-4 max-w-md text-sm leading-relaxed text-muted-foreground">
Lifetime {TOKEN_TICKER} trading fees — the funding that pays for
full-time work on Voicebox.{" "}
<a
href={`https://solscan.io/account/${TOKEN_CREATOR_ADDRESS}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 text-foreground/80 hover:text-foreground"
>
Verify <ArrowUpRight className="h-3 w-3" />
</a>
</p>
</div>
)}
{/* Locked breakdown (only if any configured) */}
{stats.lockedBreakdown.length > 0 && (
<div className="mt-4 rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6">
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground mb-4">
Locked &amp; vesting
</div>
<ul className="space-y-3">
{stats.lockedBreakdown.map((l) => (
<li
key={l.account}
className="flex items-center gap-3 text-sm"
>
<Lock className="h-4 w-4 shrink-0 text-accent" />
<span className="text-foreground/90">{l.label}</span>
{l.unlocksAt && (
<span className="text-xs text-muted-foreground">· {l.unlocksAt}</span>
)}
<span className="ml-auto font-medium tabular-nums text-foreground">
{compact(l.amount)}
</span>
<a
href={l.url ?? solscanAccount(l.account)}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground"
aria-label="View on Solscan"
>
<ArrowUpRight className="h-4 w-4" />
</a>
</li>
))}
</ul>
</div>
)}
{/* Top holders */}
{stats.topHolders.length > 0 && (
<div className="mt-4 rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6">
<div className="flex items-center justify-between mb-4">
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Top holders
</div>
<a
href={`${TOKEN_SOLSCAN_URL}#holders`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
All holders <ArrowUpRight className="h-3 w-3" />
</a>
</div>
<ul className="divide-y divide-border/60">
{stats.topHolders.map((h, i) => (
<li
key={h.owner}
className="flex items-center gap-3 py-2.5 text-sm"
>
<span className="w-5 text-xs text-muted-foreground tabular-nums">
{i + 1}
</span>
<a
href={solscanAccount(h.owner)}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-foreground/90 hover:text-foreground hover:underline"
>
{shortAddr(h.owner)}
</a>
<span className="ml-auto tabular-nums text-foreground">
{compact(h.amount)}
</span>
<span className="w-16 text-right tabular-nums text-muted-foreground">
{pct(h.pct)}
</span>
</li>
))}
</ul>
</div>
)}
{/* Footer: provenance + freshness */}
<div className="mt-6 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-muted-foreground">
<span>
{stats.live ? (
<>Updated {timeAgo(stats.updatedAt)} · data via Helius &amp; Jupiter</>
) : (
<>Live stats unavailable right now — verify on Solscan.</>
)}
</span>
<a
href={TOKEN_SOLSCAN_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors"
>
<span className="font-mono">{shortAddr(TOKEN_CONTRACT_ADDRESS)}</span>
Inspect on Solscan <ArrowUpRight className="h-3.5 w-3.5" />
</a>
</div>
</div>
</section>
);
}
function HeadlineStat({
label,
value,
sub,
}: {
label: string;
value: string;
sub?: string;
}) {
return (
<div className="rounded-2xl border border-border bg-card/60 backdrop-blur-sm p-6 text-center">
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground mb-2">
{label}
</div>
<div className="text-3xl font-semibold tracking-tight text-foreground tabular-nums">
{value}
</div>
{sub && <div className="text-xs text-muted-foreground mt-1">{sub}</div>}
</div>
);
}
+105
View File
@@ -16,6 +16,111 @@ export const TOKEN_PUMP_URL = `https://pump.fun/coin/${TOKEN_CONTRACT_ADDRESS}`;
export const TOKEN_SOLSCAN_URL = `https://solscan.io/token/${TOKEN_CONTRACT_ADDRESS}`;
export const TOKEN_TOTAL_SUPPLY = '1B';
// ── Live on-chain tracking config ───────────────────────────────────────────
// Powers the transparency dashboard on /token. Reads are done server-side via
// Helius (HELIUS_API_KEY). Every value below has a safe default so the page
// still renders if something is unset — sections you haven't configured just
// show as "not configured" rather than breaking the build.
/** Mint supply at launch, used to derive burned = initial − current supply. */
export const TOKEN_INITIAL_SUPPLY = 1_000_000_000;
/**
* pump.fun creator wallet — the address that launched the coin and earns creator
* fees. Lifetime creator rewards (in SOL) are read from pump.fun's swap-api for
* this wallet. Defaults to the dev wallet (they're the same here).
*/
export const TOKEN_CREATOR_ADDRESS = envStr(
'TOKEN_CREATOR_ADDRESS',
'BSn573bjkQa5iffMg6zA8eb9mHyzewu2ps9R85qNKXC5',
);
/**
* Dev / treasury wallets to surface as "team holdings". List every address you
* want counted; balances are summed. Public, read-only — these are already
* visible on-chain. Override at deploy time with TOKEN_DEV_WALLETS (comma list).
*/
export const TOKEN_DEV_WALLETS: string[] = envList('TOKEN_DEV_WALLETS', [
'BSn573bjkQa5iffMg6zA8eb9mHyzewu2ps9R85qNKXC5', // Jamie's dev/treasury wallet
]);
/**
* Locked supply: token accounts whose $VOICEBOX is locked (liquidity lockers,
* vesting escrows). Each entry is summed into "locked"; unlocksAt is optional
* copy for the card. Override with TOKEN_LOCKED_ACCOUNTS as a JSON array.
*/
export interface LockedAccount {
label: string;
/** The token account or owner address holding the locked $VOICEBOX. */
account: string;
/** Human-readable unlock date, e.g. "Unlocks Jun 2027" (optional). */
unlocksAt?: string;
/** Optional Solscan/locker link proving the lock. */
url?: string;
}
export const TOKEN_LOCKED_ACCOUNTS: LockedAccount[] = envJson<LockedAccount[]>(
'TOKEN_LOCKED_ACCOUNTS',
[
// Streamflow locks. `account` is each lock's escrow token account (read for
// the live balance, so it ticks down only when actually unlocked/withdrawn);
// `url` is the public Streamflow contract page for verification.
{
label: 'Streamflow lock #1',
account: 'EaPun3ZUk5XiKft2tbvVRXgq8HyXjTmg77kUYYe7Q5HM',
unlocksAt: 'Unlocks Jun 2027',
url: 'https://app.streamflow.finance/contract/solana/mainnet/AmzHaDAZWWZPkvN5zC78mQ3QAedH7hHSCEWeYSbSWXu5',
},
{
label: 'Streamflow lock #2',
account: 'FGK5G4CbtryRdoubPN7u4y3WTYS4vqoepPLpppba92cp',
unlocksAt: 'Unlocks Jun 2027',
url: 'https://app.streamflow.finance/contract/solana/mainnet/GfBjWriW8mcJWS9njC2gBBJoJuGRQQzJLRFNg6a12bW8',
},
{
label: 'Streamflow lock #3',
account: 'ELKMRnDin7w6ht4MkQ6FnDU3LvkDtoYbtR9y3P51pf1N',
unlocksAt: 'Unlocks Jun 2027',
url: 'https://app.streamflow.finance/contract/solana/mainnet/3xa49K6b8ChsL5SoPYrAWigwKmoXAge6YCAmUJWM6Ncw',
},
],
);
/**
* Burn / dead address. The standard SPL incinerator by default. Buyback+burns
* that reduce mint supply are already captured by initial − current; this is
* only used to additionally surface anything parked at a dead address.
*/
export const TOKEN_BURN_ADDRESS = envStr(
'TOKEN_BURN_ADDRESS',
'1nc1nerator11111111111111111111111111111111',
);
/** How long stats are cached server-side (ms). Keeps us off rate limits. */
export const TOKEN_STATS_CACHE_MS = 1000 * 60 * 10; // 10 minutes
// ── tiny env helpers (server-only; safe in this module, no secrets exposed) ──
function envStr(key: string, fallback: string): string {
const v = process.env[key];
return v && v.trim() ? v.trim() : fallback;
}
function envList(key: string, fallback: string[]): string[] {
const v = process.env[key];
if (!v || !v.trim()) return fallback;
return v
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
function envJson<T>(key: string, fallback: T): T {
const v = process.env[key];
if (!v || !v.trim()) return fallback;
try {
return JSON.parse(v) as T;
} catch {
return fallback;
}
}
// On-chain transparency log — locks and buyback+burns.
// Add a new entry every time a lock or burn happens; set `txUrl` to its Solscan
// link to make the card a live, verifiable proof. Entries without a txUrl render
+418
View File
@@ -0,0 +1,418 @@
// Live on-chain stats for $VOICEBOX, fetched server-side via Helius.
//
// Design goals:
// • Never throws. Every sub-fetch is isolated; a failure degrades that one
// metric to `null` and is recorded in `warnings`, so the page always renders.
// • Cheap. Results are cached in-memory for TOKEN_STATS_CACHE_MS, and holder
// enumeration is page-capped so a viral token can't blow up a request.
// • Honest. Numbers come straight from chain reads (Helius RPC) and Jupiter
// for price — nothing is asserted that can't be verified on Solscan.
import {
TOKEN_BURN_ADDRESS,
TOKEN_CONTRACT_ADDRESS,
TOKEN_CREATOR_ADDRESS,
TOKEN_DEV_WALLETS,
TOKEN_INITIAL_SUPPLY,
TOKEN_LOCKED_ACCOUNTS,
TOKEN_STATS_CACHE_MS,
} from './constants';
const MINT = TOKEN_CONTRACT_ADDRESS;
const WSOL_MINT = 'So11111111111111111111111111111111111111112';
// Let Next cache the underlying network reads and revalidate them on the same
// cadence as the page (ISR). Keeps /token static-fast and CDN-cacheable while
// staying fresh, instead of forcing the route fully dynamic with `no-store`.
const REVALIDATE_S = Math.round(TOKEN_STATS_CACHE_MS / 1000);
// Public Solana mainnet RPC — used as a fallback so supply/balances/locks work
// without any API key. Rate-limited, but our 10-minute cache keeps us under it.
const PUBLIC_RPC = 'https://api.mainnet-beta.solana.com';
function heliusRpcUrl(): string | null {
const key = process.env.HELIUS_API_KEY?.trim();
if (!key) return null;
return `https://mainnet.helius-rpc.com/?api-key=${key}`;
}
// Standard JSON-RPC reads (supply, balances) — Helius if configured, else public.
function standardRpcUrl(): string {
return heliusRpcUrl() ?? PUBLIC_RPC;
}
// Holder enumeration needs Helius' DAS `getTokenAccounts` extension; public RPC
// can't do it efficiently. Null when no key — holders degrade to "—".
function dasRpcUrl(): string | null {
return heliusRpcUrl();
}
export interface TopHolder {
owner: string;
amount: number;
pct: number; // share of current supply, 0–100
}
export interface LockedEntry {
label: string;
account: string;
amount: number | null;
unlocksAt?: string;
url?: string;
}
export interface TokenStats {
/** True only if Helius is configured and the core supply read succeeded. */
live: boolean;
decimals: number;
initialSupply: number;
/** Current on-chain mint supply (UI amount). */
totalSupply: number | null;
/** initialSupply − totalSupply: tokens permanently removed by burns. */
burned: number | null;
burnedPct: number | null;
/** Sum of configured locked accounts. */
locked: number | null;
lockedPct: number | null;
lockedBreakdown: LockedEntry[];
/** Sum of configured dev/treasury wallets. */
devBalance: number | null;
devPct: number | null;
/** Unique-owner holder count (page-capped; see holdersCapped). */
holders: number | null;
holdersCapped: boolean;
topHolders: TopHolder[];
/** Spot price in USD (Jupiter). */
priceUsd: number | null;
/** priceUsd × totalSupply. */
marketCapUsd: number | null;
/** Lifetime pump.fun creator fees earned by the creator wallet, in SOL. */
creatorRewardsSol: number | null;
/** creatorRewardsSol × SOL/USD price. */
creatorRewardsUsd: number | null;
/** Float supply = total − locked − dev − burned-at-dead-address. */
circulating: number | null;
updatedAt: number;
/** Human-readable notes about anything unconfigured or failed. */
warnings: string[];
}
// Caching is handled by Next's fetch cache (revalidate per request below), so
// this just aggregates the reads. Never throws — degrades to emptyStats.
export async function getTokenStats(): Promise<TokenStats> {
try {
return await buildTokenStats();
} catch (err) {
console.error('getTokenStats failed:', err);
return emptyStats(['Live stats are temporarily unavailable.']);
}
}
function emptyStats(warnings: string[]): TokenStats {
return {
live: false,
decimals: 6,
initialSupply: TOKEN_INITIAL_SUPPLY,
totalSupply: null,
burned: null,
burnedPct: null,
locked: null,
lockedPct: null,
lockedBreakdown: TOKEN_LOCKED_ACCOUNTS.map((l) => ({ ...l, amount: null })),
devBalance: null,
devPct: null,
holders: null,
holdersCapped: false,
topHolders: [],
priceUsd: null,
marketCapUsd: null,
creatorRewardsSol: null,
creatorRewardsUsd: null,
circulating: null,
updatedAt: Date.now(),
warnings,
};
}
async function buildTokenStats(): Promise<TokenStats> {
const warnings: string[] = [];
const rpc = standardRpcUrl(); // supply/balances/locks — public RPC if no key
const das = dasRpcUrl(); // holder enumeration — Helius only
if (!das) {
warnings.push('Set HELIUS_API_KEY to enable the holder count & top holders.');
}
// Core supply first — everything downstream is a percentage of it.
const supply = await getTokenSupply(rpc).catch((e) => {
warnings.push('Could not read token supply.');
console.error('getTokenSupply:', e);
return null;
});
const decimals = supply?.decimals ?? 6;
const totalSupply = supply?.uiAmount ?? null;
// Run the independent reads concurrently.
const [holderData, devBalance, lockedAmounts, priceUsd, creatorRewardsSol, solPrice] =
await Promise.all([
das
? getHolders(das).catch((e) => {
warnings.push('Could not enumerate holders.');
console.error('getHolders:', e);
return null;
})
: Promise.resolve(null),
TOKEN_DEV_WALLETS.length
? getOwnersBalance(rpc, TOKEN_DEV_WALLETS).catch((e) => {
warnings.push('Could not read dev wallet balance.');
console.error('getOwnersBalance(dev):', e);
return null;
})
: Promise.resolve(null),
TOKEN_LOCKED_ACCOUNTS.length
? Promise.all(
TOKEN_LOCKED_ACCOUNTS.map((l) =>
getAddressBalance(rpc, l.account)
.catch(() => null)
.then((amount) => ({ ...l, amount })),
),
)
: Promise.resolve(
[] as Array<(typeof TOKEN_LOCKED_ACCOUNTS)[number] & { amount: number | null }>,
),
getJupiterPrice(MINT).catch(() => {
warnings.push('Could not read price from Jupiter.');
return null;
}),
getCreatorRewardsSol().catch((e) => {
warnings.push('Could not read creator rewards.');
console.error('getCreatorRewardsSol:', e);
return null;
}),
getJupiterPrice(WSOL_MINT).catch(() => null),
]);
if (!TOKEN_DEV_WALLETS.length) warnings.push('No dev/treasury wallet configured.');
if (!TOKEN_LOCKED_ACCOUNTS.length) warnings.push('No locked accounts configured.');
const locked =
lockedAmounts.length && lockedAmounts.some((l) => l.amount != null)
? lockedAmounts.reduce((sum, l) => sum + (l.amount ?? 0), 0)
: lockedAmounts.length
? null
: null;
const burned =
totalSupply != null ? Math.max(0, TOKEN_INITIAL_SUPPLY - totalSupply) : null;
const pct = (n: number | null): number | null =>
n != null && totalSupply ? (n / totalSupply) * 100 : null;
const pctOfInitial = (n: number | null): number | null =>
n != null ? (n / TOKEN_INITIAL_SUPPLY) * 100 : null;
const marketCapUsd =
priceUsd != null && totalSupply != null ? priceUsd * totalSupply : null;
const creatorRewardsUsd =
creatorRewardsSol != null && solPrice != null
? creatorRewardsSol * solPrice
: null;
const circulating =
totalSupply != null
? Math.max(0, totalSupply - (locked ?? 0) - (devBalance ?? 0))
: null;
const topHolders: TopHolder[] = (holderData?.top ?? []).map((h) => ({
owner: h.owner,
amount: h.amount,
pct: totalSupply ? (h.amount / totalSupply) * 100 : 0,
}));
return {
live: totalSupply != null,
decimals,
initialSupply: TOKEN_INITIAL_SUPPLY,
totalSupply,
burned,
burnedPct: pctOfInitial(burned),
locked,
lockedPct: pct(locked),
lockedBreakdown: lockedAmounts.length
? lockedAmounts
: TOKEN_LOCKED_ACCOUNTS.map((l) => ({ ...l, amount: null })),
devBalance,
devPct: pct(devBalance),
holders: holderData?.count ?? null,
holdersCapped: holderData?.capped ?? false,
topHolders,
priceUsd,
marketCapUsd,
creatorRewardsSol,
creatorRewardsUsd,
circulating,
updatedAt: Date.now(),
warnings,
};
}
// ── Solana / Helius RPC primitives ───────────────────────────────────────────
async function rpcCall<T>(rpc: string, method: string, params: unknown): Promise<T> {
const res = await fetch(rpc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
next: { revalidate: REVALIDATE_S },
body: JSON.stringify({ jsonrpc: '2.0', id: 'voicebox', method, params }),
});
if (!res.ok) throw new Error(`RPC ${method} HTTP ${res.status}`);
const json = (await res.json()) as { result?: T; error?: { message: string } };
if (json.error) throw new Error(`RPC ${method}: ${json.error.message}`);
if (json.result === undefined) throw new Error(`RPC ${method}: empty result`);
return json.result;
}
interface SupplyResult {
value: { amount: string; decimals: number; uiAmount: number | null };
}
async function getTokenSupply(
rpc: string,
): Promise<{ uiAmount: number; decimals: number }> {
const r = await rpcCall<SupplyResult>(rpc, 'getTokenSupply', [MINT]);
const decimals = r.value.decimals;
const uiAmount =
r.value.uiAmount ?? Number(r.value.amount) / 10 ** decimals;
return { uiAmount, decimals };
}
// Sum a single owner's balance of the mint across all their token accounts.
async function getOwnerBalance(rpc: string, owner: string): Promise<number> {
const r = await rpcCall<{
value: Array<{
account: { data: { parsed: { info: { tokenAmount: { uiAmount: number | null } } } } };
}>;
}>(rpc, 'getTokenAccountsByOwner', [
owner,
{ mint: MINT },
{ encoding: 'jsonParsed' },
]);
return r.value.reduce(
(sum, a) => sum + (a.account.data.parsed.info.tokenAmount.uiAmount ?? 0),
0,
);
}
async function getOwnersBalance(rpc: string, owners: string[]): Promise<number> {
const balances = await Promise.all(owners.map((o) => getOwnerBalance(rpc, o)));
return balances.reduce((a, b) => a + b, 0);
}
// Balance for a configured "account" that may be either a token-account address
// or an owner address — try token account first, fall back to owner.
async function getAddressBalance(rpc: string, address: string): Promise<number> {
try {
const r = await rpcCall<{
value: { amount: string; decimals: number; uiAmount: number | null };
}>(rpc, 'getTokenAccountBalance', [address]);
return r.value.uiAmount ?? Number(r.value.amount) / 10 ** r.value.decimals;
} catch {
// Not a token account — treat it as an owner.
return getOwnerBalance(rpc, address);
}
}
// Holder enumeration via Helius DAS getTokenAccounts. Dedupes by owner (one
// owner can hold many token accounts) and ranks the top holders. Page-capped.
const HOLDER_PAGE_LIMIT = 1000;
const HOLDER_MAX_PAGES = 25; // up to 25k accounts before we stop and flag it
const TOP_HOLDERS = 12;
interface HeliusTokenAccount {
owner: string;
amount: number; // raw, needs / 10**decimals
}
interface HeliusTokenAccountsPage {
total: number;
limit: number;
page: number;
token_accounts: HeliusTokenAccount[];
}
async function getHolders(
rpc: string,
): Promise<{ count: number; capped: boolean; top: Array<{ owner: string; amount: number }> }> {
const balances = new Map<string, number>(); // owner -> raw amount
let page = 1;
let capped = false;
let decimals = 6;
// Grab decimals once so we can return UI amounts for the top holders.
try {
decimals = (await getTokenSupply(rpc)).decimals;
} catch {
/* fall back to 6 */
}
for (;;) {
const res = await rpcCall<HeliusTokenAccountsPage>(rpc, 'getTokenAccounts', {
mint: MINT,
page,
limit: HOLDER_PAGE_LIMIT,
options: { showZeroBalance: false },
});
const accounts = res.token_accounts ?? [];
for (const a of accounts) {
if (!a.owner || !a.amount) continue;
balances.set(a.owner, (balances.get(a.owner) ?? 0) + a.amount);
}
if (accounts.length < HOLDER_PAGE_LIMIT) break;
page += 1;
if (page > HOLDER_MAX_PAGES) {
capped = true;
break;
}
}
const top = [...balances.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, TOP_HOLDERS)
.map(([owner, raw]) => ({ owner, amount: raw / 10 ** decimals }));
return { count: balances.size, capped, top };
}
// ── Price (Jupiter, no key required) ─────────────────────────────────────────
async function getJupiterPrice(mint: string): Promise<number | null> {
const res = await fetch(`https://lite-api.jup.ag/price/v3?ids=${mint}`, {
next: { revalidate: REVALIDATE_S },
});
if (!res.ok) throw new Error(`Jupiter HTTP ${res.status}`);
const json = (await res.json()) as Record<string, { usdPrice?: number } | undefined>;
const price = json[mint]?.usdPrice;
return typeof price === 'number' ? price : null;
}
// ── Creator rewards (pump.fun swap-api) ──────────────────────────────────────
// Lifetime creator fees earned by the creator wallet, in SOL. The swap-api
// returns a daily series with a running `cumulativeCreatorFeeSOL`; the latest
// (max) bucket is the lifetime total. The per-coin endpoint is unreliable, so
// we use the per-creator one.
interface CreatorFeeBucket {
cumulativeCreatorFeeSOL: string;
}
async function getCreatorRewardsSol(): Promise<number | null> {
const res = await fetch(
`https://swap-api.pump.fun/v1/creators/${TOKEN_CREATOR_ADDRESS}/fees?interval=1d`,
{ next: { revalidate: REVALIDATE_S }, headers: { 'User-Agent': 'voicebox.sh' } },
);
if (!res.ok) throw new Error(`pump.fun swap-api HTTP ${res.status}`);
const buckets = (await res.json()) as CreatorFeeBucket[];
if (!Array.isArray(buckets) || buckets.length === 0) return null;
// Cumulative is monotonic, but take the max defensively.
const max = buckets.reduce((m, b) => {
const v = Number.parseFloat(b.cumulativeCreatorFeeSOL);
return Number.isFinite(v) && v > m ? v : m;
}, 0);
return max;
}
+251
View File
@@ -0,0 +1,251 @@
"""
Package the PyInstaller --onedir ROCm build into two archives.
Takes the PyInstaller --onedir output directory and splits it into:
1. voicebox-server-rocm.tar.gz — server core (exe + non-AMD deps)
2. rocm-libs-{version}.tar.gz — AMD/ROCm runtime libraries only
3. rocm-libs.json — version manifest for the ROCm libs
Mirrors scripts/package_cuda.py. The split lets the server core re-download on
every app update while the much larger ROCm runtime stays cached until the
toolkit version bumps.
Usage:
python scripts/package_rocm.py backend/dist/voicebox-server-rocm/
python scripts/package_rocm.py backend/dist/voicebox-server-rocm/ --output release-assets/
python scripts/package_rocm.py backend/dist/voicebox-server-rocm/ --rocm-libs-version rocm7.2-v1
"""
import argparse
import hashlib
import json
import sys
import tarfile
from pathlib import Path
# DLL/.so name prefixes that identify AMD ROCm/HIP runtime libraries. They may
# sit in torch/lib/ (torch's bundled HIP runtime) or inside the bundled ROCm SDK
# packages. Matched case-insensitively against the file's base name.
ROCM_DLL_PREFIXES = (
"amdhip",
"amd_comgr",
"amdocl",
"hiprtc",
"hipblaslt",
"hipblas",
"hipfft",
"hiprand",
"hipsolver",
"hipsparse",
"hip",
"rocblas",
"rocfft",
"rocrand",
"rocsolver",
"rocsparse",
"rocprofiler",
"roctracer",
"roctx",
"rocm_smi",
"miopen",
"rccl",
"hsa-runtime",
"hsa",
)
# Directory markers for the bundled ROCm SDK runtime packages. Everything under
# these trees except Python sources (the pure-python rocm_sdk glue) is part of
# the runtime payload — this is where rocBLAS Tensile data and MIOpen kernel
# databases live, which dominate the download size.
ROCM_LIB_DIR_MARKERS = (
"_rocm_sdk_core",
"_rocm_sdk_libraries_custom",
"rocm_sdk_core",
"rocm_sdk_libraries_custom",
)
# Heavy native/data extensions shipped by the ROCm runtime (HIP fat binaries,
# rocBLAS Tensile data, MIOpen kernel DBs).
ROCM_LIB_EXTS = (".dll", ".so", ".dat", ".db", ".kdb", ".hsaco", ".co", ".bc")
# Python sources stay in the server core so the rocm_sdk import glue remains
# alongside the exe. (Both archives extract into backends/rocm/, so this only
# affects which archive carries the file, not runtime resolution.)
_PYTHON_EXTS = (".py", ".pyc", ".pyi")
def is_rocm_file(rel_path: str) -> bool:
"""Check if a relative path belongs to the AMD ROCm runtime libraries.
Identifies large ROCm/HIP runtime DLLs and the SDK runtime payload
(kernel databases, Tensile data) regardless of where PyInstaller placed
them, while keeping pure-python glue in the server core.
"""
rel_lower = rel_path.lower().replace("\\", "/")
name = rel_lower.rsplit("/", 1)[-1]
# Never split out Python sources / stubs.
if name.endswith(_PYTHON_EXTS):
return False
# Native payload inside the bundled ROCm SDK package trees.
if any(marker in rel_lower for marker in ROCM_LIB_DIR_MARKERS):
if name.endswith(ROCM_LIB_EXTS):
return True
# ROCm/HIP DLLs/shared objects anywhere (e.g. _internal/torch/lib/amdhip64.dll).
if name.endswith((".dll", ".so")):
name_no_ext = name.rsplit(".", 1)[0]
for prefix in ROCM_DLL_PREFIXES:
if name_no_ext.startswith(prefix):
return True
return False
def sha256_file(path: Path) -> str:
"""Compute SHA-256 hex digest of a file."""
h = hashlib.sha256()
with open(path, "rb") as f:
while True:
chunk = f.read(1024 * 1024)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
def package(
onedir_path: Path,
output_dir: Path,
rocm_libs_version: str,
torch_compat: str,
):
output_dir.mkdir(parents=True, exist_ok=True)
# Collect all files in the onedir output, split into core vs rocm.
core_files = []
rocm_files = []
for item in sorted(onedir_path.rglob("*")):
if item.is_dir():
continue
rel = item.relative_to(onedir_path)
rel_str = str(rel)
if is_rocm_file(rel_str):
rocm_files.append((rel_str, item))
else:
core_files.append((rel_str, item))
core_size = sum(f.stat().st_size for _, f in core_files)
rocm_size = sum(f.stat().st_size for _, f in rocm_files)
print(f"Input directory: {onedir_path}")
print(f"Core files: {len(core_files)} ({core_size / (1024**2):.1f} MB)")
print(f"ROCm files: {len(rocm_files)} ({rocm_size / (1024**2):.1f} MB)")
if not rocm_files:
print(
f"ERROR: No ROCm files found in {onedir_path}. "
"Refusing to create an empty ROCm libs archive.",
file=sys.stderr,
)
print(
"Make sure you built with --rocm and the ROCm SDK packages are present. "
"If the layout differs, adjust ROCM_DLL_PREFIXES / ROCM_LIB_DIR_MARKERS.",
file=sys.stderr,
)
sys.exit(1)
# Create server core archive. Files are stored relative to the archive root
# (no parent prefix) so extracting to backends/rocm/ lands at the right level.
server_archive = output_dir / "voicebox-server-rocm.tar.gz"
print(f"\nCreating server core archive: {server_archive.name}")
with tarfile.open(server_archive, "w:gz") as tar:
for rel_str, full_path in core_files:
tar.add(full_path, arcname=rel_str)
server_sha = sha256_file(server_archive)
(output_dir / "voicebox-server-rocm.tar.gz.sha256").write_text(
f"{server_sha} voicebox-server-rocm.tar.gz\n"
)
print(f" Size: {server_archive.stat().st_size / (1024**2):.1f} MB")
print(f" SHA-256: {server_sha[:16]}...")
# Create ROCm libs archive.
rocm_libs_archive = output_dir / f"rocm-libs-{rocm_libs_version}.tar.gz"
print(f"\nCreating ROCm libs archive: {rocm_libs_archive.name}")
with tarfile.open(rocm_libs_archive, "w:gz") as tar:
for rel_str, full_path in rocm_files:
tar.add(full_path, arcname=rel_str)
rocm_sha = sha256_file(rocm_libs_archive)
(output_dir / f"rocm-libs-{rocm_libs_version}.tar.gz.sha256").write_text(
f"{rocm_sha} rocm-libs-{rocm_libs_version}.tar.gz\n"
)
print(f" Size: {rocm_libs_archive.stat().st_size / (1024**2):.1f} MB")
print(f" SHA-256: {rocm_sha[:16]}...")
# Write rocm-libs.json manifest.
manifest = {
"version": rocm_libs_version,
"torch_compat": torch_compat,
"archive": rocm_libs_archive.name,
"sha256": rocm_sha,
}
manifest_path = output_dir / "rocm-libs.json"
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")
print(f"\nManifest: {manifest_path.name}")
print(json.dumps(manifest, indent=2))
# Summary
total_input = core_size + rocm_size
total_output = server_archive.stat().st_size + rocm_libs_archive.stat().st_size
print(f"\nTotal input: {total_input / (1024**3):.2f} GB")
print(f"Total output: {total_output / (1024**3):.2f} GB (compressed)")
print(
f"Server core: {server_archive.stat().st_size / (1024**2):.1f} MB (redownloaded on app update)"
)
print(
f"ROCm libs: {rocm_libs_archive.stat().st_size / (1024**2):.1f} MB (cached until ROCm toolkit bump)"
)
def main():
parser = argparse.ArgumentParser(
description="Package PyInstaller --onedir ROCm build into server + ROCm libs archives"
)
parser.add_argument(
"input",
type=Path,
help="Path to PyInstaller --onedir output directory (e.g. backend/dist/voicebox-server-rocm/)",
)
parser.add_argument(
"--output",
type=Path,
default=None,
help="Output directory for archives (default: same as input parent)",
)
parser.add_argument(
"--rocm-libs-version",
type=str,
default="rocm7.2-v1",
help="Version string for the ROCm libs archive (default: rocm7.2-v1)",
)
parser.add_argument(
"--torch-compat",
type=str,
default=">=2.9.0,<2.10.0",
help="Torch version compatibility range (default: >=2.9.0,<2.10.0)",
)
args = parser.parse_args()
if not args.input.is_dir():
print(f"Error: {args.input} is not a directory", file=sys.stderr)
print("Expected a PyInstaller --onedir output directory.", file=sys.stderr)
sys.exit(1)
output_dir = args.output or args.input.parent
package(args.input, output_dir, args.rocm_libs_version, args.torch_compat)
if __name__ == "__main__":
main()
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
set -e
# Join whatever groups own the mounted GPU nodes so /dev/kfd and /dev/dri work
# on any host (no RENDER_GID/VIDEO_GID needed), then drop to the app user.
for dev in /dev/kfd /dev/dri/render*; do
[ -e "$dev" ] || continue
gid=$(stat -c %g "$dev")
grp=$(getent group "$gid" | cut -d: -f1)
[ -n "$grp" ] || {
grp="gpu$gid"
groupadd -g "$gid" "$grp"
}
usermod -aG "$grp" voicebox
done
exec gosu voicebox "$@"
+39 -63
View File
@@ -66,13 +66,46 @@ fn find_monitor_source_via_pactl() -> Option<String> {
None
}
/// Select the capture device: prefer an exact match against the monitor
/// source name reported by `pactl`, then fall back to any device whose name
/// contains "monitor", then the host's default input device.
fn select_capture_device(host: &cpal::Host, monitor_source: Option<&str>) -> Option<cpal::Device> {
let devices: Vec<cpal::Device> = host.input_devices().ok()?.collect();
if let Some(target) = monitor_source {
if let Some(pos) = devices
.iter()
.position(|d| d.name().map(|n| n == target).unwrap_or(false))
{
eprintln!(
"Linux audio capture: Using pactl monitor device: {}",
target
);
return devices.into_iter().nth(pos);
}
}
if let Some(pos) = devices.iter().position(|d| {
d.name()
.map(|n| n.to_lowercase().contains("monitor"))
.unwrap_or(false)
}) {
let name = devices[pos].name().unwrap_or_default();
eprintln!("Linux audio capture: Found monitor device by name: {}", name);
return devices.into_iter().nth(pos);
}
eprintln!("Linux audio capture: No monitor device found, falling back to default input");
host.default_input_device()
}
/// Start capturing system audio on Linux using PulseAudio monitor sources.
///
/// On modern Linux with PulseAudio or PipeWire, we first try to detect the
/// monitor source via `pactl` and set the `PULSE_SOURCE` environment variable.
/// This tells PulseAudio's ALSA plugin to use the monitor as the default input
/// source for this process. If `pactl` is unavailable, we fall back to searching
/// cpal device names for "monitor".
/// monitor source via `pactl`, then select the matching cpal input device by
/// name. This avoids mutating the process environment (`PULSE_SOURCE`), which
/// is not thread-safe and would affect every thread in the process. If `pactl`
/// is unavailable, we fall back to searching cpal device names for "monitor".
pub async fn start_capture(
state: &AudioCaptureState,
max_duration_secs: u32,
@@ -101,64 +134,10 @@ pub async fn start_capture(
// Spawn capture on a dedicated thread
thread::spawn(move || {
// Try to set PULSE_SOURCE to a monitor before initializing cpal.
// This tells PulseAudio/PipeWire's ALSA plugin to use the monitor
// as the default input source for this process.
let monitor_source = find_monitor_source_via_pactl();
if let Some(ref source_name) = monitor_source {
eprintln!(
"Linux audio capture: Setting PULSE_SOURCE={}",
source_name
);
std::env::set_var("PULSE_SOURCE", source_name);
}
let host = cpal::default_host();
let monitor_source = find_monitor_source_via_pactl();
// Select the capture device.
// If PULSE_SOURCE was set, the default input device IS the monitor.
// Otherwise, fall back to searching device names for "monitor".
let device = if monitor_source.is_some() {
// PULSE_SOURCE was set — default input IS the monitor now
match host.default_input_device() {
Some(d) => {
let name = d.name().unwrap_or_default();
eprintln!(
"Linux audio capture: Using PULSE_SOURCE monitor device: {}",
name
);
d
}
None => {
let error_msg = "No audio input device available".to_string();
eprintln!("{}", error_msg);
*error_arc.lock().unwrap() = Some(error_msg);
return;
}
}
} else {
// pactl not available — try to find monitor by name (original approach)
let mut monitor_device = None;
if let Ok(devices) = host.input_devices() {
for d in devices {
if let Ok(name) = d.name() {
let name_lower = name.to_lowercase();
if name_lower.contains("monitor") {
eprintln!(
"Linux audio capture: Found monitor device by name: {}",
name
);
monitor_device = Some(d);
break;
}
}
}
}
match monitor_device {
Some(d) => d,
None => {
eprintln!("Linux audio capture: No monitor device found, falling back to default input");
match host.default_input_device() {
let device = match select_capture_device(&host, monitor_source.as_deref()) {
Some(d) => d,
None => {
let error_msg = "No audio input device available".to_string();
@@ -166,9 +145,6 @@ pub async fn start_capture(
*error_arc.lock().unwrap() = Some(error_msg);
return;
}
}
}
}
};
let device_name = device.name().unwrap_or_else(|_| "unknown".to_string());
+3
View File
@@ -264,6 +264,9 @@ fn apply_effect(app: &AppHandle, effect: Effect) {
let _ = window.set_position(tauri::PhysicalPosition::new(x, y));
}
}
// Skip on Linux: aborts if the window was never realized
// (see show_dictate_window in main.rs).
#[cfg(not(target_os = "linux"))]
let _ = window.set_ignore_cursor_events(false);
// Deliberately no set_focus() — taking key focus would yank
// it out of whatever app the user was typing in, which is
+1
View File
@@ -30,6 +30,7 @@ pub fn key_from_str(name: &str) -> Option<Key> {
"ShiftLeft" => Key::ShiftLeft,
"ShiftRight" => Key::ShiftRight,
"CapsLock" => Key::CapsLock,
"Function" => Key::Function,
// Whitespace / navigation
"Space" => Key::Space,
+4
View File
@@ -19,19 +19,23 @@
//! regardless of the active layout — most Windows apps treat that as
//! Ctrl+V. AutoHotkey relies on the same behaviour.
#[cfg(target_os = "macos")]
use std::sync::atomic::{AtomicU16, Ordering};
/// `kVK_ANSI_V` — the keycode for the physical V key on a US QWERTY
/// layout. Used as the fallback whenever live resolution can't produce a
/// better answer (no Unicode key layout data, lookup failure, non-macOS).
#[cfg(target_os = "macos")]
const FALLBACK_V_KEYCODE: u16 = 9;
#[cfg(target_os = "macos")]
static V_KEYCODE: AtomicU16 = AtomicU16::new(FALLBACK_V_KEYCODE);
/// Returns the keycode whose current-layout translation is `'v'`. Falls
/// back to `kVK_ANSI_V` when resolution hasn't run, the active input
/// source carries no Unicode key layout data, or no keycode in the layout
/// produces `v`.
#[cfg(target_os = "macos")]
pub fn paste_keycode_v() -> u16 {
V_KEYCODE.load(Ordering::Relaxed)
}
+184 -28
View File
@@ -112,6 +112,10 @@ pub fn show_dictate_window(app: &tauri::AppHandle) {
let _ = window.set_position(PhysicalPosition::new(x, y));
}
}
// Skip on Linux: tao's CursorIgnoreEvents handler unwraps the GdkWindow,
// which is None until the window is first shown, aborting the process.
// The click-through toggle is a macOS workaround and is never set on Linux.
#[cfg(not(target_os = "linux"))]
let _ = window.set_ignore_cursor_events(false);
let _ = window.show();
}
@@ -200,6 +204,63 @@ struct ServerState {
server_pid: Mutex<Option<u32>>,
keep_running_on_close: Mutex<bool>,
models_dir: Mutex<Option<String>>,
/// Override the backend selection: Some("cpu") forces the CPU sidecar even
/// when GPU binaries exist (solving the Windows catch-22 where an active
/// .exe cannot be deleted), while Some("cuda")/Some("rocm") pin a specific
/// GPU variant when more than one is installed. None uses the on-disk
/// default (ROCm preferred, then CUDA). Persisted to disk so the choice
/// survives an app restart.
backend_override: Mutex<Option<String>>,
}
fn backend_override_file(data_dir: &std::path::Path) -> std::path::PathBuf {
data_dir.join("backend_override")
}
fn read_persisted_backend_override(data_dir: &std::path::Path) -> Option<String> {
std::fs::read_to_string(backend_override_file(data_dir))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn write_persisted_backend_override(data_dir: &std::path::Path, value: Option<&str>) {
let path = backend_override_file(data_dir);
match value {
Some(v) => {
let _ = std::fs::create_dir_all(data_dir);
if let Err(e) = std::fs::write(&path, v) {
println!("Failed to persist backend override: {}", e);
}
}
None => {
let _ = std::fs::remove_file(&path);
}
}
}
/// Run `<exe> --version` with a 10-second timeout to avoid hanging Tauri startup.
/// Returns the last whitespace-delimited token from stdout (e.g. "0.4.4"), or None on any failure.
async fn probe_binary_version(exe: &std::path::Path, cwd: &std::path::Path) -> Option<String> {
let mut cmd = tokio::process::Command::new(exe);
cmd.arg("--version")
.current_dir(cwd)
.kill_on_drop(true);
match tokio::time::timeout(std::time::Duration::from_secs(10), cmd.output()).await {
Ok(Ok(output)) => {
let s = String::from_utf8_lossy(&output.stdout);
s.trim().split_whitespace().last().map(String::from)
}
Ok(Err(e)) => {
println!("Version probe failed: {}", e);
None
}
Err(_) => {
println!("Version probe timed out after 10s");
None
}
}
}
#[command]
@@ -360,6 +421,45 @@ async fn start_server(
println!("Data directory: {:?}", data_dir);
println!("Remote mode: {}", remote.unwrap_or(false));
// Check for ROCm backend in data directory (onedir layout: backends/rocm/)
let rocm_binary = {
let rocm_dir = data_dir.join("backends").join("rocm");
let rocm_name = if cfg!(windows) {
"voicebox-server-rocm.exe"
} else {
"voicebox-server-rocm"
};
let exe_path = rocm_dir.join(rocm_name);
if exe_path.exists() {
println!("Found ROCm backend at {:?}", rocm_dir);
let app_version = app.config().version.clone().unwrap_or_default();
let binary_version = probe_binary_version(&exe_path, &rocm_dir).await;
let version_ok = if !app_version.is_empty()
&& binary_version.as_deref() == Some(app_version.as_str())
{
println!("ROCm binary version {} matches app version", app_version);
true
} else {
println!(
"ROCm binary version mismatch: binary={}, app={}. Falling back to CPU.",
binary_version.as_deref().unwrap_or("<unknown>"),
app_version
);
false
};
if version_ok {
Some(exe_path)
} else {
None
}
} else {
println!("No ROCm backend found");
None
}
};
// Check for CUDA backend in data directory (onedir layout: backends/cuda/)
let cuda_binary = {
let cuda_dir = data_dir.join("backends").join("cuda");
@@ -375,30 +475,19 @@ async fn start_server(
// Version check: run --version from the onedir directory so
// PyInstaller can find its support files for the fast --version path
let app_version = app.config().version.clone().unwrap_or_default();
let version_ok = match std::process::Command::new(&exe_path)
.arg("--version")
.current_dir(&cuda_dir)
.output()
let binary_version = probe_binary_version(&exe_path, &cuda_dir).await;
let version_ok = if !app_version.is_empty()
&& binary_version.as_deref() == Some(app_version.as_str())
{
Ok(output) => {
// Output format: "voicebox-server X.Y.Z\n"
let version_str = String::from_utf8_lossy(&output.stdout);
let binary_version = version_str.trim().split_whitespace().last().unwrap_or("");
if binary_version == app_version {
println!("CUDA binary version {} matches app version", binary_version);
println!("CUDA binary version {} matches app version", app_version);
true
} else {
println!(
"CUDA binary version mismatch: binary={}, app={}. Falling back to CPU.",
binary_version, app_version
binary_version.as_deref().unwrap_or("<unknown>"),
app_version
);
false
}
}
Err(e) => {
println!("Failed to check CUDA binary version: {}. Falling back to CPU.", e);
false
}
};
if version_ok {
@@ -465,24 +554,74 @@ async fn start_server(
println!("Custom models directory: {}", dir);
}
// Respect backend override (e.g., user wants CPU even though a GPU binary
// exists, or pinned a specific GPU variant). The in-memory value resets to
// None on app launch, so fall back to the persisted choice on disk.
let backend_override = {
let in_memory = state.backend_override.lock().unwrap().clone();
in_memory.or_else(|| read_persisted_backend_override(&data_dir))
};
// Honor a pinned GPU variant by ignoring the other one — but only when the
// pinned variant is actually installed, so a stale pin to a deleted backend
// self-heals to the default order instead of forcing CPU. With no pin, both
// stay eligible and the launch order below prefers ROCm, then CUDA.
let pin = backend_override.as_deref();
let pin_cuda = pin == Some("cuda") && cuda_binary.is_some();
let pin_rocm = pin == Some("rocm") && rocm_binary.is_some();
let rocm_binary = if pin_cuda { None } else { rocm_binary };
let cuda_binary = if pin_rocm { None } else { cuda_binary };
// If ROCm binary exists, launch it from the onedir directory.
// If CUDA binary exists, launch it from the onedir directory.
// .current_dir() is critical: PyInstaller onedir expects all DLLs and
// support files (nvidia/, _internal/, etc.) relative to the exe.
let spawn_result = if let Some(ref cuda_path) = cuda_binary {
// support files relative to the exe.
let spawn_result = if backend_override.as_deref() != Some("cpu") {
let mut gpu_spawn = None;
if let Some(ref rocm_path) = rocm_binary {
let rocm_dir = rocm_path.parent().unwrap();
println!("Launching ROCm backend: {:?} (cwd: {:?})", rocm_path, rocm_dir);
let mut cmd = app.shell().command(rocm_path.to_str().unwrap());
cmd = cmd.current_dir(rocm_dir);
cmd = cmd.args(["--data-dir", &data_dir_str, "--port", &port_str, "--parent-pid", &parent_pid_str]);
if is_remote { cmd = cmd.args(["--host", "0.0.0.0"]); }
if let Some(ref dir) = effective_models_dir { cmd = cmd.env("VOICEBOX_MODELS_DIR", dir); }
match cmd.spawn() {
Ok(r) => { gpu_spawn = Some(Ok(r)); }
Err(e) => { println!("ROCm spawn failed ({}), trying CUDA/CPU fallback", e); }
}
}
if gpu_spawn.is_none() {
if let Some(ref cuda_path) = cuda_binary {
let cuda_dir = cuda_path.parent().unwrap();
println!("Launching CUDA backend: {:?} (cwd: {:?})", cuda_path, cuda_dir);
let mut cmd = app.shell().command(cuda_path.to_str().unwrap());
cmd = cmd.current_dir(cuda_dir);
cmd = cmd.args(["--data-dir", &data_dir_str, "--port", &port_str, "--parent-pid", &parent_pid_str]);
if is_remote {
cmd = cmd.args(["--host", "0.0.0.0"]);
if is_remote { cmd = cmd.args(["--host", "0.0.0.0"]); }
if let Some(ref dir) = effective_models_dir { cmd = cmd.env("VOICEBOX_MODELS_DIR", dir); }
match cmd.spawn() {
Ok(r) => { gpu_spawn = Some(Ok(r)); }
Err(e) => { println!("CUDA spawn failed ({}), falling back to CPU", e); }
}
if let Some(ref dir) = effective_models_dir {
cmd = cmd.env("VOICEBOX_MODELS_DIR", dir);
}
cmd.spawn()
}
if let Some(result) = gpu_spawn {
result
} else {
// Use the bundled CPU sidecar
// Fall back to bundled CPU sidecar
sidecar = sidecar.args(["--data-dir", &data_dir_str, "--port", &port_str, "--parent-pid", &parent_pid_str]);
if is_remote { sidecar = sidecar.args(["--host", "0.0.0.0"]); }
if let Some(ref dir) = effective_models_dir { sidecar = sidecar.env("VOICEBOX_MODELS_DIR", dir); }
println!("Spawning bundled CPU server process...");
sidecar.spawn()
}
} else {
// Override forces CPU — use bundled sidecar, GPU binary stays on disk
println!("Backend override=cpu: using bundled CPU sidecar");
sidecar = sidecar.args(["--data-dir", &data_dir_str, "--port", &port_str, "--parent-pid", &parent_pid_str]);
if is_remote {
sidecar = sidecar.args(["--host", "0.0.0.0"]);
@@ -490,7 +629,6 @@ async fn start_server(
if let Some(ref dir) = effective_models_dir {
sidecar = sidecar.env("VOICEBOX_MODELS_DIR", dir);
}
println!("Spawning server process...");
sidecar.spawn()
};
@@ -762,9 +900,9 @@ async fn restart_server(
println!("restart_server: waiting for port release...");
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
// Start server again (will auto-detect CUDA binary and use stored models_dir)
// Start server again (will auto-detect GPU binary and use stored models_dir)
println!("restart_server: starting server...");
start_server(app, state, None, None).await
start_server(app, state.clone(), None, None).await
}
#[command]
@@ -773,6 +911,19 @@ fn set_keep_server_running(state: State<'_, ServerState>, keep_running: bool) {
*state.keep_running_on_close.lock().unwrap() = keep_running;
}
#[command]
fn set_backend_override(
app: tauri::AppHandle,
state: State<'_, ServerState>,
backend: Option<String>,
) {
println!("set_backend_override called with: {:?}", backend);
if let Ok(data_dir) = app.path().app_data_dir() {
write_persisted_backend_override(&data_dir, backend.as_deref());
}
*state.backend_override.lock().unwrap() = backend;
}
#[command]
async fn start_system_audio_capture(
state: State<'_, audio_capture::AudioCaptureState>,
@@ -1239,6 +1390,7 @@ pub fn run() {
server_pid: Mutex::new(None),
keep_running_on_close: Mutex::new(false),
models_dir: Mutex::new(None),
backend_override: Mutex::new(None),
})
.manage(audio_capture::AudioCaptureState::new())
.manage(audio_output::AudioOutputState::new())
@@ -1273,6 +1425,9 @@ pub fn run() {
let handle_for_hide = app.handle().clone();
app.handle().listen("dictate:hide", move |_event| {
if let Some(window) = handle_for_hide.get_webview_window(DICTATE_WINDOW_LABEL) {
// Skip on Linux: aborts if the window was never realized
// (see show_dictate_window).
#[cfg(not(target_os = "linux"))]
let _ = window.set_ignore_cursor_events(true);
let _ = window.set_position(PhysicalPosition::new(-10_000, -10_000));
let _ = window.hide();
@@ -1357,6 +1512,7 @@ pub fn run() {
stop_server,
restart_server,
set_keep_server_running,
set_backend_override,
start_system_audio_capture,
stop_system_audio_capture,
is_system_audio_supported,
+20 -5
View File
@@ -4,10 +4,14 @@
//! pipeline so the focused app performs its native paste action against
//! whatever the clipboard module has just staged.
//!
//! - **macOS** — Cmd down, V down with Cmd flag, V up with Cmd flag, Cmd
//! up via `CGEventPost` at `kCGHIDEventTap`. Accessibility permission is
//! load-bearing: without it the system swallows the events silently, so
//! callers must gate on [`crate::accessibility::is_trusted`].
//! - **macOS** — Cmd down with Cmd flag, V down with Cmd flag, V up with
//! Cmd flag, Cmd up via `CGEventPost` at `kCGHIDEventTap`. The Cmd-down
//! event carries the Command flag so its `flagsChanged` representation
//! matches hardware — Electron/Chromium tracks modifier state from that
//! flag and drops the paste otherwise (see the note on the event table).
//! Accessibility permission is load-bearing: without it the system
//! swallows the events silently, so callers must gate on
//! [`crate::accessibility::is_trusted`].
//! - **Windows** — Ctrl down, V down, V up, Ctrl up via `SendInput`. No
//! permission gate, but UAC/UIPI blocks delivery into elevated target
//! windows when we run non-elevated — nothing we can do short of also
@@ -101,7 +105,18 @@ pub fn send_paste() -> Result<(), String> {
let _source_guard = scopeguard::guard(source, |s| CFRelease(s as *const c_void));
let events = [
(KEYCODE_LEFT_CMD, true, 0),
// The Cmd-down event must carry the Command flag itself. On real
// hardware the Cmd keyDown is a flagsChanged event whose flags
// already include Command; Chromium/Electron builds its tracked
// modifier state from that flag. Posting Cmd-down with flags = 0
// leaves that tracker showing "Command up", so the following V —
// even though its own flags carry Command — matches neither the
// Cmd+V accelerator (tracker says no modifier) nor plain-text
// insertion (event flags say Command), and Electron drops it
// silently. AppKit reads the V event's own flags and pastes
// regardless, which is why native apps worked but Electron
// targets (Slack, VS Code) silently no-op'd.
(KEYCODE_LEFT_CMD, true, K_CG_EVENT_FLAG_MASK_COMMAND),
(v_keycode, true, K_CG_EVENT_FLAG_MASK_COMMAND),
(v_keycode, false, K_CG_EVENT_FLAG_MASK_COMMAND),
(KEYCODE_LEFT_CMD, false, 0),
+9
View File
@@ -52,6 +52,15 @@ class TauriLifecycle implements PlatformLifecycle {
}
}
async setBackendOverride(backend?: string | null): Promise<void> {
try {
await invoke('set_backend_override', { backend: backend ?? undefined });
} catch (error) {
console.error('Failed to set backend override:', error);
throw error;
}
}
async setupWindowCloseHandler(): Promise<void> {
try {
// Listen for window close request from Rust
+1 -1
View File
@@ -27,7 +27,7 @@ export default defineConfig({
strictPort: true,
// Watch files in the app directory for changes
watch: {
ignored: ['!**/../app/**'],
ignored: ['!**/../app/**', '**/target/**'],
},
},
envPrefix: ['VITE_', 'TAURI_'],

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