Compare commits

..
Author SHA1 Message Date
James Pine fc553e94e5 docs: link hermes plugin guide instead of unshipped provider-guide pages 2026-07-12 18:35:16 -07:00
James Pine 68feec8305 docs: hermes-voicebox is published on PyPI 2026-07-12 16:11:46 -07:00
James Pine 126daf53a8 docs: add Hermes Agent integration guide
New overview page covering both integration surfaces: the MCP hookup
(hermes mcp install voicebox — catalog entry submitted upstream) for
agent-invoked speak/transcribe/captures tools, and the hermes-voicebox
provider plugin (github.com/jamiepine/hermes-voicebox) that routes
Hermes's entire voice pipeline — spoken replies, Telegram voice
bubbles, and incoming voice-message transcription — through local
Voicebox.
2026-07-12 15:23:59 -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
James Pine c1814a2870 Create CLOUD_ROADMAP.md 2026-06-28 16:30:54 -07:00
James Pine 7d9a384ee4 Add $VOICEBOX token page, blog, and cloud/pricing pages
- /token: dedicated page with on-chain transparency (liquidity lock +
  buyback/burns), holder utility, official-token clarity, and FAQ. The
  landing now shows a teaser linking to it; navbar + footer repointed
  from pump.fun to /token.
- /blog: file-based markdown blog (gray-matter + marked) with per-post
  Open Graph images; first post "Why Voicebox has a token".
- /cloud: end-to-end encrypted backup & sync product page.
- /pricing: Local / Cloud / Studio tiers with a monthly/annual toggle;
  $VOICEBOX holders get Cloud free.
- Add Testimonials section to the landing.
- Navbar: remove API/Download, add Pricing/Blog, fix center-nav spacing.
- docker-compose: host port 17493->17600 for local dev coexistence.
2026-06-27 17:34:53 -07:00
Jamie Pine c6a59f4477 claude moment 2026-06-27 13:27:03 -07:00
Jamie Pine 4f13123b95 update project status & responsible use 2026-06-27 13:01:18 -07:00
James Pine 21c7e373d3 Add $VOICEBOX token section below the hero 2026-06-26 14:45:35 -07:00
James Pine 45b64e0233 Replace sponsor program with $VOICEBOX token
- Remove the sponsor feature (sponsors page, homepage promo, footer
  link, Stripe constants, and the app About-page sponsored-by section)
- Add $VOICEBOX token: navbar pill linking to pump.fun and a footer
  Token column with a copyable Solana contract address
- Drop API and Download from the navbar links
2026-06-26 14:06:47 -07:00
Jamie PineandGitHub b35b90961d Add Trendshift badge to README 2026-04-26 13:29:17 -07:00
7df366d0c8 feat: 0.5.0 Capture release — dictation, MCP, personalities (#544)
* feat(capture): dictation, personalities, 0.5.0

Ships the Capture release end to end. Global-hotkey dictation with
synthetic paste into the focused app on macOS and Windows, an on-screen
pill across recording / transcribing / refining, customizable push-to-
talk and toggle chords, and an accessibility-permission prompt scoped to
Settings → Captures with inline re-check feedback.

Voice profiles gain optional personalities that power compose / rewrite /
respond actions via a local Qwen3 LLM — shared with refinement, so there
is one local LLM in the app, not two.

Refinement hardened with deterministic Whisper-loop collapse before the
LLM sees the transcript, per-capture flag snapshots for re-runs, and a
ten-transcript evaluation harness across every bundled refinement size.

Version bump 0.4.5 → 0.5.0.

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

* feat(mcp): local MCP server exposes voicebox.* tools to AI agents

Mounts FastMCP at /mcp (Streamable HTTP) so Claude Code, Cursor,
Windsurf, and the VS Code MCP extensions can call voicebox.speak,
voicebox.transcribe, voicebox.list_captures, and voicebox.list_profiles
against the running Voicebox server.

Backend
- new backend/mcp_server package (tools, middleware, profile resolve,
  pub/sub events); named mcp_server to avoid shadowing the installed mcp
  PyPI package FastMCP imports internally
- app.py migrated from @app.on_event to lifespan= so FastMCP's session
  manager cohabits with Voicebox's startup/shutdown
- new MCPClientBinding table + /mcp/bindings CRUD; ClientIdMiddleware
  reads X-Voicebox-Client-Id into a ContextVar and stamps last_seen_at
- profile resolution precedence: explicit -> per-client binding ->
  capture_settings.default_playback_voice_id
- POST /speak REST wrapper for non-MCP callers (shell, ACP, A2A)
- GET /events/speak SSE broadcasts speak-start / speak-end so the pill
  surfaces agent-initiated speech
- backend/mcp_shim proxy (plain httpx) for stdio-only MCP clients
- PyInstaller spec updates + new --shim build target (~18 MB)

Frontend
- Settings -> MCP page with HTTP / stdio / claude-mcp-add copy snippets,
  default voice picker, per-client bindings table, connection status
- useMCPBindings, useSpeakEvents hooks
- CapturePill gains 'speaking' state; DictateWindow subscribes to SSE
  and emits dictate:show so the Rust side surfaces the pill window

Native
- tauri.conf.json externalBin now includes voicebox-mcp
- show_dictate_window helper + dictate:show listener in main.rs
- (also in this commit: InputMonitoringGate UX, hotkey_monitor tweaks,
  landing footer/navbar updates, new overview docs for captures /
  dictation / mcp-server / voice-personalities)

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

* feat(mcp): Rust-owned speaking pill with self-contained audio playback

The pill window now surfaces for agent-initiated speech without main-window
involvement. Rust subscribes to /events/speak via a tokio task + reqwest
streaming body (speak_monitor.rs), shows the pill, and forwards events to
the dictate webview over Tauri's event bus. The pill plays audio via a
plain HTMLAudioElement and emits dictate:hide when playback ends. The
pill stays hidden through the ~1 s generation wait and only surfaces when
audio actually starts, with the counter armed at that moment.

Fixes a shared-dict mutation in mcp_server/events.publish() that caused
the second subscriber (Rust speak_monitor) to receive `event: message`
instead of named speak-start/speak-end frames. Also teaches the speak_monitor
parser to handle CRLF framing (sse-starlette default). Main-window
AudioPlayer now skips autoplay for source in {mcp, rest} to avoid
double-play when both windows are alive.

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

* readme and dev script

* feat(capture): gate global hotkey on dictation readiness checklist

Stops the "stuck pill" failure where pressing the chord with missing
STT/LLM models triggers a recording that has nowhere to land. The
hotkey now stays disarmed until every gate (models downloaded, Input
Monitoring + Accessibility granted) is green; the empty-state checklist
in CapturesTab surfaces each unmet gate with a one-click action and
auto-arms the chord once everything turns green.

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

* color

* model download status

* progress

* personality: bool API, i18n across the app

- Collapse intent tri-state (respond/rewrite/compose) to `personality: bool` on /generate, /speak, and voicebox.speak. Drop respond entirely; keep compose as a standalone button via /profiles/{id}/compose. Remove /rewrite, /respond, and /speak profile endpoints.
- FloatingGenerateBox: Wand2 persona toggle + Dices compose button appear when the selected profile has a personality. ProfileCard badges Wand2 alongside the effects Sparkles.
- MCP bindings: default_intent column → default_personality: bool. Migration drops the legacy column.
- i18n: en / ja / zh-CN / zh-TW translation files filled out and wired through the capture, server, and profile UI.

```ts
voicebox.speak({
  text: "Deploy complete.",
  profile: "Morgan",
  personality: true, // rewrite through the profile's personality LLM
});
```

* i18n: GenerationPage sidebar copy

* fix: BOOL import for windows crate 0.62

BOOL moved from Win32::Foundation to windows::core in 0.62.

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

* fix(capture): layout-aware V keycode for synthetic paste on macOS

macOS apps match Cmd+V against the layout-translated character via NSMenu
key equivalents, so posting kVK_ANSI_V (= 9, the QWERTY V position) on
Dvorak produces Cmd+. and never triggers Paste. New keyboard_layout
module resolves the active layout's V keycode via
TISCopyCurrentKeyboardLayoutInputSource + UCKeyTranslate, caches it in an
AtomicU16, and refreshes on kTISNotifySelectedKeyboardInputSourceChanged.
All TIS calls run on the main thread (init from Tauri setup; observer
callback delivered to the main runloop); synthetic_keys::send_paste
reads the cached value once per paste. Falls back to kVK_ANSI_V when
resolution fails or the active input source carries no Unicode key
layout data.

Windows is intentionally left on hardcoded VK_V — SendInput delivers
WM_KEYDOWN with wParam = VK_V to the target regardless of the active
layout, which is why `Send "^v"` works for AutoHotkey on Dvorak Windows.

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

* fix(capture): cooperative app activation for synthetic paste on macOS 14+

macOS 14 deprecated NSRunningApplication.activateWithOptions: in favour
of a cooperative-activation pattern: the caller first yields activation
rights to the target, then the target activate()s against the tightened
Sonoma foreground rules. Without the yield, activate() on 14+ sometimes
silently fails or only bounces the dock icon — the exact "paste lands in
the wrong app" symptom we were previously one API break away from.

activate_pid now discovers the 14+ selector via respondsToSelector: and
branches: on 14+ it yieldActivationToApplication:'s from
NSRunningApplication.current then calls -activate on the target; on
11–13 it stays on -activateWithOptions: (still the only option). Both
branches propagate the BOOL return — if activation is refused we error
out before clobbering the clipboard instead of silently proceeding.

The respondsToSelector: result is cached in a OnceLock so the probe
isn't repeated on every paste.

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

* fix(capture): conditional clipboard restore + always-attempt on paste failure

Two bugs in paste_final_text' clipboard handling:

1. Restore was unconditional. If the user ⌘C'd in the target app during
   the 400 ms paste-consume window — or a clipboard history tool (Paste,
   Pastebot, Maccy) or Universal Clipboard sync snapshotted our staged
   text — the blind restore overwrote their newer content with the
   pre-paste snapshot, silently losing user data.

2. send_paste' errors were propagated with ? before the restore, so a
   CGEventPost / SendInput failure left the user's clipboard stuck on
   the transcript.

Fix folds both into one pattern: capture the post-write change count,
re-read it after paste-consume, restore only when they match (plus treat
a change-count read failure as "unknown, don't overwrite"). Isolate
send_paste's error so the restore runs regardless of paste success, then
propagate the paste error after.

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

* chore(deps): pin rdev to jamiepine/rdev fork

Upstream Narsil/rdev has shipped no release since 2023-06 (crates.io
still serves 0.5.3), so the Sonoma main-thread fix we depend on — PR
#147, applied at hotkey_monitor.rs:184 — is only reachable via a git
pin. A pin to a third-party repo breaks the build whenever the remote
force-pushes, renames, or is taken down, and Cargo does not durably
cache git-dep archives the way it does crates.io tarballs.

Forking to jamiepine/rdev at the same SHA removes that failure mode
without changing crate behavior and gives us a place to cherry-pick
future OS-compatibility fixes on our own timeline. The SHA was verified
to exist on the fork before re-pinning.

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

* fix(mcp): idle timeout + escalating backoff for speak-SSE monitor

Two reliability gaps in the /events/speak subscriber:

1. resp.chunk().await had no idle timeout. A backend that accepts the
   TCP connection but stops producing frames (deadlocked SSE endpoint,
   zombie process) would block the task forever without reconnecting.
   The pill window would never surface for agent-initiated speech and
   there would be nothing to log. Backend emits a `:ping` heartbeat
   every 15 s, so 45 s without any data is now treated as a dead
   stream — the task errors out and the reconnect loop takes over.

2. Flat 2 s backoff escalates nowhere. Logs fill with reconnect lines
   when the backend is down for minutes, and a backend that accepts +
   immediately closes connections (no data) spins the loop tightly.
   Backoff now escalates 500 ms → 30 s on unproductive rounds and
   resets only when at least one frame arrives (the connection was
   genuinely productive, not just accepted).

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

* fix(refinement): character-level loop collapse + pytest coverage

The word-level pass catches single-word Whisper loops ("URL URL URL…")
but misses two common hallucination patterns the PR had to claim as
"edge cases":

1. Multi-word English loops — "thanks for watching thanks for watching…"
   × 6 sails through because no two consecutive tokens are identical
   after text.split().
2. CJK loops — "謝謝觀看" × 7 sails through because text.split() returns
   a single unsplit token for the whole loop (no whitespace between
   characters).

Add a character-level second pass: a non-greedy regex finds any 2–60
char substring that repeats min_run+ times immediately after itself and
strips the run. The 2-char floor keeps emphasised single-letter runs
("wooooooow") intact. The 60-char ceiling covers every observed
Whisper tail hallucination ("Please like and subscribe to my
channel.", "Subtitles by the Amara.org community") while staying short
enough that coincidental long-phrase repetition in legitimate speech
doesn't hit the threshold. Whitespace normalisation only runs when the
pass actually stripped something, so untouched transcripts keep their
original spacing.

New test_refinement_collapse.py gives the pre-processor its first
deterministic unit-test coverage: 17 tests pinning the word-level
legacy behaviour plus the new multi-word English / CJK / Japanese /
emphasis-preservation cases.

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

* fix(db): graceful fallback when SQLite < 3.35 on MCP bindings migration

SQLite gained ALTER TABLE … DROP COLUMN in 3.35 (Mar 2021). Production
PyInstaller builds bundle Python 3.12 which links to SQLite 3.40+ so
that path is always safe, but a dev running the backend directly on
Ubuntu 20.04 (3.31) or Debian 11 (3.34) would crash on first startup
trying to drop the legacy default_intent column.

Add _supports_drop_column(engine) — returns True on non-SQLite
dialects (Postgres / MySQL have supported DROP COLUMN for decades) and
gates on the runtime sqlite_version for SQLite. When unsupported, log a
warning and leave the unused column in place: SQLAlchemy only maps
declared columns, so a stray default_intent column does no reads or
writes and can't interfere with runtime behaviour.

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

* fix(mcp): correct lifespan shutdown order — drain MCP before unloading models

The inline lifespan ran _run_shutdown inside the MCP context, so the
TTS / Whisper / LLM models were unloaded *before* FastMCP's __aexit__
got a chance to cancel its in-flight session tasks. Any MCP request
mid-generate at shutdown time would crash on "model unloaded" instead
of receiving a clean session-cancelled error.

Rewire via compose_lifespan (which was already defined in
mcp_server.server for exactly this purpose but never used):
AsyncExitStack enters factories in order and exits in LIFO, so
MCP teardown fires first — cancelling sessions — and _run_shutdown
runs after nothing is holding the models. Smoke test shows the
log order flipped as expected:

  Ready
  StreamableHTTP session manager started
  ... running ...
  StreamableHTTP session manager shutting down   ← was last, now first
  Voicebox server shutting down...               ← was first, now last

As a side benefit, _run_shutdown is now paired with _run_startup via
try/finally inside voicebox_lifespan, so a partial startup (models
half-loaded, MCP __aenter__ fails) still unloads whatever was loaded
instead of leaking it to process exit.

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

* fix(mcp): stamp last_seen_at on /speak too + tighten path predicate

POST /speak is a REST wrapper around voicebox.speak for agents that
don't talk MCP (shell scripts, ACP, A2A). It reads X-Voicebox-Client-Id
and uses it for the same per-client profile resolution + default
personality lookup the MCP tool does (speak.py:39-64), so its callers
are first-class clients — but the ClientIdMiddleware only stamped
last_seen_at on /mcp* paths. REST speak callers showed up as "never
seen" in Settings → MCP despite actively acting on their bindings.

Widen the stamp predicate to an explicit ("/mcp", "/speak") prefix
list, and require a path boundary on match so future routes named
/mcpfoo or /speakers don't silently inherit the stamp via the prefix.
New test_client_id_middleware.py pins the scope with 17 parametrised
cases (both the allowed set and the overlap cases that must not match).

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

* feat(captures): scrubbable WaveSurfer player for capture detail view

Replace the placeholder fake-waveform + play button in CapturesTab's
audio card with a real CaptureInlinePlayer (wavesurfer.js). The player
renders the actual waveform, lets users scrub through the clip, and
shows a proper current/total timestamp pair in place of the
duration-only label.

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

* feat(ui): persist selectedProfileId across sessions

Wrap useUIStore in zustand/middleware's persist under the key
voicebox-ui. partialize only selectedProfileId so volatile UI state
(dialog open flags, form drafts, engine/voice pickers, sidebar) stays
in-memory as before — but reopening the app no longer loses whichever
profile the user was last working with.

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

* feat(captures): mirror readiness checklist into the settings sidebar

The six-gate checklist only rendered in the CapturesTab empty state, so
a user already on the settings page had no single surface showing which
gate was red — the inline InputMonitoringNotice covered one, the model
pickers covered another, and Accessibility was only hinted at by the
auto-paste toggle. Mirror the same component into the right sidebar of
the settings page so every gate (STT model, LLM model, Input Monitoring,
Accessibility, plus the hotkey toggle in the main column) is always
visible while the user configures dictation.

New compact prop on DictationReadinessChecklist drops the centered
header and empty-state max-width so it fits the 280 px sidebar next to
the existing About / Differences blocks. Callers in compact mode own
the heading — CapturesPage reuses the existing captures.readiness.title
key (present in en / ja / zh-CN / zh-TW already) as an h3 matching the
sibling sidebar sections.

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

* feat(captures): move sidebar checklist below differences + hide when all green

Two small follow-ups to the sidebar checklist placement. Move it below
the What's different section so the sticky top of the sidebar stays the
page's narrative context (About → differences) and the checklist reads
as a status panel rather than preamble. Gate the whole block on
!readiness.allReady so once every gate is green the sidebar drops back
to just About + What's different — no value in real estate full of
checkmarks.

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

* fix(captures): refetch readiness immediately after STT/LLM model swap

useCaptureSettings updated its own cache optimistically but never
invalidated ['capture-readiness'], so for up to 5 s (the poll interval)
after switching stt_model or llm_model the checklist kept showing the
previous model's ready/missing state. The backend endpoint resolves
the model live on each call — it was just the frontend cache that
lagged. Invalidate in onSettled only when the patch touched a model
field, so unrelated updates (chord keys, toggles) don't pay for a
refetch.

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

* fix(captures): hide macOS-only copy when running on Windows / Linux

Two surfaces leaked macOS-specific copy onto other platforms:

1. The Input Monitoring + Accessibility rows in the readiness
   checklist rendered everywhere. On Windows/Linux the Rust permission
   stubs return true, so the rows showed as permanent green checkmarks
   with copy like "macOS allows Voicebox to detect your global
   shortcut." — nonsense when you're on Windows. Gate both rows on a
   userAgent-based isMacOS check so they only render where the
   underlying TCC permission actually exists.

2. The global-shortcut setting description ended with "macOS will ask
   for Input Monitoring permission the first time you turn this on."
   That sentence rendered on every platform. The readiness checklist
   already surfaces the TCC requirement at the right moment on macOS,
   so the description doesn't need the platform note — drop it from
   en / ja / zh-CN / zh-TW.

Other macOS strings (AccessibilityNotice, InputMonitoringNotice, their
"stillMissing" hints) are already gated behind the Rust permission
booleans returning false, which never happens on Windows/Linux, so they
stay inert without further changes.

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

* feat(capture): swap the rdev fork for keytap 0.2, delete local chord state machine

Dep swap:
- Drop the git-pinned jamiepine/rdev fork we were carrying since the
  upstream crate is abandoned.
- Depend on keytap 0.2 from crates.io — our own cross-platform global
  keyboard tap crate. Clean shutdown via Drop, Sonoma-safe by design
  (no TSMGetInputSourceProperty calls off the main thread, so
  `set_is_main_thread(false)` is gone), and properly versioned.

Chord engine rewrite:
- Delete hotkey_monitor.rs's internal Chord state machine (Match enum,
  KeyEvent enum, step()/classify() methods, associated unit tests).
  keytap's ChordMatcher subsumes it: Momentary chord for PTT,
  add_toggle() for Toggle-to-talk, longest-match resolution, sticky-end
  for Toggle. Net: -80 LOC in hotkey_monitor.rs; the remaining module
  is the dispatcher loop + Effect→Tauri translation.
- Preserve the PTT→Toggle "RestartRecording" upgrade signal. keytap
  emits End(PTT)+Start(Toggle) atomically (same Instant) when the held
  set upgrades from a shorter chord to a longer superset. The
  dispatcher peeks at the matcher with a 5 ms recv_timeout after any
  End and coalesces the pair into Effect::RestartRecording so the
  frontend still gets the "discard the transition-moment audio" signal
  instead of an unrelated Stop+Start pair.
- HotkeyMonitor::update_bindings now actually tears down the tap on
  empty bindings instead of leaving an idle CGEventTap around. New
  bindings rebuild the matcher and the dispatcher thread from scratch.

key_codes.rs:
- Rewrite the browser-code → Key table against keytap's cleaner Key
  variant names (`A`..`Z` not `KeyA`..`KeyZ`, `Digit0`..`Digit9` not
  `Num0`..`Num9`, `ArrowUp` not `UpArrow`, `AltLeft`/`AltRight` instead
  of `Alt`/`AltGr`, `Period` not `Dot`, …). On-disk chord string
  format (W3C `KeyboardEvent.code` identifiers) is unchanged, so
  capture_settings rows written before the swap round-trip identically.
  Legacy aliases (`Alt`, `AltGr`, `Num0`, `UpArrow`, `Dot`, …) kept for
  forward-compat on old rows.

main.rs / input_monitoring.rs:
- Update the few doc comments that referenced `rdev::listen` to
  describe keytap's Tap; no behavioural change.
- build_chord_bindings now imports from keytap::Key.
- enable_hotkey / disable_hotkey / update_chord_bindings reach into
  HotkeyMonitor via &mut since apply()/update_bindings() now mutate.

Tests live in keytap now (22 chord-related tests in keytap 0.2,
including the PTT→Toggle upgrade scenario that used to be tested in
hotkey_monitor.rs). Voicebox's hotkey_monitor.rs is thin enough that
local testing would be trivia.

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

* chore(deps): bump keytap 0.2 → 0.4 for macOS modifier-events fix

0.2 read CGEventFlags via CGEventGetIntegerValueField(event, 0x81),
which is not a valid CGEventField id — macOS silently returned 0, so
FlagsChanged events produced no KeyDown / KeyUp for any modifier key
and the PTT / toggle chords never armed on macOS. 0.4 uses the
documented CGEventGetFlags(event) API.

0.3 (tracing / serde / Fn / IntlBackslash) is picked up as a free
consequence; no API surface we depend on changed.

* perf(captures): stop polling readiness once both models are green

useQuery was firing GET /capture/readiness every 5s forever, and also
on every window focus. Once stt.ready and llm.ready are both true the
answer can only change when the user swaps a model in settings, and
useSettings already invalidates the query on that path — the polling
was pure noise.

Gate both refetchInterval and refetchOnWindowFocus on "not fully ready"
so we fall silent once the checklist is green.

* feat(ui): theme settings, stories polish, track editor restructure

- add dark/light/system theme with persisted choice + OS change listener
- restyle stories sidebar (search, item layout, border) to match captures
- move floating generate box to right column of stories, add top fade mask
- story track editor: sticky track labels aligned via flex rows, custom scrollbar with left/right zoom handles
- capture pill light mode pass, fix inline waveform progress color
- pull mcp_server hidden imports into the pyinstaller spec
- notarization doc draft

* fix mlx llm bundling

* feat(ui): shared ListPane primitive + misc polish

ListPane is a compound component (Header / TitleRow / Title / Actions /
Search / Scroll) that owns the relative wrapper, faded right divider
(50px top fade), top scroll mask, and absolute-positioned header used by
every list-detail tab. Wires up CapturesTab, StoryList, and EffectsList.
EffectsTab gets -mx-8 / pr-8 to match the edge-to-edge layout used
elsewhere.

Other changes:
- MCPPage: native <select> → shadcn <Select> for default voice and
  per-binding voice pickers
- Button outline variant: add hover:border-accent
- Drop hover:text-destructive from trailing delete buttons
  (HistoryTable, GpuAcceleration, GpuPage, EffectsChainEditor,
  EffectsDetail)
- HistoryTable empty state moved behind t('history.empty')
- StoryContent scroll padding pt-14 → pt-16
- backend health reports the captures dir
- landing CapturesMockup: "Send to" → "Export" with Download icon
- CHANGELOG: drop [Unreleased] personality section

* fix(captures): Play As autoplay + default voice + orphan recovery

- Hand /generate ids to the global SSE watcher so playback fires on completion. The mutation onSuccess was checking audio_path on a queued row, which is always empty — autoplay never ran.
- Bind the Play As voice selection to capture_settings.default_playback_voice_id, kept in sync with the Settings → Captures and Settings → MCP pickers. Picking from the split-button dropdown writes back to settings.
- Extract AudioBars from HistoryTable into a shared component; use it for the Play As generating state in place of Loader2.
- Stop the active-state hover from flashing white text when the button is in its lighter accent/10 fill.
- Drop the gradient avatar swatches from the Settings → Captures voice dropdown.
- Backend: when the gen worker exits without writing a terminal status (e.g. SQLite lock racing the failed-status write inside its own exception handler), the cancel endpoint now flips the row to failed instead of 409-ing. Worker also force-fails on its way out as a belt-and-suspenders.

* fix(captures+chord): Stop button stops, ChordPicker accepts shorter chords

Two unrelated correctness bugs caught in PR review:

- The Play As "Stop" button was wired to handlePlayAs() unconditionally, so clicking it during playback kicked a fresh generation instead of halting. Now pauses the player when the click came from the main button while playbackState is 'playing'. Picking a different voice from the dropdown still kicks a new generation as before.
- ChordPicker tracked the peak set of held keys but seeded the peak from initialKeys, so a user who opened the picker with a 3-key chord saved couldn't replace it with a 2-key chord — the candidate length never beat the seed. The peak now resets on the first press of a fresh sequence (when no keys were held immediately prior), then grows monotonically within that hold.

* fix(settings): honor explicit null on nullable fields, ignore on the rest

Routes were calling model_dump(exclude_none=True), which drops every
client-sent null before it reaches the service. The service then layered
on its own `if value is not None` guard. Net effect: setting a nullable
column back to null was a no-op — the MCPPage default-voice picker sends
null when the user picks "no default" and the row was silently keeping
whatever was there before.

Switched the routes to exclude_unset=True so absent fields stay absent
but explicit nulls survive the dump, and centralised the per-field
nullability check in the service. The check inspects the SQLAlchemy
column metadata so non-nullable columns (stt_model, llm_model, the chord
key lists) still drop nulls instead of crashing the request, while
default_playback_voice_id can finally be cleared.

* fix(captures): clean up audio files when create_capture fails

The create flow wrote raw audio (and a transcoded .wav for non-wav
sources) to data/captures before the DB row was committed, so any
failure between the write and the commit — a webm that decoded to a
0-length array, a whisper model that errored mid-transcribe, a SQLite
contention on the commit — left the audio on disk with nothing pointing
at it. Over enough flaky uploads the directory grows without bound.

Now every path written before the commit is tracked in a list, and the
whole stretch from the first write to db.commit() runs inside a
try/except that unlinks each tracked file on raise and re-raises. The
transcode branch removes the raw file from the cleanup list only when
the unlink actually succeeds, so an OSError on the raw-path delete
still hands cleanup the original blob to retry.

* fix(mcp): restrict voicebox.transcribe(audio_path=...) to loopback

audio_path mode took any absolute filesystem path and returned its
decoded contents as transcribed text with no caller verification beyond
the existence/size checks. The X-Voicebox-Client-Id middleware records
the header but never rejects an absent or fake one, so a Voicebox bound
to 0.0.0.0 (the documented "remote access" mode) was effectively an
unauthenticated arbitrary-local-file read primitive.

The middleware now stashes the request's remote address in a ContextVar
alongside the existing client_id, and audio_path mode refuses anything
that doesn't parse as a loopback address (IPv4 127.0.0.0/8, IPv6 ::1).
audio_base64 mode is unchanged — that path was always bounded to bytes
the caller already has.

Loopback callers (the Tauri webview, local CLI scripts, MCP clients on
the same machine) keep working. Remote callers now have to send the
audio over the wire if they want it transcribed.

* fix: PR review nits — response shape, landing copy, form reset

- /llm/generate's "model is downloading" branch was raising HTTPException(202, detail={...}), which wraps the payload in {"detail": ...} and forces clients to parse a success status as if it were an error. Switched to JSONResponse so the payload sits at the top level.
- The landing page's "Language Models" card advertised "Qwen 3.5" with sizes 4B/2B/0.8B; we ship Qwen3 at 0.6B/1.7B/4B. Aligned to what's actually in the binary.
- ProfileForm's discard-draft button reset the form without touching `personality` or `avatarFile`, so stale persona text and an attached avatar would survive the discard. The other three resets in the file already include both fields — this brings the discard path in line.

* perf(mcp): move last_seen_at stamp off the request path

ClientIdMiddleware was running the SQLAlchemy SELECT/INSERT/UPDATE/COMMIT
inline on the event loop after every /mcp/* and /speak request. SQLite
serialises writes, so concurrent MCP traffic queued behind the stamp
write — the response sat waiting on a side-effect that the client never
needs in band, and SSE streams would stall briefly per request.

The middleware now hands the stamp to asyncio.to_thread via a fire-and-
forget create_task so the response returns immediately and the write
runs on the default executor. A module-level set keeps strong refs to
in-flight tasks (per asyncio docs) so the GC can't collect them mid-
write. The fallback path runs the stamp inline if no loop is available
(tests/oddball callers) rather than silently dropping it.

* fix(dictate): force-dismiss the speaking pill when SSE never comes back

The pill subscribed to /generation/{id}/status to know when to start
playback, but EventSource.onerror was a no-op — auto-reconnect was the
intended recovery for transient drops. The gap: if the backend deletes
the gen row mid-flight or the connection silently dies in a way the
browser keeps retrying without ever getting a status event, the pill
sits in 'speaking' forever and the user has no way to clear it.

Added a 60-second hard cap that arms when the SSE opens and clears the
moment any real status event lands. If it fires while the pill is still
on the same id and audio never started, it force-dismisses. Same idea
as the existing post-speak-end 15s grace, but covers the case where the
backend never says anything at all.

* fix: i18n cleanup + readiness checklist effect cadence + ChordPicker shadow

- DictationReadinessChecklist was constructing downloadByModel as a fresh Map every render and listing it in the cleanup effect's deps. With the 1 s polling cadence and arbitrary parent rerenders the effect ran more often than it needed to. Memoised the Map on activeTasks; the effect now keys off the memo's identity.
- zh-CN persona tooltipActive/ariaLabelActive matched their inactive twins byte-for-byte ("以人物设定朗读"). The other locales differentiate the active state with a -ing / -中 suffix; zh-CN now reads "正以人物设定朗读" when active.
- personalityPlaceholder was a ~290-character paragraph that doubled as both the example text and the explanation, repeating most of what personalityHint already said. Trimmed to the example only and folded the explanation + leave-blank consequence into the hint, across all four locales.
- Refinement model size keys were size06 / size17 / size4. Renamed the 4B variant to size40 so the decimal padding is consistent.
- ChordPicker's open-effect bound a window.setTimeout id to a local `t`, shadowing the i18n `t` from useTranslation. Renamed to timeoutId.

* perf(settings): persist generation sliders on release, not per pointer-move

Both sliders on the generation settings page were calling update() —
which is a React Query mutation that PATCHes /settings/generation —
inside onValueChange. Dragging the chunk-limit slider from 800 to 3000
fired a request per pointer-move pixel, and a mid-drag failure plus
optimistic rollback would leave persisted state visibly out of sync
with the thumb position.

Local state now mirrors each slider during a drag and the persist
happens once on Radix's onValueCommit (pointer-up / keyboard-release).
useEffects keep the local state in sync if the persisted value changes
out-of-band — another window editing the same setting still updates the
slider position cleanly.

* chore(backend): Ruff lint pass — deprecated APIs, exception leaks, dead patterns

Mechanical sweep of items called out in the PR review:

- qwen_llm_backend: AutoModelForCausalLM.from_pretrained(torch_dtype=…) is deprecated in transformers ≥4.41 in favor of dtype=. Renamed.
- routes/llm: try/except around backend.generate() raised HTTPException(500, detail=str(e)) which leaks stack traces / paths to clients and trips Ruff B904. Now logs the original exception server-side and hands the client a generic message; chained via `from e` to preserve traceback context.
- mcp_bindings + mcp_server/context: datetime.utcnow() is deprecated since 3.12. Switched the two assignment sites to datetime.now(timezone.utc). The schema-level `default=datetime.utcnow` defaults in database/models.py are left for a later schema-aware pass.
- routes/generations: `logger = …` sat between two import blocks (Ruff E402). Moved below imports.
- mcp_server/server + tests/test_refinement_samples: typing.Callable / typing.Iterable have been preferred-via collections.abc since 3.9 (Ruff UP035).
- routes/events: `except asyncio.TimeoutError` aliases plain `TimeoutError` since 3.11 (UP041).
- services/captures: hoisted WHISPER_NATIVE_FORMATS to module scope (was a function-local UPPER_SNAKE that tripped N806) and replaced the raw_path.unlink try/except OSError-pass with contextlib.suppress (SIM105). Semantic equivalence preserved — written_files.remove(raw_path) still only runs when unlink succeeds because it sits inside the suppressed block after the unlink call.
- database/migrations: hoisted the duplicate `import sqlite3` from inside two helper bodies to a single module-level import.

* feat(stories): regenerate action on clips and the chat list dropdown

The track editor's clip toolbar now has a regenerate icon next to Delete; clicking it kicks a fresh take of the selected clip's underlying generation through the same /generate/{id}/regenerate path the History table uses, and pushes the id into the global pending set so the SSE watcher picks it up. The chat list's per-item dropdown gets the same action between Play-from-here and Remove. Translation keys added under storyContent.itemActions / storyContent.toast across all four locales.

* feat(stories): import external audio into the timeline (drag-drop + picker)

You can now drop a music file onto the story content area or pick one through the new "Import audio" button in the add-clip popover. Both call POST /generate/import which writes the file to data/generations/<id>.<ext>, probes duration via librosa, and inserts a Generation row pointing at a singleton "Imported Audio" profile (created lazily on first import). The existing addStoryItem flow takes over from there — the timeline doesn't care that the row didn't come out of TTS.

Engine field on the row is "import"; it's surfaced on StoryItemDetail so the chat list shows a music icon instead of the (missing) profile avatar and both the dropdown and the track-editor toolbar hide the Regenerate action — there's nothing to regenerate. Accepted formats: wav/mp3/flac/ogg/m4a/aac/webm, capped at 200 MB. Translation keys added across en/ja/zh-CN/zh-TW.

* fix(audio): serve real Content-Type so imports decode in WaveSurfer

/audio/{id} and /audio/version/{id} hardcoded media_type="audio/wav" on
the FileResponse. That was a no-op when every generation came out of
TTS (everything on disk was a .wav anyway), but imported audio keeps
its source format — .mp3 / .m4a / .ogg — and the WaveSurfer MediaElement
backend uses an <audio> tag that checks Content-Type before letting the
clip play, so an MP3 announced as audio/wav silently failed to load.

Both endpoints now derive the type via mimetypes.guess_type and fall
back to audio/wav for unknown suffixes. Download filenames also keep
the real extension instead of always saying ".wav".

* feat(stories): zoom bar bounds tracked to project length, default 60s scope

The track editor's zoom was clamped to a hardcoded [10, 200] pixels-per-second range, which had no relationship to the project — on a 4-minute story a "max zoom out" of 200 px/s still required scrolling, and on a 5-second story you could zoom all the way in to where every clip was a tiny sliver. Reframed the bounds in the unit the user actually thinks in: how many seconds of timeline are visible at once. Min scope is 10 s (most zoomed in), max scope is the entire project, and the default lands on a 60 s scope (or the full project, whichever is shorter) once the editor measures its visible track width on first mount.

The pixels-per-second value still lives in component state (because every downstream calculation already uses it) but minPps/maxPps are computed from `containerWidth − LABEL_COL_WIDTH` and the project's effective duration, so the +/- buttons and the edge-drag handles on the scrollbar all clamp to bounds that move with the project. Re-clamping fires whenever those bounds shift — adding a long clip or resizing the window pulls the current zoom inside the new range instead of leaving the user parked outside it.

* fix(stories): show the source filename on imported clips

Imports were rendering as "Imported Audio" everywhere because every
import points at the singleton voice profile. The filename was already
being stored on the generation row (in the `text` field), so the chat
item title and the timeline clip label now read from `text` when
`engine === 'import'` and fall back to the profile name otherwise. The
chat item also drops the language pill (always "en" on imports — not
informative) and skips the transcript textarea since imports have no
spoken text to show.

* fix(stories): round split_time_ms before posting

handleSplit was sending currentTimeMs - item.start_time_ms straight to the backend, which rejects it because StoryItemSplit.split_time_ms is typed as int and the playhead's currentTimeMs is a float (it's driven from HTMLAudioElement.currentTime, which carries sub-millisecond precision). Pydantic surfaced the mismatch as "Input should be a valid integer, got a number with a fractional part" and the toast read "Failed to split clip". Math.round at the call site, matching what the trim and move handlers already do.

* feat(stories): per-clip volume control on the timeline

Each story item now carries a volume column (linear gain, default 1.0,
clamped 0.0–2.0 server-side). New PUT /stories/{}/items/{}/volume route
+ useUpdateStoryItemVolume hook + a Volume2 icon in the clip-edit
toolbar that opens a popover with a 0–200% slider. Local slider state
drives the visual during a drag; the persist fires once on
onValueCommit, mirroring the generation-page slider pattern.

Web Audio playback inserts a per-clip GainNode between source and
master so volume changes apply live without re-decoding the buffer
(source -> clipGain -> masterGain -> destination). Server-side
mixdown in export multiplies the trimmed clip by its volume before
summing into the timeline. Split + duplicate carry the volume forward
to the new clips so trimming a faded section keeps the level you set.

Migration adds the volume column with default 1.0 so existing rows
read as full volume.

* fix(stories): mute the clip waveform's media element so it can't bleed audio

The clip waveforms drawn inside each timeline track use WaveSurfer with the default MediaElement backend, which creates an internal <audio> element to drive playback timing. Web Audio in useStoryPlayback is what actually produces sound, but WaveSurfer's element was happily preloading and — after the first user gesture unlocked browser autoplay — playing the source URL through the page output too.

For TTS clips it was masked: they're short, both sources start at the same time, and stopping the BufferSourceNode at pause coincides with the natural end of the audio element. For long imports (a four-minute MP3) the BufferSourceNode stops on pause but WaveSurfer's element keeps going on its own track — which is exactly the "music keeps playing when I pause" symptom.

Hand WaveSurfer a muted <audio> element via the `media` option so the visual still loads peaks but the element itself can never produce sound. preload="metadata" keeps the load lightweight.

* fix(stories): hard-cut the audio graph on stop so long imports actually halt

source.stop() was the only thing happening when a clip was halted, and on long imported buffers (multi-minute MP3s scheduled via source.start with a duration argument) it was silently failing to halt the buffer in some browsers — pause left the music playing and seek stacked another source on top of the original. The mute-the-WaveSurfer-element fix was a different bug along the same path; this is the one that actually addresses the duplicated audio.

ActiveSource now carries the per-clip GainNode alongside the source, and stopSource detaches the onended handler before calling stop() (so the natural-end callback can't race with explicit teardown and re-delete a freshly rescheduled entry at the same id), then disconnects both nodes inside their own try/catch blocks. Even when stop() doesn't actually halt the buffer the graph is severed — no path from source to destination, no audio.

* feat(stories): add empty tracks above/below the timeline

Tiny + strips sit at the top of the topmost label cell and the bottom of the bottommost one, sticky-positioned in the label column so they follow horizontal scroll. Clicking either extends the visible track stack in that direction by one — above adds max(existing)+1, below adds min(existing)-1. Both compute against the full set (defaults + item-derived + previously-added) so successive clicks keep extending instead of fighting over the same number.

Empty extras live in component state because a track only earns its keep once a clip lands on it. Once one does, item.track carries the number forward and the row keeps deriving from items naturally; if nothing lands there before reload, the empty row simply isn't there next time, which matches what the user expects of an unused affordance.

* fix(mcp): bundle stdio shim sidecar

* fix(captures): allow dictation without paste permission

* fix(mcp): preserve speak engine defaults

* fix(captures): use platform hotkey defaults

* fix(mcp): preload speak pill window

* fix(captures): hide unwired storage settings

* feat(sponsors): add /sponsors page, homepage promo, and in-app strip

* style(landing): drop pill chrome from /download maintainer kicker

* changelog

* better naming for sponsors

* windows keybind note

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-25 15:46:35 -07:00
121 changed files with 8855 additions and 6091 deletions
+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.
+30 -7
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
@@ -24,6 +31,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 +44,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 +67,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 +93,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 +100,7 @@ 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
COPY --chmod=755 scripts/rocm-entrypoint.sh /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "17493"]
+4
View File
@@ -28,6 +28,10 @@
</a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/21213" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21213" alt="jamiepine%2Fvoicebox | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<p align="center">
<a href="https://voicebox.sh">voicebox.sh</a> •
<a href="https://docs.voicebox.sh">Docs</a> •
+27
View File
@@ -0,0 +1,27 @@
# Responsible Use
Voicebox is a local-first AI voice studio. It can clone voices from short audio samples, generate speech, and make AI agents speak through voice profiles. That capability is useful for accessibility, creative production, prototyping, game development, and personal tools, but it can also be misused.
Voicebox does not and cannot independently verify who owns a voice sample. You are responsible for making sure you have the right to use every voice you clone, import, or generate with.
## Allowed Uses
- Cloning your own voice.
- Cloning a voice with explicit permission from the speaker.
- Using licensed, public-domain, or otherwise legally authorized voice material.
- Building accessibility tools, creative projects, games, podcasts, prototypes, and local workflows where the speaker's rights are respected.
## Prohibited Uses
- Impersonating someone without permission.
- Fraud, scams, phishing, social engineering, or bypassing voice authentication.
- Harassment, threats, intimidation, or non-consensual sexual content.
- Misleading political, legal, financial, medical, or emergency communications.
- Commercial use of a person's voice without the legal right to do so.
- Removing or bypassing responsible-use acknowledgements in order to misuse the software.
## Disclosure And Compliance
If you publish or distribute synthetic audio, disclose that it is AI-generated where required by law, platform policy, or audience expectations. Developers building products on top of Voicebox should treat consent records, disclosure, and jurisdiction-specific requirements as part of their own application design.
Voicebox runs locally to protect user privacy. That privacy model does not remove your responsibility to respect other people's voices.
-1
View File
@@ -52,7 +52,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",
@@ -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,39 +388,207 @@ 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) */}
{cudaDownloading && downloadProgress && (
<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>
{downloadProgress.filename ||
(cudaAvailable
? 'Updating CUDA backend...'
: 'Downloading CUDA backend...')}
</span>
</div>
{downloadProgress.total > 0 && (
<span className="text-muted-foreground">
{downloadProgress.progress.toFixed(1)}%
</span>
)}
</div>
{downloadProgress.total > 0 && (
<>
<Progress value={downloadProgress.progress} className="h-2" />
<div className="text-xs text-muted-foreground">
{formatBytes(downloadProgress.current)} /{' '}
{formatBytes(downloadProgress.total)}
</div>
</>
)}
{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">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>
{downloadProgress.filename ||
(cudaAvailable
? 'Updating CUDA backend...'
: 'Downloading CUDA backend...')}
</span>
</div>
{downloadProgress.total > 0 && (
<span className="text-muted-foreground">
{downloadProgress.progress.toFixed(1)}%
</span>
)}
</div>
{downloadProgress.total > 0 && (
<>
<Progress value={downloadProgress.progress} className="h-2" />
<div className="text-xs text-muted-foreground">
{formatBytes(downloadProgress.current)} /{' '}
{formatBytes(downloadProgress.total)}
</div>
</>
)}
</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' && (
@@ -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>
@@ -3,7 +3,6 @@ import type { CSSProperties, ReactNode } from 'react';
import { useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import voiceboxLogo from '@/assets/voicebox-logo.png';
import { SPONSORS } from '@/lib/sponsors';
import { usePlatform } from '@/platform/PlatformContext';
function FadeIn({ delay = 0, children }: { delay?: number; children: ReactNode }) {
@@ -117,36 +116,6 @@ export function AboutPage() {
</div>
</FadeIn>
{SPONSORS.length > 0 && (
<FadeIn delay={400}>
<div className="pt-4 flex flex-col items-center gap-3">
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-muted-foreground/60">
Sponsored by
</p>
<div className="flex flex-wrap items-center justify-center gap-3">
{SPONSORS.map((sponsor) => (
<a
key={sponsor.name}
href={sponsor.url}
target="_blank"
rel="noopener noreferrer"
aria-label={sponsor.name}
className="group flex h-12 min-w-[120px] items-center justify-center rounded-lg border border-border/60 bg-card/50 px-4 transition-colors hover:bg-muted/50"
>
<img
src={sponsor.logoSrc}
alt={sponsor.logoAlt ?? sponsor.name}
className={`h-5 w-auto max-w-[100px] object-contain opacity-80 transition-opacity group-hover:opacity-100 ${
sponsor.invertOnDark ? 'dark:brightness-0 dark:invert' : ''
}`}
/>
</a>
))}
</div>
</div>
</FadeIn>
)}
<FadeIn delay={480}>
<p className="text-xs text-muted-foreground/40 pt-4">
<Trans
+316 -99
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,33 +403,188 @@ export function GpuPage() {
<div className="space-y-8 max-w-2xl">
<GpuInfoCard health={health} />
{!hasNativeGpu && !isCurrentlyCuda && (
<SettingSection
title={t('settings.gpu.cuda.title')}
description={t('settings.gpu.cuda.description')}
>
{cudaDownloading && downloadProgress && (
<SettingRow title={t('settings.gpu.cuda.downloading')}>
<div className="space-y-1.5">
<Progress value={downloadProgress.progress} className="h-2" />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{downloadProgress.filename ||
(cudaAvailable
? t('settings.gpu.cuda.updating')
: t('settings.gpu.cuda.downloadingShort'))}
</span>
<span>
{downloadProgress.total > 0
? `${formatBytes(downloadProgress.current)} / ${formatBytes(downloadProgress.total)}`
: `${downloadProgress.progress.toFixed(1)}%`}
</span>
{!hasNativeGpu && !isCurrentlyCuda && !isCurrentlyRocm && (
<>
<SettingSection
title={t('settings.gpu.cuda.title')}
description={t('settings.gpu.cuda.description')}
>
{cudaDownloading && downloadProgress && (
<SettingRow title={t('settings.gpu.cuda.downloading')}>
<div className="space-y-1.5">
<Progress value={downloadProgress.progress} className="h-2" />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{downloadProgress.filename ||
(cudaAvailable
? t('settings.gpu.cuda.updating')
: t('settings.gpu.cuda.downloadingShort'))}
</span>
<span>
{downloadProgress.total > 0
? `${formatBytes(downloadProgress.current)} / ${formatBytes(downloadProgress.total)}`
: `${downloadProgress.progress.toFixed(1)}%`}
</span>
</div>
</div>
</div>
</SettingRow>
)}
</SettingRow>
)}
{restartPhase !== 'idle' && (
{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" />}
/>
)}
{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>
)}
{restartPhase === 'idle' && !cudaDownloading && (
<>
{!cudaAvailable && !isCurrentlyCuda && (
<SettingRow
title={t('settings.gpu.download.title')}
description={t('settings.gpu.download.description')}
action={
<Button onClick={handleDownloadCuda} size="sm">
<Download className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.download.button')}
</Button>
}
/>
)}
{cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && (
<SettingRow
title={t('settings.gpu.switchToCuda.title')}
description={t('settings.gpu.switchToCuda.description')}
action={
<Button onClick={handleSwitchToCuda} size="sm">
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.switchToCuda.button')}
</Button>
}
/>
)}
{cudaAvailable && !isCurrentlyCuda && (
<SettingRow
title={t('settings.gpu.remove.title')}
description={t('settings.gpu.remove.description')}
action={
<Button
onClick={handleDeleteCuda}
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.remove.button')}
</Button>
}
/>
)}
</>
)}
</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'
@@ -327,8 +595,18 @@ export function GpuPage() {
}
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">
@@ -337,67 +615,6 @@ export function GpuPage() {
</div>
</SettingRow>
)}
{restartPhase === 'idle' && !cudaDownloading && (
<>
{!cudaAvailable && !isCurrentlyCuda && (
<SettingRow
title={t('settings.gpu.download.title')}
description={t('settings.gpu.download.description')}
action={
<Button onClick={handleDownload} size="sm">
<Download className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.download.button')}
</Button>
}
/>
)}
{cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && (
<SettingRow
title={t('settings.gpu.switchToCuda.title')}
description={t('settings.gpu.switchToCuda.description')}
action={
<Button onClick={handleRestart} size="sm">
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.switchToCuda.button')}
</Button>
}
/>
)}
{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}
variant="ghost"
size="sm"
className="text-muted-foreground "
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.remove.button')}
</Button>
}
/>
)}
</>
)}
</SettingSection>
)}
-219
View File
@@ -1,219 +0,0 @@
import { Lock, MoreHorizontal, Plus, Smartphone, WifiOff } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useToast } from '@/components/ui/use-toast';
import {
usePairedDevices,
useRevokePairedDevice,
} from '@/lib/hooks/usePairedDevices';
import type { PairedDeviceResponse } from '@/lib/api/types';
import { PairDeviceDialog } from './PairDeviceDialog';
import { SettingRow, SettingSection } from './SettingRow';
function formatRelative(iso: string | null): string {
if (!iso) return 'never';
const then = new Date(iso).getTime();
const diffSec = Math.floor((Date.now() - then) / 1000);
if (diffSec < 60) return 'just now';
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
if (diffSec < 86400 * 30) return `${Math.floor(diffSec / 86400)}d ago`;
return new Date(iso).toLocaleDateString();
}
export function MobilePage() {
const [pairOpen, setPairOpen] = useState(false);
const devices = usePairedDevices();
const revoke = useRevokePairedDevice();
const { toast } = useToast();
const active = (devices.data ?? []).filter((d) => !d.revoked);
const revoked = (devices.data ?? []).filter((d) => d.revoked);
function handleRevoke(d: PairedDeviceResponse) {
revoke.mutate(d.id, {
onSuccess: () => {
toast({
title: 'Device revoked',
description: `${d.name} can no longer reach this Voicebox.`,
});
},
onError: (e) => {
toast({
title: 'Revoke failed',
description: e instanceof Error ? e.message : String(e),
variant: 'destructive',
});
},
});
}
return (
<div className="flex gap-8 items-start max-w-5xl">
<div className="flex-1 min-w-0 max-w-2xl space-y-10">
<SettingSection
title="Mobile"
description="Pair your phone to dictate, browse captures, and generate from anywhere on your network."
>
<SettingRow
title="Paired devices"
description={
active.length === 0
? 'No devices yet — pair your phone to get started.'
: `${active.length} active${revoked.length > 0 ? `, ${revoked.length} revoked` : ''}`
}
action={
<Button onClick={() => setPairOpen(true)} size="sm" className="gap-1.5">
<Plus className="h-3.5 w-3.5" />
Pair device
</Button>
}
/>
{devices.data && devices.data.length > 0 ? (
<div className="pt-3 space-y-2">
{[...active, ...revoked].map((d) => (
<DeviceRow
key={d.id}
device={d}
onRevoke={() => handleRevoke(d)}
revoking={revoke.isPending && revoke.variables === d.id}
/>
))}
</div>
) : devices.isLoading ? (
<div className="pt-3 text-sm text-muted-foreground">Loading devices…</div>
) : (
<EmptyState onPair={() => setPairOpen(true)} />
)}
</SettingSection>
</div>
<aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0">
<div className="space-y-2">
<h3 className="text-sm font-semibold">About pairing</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Pairing creates a long-lived bearer that only your phone holds.
Voicebox stores just a hash — there's no path to recover the
bearer if the device loses it.
</p>
</div>
<div className="space-y-3">
<h3 className="text-sm font-semibold">How it works</h3>
<ul className="space-y-3 text-sm text-muted-foreground">
<li className="flex gap-2.5">
<Smartphone className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">Local-first.</span>{' '}
Your phone talks to this Voicebox over LAN or Tailscale — no cloud,
no relay.
</span>
</li>
<li className="flex gap-2.5">
<Lock className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">Bearer-only.</span>{' '}
The bearer is shown to the device once at pairing time and
never persisted server-side in plaintext.
</span>
</li>
<li className="flex gap-2.5">
<WifiOff className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
<span className="leading-relaxed">
<span className="text-foreground font-medium">Revocable.</span>{' '}
Revoke any device here — its bearer stops working immediately.
</span>
</li>
</ul>
</div>
</aside>
<PairDeviceDialog open={pairOpen} onOpenChange={setPairOpen} />
</div>
);
}
function DeviceRow({
device,
onRevoke,
revoking,
}: {
device: PairedDeviceResponse;
onRevoke: () => void;
revoking: boolean;
}) {
return (
<div
className={`flex items-center justify-between gap-3 rounded-lg border px-4 py-3 ${
device.revoked ? 'border-border/50 bg-muted/20 opacity-60' : 'border-border bg-card'
}`}
>
<div className="flex items-center gap-3 min-w-0">
<div
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${
device.revoked ? 'bg-muted' : 'bg-accent/15'
}`}
>
<Smartphone
className={`h-4 w-4 ${device.revoked ? 'text-muted-foreground' : 'text-accent'}`}
/>
</div>
<div className="min-w-0">
<div className="text-sm font-medium truncate">
{device.name}
{device.revoked ? (
<span className="ml-2 text-[10px] uppercase tracking-wider text-muted-foreground">
revoked
</span>
) : null}
</div>
<div className="text-xs text-muted-foreground">
Last seen {formatRelative(device.last_seen_at)} · paired{' '}
{formatRelative(device.created_at)}
</div>
</div>
</div>
{!device.revoked ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7" disabled={revoking}>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onRevoke} className="text-destructive">
Revoke
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
);
}
function EmptyState({ onPair }: { onPair: () => void }) {
return (
<div className="pt-6 flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border/60 px-6 py-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-accent/10">
<Smartphone className="h-5 w-5 text-accent" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium">No paired devices</p>
<p className="text-xs text-muted-foreground max-w-[280px]">
Pair your phone to dictate captures, queue generations, and play back voices on the go.
</p>
</div>
<Button onClick={onPair} size="sm" className="mt-2 gap-1.5">
<Plus className="h-3.5 w-3.5" />
Pair device
</Button>
</div>
);
}
@@ -1,240 +0,0 @@
import { Check, Copy, Loader2, RefreshCw, Smartphone } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import QRCode from 'react-qr-code';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useToast } from '@/components/ui/use-toast';
import {
PAIRED_DEVICES_KEY,
useInitPairing,
usePairedDevices,
usePairHostCandidates,
} from '@/lib/hooks/usePairedDevices';
import { useQueryClient } from '@tanstack/react-query';
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
function formatRemaining(ms: number): string {
if (ms <= 0) return 'expired';
const total = Math.floor(ms / 1000);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
export function PairDeviceDialog({ open, onOpenChange }: Props) {
const { toast } = useToast();
const qc = useQueryClient();
const candidates = usePairHostCandidates(open);
const initPairing = useInitPairing();
const devices = usePairedDevices({ polling: open });
const [selectedHost, setSelectedHost] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [now, setNow] = useState(() => Date.now());
// The IDs that existed when the dialog opened — anything new is a fresh
// pairing we should celebrate.
const [baselineDeviceIds, setBaselineDeviceIds] = useState<Set<string> | null>(null);
// On open: snapshot baseline devices, default-select the first non-loopback
// candidate, and mint the first token.
useEffect(() => {
if (!open) {
setBaselineDeviceIds(null);
setSelectedHost(null);
initPairing.reset();
return;
}
if (devices.data && baselineDeviceIds === null) {
setBaselineDeviceIds(new Set(devices.data.map((d) => d.id)));
}
if (candidates.data && candidates.data.length > 0 && selectedHost === null) {
const preferred =
candidates.data.find((c) => c.kind !== 'loopback') ?? candidates.data[0];
setSelectedHost(preferred.address);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, candidates.data, devices.data]);
// Re-mint the token whenever the host selection changes (the URL embeds
// the host, so a new selection means a new QR).
useEffect(() => {
if (!open || !selectedHost) return;
initPairing.mutate(selectedHost);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, selectedHost]);
// Wall-clock tick for the countdown.
useEffect(() => {
if (!open) return;
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, [open]);
// Detect a freshly paired device and close + toast.
useEffect(() => {
if (!open || !devices.data || baselineDeviceIds === null) return;
const newDevice = devices.data.find(
(d) => !baselineDeviceIds.has(d.id) && !d.revoked,
);
if (newDevice) {
toast({
title: 'Device paired',
description: `${newDevice.name} is now connected.`,
});
qc.invalidateQueries({ queryKey: PAIRED_DEVICES_KEY });
onOpenChange(false);
}
}, [open, devices.data, baselineDeviceIds, toast, qc, onOpenChange]);
const pairing = initPairing.data;
const expiresAtMs = pairing ? new Date(pairing.expires_at).getTime() : 0;
const remainingMs = expiresAtMs - now;
const expired = pairing != null && remainingMs <= 0;
const qrValue = useMemo(() => pairing?.pairing_url ?? '', [pairing]);
async function handleCopy() {
if (!pairing) return;
try {
await navigator.clipboard.writeText(pairing.pairing_url);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch (e) {
toast({
title: 'Copy failed',
description: e instanceof Error ? e.message : 'Could not access clipboard',
variant: 'destructive',
});
}
}
function handleRegenerate() {
if (!selectedHost) return;
initPairing.mutate(selectedHost);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Smartphone className="h-4 w-4 text-accent" />
Pair a new device
</DialogTitle>
<DialogDescription>
Open Voicebox on your phone, tap <span className="text-foreground">Get started → Pair</span>,
then point its camera at this QR.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
{/* Host picker */}
<div className="flex flex-col gap-1.5">
<label className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
Reachable at
</label>
<Select
value={selectedHost ?? ''}
onValueChange={(v) => setSelectedHost(v)}
disabled={!candidates.data || candidates.data.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={candidates.isError ? 'Could not load' : 'Detecting addresses…'}
/>
</SelectTrigger>
<SelectContent>
{candidates.data?.map((c) => (
<SelectItem key={c.address} value={c.address}>
<span className="flex items-center gap-2">
<span className="font-mono text-xs">{c.address}</span>
<span className="text-xs text-muted-foreground">— {c.label}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{candidates.isError ? (
<p className="text-xs text-destructive leading-snug">
{(candidates.error as Error)?.message ??
'Failed to fetch /pair/host-candidates — is the backend up to date?'}
</p>
) : null}
</div>
{/* QR */}
<div className="flex items-center justify-center rounded-xl border border-border bg-white p-6 min-h-[260px]">
{initPairing.isPending && !pairing ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : initPairing.isError ? (
<p className="text-sm text-destructive text-center">
{(initPairing.error as Error)?.message ?? 'Failed to mint token'}
</p>
) : qrValue ? (
<QRCode value={qrValue} size={220} />
) : (
<p className="text-sm text-muted-foreground">No host selected</p>
)}
</div>
{/* Countdown + regenerate */}
{pairing ? (
<div className="flex items-center justify-between text-xs">
<span className={expired ? 'text-destructive' : 'text-muted-foreground'}>
{expired
? 'QR expired — regenerate to continue'
: `Expires in ${formatRemaining(remainingMs)}`}
</span>
<button
type="button"
onClick={handleRegenerate}
className="inline-flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className="h-3 w-3" />
{expired ? 'Regenerate' : 'New QR'}
</button>
</div>
) : null}
{/* Copyable URL */}
{pairing ? (
<div className="flex flex-col gap-1.5">
<label className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
Or paste this URL on the phone
</label>
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2">
<code className="flex-1 truncate text-xs font-mono">{pairing.pairing_url}</code>
<Button size="sm" variant="ghost" onClick={handleCopy} className="h-7 px-2">
{copied ? <Check className="h-3.5 w-3.5 text-accent" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
</div>
</div>
) : null}
<p className="text-[11px] text-muted-foreground leading-snug">
The token is single-use and expires in 5 minutes. Once paired, your phone holds a
long-lived bearer that only it knows — Voicebox stores just a hash. Revoke any time.
</p>
</div>
</DialogContent>
</Dialog>
);
}
@@ -13,7 +13,6 @@ interface SettingsTab {
| '/settings/generation'
| '/settings/captures'
| '/settings/mcp'
| '/settings/mobile'
| '/settings/gpu'
| '/settings/logs'
| '/settings/changelog'
@@ -26,9 +25,6 @@ const tabs: SettingsTab[] = [
{ labelKey: 'settings.tabs.generation', path: '/settings/generation' },
{ labelKey: 'settings.tabs.captures', path: '/settings/captures' },
{ labelKey: 'settings.tabs.mcp', path: '/settings/mcp' },
// Plain-string label for V0 — translation keys come when mobile graduates
// out of "experimental" status.
{ label: 'Mobile', path: '/settings/mobile' },
{ labelKey: 'settings.tabs.gpu', path: '/settings/gpu', tauriOnly: true },
{ labelKey: 'settings.tabs.logs', path: '/settings/logs', tauriOnly: true },
{ labelKey: 'settings.tabs.changelog', path: '/settings/changelog' },
+6
View File
@@ -3,14 +3,18 @@ import LanguageDetector from 'i18next-browser-languagedetector';
import { initReactI18next } from 'react-i18next';
import en from './locales/en/translation.json';
import ja from './locales/ja/translation.json';
import ptBR from './locales/pt-BR/translation.json';
import zhCN from './locales/zh-CN/translation.json';
import zhTW from './locales/zh-TW/translation.json';
import fr from './locales/fr/translation.json';
export const SUPPORTED_LANGUAGES = [
{ code: 'en', label: 'English' },
{ code: 'pt-BR', label: 'Português (Brasil)' },
{ code: 'ja', label: '日本語' },
{ code: 'zh-CN', label: '简体中文' },
{ code: 'zh-TW', label: '繁體中文' },
{ code: 'fr', label: 'Français' },
] as const;
export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]['code'];
@@ -21,9 +25,11 @@ i18n
.init({
resources: {
en: { translation: en },
'pt-BR': { translation: ptBR },
ja: { translation: ja },
'zh-CN': { translation: zhCN },
'zh-TW': { translation: zhTW },
fr: { translation: fr },
},
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
+18 -23
View File
@@ -20,6 +20,7 @@ import type {
PresetVoice,
PersonalityTextResponse,
ProfileSampleResponse,
RocmStatus,
StoryCreate,
StoryDetailResponse,
StoryItemBatchUpdate,
@@ -50,9 +51,6 @@ import type {
MCPClientBinding,
MCPClientBindingListResponse,
MCPClientBindingUpsert,
HostCandidate,
PairInitResponse,
PairedDeviceResponse,
} from './types';
function formatErrorDetail(detail: unknown, fallback: string): string {
@@ -696,6 +694,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');
@@ -923,26 +938,6 @@ class ApiClient {
return response.blob();
}
// Mobile pairing
async getPairHostCandidates(): Promise<HostCandidate[]> {
return this.request<HostCandidate[]>('/pair/host-candidates');
}
async initPairing(host: string): Promise<PairInitResponse> {
return this.request<PairInitResponse>(
`/pair/init?host=${encodeURIComponent(host)}`,
{ method: 'POST' },
);
}
async listPairedDevices(): Promise<PairedDeviceResponse[]> {
return this.request<PairedDeviceResponse[]>('/devices');
}
async revokePairedDevice(deviceId: string): Promise<void> {
await this.request<void>(`/devices/${deviceId}`, { method: 'DELETE' });
}
}
export const apiClient = new ApiClient();
+1 -1
View File
@@ -9,7 +9,7 @@ export type ModelStatus = {
model_name: string;
display_name: string;
downloaded: boolean;
downloading?: boolean; // True if download is in progress
downloading?: boolean; // True if download is in progress
size_mb?: number | null;
loaded?: boolean;
};
+22 -25
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 {
@@ -291,6 +292,26 @@ export interface CudaStatus {
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,27 +542,3 @@ export interface MCPClientBindingUpsert {
export interface MCPClientBindingListResponse {
items: MCPClientBinding[];
}
/* ─── Mobile pairing (V0) ────────────────────────────────────────────── */
export type HostCandidateKind = 'lan' | 'tailscale' | 'loopback';
export interface HostCandidate {
address: string; // host:port
label: string; // human-friendly name
kind: HostCandidateKind;
}
export interface PairInitResponse {
token: string;
expires_at: string;
pairing_url: string; // voicebox://pair?host=…&token=…
}
export interface PairedDeviceResponse {
id: string;
name: string;
revoked: boolean;
created_at: string;
last_seen_at: string | null;
}
-57
View File
@@ -1,57 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
export const PAIRED_DEVICES_KEY = ['paired-devices'] as const;
export const PAIR_HOST_CANDIDATES_KEY = ['pair-host-candidates'] as const;
/**
* List all paired (and revoked) devices. Pass ``polling: true`` while the
* pair dialog is open so the device list refreshes when the user finishes
* scanning on their phone — this is how the desktop UI detects success
* without needing an SSE stream.
*/
export function usePairedDevices({ polling = false }: { polling?: boolean } = {}) {
return useQuery({
queryKey: PAIRED_DEVICES_KEY,
queryFn: () => apiClient.listPairedDevices(),
refetchInterval: polling ? 2000 : false,
});
}
/**
* The candidate addresses the desktop can embed in the QR (LAN, Tailscale,
* loopback). Cached for the lifetime of the dialog — interfaces don't
* change often enough to be worth re-polling.
*/
export function usePairHostCandidates(enabled: boolean) {
return useQuery({
queryKey: PAIR_HOST_CANDIDATES_KEY,
queryFn: () => apiClient.getPairHostCandidates(),
enabled,
staleTime: Infinity,
});
}
/**
* Mint a fresh pairing token for a chosen host. The result is short-lived
* (5 min); the dialog should re-mint when expiry is hit.
*/
export function useInitPairing() {
return useMutation({
mutationFn: (host: string) => apiClient.initPairing(host),
});
}
/**
* Revoke a paired device. Invalidates the device list so the row disappears
* on success.
*/
export function useRevokePairedDevice() {
const qc = useQueryClient();
return useMutation({
mutationFn: (deviceId: string) => apiClient.revokePairedDevice(deviceId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: PAIRED_DEVICES_KEY });
},
});
}
-10
View File
@@ -1,10 +0,0 @@
export type Sponsor = {
name: string;
url: string;
logoSrc: string;
logoAlt?: string;
/** Set true for solid-black logos that need to flip white in dark mode. */
invertOnDark?: boolean;
};
export const SPONSORS: Sponsor[] = [];
+3 -1
View File
@@ -1,5 +1,5 @@
import { formatDistance } from 'date-fns';
import { ja, zhCN, zhTW } from 'date-fns/locale';
import { ja, zhCN, zhTW, fr } from 'date-fns/locale';
import i18n from '@/i18n';
export function formatDuration(seconds: number): string {
@@ -16,6 +16,8 @@ function getDateLocale() {
return zhCN;
case 'zh-TW':
return zhTW;
case 'fr':
return fr;
default:
return undefined;
}
+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;
-8
View File
@@ -18,7 +18,6 @@ import { GenerationPage } from '@/components/ServerTab/GenerationPage';
import { GpuPage } from '@/components/ServerTab/GpuPage';
import { LogsPage } from '@/components/ServerTab/LogsPage';
import { MCPPage } from '@/components/ServerTab/MCPPage';
import { MobilePage } from '@/components/ServerTab/MobilePage';
import { SettingsLayout } from '@/components/ServerTab/ServerTab';
import { Sidebar } from '@/components/Sidebar';
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
@@ -167,12 +166,6 @@ const settingsMCPRoute = createRoute({
component: MCPPage,
});
const settingsMobileRoute = createRoute({
getParentRoute: () => settingsRoute,
path: '/mobile',
component: MobilePage,
});
const settingsGpuRoute = createRoute({
getParentRoute: () => settingsRoute,
path: '/gpu',
@@ -219,7 +212,6 @@ const routeTree = rootRoute.addChildren([
settingsGenerationRoute,
settingsCapturesRoute,
settingsMCPRoute,
settingsMobileRoute,
settingsGpuRoute,
settingsLogsRoute,
settingsChangelogRoute,
+56 -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
@@ -37,8 +39,59 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
# 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"):
os.environ["HSA_OVERRIDE_GFX_VERSION"] = "10.3.0"
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 +326,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()
+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)
+9 -1
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
+249 -54
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,74 @@ def build_server(cuda=False):
]
)
# Add CUDA-specific hidden imports
if cuda:
logger.info("Building with CUDA support")
# 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",
]
)
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",
"torch.cuda",
"_rocm_sdk_core",
"--hidden-import",
"torch.backends.cudnn",
"_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",
]
)
else:
# Exclude NVIDIA CUDA packages from CPU-only 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.
# 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. This applies to CPU and ROCm builds.
if not cuda:
nvidia_packages = [
"nvidia",
"nvidia.cublas",
@@ -354,8 +416,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 +461,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,43 +482,128 @@ 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":
import subprocess
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:
logger.info("CUDA 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",
"-q",
],
check=True,
)
restore_cuda = True
# Run PyInstaller
# 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
cuda_result = subprocess.run(
[sys.executable, "-c", "import torch; print(torch.version.cuda or '')"], capture_output=True, text=True
)
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(
[
sys.executable,
"-m",
"pip",
"install",
"torch",
"torchvision",
"torchaudio",
"--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,
)
# Run PyInstaller
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 +619,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 +766,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 +780,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)
-4
View File
@@ -16,8 +16,6 @@ from .models import (
GenerationSettings,
GenerationVersion,
MCPClientBinding,
PairedDevice,
PairingToken,
ProfileChannelMapping,
ProfileSample,
Project,
@@ -39,8 +37,6 @@ __all__ = [
"GenerationSettings",
"GenerationVersion",
"MCPClientBinding",
"PairedDevice",
"PairingToken",
"ProfileChannelMapping",
"ProfileSample",
"Project",
-34
View File
@@ -279,37 +279,3 @@ class Capture(Base):
llm_model = Column(String, nullable=True)
refinement_flags = Column(Text, nullable=True) # JSON blob
created_at = Column(DateTime, default=datetime.utcnow)
class PairedDevice(Base):
"""A mobile device paired with this Voicebox install (V0 pair flow).
Stores only the SHA-256 of the bearer token; the bearer plaintext is
returned to the device once at pairing time and never persisted
server-side. If the device loses its bearer the user must re-pair.
"""
__tablename__ = "paired_devices"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, nullable=False)
bearer_hash = Column(String, nullable=False, unique=True, index=True)
revoked = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
last_seen_at = Column(DateTime, nullable=True)
class PairingToken(Base):
"""One-time token used to complete a device pairing.
Minted by the desktop UI via /pair/init, redeemed by the mobile
device via /pair/complete in exchange for a long-lived bearer.
Single-use; expires after ~5 minutes.
"""
__tablename__ = "pairing_tokens"
token = Column(String, primary_key=True)
expires_at = Column(DateTime, nullable=False)
used_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
+2 -59
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,61 +794,3 @@ class AvailableEffectsResponse(BaseModel):
"""Response listing all available effect types."""
effects: List[AvailableEffect]
# --- Mobile pairing (V0) -----------------------------------------------------
class HostCandidate(BaseModel):
"""A reachable address the desktop can embed in the pair QR."""
address: str # ``host:port``, e.g. "192.168.1.5:17493"
label: str # human-friendly name shown in the desktop host picker
kind: str # "lan" | "tailscale" | "loopback"
class PairInitResponse(BaseModel):
"""Response for POST /pair/init — desktop renders ``pairing_url`` as a QR."""
token: str
expires_at: datetime
pairing_url: str # full voicebox://pair?host=…&token=… URL
class PairCompleteRequest(BaseModel):
"""Mobile-side request body for POST /pair/complete."""
token: str = Field(..., min_length=1, max_length=128)
device_name: str = Field(..., min_length=1, max_length=80)
class PairCompleteResponse(BaseModel):
"""One-time response after successful pairing.
``bearer`` is returned in plaintext exactly once and is never persisted
server-side; the device must save it (e.g. iOS SecureStore) immediately.
"""
device_id: str
bearer: str
device_name: str
class PairedDeviceResponse(BaseModel):
"""A row in the desktop's Settings → Mobile device list."""
id: str
name: str
revoked: bool
created_at: datetime
last_seen_at: Optional[datetime] = None
class Config:
from_attributes = True
class MeResponse(BaseModel):
"""Identity of the bearer-authenticated caller."""
device_id: str
device_name: str
last_seen_at: Optional[datetime] = None
+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()
+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
+21 -42
View File
@@ -1,23 +1,6 @@
"""Route registration for the voicebox API.
"""Route registration for the voicebox API."""
Authentication model
--------------------
Two router groups:
* **Open** — ``health`` (status checks anyone on the LAN may probe) and
``pairing`` (pre-pair endpoints + admin endpoints with their own
loopback-only or token-only gates).
* **Protected** — everything else, gated by ``require_bearer_or_loopback``:
loopback callers (the desktop app over 127.0.0.1) pass without auth as
before; LAN/Tailscale callers must present a valid paired-device bearer.
This is what lets ``just dev`` bind to 0.0.0.0 without exposing user
data to anyone on the same network.
"""
from fastapi import Depends, FastAPI
from ..utils.auth import require_bearer_or_loopback
from fastapi import FastAPI
def register_routers(app: FastAPI) -> None:
@@ -37,31 +20,27 @@ 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 .pairing import router as pairing_router
# Open — health probes and the pre-pair / admin pairing endpoints.
app.include_router(health_router)
app.include_router(pairing_router)
# Protected — loopback callers pass through; LAN callers need a paired bearer.
protected = [Depends(require_bearer_or_loopback)]
app.include_router(profiles_router, dependencies=protected)
app.include_router(channels_router, dependencies=protected)
app.include_router(generations_router, dependencies=protected)
app.include_router(history_router, dependencies=protected)
app.include_router(transcription_router, dependencies=protected)
app.include_router(llm_router, dependencies=protected)
app.include_router(captures_router, dependencies=protected)
app.include_router(stories_router, dependencies=protected)
app.include_router(effects_router, dependencies=protected)
app.include_router(audio_router, dependencies=protected)
app.include_router(models_router, dependencies=protected)
app.include_router(settings_router, dependencies=protected)
app.include_router(tasks_router, dependencies=protected)
app.include_router(cuda_router, dependencies=protected)
app.include_router(speak_router, dependencies=protected)
app.include_router(mcp_bindings_router, dependencies=protected)
app.include_router(events_router, dependencies=protected)
app.include_router(profiles_router)
app.include_router(channels_router)
app.include_router(generations_router)
app.include_router(history_router)
app.include_router(transcription_router)
app.include_router(llm_router)
app.include_router(captures_router)
app.include_router(stories_router)
app.include_router(effects_router)
app.include_router(audio_router)
app.include_router(models_router)
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)
+16 -6
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,7 +103,10 @@ async def health():
gpu_type = None
if has_cuda:
gpu_type = f"CUDA ({torch.cuda.get_device_name(0)})"
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)"
elif backend_type == "mlx":
@@ -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,
)
-92
View File
@@ -1,92 +0,0 @@
"""Mobile device pairing endpoints (V0 — bearer auth)."""
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from .. import models
from ..database import PairedDevice, get_db
from ..services import pairing
from ..utils.auth import require_loopback, require_paired_device
router = APIRouter()
@router.post(
"/pair/init",
response_model=models.PairInitResponse,
dependencies=[Depends(require_loopback)],
)
async def pair_init(request: Request, db: Session = Depends(get_db)):
"""Mint a one-time pairing token (loopback callers only).
Optional ``?host=`` query param overrides what's embedded in the QR's
pairing URL. The desktop UI should pass whatever address is reachable
from the mobile device — LAN IP, Tailscale 100.x address, or MagicDNS
name. Defaults to the request Host header for curl-driven local testing.
"""
host = request.query_params.get("host") or (
request.headers.get("host") or "127.0.0.1:17493"
)
return pairing.init_pairing_token(db, host=host)
@router.post("/pair/complete", response_model=models.PairCompleteResponse)
async def pair_complete(
body: models.PairCompleteRequest,
db: Session = Depends(get_db),
):
"""Exchange a pairing token for a long-lived bearer.
Open endpoint — possession of the (one-time, short-TTL) token is
itself the proof of authorization.
"""
try:
return pairing.complete_pairing(db, body.token, body.device_name)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get(
"/devices",
response_model=list[models.PairedDeviceResponse],
dependencies=[Depends(require_loopback)],
)
async def list_paired_devices(db: Session = Depends(get_db)):
"""List paired devices for the desktop Settings → Mobile pane."""
return pairing.list_devices(db)
@router.delete(
"/devices/{device_id}",
status_code=204,
dependencies=[Depends(require_loopback)],
)
async def revoke_paired_device(device_id: str, db: Session = Depends(get_db)):
"""Revoke a paired device's bearer."""
try:
pairing.revoke_device(db, device_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.get("/me", response_model=models.MeResponse)
async def me(device: PairedDevice = Depends(require_paired_device)):
"""Identity of the bearer-authenticated caller — used by mobile to
confirm pairing succeeded and the bearer round-trips.
"""
return models.MeResponse(
device_id=device.id,
device_name=device.name,
last_seen_at=device.last_seen_at,
)
@router.get(
"/pair/host-candidates",
response_model=list[models.HostCandidate],
dependencies=[Depends(require_loopback)],
)
async def host_candidates(request: Request):
"""Suggested host strings (LAN IP, Tailscale, loopback) for the QR."""
port = request.url.port or 17493
return pairing.list_host_candidates(port=port)
+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",
},
)
+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:
-201
View File
@@ -1,201 +0,0 @@
"""Mobile device pairing service (V0 — bearer auth, no E2E payload encryption yet).
Flow:
1. Desktop UI (loopback) calls POST /pair/init → mints a single-use token
with a 5-minute TTL and returns a ``voicebox://pair?...`` URL.
2. Mobile scans the QR (or pastes the URL) and POSTs /pair/complete with
the token + a human-readable device name.
3. Server validates the token, mints a long-lived bearer, and stores
only SHA-256(bearer). The bearer plaintext is returned exactly once.
4. Mobile saves the bearer in SecureStore. Subsequent calls carry
``Authorization: Bearer <bearer>``.
The bearer is returned exactly once. Server has no path to recover it; if
the device loses its key the user must re-pair (and revoke the old device
from Settings → Mobile if they want to be tidy).
Phase 2 will layer XChaCha20-Poly1305 payload encryption + HKDF session
keys on top — see ``mobile/PLAN.md`` § Pairing & transport. The bearer
established here is the foundation either way.
"""
import hashlib
import logging
import secrets
import socket
import subprocess
import uuid
from datetime import datetime, timedelta
from typing import Optional
from sqlalchemy.orm import Session
from ..database import PairedDevice, PairingToken
from ..models import (
HostCandidate,
PairCompleteResponse,
PairInitResponse,
PairedDeviceResponse,
)
logger = logging.getLogger(__name__)
PAIRING_TOKEN_TTL = timedelta(minutes=5)
TOKEN_BYTES = 32 # urlsafe-b64 encoded → ~44 chars
def _hash_bearer(bearer: str) -> str:
return hashlib.sha256(bearer.encode("utf-8")).hexdigest()
def _build_pairing_url(token: str, host: str) -> str:
# ``host`` should be reachable from the mobile device — LAN IP, Tailscale
# 100.x address, MagicDNS hostname (e.g. ``mac.tail-xxxx.ts.net:17493``).
# The desktop UI is responsible for picking the right host; loopback is
# only useful for curl-driven local testing.
return f"voicebox://pair?host={host}&token={token}"
def init_pairing_token(db: Session, host: str) -> PairInitResponse:
"""Mint a one-time pairing token. Caller must already be authorized as loopback."""
token = secrets.token_urlsafe(TOKEN_BYTES)
expires_at = datetime.utcnow() + PAIRING_TOKEN_TTL
db.add(PairingToken(token=token, expires_at=expires_at))
db.commit()
return PairInitResponse(
token=token,
expires_at=expires_at,
pairing_url=_build_pairing_url(token, host),
)
def complete_pairing(db: Session, token: str, device_name: str) -> PairCompleteResponse:
"""Exchange a pairing token for a long-lived device bearer.
Raises ValueError on invalid / expired / already-used token.
"""
row = db.query(PairingToken).filter(PairingToken.token == token).first()
if row is None:
raise ValueError("Invalid pairing token")
if row.used_at is not None:
raise ValueError("Pairing token already used")
if row.expires_at < datetime.utcnow():
raise ValueError("Pairing token expired")
row.used_at = datetime.utcnow()
bearer = secrets.token_urlsafe(TOKEN_BYTES)
device = PairedDevice(
id=str(uuid.uuid4()),
name=device_name.strip(),
bearer_hash=_hash_bearer(bearer),
)
db.add(device)
db.commit()
logger.info("Paired new device id=%s name=%s", device.id, device.name)
return PairCompleteResponse(
device_id=device.id,
bearer=bearer,
device_name=device.name,
)
def authenticate_bearer(db: Session, bearer: str) -> Optional[PairedDevice]:
"""Look up a paired device by bearer. Bumps last_seen_at on success."""
if not bearer:
return None
bearer_hash = _hash_bearer(bearer)
device = (
db.query(PairedDevice)
.filter(PairedDevice.bearer_hash == bearer_hash, PairedDevice.revoked.is_(False))
.first()
)
if device is not None:
device.last_seen_at = datetime.utcnow()
db.commit()
return device
def list_devices(db: Session) -> list[PairedDeviceResponse]:
"""Return all paired devices (revoked included) for the desktop UI."""
rows = db.query(PairedDevice).order_by(PairedDevice.created_at.desc()).all()
return [PairedDeviceResponse.model_validate(r) for r in rows]
def revoke_device(db: Session, device_id: str) -> None:
"""Mark a device as revoked. Idempotent on repeat calls."""
device = db.query(PairedDevice).filter(PairedDevice.id == device_id).first()
if device is None:
raise ValueError("Device not found")
device.revoked = True
db.commit()
logger.info("Revoked device id=%s name=%s", device.id, device.name)
# --- Host discovery ---------------------------------------------------------
def _get_lan_ip() -> Optional[str]:
"""Best-effort outbound IPv4 of the host. Uses the UDP-connect trick —
no packets are actually sent; the kernel just picks the source IP it
would use to reach 8.8.8.8.
"""
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except OSError:
return None
def _get_tailscale_ip() -> Optional[str]:
"""Return the host's Tailscale 100.x address if Tailscale is installed
and reports one. Returns ``None`` on any failure — Tailscale is optional.
Tries ``tailscale`` on PATH first, then falls back to the Mac App Store
install location (the macOS App bundle doesn't symlink onto PATH by
default, only into the user's shell init via an alias subprocess can't see).
"""
candidate_binaries = [
"tailscale",
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
]
for binary in candidate_binaries:
try:
result = subprocess.run(
[binary, "ip", "--4"],
capture_output=True,
text=True,
timeout=2,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
continue
if result.returncode != 0:
continue
ip = result.stdout.strip().splitlines()[0].strip() if result.stdout else ""
if ip:
return ip
return None
def list_host_candidates(port: int) -> list[HostCandidate]:
"""Return suggested host strings the desktop can embed in the QR.
Order matters — the UI defaults to the first non-loopback entry.
"""
candidates: list[HostCandidate] = []
lan_ip = _get_lan_ip()
if lan_ip and not lan_ip.startswith("127."):
candidates.append(
HostCandidate(address=f"{lan_ip}:{port}", label="Local network", kind="lan")
)
tailscale_ip = _get_tailscale_ip()
if tailscale_ip and tailscale_ip != lan_ip:
candidates.append(
HostCandidate(address=f"{tailscale_ip}:{port}", label="Tailscale", kind="tailscale")
)
candidates.append(
HostCandidate(address=f"127.0.0.1:{port}", label="Loopback (testing only)", kind="loopback")
)
return candidates
+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
+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
+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")
+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}"
)
-103
View File
@@ -1,103 +0,0 @@
"""FastAPI dependencies for the V0 mobile-pair auth model.
Two dependencies are exposed:
* ``require_loopback`` — reject calls that don't originate from a loopback
address. Used to gate desktop-only admin endpoints (pair init, devices
list, revoke). Loopback callers stay unauthenticated everywhere else
too — the desktop app talks to its own backend over 127.0.0.1.
* ``require_paired_device`` — validate ``Authorization: Bearer <token>``
against the ``paired_devices`` table. Used to identify paired mobile
callers and bumps ``last_seen_at`` on success.
Phase 2 will layer XChaCha20-Poly1305 payload encryption on top of the
bearer (see ``mobile/PLAN.md``); the bearer stays the identity primitive.
"""
from typing import Optional
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from ..database import PairedDevice, get_db
from ..services import pairing as pairing_service
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
def require_loopback(request: Request) -> None:
"""Reject calls from non-loopback addresses."""
client = request.client
host = client.host if client else None
if host not in LOOPBACK_HOSTS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Loopback only",
)
def _extract_bearer(request: Request) -> Optional[str]:
auth = request.headers.get("Authorization") or request.headers.get("authorization")
if not auth:
return None
parts = auth.split(None, 1)
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
return parts[1].strip()
def require_paired_device(
request: Request,
db: Session = Depends(get_db),
) -> PairedDevice:
"""Resolve the PairedDevice authenticated by the request bearer."""
bearer = _extract_bearer(request)
if not bearer:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
device = pairing_service.authenticate_bearer(db, bearer)
if device is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or revoked bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
return device
def require_bearer_or_loopback(
request: Request,
db: Session = Depends(get_db),
) -> None:
"""Loopback callers pass without auth; everyone else needs a paired bearer.
Applied as a router-level dependency on user-data endpoints so the
desktop UI (which talks over 127.0.0.1) keeps its current friction-free
access while LAN-reachable callers must be a paired mobile device.
The pre-pair endpoints (``POST /pair/complete``) and the desktop-only
admin endpoints (``POST /pair/init``, ``GET /devices``) intentionally
stay outside this gate — they have their own dependencies.
"""
client = request.client
host = client.host if client else None
if host in LOOPBACK_HOSTS:
return
bearer = _extract_bearer(request)
if not bearer:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
device = pairing_service.authenticate_bearer(db, bearer)
if device is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or revoked bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
+54 -1
View File
@@ -3,19 +3,72 @@ Platform detection for backend selection.
"""
import platform
import subprocess
from functools import lru_cache
from typing import Literal
def is_apple_silicon() -> bool:
"""
Check if running on Apple Silicon (arm64 macOS).
Returns:
True if on Apple Silicon, False otherwise
"""
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.
+30 -2
View File
@@ -86,7 +86,9 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.36.0",
"gray-matter": "^4.0.3",
"lucide-react": "^0.316.0",
"marked": "^18.0.5",
"next": "^16.1.3",
"postcss": "^8.4.33",
"react": "^18.2.0",
@@ -664,7 +666,7 @@
"arg": ["[email protected]", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"aria-hidden": ["[email protected]", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
@@ -760,6 +762,8 @@
"espree": ["[email protected]", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="],
"esprima": ["[email protected]", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"esquery": ["[email protected]", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["[email protected]", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
@@ -768,6 +772,8 @@
"esutils": ["[email protected]", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"extend-shallow": ["[email protected]", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
"fast-deep-equal": ["[email protected]", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
@@ -816,6 +822,8 @@
"graphemer": ["[email protected]", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
"gray-matter": ["[email protected]", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="],
"has-flag": ["[email protected]", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
@@ -840,6 +848,8 @@
"is-core-module": ["[email protected]", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-extendable": ["[email protected]", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
"is-extglob": ["[email protected]", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["[email protected]", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
@@ -856,7 +866,7 @@
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"js-yaml": ["js-yaml@3.15.0", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog=="],
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
@@ -870,6 +880,8 @@
"keyv": ["[email protected]", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"kind-of": ["[email protected]", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
"levn": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lightningcss": ["[email protected]", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
@@ -914,6 +926,8 @@
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
"merge2": ["[email protected]", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["[email protected]", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
@@ -1038,6 +1052,8 @@
"scheduler": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"section-matter": ["[email protected]", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"seroval": ["[email protected]", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
@@ -1056,8 +1072,12 @@
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"sprintf-js": ["[email protected]", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-bom-string": ["[email protected]", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
"strip-json-comments": ["[email protected]", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"styled-jsx": ["[email protected]", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
@@ -1136,6 +1156,8 @@
"zustand": ["[email protected]", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
"@eslint/eslintrc/js-yaml": ["[email protected]", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/[email protected]", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
@@ -1190,6 +1212,8 @@
"chokidar/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"eslint/js-yaml": ["[email protected]", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"motion/framer-motion": ["[email protected]", "", { "dependencies": { "motion-dom": "^12.29.0", "motion-utils": "^12.27.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1gEFGXHYV2BD42ZPTFmSU9buehppU+bCuOnHU0AD18DKh9j4DuTx47MvqY5ax+NNWRtK32qIcJf1UxKo1WwjWg=="],
@@ -1200,8 +1224,12 @@
"tinyglobby/picomatch": ["[email protected]", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"@eslint/eslintrc/js-yaml/argparse": ["[email protected]", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"eslint/js-yaml/argparse": ["[email protected]", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"motion/framer-motion/motion-dom": ["[email protected]", "", { "dependencies": { "motion-utils": "^12.27.2" } }, "sha512-3eiz9bb32yvY8Q6XNM4AwkSOBPgU//EIKTZwsSWgA9uzbPBhZJeScCVcBuwwYVqhfamewpv7ZNmVKTGp5qnzkA=="],
"motion/framer-motion/motion-utils": ["[email protected]", "", {}, "sha512-B55gcoL85Mcdt2IEStY5EEAsrMSVE2sI14xQ/uAdPL+mfQxhKKFaEag9JmfxedJOR4vZpBGoPeC/Gm13I/4g5Q=="],
+36
View File
@@ -0,0 +1,36 @@
---
# 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
+7 -2
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: .
@@ -5,8 +9,9 @@ services:
restart: unless-stopped
ports:
# Bind to localhost only for security
- "127.0.0.1:17493:17493"
# Host-side moved to 17600 so the dev/installed Voicebox can keep 17493.
# Container still listens on its native port internally.
- "127.0.0.1:17600:17493"
volumes:
# Bind-mount for generated audio (customize the host path as needed)
+254 -61
View File
@@ -1,6 +1,6 @@
# Voicebox Project Status & Roadmap
> Last updated: 2026-04-18 | Current version: **v0.4.1** | 232 open issues | 12 open PRs
> Last updated: 2026-07-02 | Current version: **v0.5.0** | 402 open issues | 88 open PRs | 1.3M downloads · 34.8k stars
---
@@ -86,6 +86,46 @@ POST /generate
## Current State
### Since v0.5.0 — Two-Month Pulse (2026-04-25 → 2026-06-27)
**The repo went quiet while demand kept climbing.** 0.5.0 (the Capture release) shipped 2026-04-25. In the two months since, only **2 PRs merged** (#544 the release itself, #550 a remote-URL fix) while **150 new issues** were opened and the open-PR queue more than tripled to **88**. The community kept contributing — translations, new engines, GPU fixes — but nothing's been reviewed or merged. This is a review-and-merge backlog, not a build backlog.
| Metric | At v0.4.1 (2026-04-18) | Now (2026-06-27) | Δ |
|--------|------------------------|------------------|---|
| Open issues | 232 | 402 | +170 |
| Open PRs | 12 | 88 | +76 |
| GitHub stars | ~28k | 34.8k | +~7k |
| Downloads | — | 1.3M | — |
**What the two months actually produced (all unmerged):**
- **A flood of community translations** — pt-BR, de-DE, Russian (+docs), Arabic+RTL, Spanish (+docs site), French, Cantonese. ~12 i18n PRs sitting on the i18next foundation that landed in 0.5.0.
- **GPU coverage PRs** — AMD ROCm on Windows (#538), Intel XPU (#539), DirectML for Intel iGPU (#674), Blackwell diagnostic (#653), MLX threading fix (#789).
- **A 17-PR hardening dump** from one contributor (@neuron-tech-ai, all 2026-05-14): CI pipeline, Biome, OpenAI-compatible `/v1/audio/speech`, SQLite WAL + indexes, LIKE-injection / upload-limit / N+1 fixes, platform gating. High value, entirely unreviewed.
- **New engine PRs** — MiniMax cloud, MOSS-TTS-Nano, Fun-CosyVoice3, Parakeet STT.
- **Two giant Linux PRs** — vendored `tao` patch for the Wayland startup panic (#748), and an SRT2Voice workflow (#673).
**0.5.0 regressions worth triaging first:**
- macOS Apple Silicon — all TTS models crash the server on load (#606, #615), MLX falls back to CPU on M4/M5 (#706, #650).
- Capture cutoffs at 30s for imported audio (#609, #626); paste broken in 0.5.0 (#762).
- MCP rough edges — dotted tool names violate Claude Desktop's name pattern (#790), audio scrambled over MCP (#780).
- Refinement silently translates non-English transcripts to English (#603).
**Funding model — `$VOICEBOX` token (#806):** the two-month gap was a solo-dev decision about long-term sustainability, not neglect or a compromise. `$VOICEBOX` (Solana) is the **official, dev-controlled** token and the chosen revenue path — donations/sponsors didn't cover full-time work. The app stays 100% free, open-source, local-first, no subscriptions. Dev supply is being bought back and burned (done twice), liquidity locked. It has funded ~2–3 months of full-time work, so the cadence resumes this week. The #806 thread was a community concern, addressed transparently and resolved amicably; keep an eye out for actual impersonator/community tokens, which are a separate thing.
**Other trust/security signals:** macOS malware-flag reports continue (#369); a DNS-rebinding / Host-header exposure on the local API+MCP server was reported with fixes attached (#778).
---
### What's Shipped (v0.5.0 — the Capture release)
Shipped 2026-04-25 (PR #544). Voicebox went from a voice-cloning studio to a full voice studio — dictation in, agent speech out, a local LLM in the middle.
- **Dictation** — global hotkey capture (push-to-talk + toggle chords), on-screen pill with live state, auto-paste into the focused field with clipboard save/restore, chord-picker UI. Scoped Accessibility permission (transcripts still land if paste is denied).
- **MCP server** at `http://127.0.0.1:17493/mcp` — `voicebox.speak` / `.transcribe` / `.list_captures` / `.list_profiles`. Streamable HTTP primary transport, stdio sidecar shim, per-client voice binding via `X-Voicebox-Client-Id`. Speaking pill always shows agent-initiated output.
- **Personality** — voice profiles carry an optional ≤2000-char persona. Compose (shuffle an in-character line) and Speak-in-character (rewrite input before TTS), both on a local Qwen3 LLM that doubles as the refinement model.
- **Refinement** — on-device Qwen3 strips fillers, fixes punctuation, optional self-correction rewrites; Whisper hallucination-loop stripping at a 6-token threshold; per-capture flag snapshots; model picker (0.6B / 1.7B / 4B).
- **`POST /speak` REST wrapper** and **i18next foundation** (English + zh-CN) also landed.
### What's Shipped (v0.4.x)
**New since v0.3.0:**
@@ -178,6 +218,19 @@ POST /generate
**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 |
@@ -186,7 +239,7 @@ POST /generate
| 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
@@ -231,64 +284,144 @@ POST /generate
## Open PRs — Triage & Analysis
### Recently Merged (Since Last Update — 2026-03-18 → 2026-04-18)
**88 open PRs, only 2 merged since 0.5.0.** The queue is the single biggest lever right now — a lot of finished community work is waiting on review. Clustered below by theme. Counts are approximate; a PR can span clusters.
### Merged since 0.5.0
| PR | Title | Merged |
|----|-------|--------|
| **#481** | fix(build): pin transformers in MLX requirements to prevent 5.x upgrade | 2026-04-19 |
| **#470** | fix(api-client): declare moved + errors on migrateModels response type | 2026-04-18 |
| **#457** | fix(linux): use pactl to detect PipeWire/PulseAudio monitor | 2026-04-18 |
| **#450** | docs: clarify paralinguistic tag support in quick start | 2026-04-18 |
| **#447** | fix: delete version rows and files in delete_generations_by_profile | 2026-04-18 |
| **#444** | Fix generation cancellation flow | 2026-04-18 |
| **#440** | fix(paths): strip legacy "data/" prefix when resolving stored paths | 2026-04-18 |
| **#439** | Fix migration dialog hanging when no models are present | 2026-04-18 |
| **#438** | fix(build): repair frozen-binary imports for kokoro/chatterbox-multilingual/scipy/transformers | 2026-04-18 |
| **#433** | fix: warn user when no models to migrate during storage change | 2026-04-18 |
| **#425** | Add NUMBA_CACHE_DIR environment variable | 2026-04-16 |
| **#424** | fix: avoid ScreenCaptureKit launch crash on macOS 11 | 2026-04-16 |
| **#418** | Frontend quality gates + TypeScript hardening | 2026-04-18 |
| **#416** | fix(deps): relax PyTorch requirement for macOS Intel (x86_64) | 2026-04-16 |
| **#412** | feat(history): add "Clear failed" button | 2026-04-16 |
| **#405** | fix: keep cpal Stream alive until playback completes | 2026-04-16 |
| **#403** | fix: prevent intermittent clip splitting failures | 2026-04-16 |
| **#402** | fix: reliably keep server alive after GUI close on Windows | 2026-04-16 |
| **#401** | feat: add Blackwell GPU (sm_120) CUDA support | 2026-04-16 |
| **#394** | fix(history): populate status/error/engine fields from DB row | 2026-04-16 |
| **#384** | Fix: Resolve ModuleNotFoundError in effects service | 2026-04-16 |
| **#361** | fix: torch.from_numpy crash with numpy 2.x in frozen binary | 2026-04-16 |
| **#345** | Fix: "Failed to Save" preset error by resolving backend import path | 2026-03-22 |
| **#344** | fix: include changelog in docker web build | 2026-03-27 |
| **#332** | Fix links in Get Started section of index.mdx | 2026-03-21 |
| **#328** | feat: add Qwen CustomVoice preset engine | 2026-03-27 |
| **#325** | feat: Kokoro 82M TTS engine + voice profile type system | 2026-03-20 |
| **#321** | fix: allows deletion of failed generations | 2026-03-19 |
| **#320** | feat: Intel Arc (XPU) GPU support | 2026-03-21 |
| **#319** | fix: GUI startup with external server + data refresh on server switch | 2026-03-27 |
| **#318** | fix: force offline mode when loading cached models (Qwen TTS & Whisper) | 2026-03-21 |
| **#316** | Upgrade CUDA backend from cu126 to cu128, fix GPU settings UI | 2026-03-18 |
| **#550** | Fix web API URL for remote access | 2026-04-25 |
| **#544** | feat: 0.5.0 Capture release — dictation, MCP, personalities | 2026-04-25 |
### Currently Open (12 PRs)
### i18n / translations (~12) — easy wins, unblock a large user segment
| PR | Title | Status | Notes |
|----|-------|--------|-------|
| **#465** | docs: define tier-1 and tier-2 platform support targets | Community PR | Pairs with issue #420. Important for scoping. |
| **#463** | feat(actions): add docker-registry.yml for automatic ghcr.io publishing | Community PR | Pairs with issue #453. Low risk. |
| **#443** | fix: prevent infinite retry loop in offline mode (#434) | Community PR | Fixes reported bug. |
| **#430** | feat: add MiniMax TTS provider support | Community PR | Cloud TTS provider — new direction (external API). Superset of #331? |
| **#331** | feat: add MiniMax Cloud TTS as a built-in engine | Community PR | Likely superseded by #430. Dedupe. |
| **#311** | feat: add CosyVoice2/3 TTS engine | **Close** | Abandoned — output quality too poor. |
| **#253** | Enhance speech tokenizer with 48kHz version | Community PR | Qwen tokenizer upgrade. Still worth reviewing. |
| **#227** | fix: harden input validation & file safety | Community PR | Coupled to #225 (custom models). |
| **#225** | feat: custom HuggingFace voice model support | Community PR | Needs rework for multi-engine arch. |
| **#195** | feat: per-profile LoRA fine-tuning | Draft | Complex. 15 new endpoints. |
| **#154** | feat: Audiobook tab | Community PR | Chunked generation now shipped (#266). |
| **#91** | fix: CoreAudio device enumeration | Draft | macOS audio device handling. |
The i18next + zh-CN foundation shipped in 0.5.0; these stack on it. Triage as a batch.
| PR | Locale / scope |
|----|----------------|
| #528 | pt-BR translation |
| #571 | de-DE translation |
| #599 / #600 / #601 | Russian — app, landing routes, docs |
| #569 | Arabic + RTL layout fixes |
| #798 / #799 / #801 | Spanish — locale, README/CONTRIBUTING/SECURITY, docs site (see #800 for approach alignment) |
| #802 | French translation |
| #776 | Cantonese language option |
| #688 | Compose follows the selected language |
### New engines / models (~7)
| PR | Engine | Notes |
|----|--------|-------|
| #507 | MOSS-TTS-Nano (0.1B, 20 langs, CPU realtime) | Matches our cross-platform criteria — top engine candidate |
| #331 / #430 | MiniMax Cloud TTS | Two PRs, same provider — **dedupe**. External-API direction. |
| #777 | Fun-CosyVoice3 (draft) | We abandoned CosyVoice2/3 once on quality — re-evaluate output before reviving |
| #766 | Parakeet as STT model | Whisper alternative |
| #563 | 4-bit quantized Qwen + Russian abbreviations | Smaller/faster Qwen |
| #225 | Custom HuggingFace voice models | Long-lived; needs rework for multi-engine arch |
| #195 | Per-profile LoRA fine-tuning (draft, +6.2k) | Complex, 15 endpoints — addresses #185/#224 demand |
### GPU / hardware (~9)
| PR | Scope |
|----|-------|
| #538 | Native AMD ROCm on Windows (+2.8k, resolves #531) |
| #539 | Optional IPEX + native Intel XPU detection for PyTorch 2.9+ |
| #674 | DirectML for Intel iGPU (Iris/UHD/Arc) — pairs with demand in #676, #759 |
| #653 | Blackwell GPU arch-mismatch diagnostic — directly targets the sm_120 cluster |
| #789 | Run MLX load+inference on one thread (fixes #699) — likely fixes the M-series load crashes |
| #560 / #561 | Linux NVIDIA auto-detect + Linux CUDA backend build |
| #736 / #785 / #769 | ROCm `HSA_OVERRIDE_GFX_VERSION` cleanup (resolves #469) |
| #770 | Fix CUDA downloads on unsupported platforms |
### Capture / transcription / refinement (~6) — fixes 0.5.0 regressions
| PR | Fix |
|----|-----|
| #602 | Re-encode uploaded audio as PCM WAV before Whisper — likely fixes the 30s import cutoff (#609/#626) |
| #616 | Enable long-form Whisper on the PyTorch path |
| #629 | Preserve source language in refinement (fixes #603 English translation) |
| #637 | MCP/REST generations incorrectly trigger autoplay |
| #712 | Personality LLM respects the selected refinement model |
| #796 | Capture preview placement setting (#698) |
### Long-form / stories / streaming (~5)
| PR | Scope |
|----|-------|
| #154 | Audiobook tab with chunked generation (predates shipped chunking — reconcile) |
| #673 | SRT2Voice workflow (+14k) — large, subtitle-driven generation |
| #787 | m4b/mp3 story export with auto chapter markers |
| #642 | `stream=true` immediate-audio mode for `GET /tts` |
| #804 | Stream MLX TTS audio chunks (draft) |
### Linux / Wayland (~5)
| PR | Scope |
|----|-------|
| #748 | Vendor-patch tao 0.34.8 for Wayland startup panic (+39k — large vendored diff, verify approach) |
| #624 | Linux tauri schema + build deps (+6.2k) |
| #747 | Avoid abort when hiding the dictate pill |
| #622 / #768 | Linux audio monitor selection / thread-unsafe `PULSE_SOURCE` |
| #677 | HF cache permissions + startup fallback |
### Hardening / CI / perf — the @neuron-tech-ai batch (all 2026-05-14, ~17 PRs)
One contributor opened a large, coherent quality suite in a single day. Review as a group; #662 is the headline.
| PR | Scope |
|----|-------|
| #662 | LIKE injection, upload-size enforcement, N+1 queries, SSE reconnect, memory leaks, model display names (+6.8k) |
| #654 | CI pipeline + pre-commit hooks + Biome config + test suite |
| #656 | OpenAI-compatible `/v1/audio/speech` + `/v1/models` (addresses #10) |
| #657 | Platform gating on `ModelConfig` + UI (addresses bottleneck #6 / issue #419) |
| #666 / #667 | DB indexes on hot FKs + SQLite WAL + busy timeout |
| #659 / #660 / #661 / #663 / #665 / #668 | datetime.utcnow→UTC, MediaRecorder crash + sample reorder, fail-fast on missing model, batch story counts, Metal warmup, drop debug logs |
| #652 / #655 / #658 / #664 | AGENTS.md, docs GitHub Pages, avatar size limit, non-fatal actool |
### Build / dev tooling / docker (~6)
#764 uv for backend env · #632 docker GPU build + cache + fastmcp (+7.9k) · #630 ROCm docker overlay · #463 ghcr.io auto-publish · #543 / #681 setup-script fixes · #584 docker permission fix
### Smaller fixes worth grabbing
#786 remove 50k char limit (#464) · #621 broken-pipe crashes on model load · #743 harden mac generation status + MLX threading · #788 missing male Mandarin Kokoro voices · #794 build mcp shim on Windows · #527 Chatterbox exaggeration + CFG sliders · #253 48kHz speech tokenizer
### Stale / low-signal — close or request changes
#91 (draft, Feb, CoreAudio, +6.2k unrebased) · #649 ("fix this errors") · #623 / #782 (badges / package tweaks) · #311-style abandoned engines — verify before merging anything older than ~April against the 0.5.0 codebase.
---
## Open Issues — Categorized
**402 open, +150 in the two months since 0.5.0.** Demand snapshot from a keyword sweep over all open titles (buckets overlap):
| Theme | ~Open | Signal |
|-------|-------|--------|
| New model / engine requests | ~79 | Largest category. Voxtral, OmniVoice, VibeVoice, VoxCPM2, CosyVoice3, Dramabox, Parakeet, GGUF, ONNX/Piper export |
| CUDA / GPU / Blackwell | ~53 | Still the #1 *bug* driver — sm_120 "no kernel image", ROCm, DirectML, Intel Arc, VRAM/load times |
| Model download / server startup | ~42 | Stuck downloads, "server process ended unexpectedly", `loading_model` hangs |
| Capture / dictation / transcribe | ~31 | New surface from 0.5.0 — 30s cutoffs, paste, mic permission, refinement translation |
| Language / locale requests | ~27 | Bengali, Ukrainian, Filipino, Indonesian, Cantonese, zh-TW; plus UI localization |
| Fine-tune / clone quality | ~22 | #185 (top-engagement issue), accent leakage, "finetunes not working" |
| Long-form / chunking / export | ~16 | Pause control, speed control, audiobook export, >50k chars |
| Linux / Wayland | ~11 | Build failures, Wayland panics, CUDA-on-Linux packaging |
| MCP / agent / API | ~9 | Dotted tool names (#790), scrambled audio (#780), OpenAI compat (#10) |
| Security / trust | ~4 | DNS-rebinding (#778), malware flag (#369); funding via official $VOICEBOX token (#806) |
**Highest-engagement open issues:** #185 Fine-tune instructions (32c) · #98 Connecting to Download (16c) · #301 CUDA generation failure (18c) · #20 Model download failed (13c) · #364 Voxtral-TTS FR (11r) · #341 Arch Linux build · #513 server startup failed (12c) · #138 ONNX/Piper export (9r) · #10 OpenAI API compat.
### New since 0.5.0 — clusters to triage first
- **macOS Apple Silicon load crashes (regression):** #606, #615 — all TTS models crash the server on load; #706, #650 — MLX falls back to CPU / 7-min VRAM load on M4/M5. PR #789 (single-thread MLX, fixes #699) and #743 are the candidate fixes. **Highest priority — breaks the primary platform.**
- **Capture cutoffs & paste:** #609, #626 — transcription stops at 30s for imported audio (PR #602 re-encodes WAV); #762 — paste broken in 0.5.0; #698, #577 — capture/output folder locations.
- **MCP integration:** #790 — dotted tool names violate Claude Desktop's `^[a-zA-Z0-9_-]{1,64}$`; #780 — audio scrambled over MCP; #728 — CUDA re-downloads on cold start.
- **Refinement:** #603 — silently translates non-English transcripts to English (PR #629 preserves source language).
- **GPU expansion requests:** #676 DirectML (AMD/Intel), #759 Intel Arc, #684 RTX 5060 Ti CUDA 13, #774 CUDA 11.x for older cards, #767 Linux CUDA installs Windows `.exe`.
- **New engines/langs:** #791 OmniVoice, #633 VoxCPM2, #690 Dramabox, #638 Bengali, #754 zh-TW, #761 Filipino.
- **Open plugin interface (#771):** request for a community engine/provider plugin API — ties into engine-sprawl (#419) and platform-gating work.
- **Trust/security:** #806 — `$VOICEBOX` is the official dev-backed funding token (concern raised and resolved on-thread; see funding note above); #778 DNS-rebinding/Host-header exposure on local API+MCP (fixes attached) — genuine security item; #369 macOS malware flag (ongoing).
### GPU / Hardware Detection — still the top category
**RTX 50-series (Blackwell / sm_120) cluster — NEW:** #417, #400, #396, #395, #390, #362 all report `cudaErrorNoKernelImageForDevice` / "no kernel image available." sm_120 support shipped in PR #401 + cu128 in PR #316, but users on upgraded installs still hit it — likely stale CUDA binary. Needs a diagnostic that detects binary/GPU-arch mismatch and prompts re-download.
@@ -472,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:
@@ -523,19 +693,21 @@ Seven TTS engines shipped, more candidates queued. Issue #419 asks for a first-c
## Recommended Priorities
### Tier 1 — Ship Now
### Tier 1 — Ship Now (the next release is mostly a merge-and-fix pass)
The two-month gap means the highest-leverage work isn't new code — it's reviewing the 88-PR queue and shipping the 0.5.0 regression fixes that are already written.
| Priority | PR/Item | Impact | Effort |
|----------|---------|--------|--------|
| 1 | **RTX 50-series / Blackwell diagnostic** — detect stale CUDA binary vs GPU arch, prompt re-download (#417, #400, #396, #395, #390, #362) | Large cluster of user-blocking errors | Medium |
| 2 | **CustomVoice download failures** (#475, #445) | New engine blocked on MAC/Win — regression triage | Medium |
| 3 | **50k char limit on GPU** (#464) | Regression — chunking should handle this | Medium |
| 4 | Close PR #311 (CosyVoice) and dedupe #331/#430 (MiniMax) | Housekeeping | None |
| 5 | **PR #443** — infinite offline retry loop | Bug fix, reviewable | Low |
| 6 | **PR #465** — define tier-1 / tier-2 platforms | Unblocks engine-sprawl decision (#419) | Low |
| 7 | **PR #463** — docker registry auto-publish | Community PR, low risk | Low |
| 8 | **#253** — 48kHz speech tokenizer | Quality improvement for Qwen | Medium |
| 9 | **Kokoro profile UX** (#360) — partially addressed by auto-switch | Polish | Low |
| 1 | **macOS Apple Silicon load crash** (#606, #615, #706, #650) — review/merge PR #789 (single-thread MLX) + #743 | Breaks the primary platform on 0.5.0 | Low (PRs exist) |
| 2 | **Capture 30s import cutoff** (#609, #626) — review PR #602; paste-broken #762 | Core 0.5.0 feature degraded | Low–Medium |
| 3 | **Refinement translates to English** (#603) — merge PR #629 | Silent data loss for non-English users | Low |
| 4 | **MCP dotted tool names** (#790) — breaks Claude Desktop; scrambled audio #780 | Flagship integration broken for some clients | Low–Medium |
| 5 | **Blackwell / sm_120 diagnostic** — review PR #653; stale-binary re-download path | Largest GPU bug cluster | Medium |
| 6 | **Drain the i18n batch** (#528, #571, #599–601, #569, #798–801, #802, #776) | ~12 finished PRs, large user segment | Low (review-bound) |
| 7 | **Review the @neuron-tech-ai hardening batch** — start with #662, #657 (platform gating), #656 (OpenAI API), #654 (CI) | Security + perf + bottleneck #6 in one sweep | Medium (review-bound) |
| 8 | **Remove 50k char limit** (#464) — merge PR #786; tune chunk boundaries | Long-standing regression | Low |
| 9 | Housekeeping — dedupe MiniMax #331/#430, re-evaluate CosyVoice #777, close spam/empty issues (#805, #775) | Triage hygiene | Low |
### Tier 2 — Feature Work
@@ -553,9 +725,11 @@ Seven TTS engines shipped, more candidates queued. Issue #419 asks for a first-c
### 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. |
@@ -564,6 +738,25 @@ Seven TTS engines shipped, more candidates queued. Issue #419 asks for a first-c
| 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~~
+138
View File
@@ -0,0 +1,138 @@
---
title: "Hermes Agent"
description: "Use Voicebox as the voice and ears of Hermes Agent — spoken replies and voice-message transcription, fully local."
---
## Overview
[Hermes Agent](https://github.com/NousResearch/hermes-agent) is Nous
Research's open-source self-improving agent: a terminal CLI/TUI plus a
messaging gateway that connects one agent to Telegram, Discord, WhatsApp,
Slack, and Signal. It has first-class voice features — spoken replies,
voice-bubble delivery on chat platforms, push-to-talk dictation, and
automatic transcription of incoming voice messages — and every one of them
is pluggable.
Voicebox slots into both directions of that loop, entirely on-device:
- **Voice out** — Hermes speaks its replies in one of your cloned or preset
voices instead of a stock cloud voice.
- **Voice in** — voice messages and push-to-talk audio are transcribed by
the Whisper models already bundled with Voicebox. Audio never leaves your
machine.
There are two integration surfaces, and they compose — most people will
want both. Everything talks to the same local API
(`http://127.0.0.1:17493` while the Voicebox app is running).
<Callout type="info">
Running Voicebox in Docker instead of the desktop app? The API is on
`http://127.0.0.1:17600` — set `VOICEBOX_BASE_URL` accordingly wherever it
appears below. See [Docker](/overview/docker).
</Callout>
## MCP: agent-invoked voice tools
Hermes speaks MCP natively, and Voicebox ships a built-in
[MCP server](/overview/mcp-server). Voicebox is in Hermes's approved MCP
catalog, so:
```bash
hermes mcp install voicebox
```
(Or add the block manually to `~/.hermes/config.yaml`:)
```yaml
mcp_servers:
voicebox:
url: "http://127.0.0.1:17493/mcp"
headers:
X-Voicebox-Client-Id: "hermes"
```
Hermes discovers the tools — `voicebox.speak`, `voicebox.transcribe`,
`voicebox.list_profiles`, `voicebox.list_captures` — and the agent can now
*choose* to use them: "read me that summary in Morgan's voice" works
immediately, and the [per-client binding](/overview/mcp-server#per-client-bindings)
for `hermes` lets you pin its default voice from the Voicebox UI.
MCP makes Voicebox a set of tools the agent may call. It does **not**
reroute Hermes's own voice pipeline — spoken replies, voice bubbles, and
incoming voice-message transcription still use whatever `tts.provider` /
`stt.provider` are set to. That's the plugin's job.
## Provider plugin: Hermes's own voice pipeline
[`hermes-voicebox`](https://github.com/jamiepine/hermes-voicebox) registers
Voicebox as a Hermes **TTS provider** and **STT provider** via Hermes's
pluggable backend interfaces (`register_tts_provider` /
`register_transcription_provider` — see
[Build a Hermes Plugin](https://hermes-agent.nousresearch.com/docs/developer-guide/plugins)).
Once selected, the providers service the *entire* voice pipeline: every
spoken reply, every Telegram voice bubble, every incoming voice memo — plus
a bundled skill that teaches the agent when speaking aloud is appropriate
and to recall your dictated [Captures](/overview/captures) through MCP.
<Steps>
### Install the plugin
Into the same Python environment Hermes runs in:
```bash
pip install hermes-voicebox
```
No pip? Copy it in as a directory plugin instead:
```bash
git clone https://github.com/jamiepine/hermes-voicebox /tmp/hermes-voicebox
cp -r /tmp/hermes-voicebox/hermes_voicebox ~/.hermes/plugins/voicebox
hermes plugins enable voicebox
```
### Select the providers
In `~/.hermes/config.yaml`:
```yaml
tts:
provider: voicebox
stt:
provider: voicebox
```
### Try it
With the Voicebox app open, start `hermes chat` and ask it to say
something out loud — or send your Hermes bot a voice message on Telegram
and watch the transcript come back from your local Whisper.
</Steps>
## Behavior notes
- **Voicebox must be running.** The desktop app only serves the API while
it's open. Both providers implement availability as a live `/health`
check, so Hermes's provider picker reflects reality.
- **First generation is slower** while the TTS engine loads into memory;
subsequent calls are fast. Same for the first transcription with a new
Whisper size — Voicebox answers `202` while the model downloads, and the
plugin surfaces a friendly "try again in a minute".
- **Voice selection**: `tts.voice` in Hermes config (or the tool's `voice`
argument) accepts a Voicebox profile **name or id**. With no voice set,
the first profile is used.
- **Engines**: pass a Voicebox engine id (`qwen`, `kokoro`,
`chatterbox`, …) as the Hermes `model` to override the profile's
default engine.
## Next steps
- [MCP Server](/overview/mcp-server) — the tool-call route, per-client
bindings, and the speaking pill
- [Creating Voice Profiles](/overview/creating-voice-profiles) — clone the
voice Hermes will speak in
- [Remote Mode](/overview/remote-mode) — reaching a Voicebox instance on
another machine (read the security notes first: the API has no auth)
+1
View File
@@ -13,6 +13,7 @@
"preset-voices",
"voice-personalities",
"mcp-server",
"hermes-agent",
"stories-editor",
"recording-transcription",
"generation-history",
+168
View File
@@ -0,0 +1,168 @@
# Voicebox Cloud Roadmap
The post-mobile commercial trajectory. Captures the strategic arc beyond `mobile/PLAN.md` — what Voicebox becomes once the mobile companion ships and we start layering optional cloud services on top of the local-first base.
The desktop app stays free. Paid surface is the cloud layer, gated behind a Voicebox account, designed so the server sees as little as possible.
---
## Phases
### Phase 0 — Mobile companion (in progress)
See [`mobile/PLAN.md`](../../mobile/PLAN.md). Entirely local: paired-device keys live on the iPhone, traffic goes over Tailscale or LAN, no cloud account required. This is the wedge — it establishes the device-key primitive that every later phase reuses.
### Phase 1 — Backup & Sync (next big feature)
First introduction of a Voicebox cloud account. Server stores **only encrypted blobs**.
- **E2E encryption keyed off the device key** from the mobile pairing flow. Audio + transcript blobs are encrypted client-side before upload; the server never has the plaintext or the key.
- **Quota by number of generations**, not by storage GB. Avoids "how many GB do you offer" framing and keeps tiering legible. (Word-count quotas are an alternative — closer to the ElevenLabs model — but generations are simpler to communicate.)
- **What's synced:** captures (audio + transcripts), generations, voice profiles **as ciphertext**, settings.
- **What's NOT synced:** voice profile audio in plaintext, refinement LLM context, anything that would let us reconstruct what a user said or who they sound like.
- **Multi-device read:** the same paired-device key on a second device decrypts the backup. Recovery via printable key on first pairing.
The privacy framing is load-bearing. "We see encrypted blobs and that's it" is the commitment the rest of the cloud story rests on.
### Phase 2 — Private Voice Inference ("the OpenRouter for voice")
The big bet. Today there is no major neutral voice-inference provider — every cloud TTS service ships its own proprietary models. Open-source TTS models exist and keep getting better, but nobody runs them as a paid hosted catalog at scale.
Voicebox already has the distribution. The thesis is: the same users who chose local-first specifically to avoid sending voice data to ElevenLabs will pay a fair markup to run open-source voices on hosted GPUs **when they don't have local hardware** (mobile-only users, low-end laptops, "I just don't want to manage CUDA"), provided the privacy story stays consistent.
- **Catalog-first positioning.** Cloud can offer more voices than the desktop binary bundles (the bundle is already 500MB without CUDA, ~3GB with — there's a hard ceiling on what we can ship locally). Catalog grows over time.
- **Pricing tiers (rough first cut):** $5 / $15 / $25 / month, plus Enterprise. Final numbers depend on benchmarking — see below.
- **Unit economics work to do:** benchmark every open-source TTS engine in the lineup (Qwen3-TTS, Chatterbox Multilingual + Turbo, TADA, Kokoro, LuxTTS, plus future additions) for cost-per-generation on candidate hardware. Find the engines where our markup is comfortably below ElevenLabs's per-character cost.
- **Privacy ceiling:** server-side inference cannot be cryptographically verified the way E2E backup can. The honest framing is "we don't log inputs, we don't train on your data, audited" — not "we mathematically can't see it." That's a real step down from Phase 1's guarantee, and the product has to be clear about it.
- **Mobile + OS integrations.** Once cloud inference exists, the mobile app unlocks the same OS-level surfaces ElevenLabs has (keyboard-tied dictation, share-sheet TTS, Siri-equivalent). Local-first users still get them via paired desktop; cloud users get them without needing a desktop at all.
#### Inference architecture
**Compute layer.** Modal as the v1 platform — per-second billing, scale-to-zero, volume mounts for model weights, runs our existing Python code with a thin decorator. The ~30-50% premium over raw GPU cost is irrelevant at launch scale and small (less than one DevOps hire) at $1M ARR. Migrate engine-by-engine to bare-metal (Lambda Labs, Crusoe, CoreWeave) once any single engine has predictable demand. Hyperscalers (AWS / GCP) only for enterprise contracts that require it.
**Topology.**
```
Client (desktop / mobile / API user)
│
▼ HTTPS, bearer auth
Gateway ← R2: encrypted profile blobs
│ ← D1 / Postgres: users, billing, quotas, profile metadata
▼ internal RPC
Per-engine Modal apps (Kokoro / Chatterbox / TADA / Whisper / …)
```
**Gateway.** Cloudflare Workers + R2 + D1 for v1 — Workers handle auth and routing, R2 has no egress fees which matters when the payload is audio, D1 handles small relational state (users, quotas, profile metadata). Auth, billing, rate limits, profile resolution, engine routing, and log redaction all live in the gateway. Workers stay dumb: they receive a request with the profile envelope already in hand, run inference, stream audio back. The gateway is what makes engine migration painless — moving TADA to bare-metal later is a routing config change, not a client change.
**Model packaging.** Each `backend/backends/<engine>.py` class becomes a Modal `@app.cls` wrapper. Same inference code as desktop. Weights download on container build, live on a Modal Volume, get reused by warm containers. The PyInstaller-specific runtime hooks from 0.4.x (scipy / transformers / `torch._dynamo` workarounds for the frozen binary) factor into a `frozen.py` runtime hook the desktop build imports — cloud doesn't. Single source of truth for inference logic; two entry points for two runtimes.
**Streaming.** SSE over HTTPS, base64-encoded audio frames, interleaved status events (`queued` / `generating` / `done`), usage event at the end with `characters_consumed` and `seconds_generated`. Wire format identical to the desktop SSE pattern from 0.2.x — cloud is the same shape at a different URL.
**Latency budgets** (first audio chunk, warm / cold):
| Engine | Hardware | Warm | Cold | Pool strategy |
| ---------------------------------- | --------- | ---- | ---- | ------------------------------ |
| Kokoro, LuxTTS | CPU | <1s | ~5s | Scale-to-zero |
| Chatterbox Turbo, Whisper Turbo | A10g / L4 | 1-3s | ~15s | Small warm pool, p95 sizing |
| Qwen3-TTS, Chatterbox Multilingual | A10g / L4 | 2-5s | ~30s | Larger warm pool |
| TADA-3B | A100 | ~5s | ~60s | Premium tier only, capped pool |
Scale-to-zero where cold start fits the budget. Hot engines need warm pools sized to p95 demand — that's where unit economics get sensitive. Reserved capacity only after a quarterly demand baseline.
**Profile pipeline.** Cloned voice → encrypted blob with user-account-key → uploaded to R2 cold storage → fetched into worker memory at job start → decrypted in memory only, never written to worker disk → discarded on worker idle. TTL applies at the R2 layer (cold storage retention); worker hot-path retention is bounded by warmup window. Embedding-only caching where the engine exposes a stable embedding interface; raw-audio caching is the fallback. Per-engine audit needed before launch — see open questions.
**Hybrid routing on the client.** Desktop, mobile, and MCP clients already speak `127.0.0.1:17493`. Add `VOICEBOX_API_URL` + `VOICEBOX_API_KEY` plus a routing function:
```
if local_backend_reachable() and engine in local_engines:
→ 127.0.0.1:17493
else:
→ api.voicebox.sh/v1
```
Mobile-without-paired-desktop falls through to cloud automatically. Desktop without a usable GPU falls through for big engines, stays local for Kokoro. Same `voicebox.speak()` MCP call works either way. This is the differentiator versus ElevenLabs (cloud-only) and pure local-first competitors (no fallback).
#### Cloud-cached voice profiles
Inference latency makes it untenable to re-upload reference samples per call. Cloud caches the user's own voice profiles for the user's own inference, under tight guardrails:
- **Per-profile opt-in.** Profiles are local-only by default. A "Cloud-enabled" toggle (per-profile, never global, never automatic) is what triggers upload on first cloud generation. Mobile-without-paired-desktop is the main upgrade path here — without cached profiles, mobile cloud is preset-voices only.
- **User-controlled TTL.** `Session only` / `24h` / `7d` / `30d` / `Never expire`. Conservative default (24h). Auto-purge on inactivity regardless of ceiling.
- **Encrypted at rest under a user-account-key envelope.** Inference workers decrypt in memory only. Keys derived from the same identity primitive that backs Phase 1.
- **Cache embeddings, not raw audio, where the engine supports it.** Speaker embeddings (Chatterbox-style) are derived vectors — cache *those* instead of the .wav. Smaller blast radius, not reconstructible to original speech. Per-engine audit needed before launch (Qwen3-TTS, Chatterbox Multilingual + Turbo, TADA all do speaker conditioning differently); raw-audio caching is the fallback when the engine doesn't expose a stable embedding interface.
- **Consent attestation logged at upload.** "I have rights to this voice." Timestamped, retained. Doesn't shield from claims but it's the legal posture.
- **Verifiable deletion.** `DELETE /v2/profiles/{id}/cloud-cache` from day one, enforced across replicas, surfaced in-app as a one-click action.
- **Trust tier:** audited-no-log, encrypted at rest, user-controlled TTL — *not* the cryptographic guarantee Phase 1 backup carries. The product has to communicate this difference clearly so cached profiles don't bleed into the Phase 1 framing. The TTL control is the marketable differentiator versus ElevenLabs, which doesn't expose retention as a user lever at all.
- **Legal line items.** GDPR Article 9 (biometrics are special category), BIPA ($1k–5k statutory damages per violation), Texas CUBI, Washington MHMD. Real consent flow, retention controls, deletion rights, breach notification, signed DPAs for enterprise. SOC 2 + pen test before this surface goes public — not optional.
### Phase 3 — Voice Marketplace (much later)
A marketplace where voice owners license their cloned voices for others to use, with revenue sharing. Possibly: "rent out your AI voice."
This is the only phase that requires hosting voice profiles, and it requires real licensing infrastructure first — consent verification, takedown flow, identity claims, revenue accounting. Until that exists, **Voicebox does not host voice profiles in cloud at all** (see constraint below). Marketplace is the long-term endgame, not the next quarter.
---
## Cross-cutting constraints
### Voice profiles in cloud: owner-only, opt-in, time-bound
Three rules, in increasing strictness depending on phase:
- **Phase 1 (backup & sync):** profiles travel as ciphertext the server cannot decrypt. The server has no path to plaintext for any reason.
- **Phase 2 (inference):** the user's own profiles can be cached for the user's own inference, but only with per-profile opt-in, user-controlled TTL, encryption at rest, and verifiable deletion. The server holds plaintext (or derived embeddings) under audited-no-log terms — a real downshift from Phase 1's cryptographic guarantee, and one the product has to communicate honestly.
- **Phase 3 (marketplace):** hosting other users' voices for non-owners is gated on consent verification, licensing, takedown, and revenue accounting infrastructure. Until those exist, no profile is served to anyone but its owner. No shortcuts.
This protects two things at once:
- **Legal posture.** Biometric voice data triggers GDPR Article 9, BIPA, Texas CUBI, Washington MHMD. The trust hierarchy above maps to the consent and retention story we can defend at each phase.
- **Privacy positioning.** Phase 1 is "cryptographically can't see." Phase 2 is "audited won't see, with a timer you control." Both are honest, both sit above ElevenLabs's posture, and both have to be communicated as distinct trust tiers — not blurred together.
### Privacy is the moat, not a feature
The "private LLM users → ElevenLabs voice" workflow is incoherent: people pay to keep their text private and then hand their speech to a cloud vendor that trains on it. Voicebox is the consistent answer for that audience. Every cloud feature should be designed so a privacy-conscious user can adopt it without breaking that internal consistency — which is why Phase 1 is fully E2E and Phase 2 is "audited no-log" rather than "we have your audio but trust us."
### Revenue stack is multi-source
Subscriptions are not the only line. The full picture:
- **Subscriptions** — Phase 1 quotas + Phase 2 inference
- **Corporate sponsorship** — `landing/src/app/sponsors/page.tsx`, $500/mo tier live in 0.5
- **Individual donations** — Buy Me a Coffee
- **Marketplace revenue share** — Phase 3, far off
Diversification matters because the desktop app stays free forever. Subscriptions never have to carry the whole product.
---
## Sequencing & "ease it onto them"
The deliberate ordering is privacy-additive: each phase introduces the next layer of cloud only after the user has had time to trust the previous one.
1. **Mobile (entirely local)** — no account, no cloud, just a companion to the desktop you already trust.
2. **Backup & sync (cloud, fully E2E)** — first cloud account. Server sees nothing. Trust is bootstrapped on "we built the math so we can't see your data even if we wanted to."
3. **Private inference (cloud, audited no-log)** — second cloud surface. Honest about the ceiling: server-side inference can't carry the same cryptographic guarantee, but the operational commitment is no logs, no training, audited.
4. **Marketplace (cloud, profiles hosted with consent)** — only after licensing infra. The most invasive surface, gated behind real verification.
Skipping ahead breaks the trust ladder. Don't ship marketplace before backup & sync is mature; don't ship hosted inference before users are comfortable holding accounts at all.
---
## Open questions
1. **Quota unit.** Generations vs. words vs. characters. Generations is the cleanest to communicate; words/characters maps onto how ElevenLabs prices and might be required for inference billing. Could be different units per phase (generations for backup, characters for inference).
2. **Recovery key UX.** First pairing in Phase 1 needs to print a recovery key. How prominent? Force-display vs. hide-behind-link?
3. **Inference billing model.** Per-character (ElevenLabs-style), per-generation (simpler), per-second-of-output (closest to GPU cost). Pick before pricing tiers are finalized.
4. **Bring-your-own-key for inference?** Some privacy-conscious users may prefer to provide their own GPU credits / API keys to a third-party host through us. Worth considering for Enterprise.
5. **Marketplace consent verification.** What's the bar? Notarized release? Real-time liveness check? Out of scope for Phase 1-2 but informs how the device key is structured today.
6. **Default cloud-cache TTL.** 24h is the proposed conservative default. Worth A/B testing against `Session only` for first-time users — the "auto-purge after this session" framing might be a stronger trust signal than any number.
7. **Embedding vs. raw-audio caching, per engine.** Chatterbox produces stable speaker embeddings; Qwen3-TTS, TADA, and others use different conditioning strategies. Audit needed before launch — embedding-only caching shrinks the legal/privacy surface meaningfully, but only where the engine exposes a clean embedding interface.
8. **Single gateway region or multi-region?** Cloudflare is global by default, but Modal apps are primarily us-east / us-west. EU users hitting US compute = +100ms first-token latency, and GDPR pushes toward EU compute regardless. v1 single-region or hold launch for EU?
9. **SSE vs WebSocket for streaming.** SSE works through any proxy and is what desktop already uses, so the wire format is shared for free. WebSocket is bidirectional and unlocks "interrupt mid-generation" and live duplex features later. Default: SSE for v1, WS as a follow-on.
10. **Cloud Whisper in the v1 bundle?** Phase 2 was framed as TTS-only ("OpenRouter for voice"), but mobile dictation hitting cloud Whisper instead of a paired desktop is the obvious mobile-only feature. Same launch bundle, or hold for Phase 2.5?
11. **Billing integration.** Stripe Metered + customer portal (~2 weeks of work, 2.9% fee) vs self-hosted (saves the fee, adds significant ongoing work). Default: Stripe.
---
## How this connects to mobile V1
The encryption story starts with the device key minted during mobile pairing (`mobile/PLAN.md` → "Pairing & transport"). That same key — or a key derived from it — is what encrypts cloud blobs in Phase 1. Don't treat the mobile pairing key as a one-off; design it as the root of the user's lifetime encryption identity, with rotation + multi-device-add flows in mind even if those don't ship until Phase 1.
+27 -25
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,10 +72,6 @@ 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-audio + mlx-lm are intentionally --no-deps (their transformers>=5
# pin conflicts with our pinned 4.x). The runtime API surface we use
# works fine on transformers 4.57.x. See requirements-mlx.txt notes.
{{ pip }} install --no-deps mlx-audio==0.4.1 mlx-lm
fi
{{ pip }} install git+https://github.com/QwenLM/Qwen3-TTS.git
{{ pip }} install pyinstaller ruff pytest pytest-asyncio -q
@@ -104,11 +120,6 @@ setup-js:
# ─── Development ──────────────────────────────────────────────────────
# Start backend (if not already running) + frontend for development
# Binds the backend to 0.0.0.0 so paired mobile devices can reach it over the
# LAN/Tailscale. This exposes existing unauth routes (/generate, /transcribe,
# /captures, /profiles) to anyone on the same network — fine for a trusted
# home network, NOT fine on public Wi-Fi. Bearer-auth middleware on those
# routes is on the roadmap; until then, treat dev as LAN-trusted.
[unix]
dev: _ensure-venv _ensure-sidecar
#!/usr/bin/env bash
@@ -118,8 +129,8 @@ dev: _ensure-venv _ensure-sidecar
if curl -sf http://127.0.0.1:17493/health > /dev/null 2>&1; then
echo "Backend already running on http://localhost:17493"
else
echo "Starting backend on http://0.0.0.0:17493 (LAN-reachable) ..."
{{ venv_bin }}/uvicorn backend.main:app --reload --host 0.0.0.0 --port 17493 &
echo "Starting backend on http://localhost:17493 ..."
{{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 &
backend_pid=$!
sleep 2
fi
@@ -133,21 +144,21 @@ dev: _ensure-venv _ensure-sidecar
dev: _ensure-venv _ensure-sidecar
$backendJob = $null; \
try { $null = Invoke-WebRequest -Uri "http://127.0.0.1:17493/health" -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop; Write-Host "Backend already running on http://localhost:17493" } catch { \
Write-Host "Starting backend on http://0.0.0.0:17493 (LAN-reachable) ..."; \
$backendJob = Start-Process -PassThru -NoNewWindow -FilePath "{{ python }}" -ArgumentList "-m","uvicorn","backend.main:app","--reload","--host","0.0.0.0","--port","17493"; \
Write-Host "Starting backend on http://localhost:17493 ..."; \
$backendJob = Start-Process -PassThru -NoNewWindow -FilePath "{{ python }}" -ArgumentList "-m","uvicorn","backend.main:app","--reload","--port","17493"; \
Start-Sleep -Seconds 2; \
}; \
Write-Host "Starting Tauri desktop app..."; \
try { Set-Location "{{ tauri_dir }}"; bun run tauri dev } finally { if ($backendJob) { taskkill /PID $backendJob.Id /T /F 2>$null | Out-Null } }
# Start backend only — bound to 0.0.0.0 for mobile pairing (see `dev` above)
# Start backend only
[unix]
dev-backend: _ensure-venv
{{ venv_bin }}/uvicorn backend.main:app --reload --host 0.0.0.0 --port 17493
{{ venv_bin }}/uvicorn backend.main:app --reload --port 17493
[windows]
dev-backend: _ensure-venv
& "{{ python }}" -m uvicorn backend.main:app --reload --host 0.0.0.0 --port 17493
& "{{ python }}" -m uvicorn backend.main:app --reload --port 17493
# Start Tauri desktop app only (backend must be running separately)
[unix]
@@ -189,15 +200,6 @@ dev-web: _ensure-venv
Write-Host "Starting web app..."; \
try { Set-Location "{{ web_dir }}"; bun run dev } finally { if ($backendJob) { taskkill /PID $backendJob.Id /T /F 2>$null | Out-Null } }
# Start Expo dev server for the mobile companion app (run `cd mobile && bun install` once first)
[unix]
dev-mobile:
cd mobile && bunx expo start
[windows]
dev-mobile:
Set-Location "mobile"; bunx expo start
# Kill all dev processes
[unix]
kill:
+2
View File
@@ -17,7 +17,9 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.36.0",
"gray-matter": "^4.0.3",
"lucide-react": "^0.316.0",
"marked": "^18.0.5",
"next": "^16.1.3",
"postcss": "^8.4.33",
"react": "^18.2.0",
-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 1180 320" xmlns="http://www.w3.org/2000/svg"><path d="m367.44 153.84c0 52.32 33.6 88.8 80.16 88.8s80.16-36.48 80.16-88.8-33.6-88.8-80.16-88.8-80.16 36.48-80.16 88.8zm129.6 0c0 37.44-20.4 61.68-49.44 61.68s-49.44-24.24-49.44-61.68 20.4-61.68 49.44-61.68 49.44 24.24 49.44 61.68z"/><path d="m614.27 242.64c35.28 0 55.44-29.76 55.44-65.52s-20.16-65.52-55.44-65.52c-16.32 0-28.32 6.48-36.24 15.84v-13.44h-28.8v169.2h28.8v-56.4c7.92 9.36 19.92 15.84 36.24 15.84zm-36.96-69.12c0-23.76 13.44-36.72 31.2-36.72 20.88 0 32.16 16.32 32.16 40.32s-11.28 40.32-32.16 40.32c-17.76 0-31.2-13.2-31.2-36.48z"/><path d="m747.65 242.64c25.2 0 45.12-13.2 54-35.28l-24.72-9.36c-3.84 12.96-15.12 20.16-29.28 20.16-18.48 0-31.44-13.2-33.6-34.8h88.32v-9.6c0-34.56-19.44-62.16-55.92-62.16s-60 28.56-60 65.52c0 38.88 25.2 65.52 61.2 65.52zm-1.44-106.8c18.24 0 26.88 12 27.12 25.92h-57.84c4.32-17.04 15.84-25.92 30.72-25.92z"/><path d="m823.98 240h28.8v-73.92c0-18 13.2-27.6 26.16-27.6 15.84 0 22.08 11.28 22.08 26.88v74.64h28.8v-83.04c0-27.12-15.84-45.36-42.24-45.36-16.32 0-27.6 7.44-34.8 15.84v-13.44h-28.8z"/><path d="m1014.17 67.68-65.28 172.32h30.48l14.64-39.36h74.4l14.88 39.36h30.96l-65.28-172.32zm16.8 34.08 27.36 72h-54.24z"/><path d="m1163.69 68.18h-30.72v172.32h30.72z"/><path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z"/></svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,104 @@
import {readFileSync} from "node:fs";
import {join} from "node:path";
import {ImageResponse} from "next/og";
import {formatDate, getPost, loadAllPosts} from "@/lib/blog";
// Per-post Open Graph image, generated with Satori at build time (static export
// of each post route) and served as PNG. Note: this runs in the Satori renderer,
// which only understands inline styles + flexbox and a subset of CSS — no
// Tailwind classes, no `filter: blur()`. Glows are done with radial gradients.
export const size = {width: 1200, height: 630};
export const contentType = "image/png";
export const alt = "Voicebox Blog";
// Pre-build an image for every post route (mirrors the page's static params).
export function generateStaticParams() {
return loadAllPosts().map((post) => ({slug: post.slug}));
}
// 8-bit PNG decodes reliably in Satori; the 1024px logos are 16-bit and don't.
const logo = `data:image/png;base64,${readFileSync(
join(process.cwd(), "public/apple-touch-icon.png"),
).toString("base64")}`;
function titleFontSize(title: string): number {
if (title.length <= 38) return 76;
if (title.length <= 64) return 60;
return 48;
}
export default async function OgImage({
params,
}: {
params: Promise<{slug: string}>;
}) {
const {slug} = await params;
const post = getPost(slug);
const title = post?.title ?? "Voicebox Blog";
const meta = post
? `${post.author} · ${formatDate(post.date)}`
: "Open source voice cloning. Local-first.";
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: 80,
background:
"radial-gradient(ellipse 80% 70% at 30% 30%, hsla(43,60%,50%,0.14) 0%, hsla(43,60%,50%,0.04) 40%, transparent 70%), linear-gradient(180deg, hsl(30,4%,6%) 0%, hsl(30,4%,4%) 100%)",
}}
>
{/* Top: logo + eyebrow */}
<div style={{display: "flex", alignItems: "center", gap: 24}}>
{/* biome-ignore lint/performance/noImgElement: Satori only renders <img> */}
<img src={logo} width={88} height={88} alt="" />
<div
style={{
display: "flex",
fontSize: 26,
letterSpacing: 6,
fontWeight: 600,
textTransform: "uppercase",
color: "hsl(43, 60%, 58%)",
}}
>
Voicebox Blog
</div>
</div>
{/* Title */}
<div
style={{
display: "flex",
fontSize: titleFontSize(title),
lineHeight: 1.1,
fontWeight: 700,
letterSpacing: -1,
color: "hsl(30, 10%, 94%)",
maxWidth: 1000,
}}
>
{title}
</div>
{/* Footer meta */}
<div
style={{
display: "flex",
fontSize: 28,
color: "hsl(30, 5%, 55%)",
}}
>
{meta}
</div>
</div>
),
size,
);
}
+92
View File
@@ -0,0 +1,92 @@
import type {Metadata} from "next";
import Link from "next/link";
import {notFound} from "next/navigation";
import {Footer} from "@/components/Footer";
import {Navbar} from "@/components/Navbar";
import {formatDate, getPost, loadAllPosts} from "@/lib/blog";
export function generateStaticParams() {
return loadAllPosts().map((post) => ({slug: post.slug}));
}
export async function generateMetadata({
params,
}: {
params: Promise<{slug: string}>;
}): Promise<Metadata> {
const {slug} = await params;
const post = getPost(slug);
if (!post) return {title: "Post not found — Voicebox"};
return {
title: `${post.title} — Voicebox`,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: "article",
url: `https://voicebox.sh/blog/${post.slug}`,
// og:image / twitter:image come from the colocated opengraph-image.tsx
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
},
};
}
export default async function BlogPostPage({
params,
}: {
params: Promise<{slug: string}>;
}) {
const {slug} = await params;
const post = getPost(slug);
if (!post) notFound();
return (
<>
<Navbar />
<main className="mx-auto w-full max-w-3xl px-6 pt-32 pb-20">
<Link
href="/blog"
className="font-mono text-sm text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline"
>
← Back to blog
</Link>
<header className="mt-8 border-b border-border pb-10">
{post.tags.length > 0 ? (
<div className="mb-5 flex flex-wrap gap-2">
{post.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-border/60 bg-card/40 px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
>
{tag}
</span>
))}
</div>
) : null}
<h1 className="text-4xl md:text-5xl font-bold tracking-tighter text-foreground">
{post.title}
</h1>
<p className="mt-5 font-mono text-sm text-muted-foreground">
by <span className="text-foreground">{post.author}</span> ·{" "}
{formatDate(post.date)} · {post.readingMinutes} min read
</p>
</header>
<article
className="blog-prose mt-10"
// Content is authored markdown from this repo, not user input.
// biome-ignore lint/security/noDangerouslySetInnerHtml: trusted local markdown
dangerouslySetInnerHTML={{__html: post.html}}
/>
</main>
<Footer />
</>
);
}
+86
View File
@@ -0,0 +1,86 @@
import type {Metadata} from "next";
import Link from "next/link";
import {Footer} from "@/components/Footer";
import {Navbar} from "@/components/Navbar";
import {formatDate, listPosts} from "@/lib/blog";
export const metadata: Metadata = {
title: "Blog — Voicebox",
description: "Notes from building Voicebox — the open-source AI voice studio.",
openGraph: {
title: "Voicebox Blog",
description: "Notes from building Voicebox — the open-source AI voice studio.",
type: "website",
url: "https://voicebox.sh/blog",
images: [{url: "/og.webp", width: 1200, height: 630}],
},
};
export default function BlogIndexPage() {
const posts = listPosts();
return (
<>
<Navbar />
<main className="mx-auto w-full max-w-3xl px-6 pt-32 pb-20">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
Blog
</div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tighter text-foreground">
Notes from building Voicebox.
</h1>
<p className="mt-5 max-w-2xl text-lg text-muted-foreground">
The story behind the project, what's shipping next, and the occasional
look under the hood.
</p>
{posts.length === 0 ? (
<p className="mt-16 border-t border-border pt-10 text-muted-foreground">
Nothing published yet.
</p>
) : (
<ul className="mt-16 border-t border-border">
{posts.map((post) => (
<li key={post.slug}>
<Link
href={`/blog/${post.slug}`}
className="group grid gap-4 border-b border-border py-10 md:grid-cols-[11rem_1fr] md:gap-10"
>
<div className="font-mono text-sm text-muted-foreground md:pt-1.5">
<p>{formatDate(post.date)}</p>
<p className="mt-1">{post.readingMinutes} min read</p>
</div>
<div className="max-w-2xl">
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground transition-colors group-hover:text-accent">
{post.title}
</h2>
{post.excerpt ? (
<p className="mt-3 leading-7 text-muted-foreground">
{post.excerpt}
</p>
) : null}
{post.tags.length > 0 ? (
<div className="mt-5 flex flex-wrap gap-2">
{post.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-border/60 bg-card/40 px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
>
{tag}
</span>
))}
</div>
) : null}
</div>
</Link>
</li>
))}
</ul>
)}
</main>
<Footer />
</>
);
}
+188
View File
@@ -0,0 +1,188 @@
import {ArrowRight, Cloud, KeyRound, Lock, ShieldCheck} from "lucide-react";
import type {Metadata} from "next";
import Link from "next/link";
import {Footer} from "@/components/Footer";
import {Navbar} from "@/components/Navbar";
import {CLOUD_FEATURES, CLOUD_NOTIFY_URL} from "@/lib/pricing";
export const metadata: Metadata = {
title: "Cloud Backup & Sync — Voicebox",
description:
"End-to-end encrypted backup and sync for your Voicebox library. We can't read your data — only your devices can. Optional, local-first, free for $VOICEBOX holders.",
openGraph: {
title: "Voicebox Cloud — encrypted backup & sync",
description:
"End-to-end encrypted backup and sync across desktop and mobile. The server is blind — only your devices can decrypt.",
type: "website",
url: "https://voicebox.sh/cloud",
images: [{url: "/og.webp", width: 1200, height: 630}],
},
};
const STEPS = [
{
icon: Lock,
title: "Encrypted on your device",
body: "Profiles, generations, and captures are encrypted locally with keys only you hold — before anything is uploaded.",
},
{
icon: Cloud,
title: "Stored as opaque blobs",
body: "The server keeps your encrypted objects and a sync feed. It can route and store them, but never decrypt them.",
},
{
icon: KeyRound,
title: "Only your devices decrypt",
body: "Each device unwraps your master key on pairing. A recovery phrase you control lets you restore everything to a new one.",
},
];
export default function CloudPage() {
return (
<>
<Navbar />
{/* ── Hero ─────────────────────────────────────────────────── */}
<section className="relative pt-32 pb-16">
<div className="hero-glow hero-glow-fade pointer-events-none absolute inset-0 -top-32">
<div className="absolute left-1/2 top-0 -translate-x-1/2 w-[900px] h-[500px] rounded-full bg-accent/12 blur-[140px]" />
</div>
<div className="relative mx-auto max-w-4xl px-6 text-center">
<div className="fade-in mb-6 inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/40 px-3 py-1">
<Cloud className="h-3.5 w-3.5 text-accent" />
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Voicebox Cloud · coming soon
</span>
</div>
<h1 className="fade-in text-5xl font-bold tracking-tighter leading-[0.95] text-foreground md:text-6xl lg:text-7xl">
Your studio, backed up and in sync.
</h1>
<p className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl">
Optional, end-to-end encrypted backup and sync for your entire
Voicebox library. We can't read a byte of it — only your devices
can. Free for{" "}
<Link href="/token" className="text-foreground underline-offset-4 hover:underline">
$VOICEBOX
</Link>{" "}
holders.
</p>
<div className="fade-in mt-10 flex flex-row items-center justify-center gap-3 sm:gap-4">
<Link
href="/pricing"
className="rounded-full bg-accent px-8 py-3.5 text-sm font-semibold uppercase tracking-wider text-white shadow-[0_4px_20px_hsl(43_60%_50%/0.3),inset_0_2px_0_rgba(255,255,255,0.2),inset_0_-2px_0_rgba(0,0,0,0.1)] transition-all hover:bg-accent-faint"
>
See pricing
</Link>
<a
href={CLOUD_NOTIFY_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-full border border-border/60 bg-card/40 backdrop-blur-sm px-6 py-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:border-border"
>
Get notified
<ArrowRight className="h-4 w-4" />
</a>
</div>
</div>
</section>
{/* ── Features ─────────────────────────────────────────────── */}
<section className="border-t border-border py-20">
<div className="mx-auto max-w-5xl px-6">
<div className="grid gap-4 md:grid-cols-3">
{CLOUD_FEATURES.map((f) => (
<div
key={f.title}
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
>
<h3 className="text-[15px] font-semibold text-foreground mb-2">
{f.title}
</h3>
<p className="text-sm leading-relaxed text-muted-foreground">
{f.body}
</p>
</div>
))}
</div>
</div>
</section>
{/* ── How it works ─────────────────────────────────────────── */}
<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">
How it works
</div>
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
Zero-knowledge by design.
</h2>
</div>
<div className="grid gap-4 md:grid-cols-3">
{STEPS.map((step, i) => {
const Icon = step.icon;
return (
<div
key={step.title}
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
>
<div className="flex items-center gap-3 mb-3">
<Icon className="h-5 w-5 text-accent" />
<span className="font-mono text-xs text-muted-foreground/60">
0{i + 1}
</span>
</div>
<h3 className="text-[15px] font-semibold text-foreground mb-2">
{step.title}
</h3>
<p className="text-sm leading-relaxed text-muted-foreground">
{step.body}
</p>
</div>
);
})}
</div>
</div>
</section>
{/* ── Trust callout ────────────────────────────────────────── */}
<section className="border-t border-border py-20">
<div className="mx-auto max-w-3xl px-6">
<div className="rounded-2xl border-2 border-accent/40 bg-card/60 backdrop-blur-sm p-8 md:p-10 text-center shadow-[0_8px_40px_hsl(43_60%_50%/0.08)]">
<ShieldCheck className="h-7 w-7 text-accent mx-auto mb-4" />
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground mb-3">
We can't see your data. That's the point.
</h2>
<p className="text-muted-foreground leading-relaxed max-w-2xl mx-auto">
Voicebox is local-first and privacy-first. The cloud keeps that
promise: your library is encrypted before it leaves your device,
the server stores only ciphertext, and the keys never leave your
control. Same philosophy as the app — just backed up.
</p>
<div className="mt-8 flex flex-row items-center justify-center gap-3">
<Link
href="/pricing"
className="rounded-full bg-accent px-6 py-3 text-sm font-semibold text-white shadow-[0_4px_20px_hsl(43_60%_50%/0.3)] transition-all hover:bg-accent-faint"
>
See pricing
</Link>
<Link
href="/token"
className="rounded-full border border-border/60 bg-card/40 px-6 py-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:border-border"
>
Free for holders →
</Link>
</div>
</div>
</div>
</section>
<Footer />
</>
);
}
+93
View File
@@ -142,6 +142,99 @@
will-change: transform;
} */
/* Blog post typography (rendered markdown via marked) */
.blog-prose {
color: hsl(var(--muted-foreground));
font-size: 1.0625rem;
line-height: 1.75;
}
.blog-prose > * + * {
margin-top: 1.25em;
}
.blog-prose h2 {
margin-top: 2.25em;
margin-bottom: 0.75em;
font-size: 1.6rem;
font-weight: 600;
letter-spacing: -0.02em;
color: hsl(var(--foreground));
}
.blog-prose h3 {
margin-top: 1.75em;
margin-bottom: 0.5em;
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.blog-prose p,
.blog-prose ul,
.blog-prose ol,
.blog-prose blockquote {
color: hsl(var(--muted-foreground));
}
.blog-prose strong {
color: hsl(var(--foreground));
font-weight: 600;
}
.blog-prose a {
color: hsl(var(--foreground));
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-color: hsl(var(--accent) / 0.5);
transition: color 0.15s;
}
.blog-prose a:hover {
color: hsl(var(--accent));
}
.blog-prose ul,
.blog-prose ol {
padding-left: 1.4em;
}
.blog-prose ul {
list-style: disc;
}
.blog-prose ol {
list-style: decimal;
}
.blog-prose li + li {
margin-top: 0.4em;
}
.blog-prose blockquote {
border-left: 2px solid hsl(var(--accent) / 0.5);
padding-left: 1.25em;
font-style: italic;
}
.blog-prose code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.875em;
background: hsl(var(--muted));
color: hsl(var(--foreground));
padding: 0.15em 0.4em;
border-radius: 0.3rem;
}
.blog-prose pre {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
padding: 1.1em 1.25em;
overflow-x: auto;
}
.blog-prose pre code {
background: transparent;
padding: 0;
font-size: 0.875rem;
color: hsl(var(--foreground));
}
.blog-prose hr {
border: none;
border-top: 1px solid hsl(var(--border));
margin: 2.5em 0;
}
.blog-prose img {
border-radius: 0.75rem;
border: 1px solid hsl(var(--border));
}
/* Scrollbar hiding */
::-webkit-scrollbar {
display: none;
+8 -4
View File
@@ -11,8 +11,9 @@ import {Footer} from "@/components/Footer";
import {Navbar} from "@/components/Navbar";
import {Personalities} from "@/components/Personalities";
import {AppleIcon, LinuxIcon, WindowsIcon} from "@/components/PlatformIcons";
import {SponsorPromo} from "@/components/SponsorPromo";
import {SupportedModels} from "@/components/SupportedModels";
import {Testimonials} from "@/components/Testimonials";
import {TokenTeaser} from "@/components/TokenTeaser";
import {TutorialsSection} from "@/components/TutorialsSection";
import {VoiceCreator} from "@/components/VoiceCreator";
import {GITHUB_REPO} from "@/lib/constants";
@@ -131,9 +132,6 @@ export default function Home() {
</div>
</section>
{/* ── Sponsor promo ────────────────────────────────────────── */}
<SponsorPromo />
{/* ── Features ─────────────────────────────────────────────── */}
<Features />
@@ -158,6 +156,9 @@ export default function Home() {
{/* ── Supported models ─────────────────────────────────────── */}
<SupportedModels />
{/* ── Testimonials ─────────────────────────────────────────── */}
<Testimonials />
{/* ── Download Section ─────────────────────────────────────── */}
<section id="download" className="border-t border-border py-24">
<div className="mx-auto max-w-4xl px-6">
@@ -241,6 +242,9 @@ export default function Home() {
</div>
</section>
{/* ── $VOICEBOX token (teaser → /token) ─────────────────────── */}
<TokenTeaser />
{/* ── Footer ───────────────────────────────────────────────── */}
<Footer />
</>
+131
View File
@@ -0,0 +1,131 @@
import {Coins} from "lucide-react";
import type {Metadata} from "next";
import Link from "next/link";
import {Footer} from "@/components/Footer";
import {Navbar} from "@/components/Navbar";
import {PricingTiers} from "@/components/PricingTiers";
export const metadata: Metadata = {
title: "Pricing — Voicebox",
description:
"Voicebox is free and open source forever. Optional, end-to-end encrypted cloud backup & sync — free for $VOICEBOX holders.",
openGraph: {
title: "Voicebox Pricing",
description:
"The app is free forever. Cloud backup & sync is an optional add-on — free for $VOICEBOX holders.",
type: "website",
url: "https://voicebox.sh/pricing",
images: [{url: "/og.webp", width: 1200, height: 630}],
},
};
const FAQ = [
{
q: "Is the app really free?",
a: "Yes — Voicebox is free and open source, forever. Cloning, dictation, every TTS engine, MCP, personalities: all of it runs locally with no account. The paid plans only add optional cloud backup & sync.",
},
{
q: "What's encrypted in the cloud?",
a: "Everything. Your profiles, generations, and captures are end-to-end encrypted on your device before upload. The server stores only ciphertext and can never read your data.",
},
{
q: "Do $VOICEBOX holders really get Cloud free?",
a: "Yes. Holding the token unlocks the Cloud tier at no cost. The app itself is free regardless — the token is an optional way to support the project.",
},
{
q: "What counts toward storage?",
a: "Your encrypted objects — generated audio, the original audio kept with each capture, and profile data. Plans differ mainly on storage, device count, and version-history length.",
},
{
q: "Can I cancel anytime?",
a: "Yes. Cloud is a subscription you can cancel whenever you like; your local library always stays on your machine and keeps working.",
},
];
export default function PricingPage() {
return (
<>
<Navbar />
{/* ── Hero ─────────────────────────────────────────────────── */}
<section className="relative pt-32 pb-12">
<div className="hero-glow hero-glow-fade pointer-events-none absolute inset-0 -top-32">
<div className="absolute left-1/2 top-0 -translate-x-1/2 w-[900px] h-[460px] rounded-full bg-accent/12 blur-[140px]" />
</div>
<div className="relative mx-auto max-w-4xl px-6 text-center">
<div className="fade-in mb-4 text-[11px] font-semibold uppercase tracking-[0.22em] text-accent">
Pricing
</div>
<h1 className="fade-in text-5xl font-bold tracking-tighter text-foreground md:text-6xl">
The app is free. Forever.
</h1>
<p className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">
Everything that makes Voicebox great runs locally at no cost. Pay
only if you want optional, encrypted cloud backup & sync — and
holders get that free.
</p>
</div>
</section>
{/* ── Tiers (with monthly/annual toggle) ───────────────────── */}
<section className="pb-8">
<PricingTiers />
</section>
{/* ── Holder callout ───────────────────────────────────────── */}
<section className="py-12">
<div className="mx-auto max-w-3xl px-6">
<Link
href="/token"
className="group flex flex-col items-center gap-3 rounded-2xl border border-accent/30 bg-card/40 backdrop-blur-sm px-6 py-8 text-center transition-colors hover:border-accent/50"
>
<Coins className="h-6 w-6 text-accent" />
<h2 className="text-xl md:text-2xl font-semibold tracking-tight text-foreground">
Hold $VOICEBOX, get Cloud free.
</h2>
<p className="max-w-xl text-sm text-muted-foreground">
The token is an optional way to back the project — and holders get
the Cloud tier at no cost. Learn how it works and verify everything
on-chain.
</p>
<span className="mt-1 text-sm font-medium text-accent group-hover:underline underline-offset-4">
View the token →
</span>
</Link>
</div>
</section>
{/* ── FAQ ──────────────────────────────────────────────────── */}
<section className="border-t border-border py-20">
<div className="mx-auto max-w-3xl px-6">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
Questions
</h2>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{FAQ.map((item) => (
<div
key={item.q}
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
>
<h3 className="text-[15px] font-semibold text-foreground mb-2">
{item.q}
</h3>
<p className="text-sm leading-relaxed text-muted-foreground">
{item.a}
</p>
</div>
))}
</div>
<p className="text-center text-xs text-muted-foreground/70 mt-10 max-w-2xl mx-auto">
Cloud pricing and limits are not final — they'll be confirmed at
launch.
</p>
</div>
</section>
<Footer />
</>
);
}
-324
View File
@@ -1,324 +0,0 @@
'use client';
import { ArrowRight, Check, Coffee, Mail } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Footer } from '@/components/Footer';
import { Navbar } from '@/components/Navbar';
import {
DONATE_URL,
SPONSOR_CHECKOUT_URL,
SPONSOR_CONTACT_EMAIL,
} from '@/lib/constants';
function formatCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
return n.toLocaleString();
}
export default function SponsorsPage() {
const [downloads, setDownloads] = useState<number | null>(null);
const [stars, setStars] = useState<number | null>(null);
useEffect(() => {
fetch('/api/releases')
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data?.totalDownloads != null) setDownloads(data.totalDownloads);
})
.catch(() => {});
fetch('/api/stars')
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (typeof data?.count === 'number') setStars(data.count);
})
.catch(() => {});
}, []);
return (
<>
<Navbar />
{/* ── Hero ─────────────────────────────────────────────────── */}
<section className="relative pt-32 pb-16">
<div className="hero-glow hero-glow-fade pointer-events-none absolute inset-0 -top-32">
<div className="absolute left-1/2 top-0 -translate-x-1/2 w-[900px] h-[500px] rounded-full bg-accent/12 blur-[140px]" />
<div className="absolute left-1/2 top-16 -translate-x-1/2 w-[520px] h-[360px] rounded-full bg-accent/8 blur-[80px]" />
</div>
<div className="relative mx-auto max-w-4xl px-6 text-center">
<div
className="fade-in mb-6 text-[11px] font-semibold uppercase tracking-[0.22em] text-accent"
style={{ animationDelay: '50ms' }}
>
VIP Sponsor
</div>
<h1
className="fade-in text-5xl font-bold tracking-tighter leading-[0.95] text-foreground md:text-6xl lg:text-7xl"
style={{ animationDelay: '100ms' }}
>
Get your brand in front of half a million creators.
</h1>
<p
className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl"
style={{ animationDelay: '200ms' }}
>
Voicebox is the open-source AI voice studio used by creators, podcasters, voice
artists, writers, developers, accessibility users, and curious humans all over the
world. Sponsor the project, get your logo in front of all of them.
</p>
<div
className="fade-in mt-10 flex flex-row items-center justify-center gap-3 sm:gap-4"
style={{ animationDelay: '300ms' }}
>
<a
href="#sponsor"
className="rounded-full bg-accent px-8 py-3.5 text-sm font-semibold uppercase tracking-wider text-white shadow-[0_4px_20px_hsl(43_60%_50%/0.3),inset_0_2px_0_rgba(255,255,255,0.2),inset_0_-2px_0_rgba(0,0,0,0.1)] transition-all hover:bg-accent-faint active:shadow-[0_2px_10px_hsl(43_60%_50%/0.3),inset_0_4px_8px_rgba(0,0,0,0.3)]"
>
Become a sponsor
</a>
<a
href={`mailto:${SPONSOR_CONTACT_EMAIL}`}
className="flex items-center gap-2 rounded-full border border-border/60 bg-card/40 backdrop-blur-sm px-6 py-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:border-border"
>
<Mail className="h-4 w-4" />
Talk to us
</a>
</div>
</div>
</section>
{/* ── Traction ────────────────────────────────────────────── */}
<section className="border-t border-border py-20">
<div className="mx-auto max-w-5xl px-6">
<div className="text-center mb-12">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
Reach
</div>
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
Real distribution, real attention.
</h2>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Stat
value={downloads != null ? formatCount(downloads) : '500k+'}
label="Downloads"
note="Since launch in February 2026"
/>
<Stat
value={stars != null ? formatCount(stars) : '22k+'}
label="GitHub stars"
note="Trending #1 maintainer, #4 repo"
/>
<Stat
value="170k+"
label="Monthly site visitors"
note="voicebox.sh, last 30 days · growing 5×"
/>
<Stat
value="Millions"
label="Social reach"
note="Tutorials, reels, TikToks"
/>
</div>
<p className="text-center text-sm text-muted-foreground mt-10 max-w-2xl mx-auto">
Voicebox users are content creators, podcasters, voice artists, writers, developers,
accessibility users, hobbyists, and AI enthusiasts. They picked a local-first tool
over a cloud subscription — they care about owning their software and the brands
behind it.
</p>
</div>
</section>
{/* ── What you get ────────────────────────────────────────── */}
<section className="border-t border-border py-20">
<div className="mx-auto max-w-5xl px-6">
<div className="text-center mb-12">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
Placement
</div>
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
Where your logo shows up.
</h2>
</div>
<div className="rounded-2xl border-2 border-accent/40 bg-card/60 backdrop-blur-sm p-8 mb-4 shadow-[0_8px_40px_hsl(43_60%_50%/0.08)]">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-2">
Headline placement
</div>
<h3 className="text-xl md:text-2xl font-semibold tracking-tight text-foreground mb-2">
voicebox.sh — directly below the hero.
</h3>
<p className="text-sm md:text-base text-muted-foreground leading-relaxed max-w-2xl mb-8">
Your logo lives in the prime slot on the homepage — the first thing every visitor
sees after the hero. Same row as every other VIP Sponsor, linked to your URL
of choice. This is the placement that actually moves the needle.
</p>
{/* Preview of what the placement looks like on the homepage */}
<div className="rounded-xl border border-dashed border-border/80 bg-background/50 p-6 md:p-8">
<div className="text-center mb-6">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground/80">
Sponsored by
</div>
</div>
<div className="flex flex-wrap items-center justify-center gap-5 md:gap-6">
<div className="flex h-32 min-w-[260px] items-center justify-center rounded-2xl border border-border bg-card/40 backdrop-blur-sm px-10">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/sponsors/openai.svg"
alt="Example sponsor logo"
className="h-14 w-auto max-w-[220px] object-contain brightness-0 invert opacity-80"
/>
</div>
</div>
<p className="mt-6 text-center text-xs text-muted-foreground/70 italic">
Example only — actual sponsor logos appear here once placements are live.
</p>
</div>
</div>
<div className="grid md:grid-cols-3 gap-4">
<Perk
title="GitHub README"
body="Logo in the repo's Sponsors section. The README is one of the most-viewed docs on GitHub for any trending project."
/>
<Perk
title="/sponsors page"
body="Dedicated logo card on this page with your tagline, what your company does, and a direct link out."
/>
<Perk
title="Release notes"
body="One-line acknowledgement in the next major release post — read by the long tail of users who follow Voicebox updates."
/>
</div>
</div>
</section>
{/* ── Pricing ─────────────────────────────────────────────── */}
<section id="sponsor" className="border-t border-border py-20">
<div className="mx-auto max-w-3xl px-6">
<div className="text-center mb-10">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
Pricing
</div>
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
One tier. Month-to-month. Cancel anytime.
</h2>
</div>
<div className="rounded-2xl border-2 border-accent/40 bg-card/60 backdrop-blur-sm p-8 md:p-10 shadow-[0_8px_40px_hsl(43_60%_50%/0.1)]">
<div className="flex items-baseline gap-2 mb-2">
<span className="text-5xl font-bold tracking-tight text-foreground">$500</span>
<span className="text-base text-muted-foreground">/ month</span>
</div>
<p className="text-sm text-muted-foreground mb-8">
Billed monthly via Stripe. Logo goes live within 48 hours of payment.
</p>
<ul className="space-y-3 mb-8">
<PerkRow text="Logo on voicebox.sh — directly below the hero" />
<PerkRow text="Logo in the GitHub README sponsors section" />
<PerkRow text="Featured card on /sponsors with your tagline and link" />
<PerkRow text="Acknowledgement in the next major release post" />
<PerkRow text="Direct line to the team for collaboration" />
</ul>
<a
href={SPONSOR_CHECKOUT_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full rounded-full bg-accent px-6 py-3.5 text-sm font-semibold uppercase tracking-wider text-white shadow-[0_4px_20px_hsl(43_60%_50%/0.3),inset_0_2px_0_rgba(255,255,255,0.2),inset_0_-2px_0_rgba(0,0,0,0.1)] transition-all hover:bg-accent-faint"
>
Sponsor Voicebox
<ArrowRight className="h-4 w-4" />
</a>
<p className="text-center text-xs text-muted-foreground/70 mt-4">
Need an annual contract, invoicing, or higher placement?{' '}
<a
href={`mailto:${SPONSOR_CONTACT_EMAIL}`}
className="text-foreground/80 underline-offset-4 hover:underline"
>
{SPONSOR_CONTACT_EMAIL}
</a>
</p>
</div>
</div>
</section>
{/* ── Individual / policy ─────────────────────────────────── */}
<section className="border-t border-border py-20">
<div className="mx-auto max-w-4xl px-6 grid md:grid-cols-2 gap-4">
<div className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6">
<div className="flex items-center gap-2 mb-3">
<Coffee className="h-5 w-5 text-[#FFDD00]" />
<h3 className="text-[15px] font-semibold text-foreground">Not a company?</h3>
</div>
<p className="text-sm leading-relaxed text-muted-foreground mb-4">
Individual supporters keep Voicebox running too. Drop a tip on Buy Me a Coffee and
your name shows up in the supporters list.
</p>
<a
href={DONATE_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm font-medium text-foreground/80 hover:text-foreground transition-colors"
>
Support on Buy Me a Coffee
<ArrowRight className="h-4 w-4" />
</a>
</div>
<div className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6">
<h3 className="text-[15px] font-semibold text-foreground mb-3">
Who we accept
</h3>
<p className="text-sm leading-relaxed text-muted-foreground">
Voicebox is local-first and privacy-first. We don't accept sponsorships from
companies whose business model conflicts with that — voice-data brokers, ad-tech
built on speech, or surveillance vendors. Everyone else is welcome.
</p>
</div>
</div>
</section>
<Footer />
</>
);
}
function Stat({ value, label, note }: { value: string; label: string; note: string }) {
return (
<div className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-5 text-center">
<div className="text-3xl md:text-4xl font-bold tracking-tight text-foreground">{value}</div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-accent mt-2">
{label}
</div>
<div className="text-xs text-muted-foreground mt-2">{note}</div>
</div>
);
}
function Perk({ title, body }: { title: string; body: string }) {
return (
<div className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6">
<h3 className="text-[15px] font-semibold text-foreground mb-2">{title}</h3>
<p className="text-sm leading-relaxed text-muted-foreground">{body}</p>
</div>
);
}
function PerkRow({ text }: { text: string }) {
return (
<li className="flex items-start gap-3 text-sm text-foreground/90">
<Check className="h-5 w-5 shrink-0 text-accent mt-px" />
<span>{text}</span>
</li>
);
}
+223
View File
@@ -0,0 +1,223 @@
import {
ArrowUpRight,
Check,
Cloud,
Flame,
Heart,
Lock,
Rocket,
ShieldCheck,
} from "lucide-react";
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,
TOKEN_TICKER,
} from "@/lib/constants";
export const metadata: Metadata = {
title: `${TOKEN_TICKER} — The official Voicebox token`,
description: `${TOKEN_TICKER} is the official community token for Voicebox on Solana. Entirely optional — Voicebox is, and always will be, free and open source.`,
openGraph: {
title: `${TOKEN_TICKER} on Solana`,
description: `The official community token for Voicebox. Optional, just for fun — Voicebox stays free and open source.`,
type: "website",
url: "https://voicebox.sh/token",
images: [{url: "/og.webp", width: 1200, height: 630}],
},
};
// 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,
title: "Full-time development",
body: "The token is the equivalent of a salary — it lets me work on Voicebox every day instead of squeezing it around other work.",
},
{
icon: Cloud,
title: "Mobile + cloud backup & sync",
body: "Shipping the mobile app and encrypted cloud backup/sync so your generations and captures are safe and available anywhere.",
},
{
icon: Heart,
title: "More engines, more hardware",
body: "Adding TTS engines and broadening GPU / OS support so Voicebox runs great on whatever you've got.",
},
];
const FAQ = [
{
q: "Do I need the token to use Voicebox?",
a: "No. Voicebox is free and open source, and every feature works without ever touching the token. It exists purely for supporters who want to back the project and have some fun.",
},
{
q: "Is this an investment?",
a: "No. $VOICEBOX is a community token, not a security or a promise of returns. There is no roadmap of financial milestones, and nothing here is financial advice. Only spend what you're comfortable with.",
},
{
q: "How do I buy it?",
a: "Copy the contract address above, then buy on pump.fun with a Solana wallet. Always verify the address matches the one on this page — impersonators are common.",
},
{
q: "Does buying it fund development?",
a: "Yes — going full-time on Voicebox is funded by the token, alongside donations. The surest way to support the project either way is to use it, star the repo, and tell people about it.",
},
];
export default function TokenPage() {
return (
<>
<Navbar />
{/* Top padding clears the fixed navbar; TokenSection carries the
header, contract address, and buy CTA. */}
<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">
<div className="text-center mb-12">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
Why a token
</div>
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
So I can build this full-time.
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto mt-4">
Voicebox grew to over a million downloads with zero marketing —
but donations alone never made full-time work sustainable.{" "}
{TOKEN_TICKER} changed that overnight, and it's already
accelerating everything below. The app stays{" "}
<b className="text-foreground">free, open source, and local-first</b>{" "}
— forever.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-3">
{USE_OF_FUNDS.map((item) => {
const Icon = item.icon;
return (
<div
key={item.title}
className="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">
{item.title}
</h3>
<p className="text-sm leading-relaxed text-muted-foreground">
{item.body}
</p>
</div>
);
})}
</div>
</div>
</section>
{/* ── Holder utility ───────────────────────────────────────── */}
<section className="border-t border-border py-20">
<div className="mx-auto max-w-3xl px-6">
<div className="rounded-2xl border-2 border-accent/40 bg-card/60 backdrop-blur-sm p-8 md:p-10 shadow-[0_8px_40px_hsl(43_60%_50%/0.08)]">
<div className="flex items-center gap-2 mb-4">
<Cloud className="h-5 w-5 text-accent" />
<span className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent">
Holder perk · coming soon
</span>
</div>
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground mb-3">
Cloud backup & sync — free for holders.
</h2>
<p className="text-muted-foreground leading-relaxed">
Encrypted cloud backup and sync (and the mobile cloud) will be a
paid service — roughly{" "}
<b className="text-foreground">$12/year</b> for everyone else, and{" "}
<b className="text-foreground">free for {TOKEN_TICKER} holders</b>.
Generate on the go, keep your captures and generations safe, and
pick up on any device.
</p>
</div>
</div>
</section>
{/* ── Official vs community ────────────────────────────────── */}
<section className="border-t border-border py-20">
<div className="mx-auto max-w-3xl px-6">
<div className="rounded-2xl border border-border bg-card/40 backdrop-blur-sm p-8">
<div className="flex items-center gap-2 mb-4">
<ShieldCheck className="h-5 w-5 text-accent" />
<h2 className="text-xl md:text-2xl font-semibold tracking-tight text-foreground">
One official token. Accept no substitutes.
</h2>
</div>
<ul className="space-y-3">
<ProofRow text={`${TOKEN_TICKER} is the only official Voicebox token. The mint address on this page is the single source of truth — always verify it.`} />
<ProofRow text="My other projects (including Spacedrive) will never have an official token. This is the only one I'll ever make." />
<ProofRow text="I deployed it myself so liquidity can be locked and the trajectory controlled — and I no longer claim fees on any other community tokens." />
</ul>
</div>
</div>
</section>
{/* ── Good to know ─────────────────────────────────────────── */}
<section className="border-t border-border py-20">
<div className="mx-auto max-w-3xl px-6">
<div className="text-center mb-12">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
Good to know
</div>
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
Optional, just for fun.
</h2>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{FAQ.map((item) => (
<div
key={item.q}
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
>
<h3 className="text-[15px] font-semibold text-foreground mb-2">
{item.q}
</h3>
<p className="text-sm leading-relaxed text-muted-foreground">
{item.a}
</p>
</div>
))}
</div>
<p className="text-center text-xs text-muted-foreground/70 mt-10 max-w-2xl mx-auto">
{TOKEN_TICKER} is a community token with no affiliation to any
exchange or financial product. Nothing on this page is financial
advice. Verify the contract address before buying.
</p>
</div>
</section>
</main>
<Footer />
</>
);
}
function ProofRow({text}: {text: string}) {
return (
<li className="flex items-start gap-3 text-sm text-foreground/90">
<Check className="h-5 w-5 shrink-0 text-accent mt-px" />
<span>{text}</span>
</li>
);
}
+38
View File
@@ -0,0 +1,38 @@
'use client';
import { Check, Copy } from 'lucide-react';
import { useState } from 'react';
/** Compact, copyable contract address — short display, full value to clipboard. */
export function CopyAddress({ address }: { address: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(address);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard unavailable (e.g. insecure context) — silently no-op.
}
};
const short = `${address.slice(0, 4)}…${address.slice(-4)}`;
return (
<button
type="button"
onClick={handleCopy}
title={address}
aria-label={copied ? 'Contract address copied' : 'Copy contract address'}
className="group inline-flex items-center gap-2 rounded-lg border border-border/60 bg-card/60 px-2.5 py-1.5 font-mono text-xs text-muted-foreground transition-colors hover:border-border hover:text-foreground"
>
<span>{short}</span>
{copied ? (
<Check className="h-3.5 w-3.5 text-accent" />
) : (
<Copy className="h-3.5 w-3.5 opacity-60 transition-opacity group-hover:opacity-100" />
)}
</button>
);
}
+44 -3
View File
@@ -1,13 +1,19 @@
import { Coffee } from 'lucide-react';
import { ArrowUpRight, Coffee, Coins } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { DONATE_URL, GITHUB_REPO } from '@/lib/constants';
import { CopyAddress } from '@/components/CopyAddress';
import {
DONATE_URL,
GITHUB_REPO,
TOKEN_CONTRACT_ADDRESS,
TOKEN_TICKER,
} from '@/lib/constants';
export function Footer() {
return (
<footer className="border-t border-border py-12">
<div className="mx-auto max-w-7xl px-6">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 mb-10">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-8 mb-10">
{/* Brand */}
<div className="md:col-span-1">
<div className="flex items-center gap-2.5 mb-4">
@@ -64,6 +70,16 @@ export function Footer() {
API
</a>
</li>
<li>
<a href="/cloud" className="hover:text-foreground transition-colors">
Cloud
</a>
</li>
<li>
<a href="/pricing" className="hover:text-foreground transition-colors">
Pricing
</a>
</li>
<li>
<a href="/download" className="hover:text-foreground transition-colors">
Download
@@ -76,6 +92,11 @@ export function Footer() {
<div>
<h4 className="text-sm font-semibold mb-3">Resources</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
<a href="/blog" className="hover:text-foreground transition-colors">
Blog
</a>
</li>
<li>
<Link
href="https://docs.voicebox.sh"
@@ -150,6 +171,26 @@ export function Footer() {
</li>
</ul>
</div>
{/* Token */}
<div>
<h4 className="text-sm font-semibold mb-3">Token</h4>
<div className="space-y-3 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Coins className="h-4 w-4 text-accent" />
<span className="font-semibold text-foreground">{TOKEN_TICKER}</span>
<span className="text-xs text-muted-foreground/60">Solana</span>
</div>
<CopyAddress address={TOKEN_CONTRACT_ADDRESS} />
<Link
href="/token"
className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors"
>
Token details
<ArrowUpRight className="h-3.5 w-3.5" />
</Link>
</div>
</div>
</div>
<div className="border-t border-border pt-6">
+18 -8
View File
@@ -1,9 +1,9 @@
'use client';
import { Coffee, Github } from 'lucide-react';
import { Coffee, Coins, Github } from 'lucide-react';
import Image from 'next/image';
import { useEffect, useState } from 'react';
import { DONATE_URL, GITHUB_REPO } from '@/lib/constants';
import { DONATE_URL, GITHUB_REPO, TOKEN_TICKER } from '@/lib/constants';
function formatStarCount(count: number): string {
if (count >= 1000) {
@@ -32,7 +32,7 @@ export function Navbar() {
return (
<nav className="fixed inset-x-0 top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl">
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-3 sm:grid sm:grid-cols-3">
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-3 sm:grid sm:grid-cols-[1fr_auto_1fr] sm:gap-x-6">
{/* Logo + wordmark */}
<a href="/" className="flex items-center gap-2.5 justify-self-start">
<Image
@@ -75,16 +75,16 @@ export function Navbar() {
Models
</a>
<a
href="/#api"
href="/pricing"
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
API
Pricing
</a>
<a
href="/download"
href="/blog"
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
Download
Blog
</a>
<a
href="https://docs.voicebox.sh"
@@ -96,8 +96,18 @@ export function Navbar() {
</a>
</div>
{/* Donate + GitHub star buttons */}
{/* Token + Donate + GitHub star buttons */}
<div className="flex items-center gap-2 justify-self-end">
<a
href="/token"
className="hidden sm:flex items-center gap-2 rounded-lg border border-border/60 bg-card/60 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground hover:border-accent/40"
aria-label={`${TOKEN_TICKER} token`}
>
<Coins className="h-4 w-4 text-accent" />
<span className="text-[13px] font-semibold tracking-wide text-foreground">
{TOKEN_TICKER}
</span>
</a>
<a
href={DONATE_URL}
target="_blank"
+157
View File
@@ -0,0 +1,157 @@
"use client";
import {Check} from "lucide-react";
import {useState} from "react";
import {
annualSavingsPercent,
type BillingPeriod,
PRICING_TIERS,
} from "@/lib/pricing";
const MAX_ANNUAL_SAVINGS = Math.max(
...PRICING_TIERS.map((t) => annualSavingsPercent(t)),
);
export function PricingTiers() {
const [period, setPeriod] = useState<BillingPeriod>("annual");
return (
<div className="mx-auto max-w-6xl px-6">
{/* Billing toggle */}
<div className="mb-10 flex items-center justify-center">
<div className="inline-flex items-center gap-1 rounded-full border border-border bg-card/40 p-1">
<ToggleButton
active={period === "monthly"}
onClick={() => setPeriod("monthly")}
>
Monthly
</ToggleButton>
<ToggleButton
active={period === "annual"}
onClick={() => setPeriod("annual")}
>
Annual
{MAX_ANNUAL_SAVINGS > 0 ? (
<span
className={`ml-1.5 rounded-full px-1.5 py-0.5 text-[10px] font-semibold transition-colors ${
period === "annual"
? "bg-white/25 text-white"
: "bg-accent/15 text-accent"
}`}
>
Save {MAX_ANNUAL_SAVINGS}%
</span>
) : null}
</ToggleButton>
</div>
</div>
<div className="grid items-start gap-4 md:grid-cols-3">
{PRICING_TIERS.map((tier) => {
const isFree = tier.monthly === 0 && tier.annual === 0;
const amount = period === "monthly" ? tier.monthly : tier.annual;
const unit = period === "monthly" ? "/month" : "/year";
const isExternal = tier.cta.href.startsWith("http");
return (
<div
key={tier.id}
className={`flex flex-col rounded-2xl border bg-card/50 backdrop-blur-sm p-7 ${
tier.highlighted
? "border-2 border-accent/50 shadow-[0_8px_40px_hsl(43_60%_50%/0.1)] md:-mt-2"
: "border-border"
}`}
>
<div className="mb-1 flex items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-foreground">
{tier.name}
</h2>
{tier.badge ? (
<span
className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${
tier.highlighted
? "bg-accent/15 text-accent"
: "border border-border/60 text-muted-foreground"
}`}
>
{tier.badge}
</span>
) : null}
</div>
<p className="mb-5 min-h-[2.5rem] text-sm text-muted-foreground">
{tier.tagline}
</p>
<div className="mb-1 flex items-baseline gap-1">
<span className="text-4xl font-bold tracking-tight text-foreground">
${amount}
</span>
{!isFree ? (
<span className="text-sm text-muted-foreground">{unit}</span>
) : null}
</div>
<p className="mb-6 min-h-[1rem] text-xs text-muted-foreground/70">
{isFree
? tier.priceNote
: period === "annual"
? tier.priceNote
: `Billed monthly · ${tier.priceNote ?? ""}`}
</p>
<a
href={tier.cta.href}
{...(isExternal
? {target: "_blank", rel: "noopener noreferrer"}
: {})}
className={`mb-7 inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-semibold transition-all ${
tier.highlighted
? "bg-accent text-white shadow-[0_4px_20px_hsl(43_60%_50%/0.3)] hover:bg-accent-faint"
: "border border-border/60 bg-card/40 text-foreground hover:border-accent/40"
}`}
>
{tier.cta.label}
</a>
<ul className="space-y-3">
{tier.features.map((feature) => (
<li
key={feature}
className="flex items-start gap-3 text-sm text-foreground/90"
>
<Check className="h-4 w-4 shrink-0 text-accent mt-0.5" />
<span>{feature}</span>
</li>
))}
</ul>
</div>
);
})}
</div>
</div>
);
}
function ToggleButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={`flex items-center rounded-full px-4 py-1.5 text-sm font-medium transition-colors ${
active
? "bg-accent text-white"
: "text-muted-foreground hover:text-foreground"
}`}
>
{children}
</button>
);
}
-94
View File
@@ -1,94 +0,0 @@
import { ArrowRight, Heart } from 'lucide-react';
import { SPONSORS, type Sponsor } from '@/lib/sponsors';
export function SponsorPromo() {
if (SPONSORS.length === 0) {
return <SponsorPromoEmpty />;
}
return <SponsorStrip sponsors={SPONSORS} />;
}
function SponsorPromoEmpty() {
return (
<section className="border-t border-border py-16">
<div className="mx-auto max-w-5xl px-6">
<div className="rounded-2xl border-2 border-accent/40 bg-gradient-to-br from-card/80 to-card/40 backdrop-blur-sm p-8 md:p-10 shadow-[0_8px_40px_hsl(43_60%_50%/0.08)]">
<div className="grid md:grid-cols-[1fr_auto] items-center gap-8">
<div>
<div className="inline-flex items-center gap-2 mb-4 text-[11px] font-semibold uppercase tracking-[0.22em] text-accent">
<Heart className="h-3.5 w-3.5" />
Sponsor Voicebox
</div>
<h3 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground mb-3">
Get your logo in front of 170k+ monthly visitors.
</h3>
<p className="text-sm md:text-base text-muted-foreground leading-relaxed max-w-2xl">
Voicebox is open-source and used by creators, voice artists, podcasters,
writers, developers, accessibility users, and curious humans all over the world.
Sponsor the project and your logo lands on the homepage, in the app, in the
README, and on the sponsors page — in front of every one of them.
</p>
</div>
<div className="flex flex-col items-start md:items-end gap-2">
<a
href="/sponsors"
className="inline-flex items-center gap-2 rounded-full bg-accent px-6 py-3 text-sm font-semibold uppercase tracking-wider text-white shadow-[0_4px_20px_hsl(43_60%_50%/0.3),inset_0_2px_0_rgba(255,255,255,0.2),inset_0_-2px_0_rgba(0,0,0,0.1)] transition-all hover:bg-accent-faint whitespace-nowrap"
>
Become a sponsor
<ArrowRight className="h-4 w-4" />
</a>
<span className="text-xs text-muted-foreground/70">From $500 / month</span>
</div>
</div>
</div>
</div>
</section>
);
}
function SponsorStrip({ sponsors }: { sponsors: Sponsor[] }) {
return (
<section className="border-t border-border py-14">
<div className="mx-auto max-w-6xl px-6">
<div className="text-center mb-8">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground/80">
Sponsored by
</div>
</div>
<div className="flex flex-wrap items-center justify-center gap-5 md:gap-6">
{sponsors.map((sponsor) => (
<a
key={sponsor.name}
href={sponsor.url}
target="_blank"
rel="noopener noreferrer"
aria-label={sponsor.name}
className="group flex h-32 min-w-[260px] items-center justify-center rounded-2xl border border-border bg-card/40 backdrop-blur-sm px-10 transition-all hover:border-accent/40 hover:bg-card"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={sponsor.logoSrc}
alt={sponsor.logoAlt ?? sponsor.name}
className={`h-14 w-auto max-w-[220px] object-contain opacity-80 transition-opacity group-hover:opacity-100 ${
sponsor.invert ? 'brightness-0 invert' : ''
}`}
/>
</a>
))}
</div>
<div className="mt-8 text-center">
<a
href="/sponsors"
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
>
Become a sponsor
<ArrowRight className="h-3.5 w-3.5" />
</a>
</div>
</div>
</section>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import {Heart, Quote} from "lucide-react";
import {DONATE_URL} from "@/lib/constants";
type Testimonial = {
quote: string;
author: string;
};
/** Verbatim supporter messages from Buy Me a Coffee. */
const TESTIMONIALS: Testimonial[] = [
{
quote:
"Cloning my own voice was a snap — and now I can hear my reminders and to-dos from my digital doppelgänger. Very cool.",
author: "jimzip",
},
{
quote: "It's better than most other paid services.",
author: "Peiming Pai",
},
{
quote:
"I'm using this great tool for my multimedia course project — you made it into the classrooms!",
author: "theanoma.ly",
},
{
quote:
"This app is fantastic! I use it to learn languages and for my learning materials. Congratulations, it's great!",
author: "Kevin Serrano",
},
{
quote:
"Absolutely amazing! The learning curve was very short. Thank you for a great program and for making it free.",
author: "DJWhy",
},
{
quote: "First engine I tried, zero config. It worked! Amazing.",
author: "Fitz",
},
{
quote:
"This is great for people who are uncomfortable with advocating for themselves in public. Thanks for making it.",
author: "creativeaction.ca",
},
{
quote: "Thanks for this. It's a life-saver!",
author: "The Cowboy Movie Channel",
},
{
quote: "Fantastic open-source app!",
author: "Mitja",
},
];
export function Testimonials() {
return (
<section id="testimonials" className="border-t border-border py-24">
<div className="mx-auto max-w-6xl px-6">
{/* Header */}
<div className="text-center mb-14">
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/40 backdrop-blur-sm px-3 py-1 mb-4">
<Heart className="h-3 w-3 text-accent" />
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Loved by users
</span>
</div>
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
What people are saying
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Voicebox has passed 1M+ downloads. Here's a handful of notes from
the people using it every day.
</p>
</div>
{/* Masonry-style columns so cards flow naturally regardless of length */}
<div className="columns-1 gap-4 sm:columns-2 lg:columns-3 [&>*]:mb-4">
{TESTIMONIALS.map((t) => (
<figure
key={t.author}
className="break-inside-avoid rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5 transition-colors hover:border-accent/30"
>
<Quote className="h-4 w-4 text-accent/60 mb-3" />
<blockquote className="text-sm leading-relaxed text-foreground/90">
{t.quote}
</blockquote>
<figcaption className="mt-4 text-xs font-medium text-muted-foreground">
{t.author}
</figcaption>
</figure>
))}
</div>
{/* Attribution + soft CTA */}
<div className="mt-10 text-center">
<a
href={DONATE_URL}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground/70 hover:text-foreground transition-colors"
>
From supporters on Buy Me a Coffee →
</a>
</div>
</div>
</section>
);
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
import {ArrowUpRight, Check, Coins, Copy} from "lucide-react";
import {useState} from "react";
import {
TOKEN_CONTRACT_ADDRESS,
TOKEN_PUMP_URL,
TOKEN_TICKER,
} from "@/lib/constants";
export function TokenSection() {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(TOKEN_CONTRACT_ADDRESS);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard unavailable (e.g. insecure context) — silently no-op.
}
};
return (
<section id="token" className="border-t border-border py-24">
<div className="relative mx-auto max-w-4xl px-6">
{/* Subtle accent glow */}
<div className="pointer-events-none absolute inset-0 -z-10 flex justify-center">
<div className="h-[260px] w-[520px] rounded-full bg-accent/10 blur-[140px]" />
</div>
{/* Header */}
<div className="text-center mb-10">
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/40 backdrop-blur-sm px-3 py-1 mb-4">
<Coins className="h-3 w-3 text-accent" />
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Official token
</span>
</div>
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
{TOKEN_TICKER} on Solana
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
The official {TOKEN_TICKER} token for supporters who want to back
the project and have some fun. Voicebox is and always will be{" "}
<b className="text-foreground">free and open source</b> — the token
is entirely optional and not required to use anything here.
</p>
</div>
{/* Contract address + CTA */}
<div className="mx-auto max-w-2xl rounded-2xl border border-border bg-card/60 backdrop-blur-sm p-5 sm:p-6">
<div className="flex items-center gap-2 mb-3">
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Contract address
</span>
<span className="ml-auto inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-background/60 px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-accent" />
Solana
</span>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
{/* Address bar */}
<button
type="button"
onClick={handleCopy}
title="Copy contract address"
aria-label={
copied ? "Contract address copied" : "Copy contract address"
}
className="group flex min-w-0 flex-1 items-center gap-3 rounded-xl border border-border bg-background/60 px-4 py-3 text-left transition-colors hover:border-accent/40"
>
<code className="min-w-0 flex-1 truncate font-mono text-xs text-foreground/90 sm:text-sm">
{TOKEN_CONTRACT_ADDRESS}
</code>
{copied ? (
<span className="inline-flex shrink-0 items-center gap-1.5 text-xs font-medium text-accent">
<Check className="h-4 w-4" />
Copied
</span>
) : (
<span className="inline-flex shrink-0 items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors group-hover:text-foreground">
<Copy className="h-4 w-4" />
Copy
</span>
)}
</button>
{/* Buy CTA */}
<a
href={TOKEN_PUMP_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-accent px-5 py-3 text-sm font-semibold text-white shadow-[0_4px_20px_hsl(43_60%_50%/0.3),inset_0_2px_0_rgba(255,255,255,0.2),inset_0_-2px_0_rgba(0,0,0,0.1)] transition-all hover:bg-accent-faint"
>
Buy on pump.fun
<ArrowUpRight className="h-4 w-4" />
</a>
</div>
</div>
</div>
</section>
);
}
+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>
);
}
+48
View File
@@ -0,0 +1,48 @@
import {ArrowUpRight, Coins} from "lucide-react";
import {TOKEN_TICKER} from "@/lib/constants";
/**
* Compact teaser shown near the bottom of the landing page. The full token
* details (contract address, buy CTA, disclaimers) live on the dedicated
* /token page — this just points there.
*/
export function TokenTeaser() {
return (
<section className="border-t border-border py-20">
<div className="mx-auto max-w-4xl px-6">
<div className="relative flex flex-col items-center gap-5 overflow-hidden rounded-2xl border border-border bg-card/40 backdrop-blur-sm px-6 py-10 text-center">
{/* Subtle accent glow */}
<div className="pointer-events-none absolute inset-0 -z-10 flex justify-center">
<div className="h-[200px] w-[420px] rounded-full bg-accent/10 blur-[130px]" />
</div>
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/40 px-3 py-1">
<Coins className="h-3 w-3 text-accent" />
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Official token
</span>
</div>
<h2 className="text-2xl font-semibold tracking-tight text-foreground md:text-3xl">
{TOKEN_TICKER} on Solana
</h2>
<p className="max-w-xl text-muted-foreground">
An optional way to back the project and have some fun. Voicebox is and
always will be{" "}
<b className="text-foreground">free and open source</b> — the token is
not required to use anything here.
</p>
<a
href="/token"
className="inline-flex items-center gap-2 rounded-full bg-accent px-6 py-3 text-sm font-semibold text-white shadow-[0_4px_20px_hsl(43_60%_50%/0.3),inset_0_2px_0_rgba(255,255,255,0.2),inset_0_-2px_0_rgba(0,0,0,0.1)] transition-all hover:bg-accent-faint"
>
Learn about {TOKEN_TICKER}
<ArrowUpRight className="h-4 w-4" />
</a>
</div>
</div>
</section>
);
}
+77
View File
@@ -0,0 +1,77 @@
import {readdirSync, readFileSync} from "node:fs";
import {join} from "node:path";
import matter from "gray-matter";
import {marked} from "marked";
// Posts are authored as markdown under src/posts. This module uses the Node fs
// API and must only be imported from server components / server code — never
// from a "use client" file, or the markdown libraries leak into the bundle.
export type BlogPost = {
slug: string;
title: string;
author: string;
date: string;
tags: string[];
excerpt: string;
readingMinutes: number;
html: string;
};
export type BlogPostSummary = Omit<BlogPost, "html">;
const POSTS_DIR = join(process.cwd(), "src/posts");
function slugFromFile(file: string): string {
return file.replace(/\.md$/, "");
}
function parsePost(file: string, raw: string): BlogPost {
const {data, content} = matter(raw);
const words = content.trim().split(/\s+/).filter(Boolean).length;
return {
slug: slugFromFile(file),
title: String(data.title ?? "Untitled"),
author: String(data.author ?? "Jamie Pine"),
date:
data.date instanceof Date
? data.date.toISOString().slice(0, 10)
: String(data.date ?? ""),
tags: Array.isArray(data.tags) ? data.tags.map(String) : [],
excerpt: String(data.excerpt ?? ""),
readingMinutes: Math.max(1, Math.ceil(words / 220)),
html: marked.parse(content, {async: false}) as string,
};
}
export function loadAllPosts(): BlogPost[] {
let files: string[] = [];
try {
files = readdirSync(POSTS_DIR).filter((f) => f.endsWith(".md"));
} catch {
return []; // posts dir doesn't exist yet — treat as empty
}
return files
.map((file) => parsePost(file, readFileSync(join(POSTS_DIR, file), "utf8")))
.sort((a, b) => (a.date < b.date ? 1 : -1));
}
export function listPosts(): BlogPostSummary[] {
return loadAllPosts().map(({html: _html, ...summary}) => summary);
}
export function getPost(slug: string): BlogPost | null {
return loadAllPosts().find((post) => post.slug === slug) ?? null;
}
export function formatDate(date: string): string {
if (!date) return "";
const d = new Date(`${date}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return date;
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
});
}
+150
View File
@@ -8,6 +8,156 @@ export const DONATE_URL = 'https://buymeacoffee.com/jamiepine';
export const SPONSOR_CHECKOUT_URL = 'https://buy.stripe.com/eVqdRad3n16ubcqf201Jm00';
export const SPONSOR_CONTACT_EMAIL = '[email protected]';
// $VOICEBOX — the official community token on Solana
export const TOKEN_TICKER = '$VOICEBOX';
export const TOKEN_CONTRACT_ADDRESS = 'FpzZHtp5tbvz6xndEtoJHoGEWcT7cFEuscdCh9RApump';
export const TOKEN_PUMP_URL = `https://pump.fun/coin/${TOKEN_CONTRACT_ADDRESS}`;
// Solscan token page — lets anyone inspect supply, holders, and history.
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
// as "proof link pending" — fill them in as soon as the hash is available.
export interface TokenProof {
kind: 'lock' | 'burn';
label: string;
detail: string;
/** Solscan (or locker) URL proving the action. Empty = pending, shown as such. */
txUrl: string;
}
export const TOKEN_PROOFS: TokenProof[] = [
{
kind: 'lock',
label: 'Launch liquidity lock',
detail:
'Liquidity and a portion of dev holdings were locked at launch (~6.6% top holder), so the supply can be verified on-chain from day one.',
txUrl: '', // TODO(jamie): add the Solscan/locker link for the launch lock
},
{
kind: 'burn',
label: 'Buyback & burn',
detail:
'Bought $VOICEBOX back from the open market and burned it to a dead address, permanently removing it from supply.',
txUrl:
'https://solscan.io/tx/5MjK4CYMBKAewLcjdD6QkM8ctkeG2bjyQhpjNgEumkDbDtoKCVmzKcWwLWsd4QJov8hs5zbGLt3g5vVCp4CBmze5',
},
{
kind: 'burn',
label: 'Buyback & burn',
detail:
'A second buyback and burn — part of an ongoing commitment to keep buying back and reducing supply over time.',
txUrl: '', // TODO(jamie): add the Solscan link for the second burn
},
];
export const DOWNLOAD_LINKS = {
macArm: GITHUB_RELEASES_PAGE,
macIntel: GITHUB_RELEASES_PAGE,
+126
View File
@@ -0,0 +1,126 @@
// Pricing + cloud data — single source of truth for /pricing and /cloud.
//
// DRAFT: the cloud service is not live yet and the pricing model is not final.
// $12/yr is a launch/intro figure; tier limits below are placeholders to shape
// the page — tune them once the model is decided. Keeping it all here means the
// pages update by editing this file only.
export const CLOUD_STATUS = "coming-soon" as const; // → flips to "live" at launch
export const CLOUD_PRICE_YEARLY = 12; // launch price for the Cloud tier (USD/yr)
/** Where "get notified" CTAs point until there's a real signup flow. */
export const CLOUD_NOTIFY_URL = "https://x.com/VoiceboxAI";
export type BillingPeriod = "monthly" | "annual";
export interface PricingTier {
id: string;
name: string;
tagline: string;
/** USD per month. 0 = free. */
monthly: number;
/** USD per year. 0 = free. Set below 12×monthly to reward annual. */
annual: number;
priceNote?: string;
/** Visually emphasize this tier (the headline plan). */
highlighted?: boolean;
/** Status pill, e.g. "Available now" / "Coming soon". */
badge?: string;
cta: {label: string; href: string};
features: string[];
}
export const PRICING_TIERS: PricingTier[] = [
{
id: "local",
name: "Local",
tagline: "The full app, free forever.",
monthly: 0,
annual: 0,
priceNote: "No account required",
badge: "Available now",
cta: {label: "Download Voicebox", href: "/download"},
features: [
"Voice cloning across every TTS engine",
"Dictation & Capture (audio kept alongside transcript)",
"MCP / agent integration & personalities",
"Unlimited local generations & captures",
"100% open source, runs entirely on your machine",
],
},
{
id: "cloud",
name: "Cloud",
tagline: "Backup & sync for everything you make.",
monthly: 2, // placeholder
annual: CLOUD_PRICE_YEARLY, // $12 launch price (≈50% off monthly)
priceNote: "Launch price · free for $VOICEBOX holders",
highlighted: true,
badge: "Coming soon",
cta: {label: "Get notified", href: CLOUD_NOTIFY_URL},
features: [
"Everything in Local",
"End-to-end encrypted backup — we can't read it",
"Sync across desktop & mobile",
"25 GB encrypted storage", // placeholder
"Up to 5 devices", // placeholder
"30-day version history", // placeholder
],
},
{
id: "studio",
name: "Studio",
tagline: "For power users and professionals.",
monthly: 6, // placeholder
annual: 48, // placeholder
priceNote: "Placeholder — pricing TBD",
badge: "Coming soon",
cta: {label: "Get notified", href: CLOUD_NOTIFY_URL},
features: [
"Everything in Cloud",
"250 GB encrypted storage", // placeholder
"Unlimited devices", // placeholder
"1-year version history", // placeholder
"Priority support",
],
},
];
/** Annual savings vs paying 12× the monthly price, as a whole percent (0 if none). */
export function annualSavingsPercent(tier: PricingTier): number {
if (tier.monthly <= 0 || tier.annual <= 0) return 0;
const full = tier.monthly * 12;
return Math.max(0, Math.round((1 - tier.annual / full) * 100));
}
export interface CloudFeature {
title: string;
body: string;
}
export const CLOUD_FEATURES: CloudFeature[] = [
{
title: "End-to-end encrypted",
body: "Everything is encrypted on your device before it leaves it. The server only ever stores opaque blobs — it literally cannot read your voices, generations, or captures.",
},
{
title: "Back up everything",
body: "Voice profiles, generations, and captures — including the original audio Voicebox keeps alongside each transcript — safe off your machine.",
},
{
title: "Sync across devices",
body: "Pick up on desktop or mobile. Your library follows you, encrypted in transit and at rest, with a per-device key.",
},
{
title: "Generate on the go",
body: "The mobile app pairs with your library so you can dictate and generate anywhere, then find it all waiting back at your desk.",
},
{
title: "You hold the keys",
body: "A recovery phrase you control is the root of your encryption. Lose your devices and you can still restore — but no one else, including us, ever can.",
},
{
title: "Optional & local-first",
body: "Voicebox works fully offline without an account. Cloud is an add-on for when you want backup and sync — never a requirement.",
},
];
-12
View File
@@ -1,12 +0,0 @@
export type Sponsor = {
name: string;
url: string;
logoSrc: string;
logoAlt?: string;
tagline?: string;
/** Set true for solid-black logos that need to render white on the dark theme. */
invert?: boolean;
};
export const SPONSORS: Sponsor[] = [
];
+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;
}
@@ -0,0 +1,49 @@
---
title: "Why Voicebox has a token"
author: Jamie Pine
date: 2026-06-27
tags: [Token, Transparency]
excerpt: "Voicebox grew to over a million downloads with zero marketing — but donations never made full-time work sustainable. Here's the honest reasoning behind $VOICEBOX, and the commitments that come with it."
---
Voicebox started as a one-day experiment. The Qwen3-TTS model dropped, I wanted to try it, so I built a small CLI to load voice profiles and generate speech. As a designer I already had the interface in my head — profiles as cards, a floating generation box, a player spanning the bottom of the app, a gold accent and a microphone logo to make it feel like a real studio. First working version in a day. Open sourced in three.
I did no marketing. None, to this day. But Reddit found it, creators started making tutorials, and the "ElevenLabs just lost its moat" posts began. It crossed a million downloads and is closing in on Spacedrive's GitHub star count — entirely organically. Along the way it became two things at once: a free alternative to ElevenLabs for voice cloning, and a free alternative to Wispr Flow for dictation.
So a fair question keeps coming up: if it's this successful, why a token? Why not just put it behind a subscription, or turn on GitHub Sponsors and call it a day?
## The honest version
I could have built this as a cloud app with a monthly subscription and probably done well. I chose open source instead, and I'd make the same call again. I don't think it would have grown like this behind a paywall — people trust it more when they can read the code and run it entirely on their own machine, it earns a ton of organic exposure for free, and I get the community's help making it work across the endless combinations of GPUs and operating systems I could never test alone.
But open source doesn't pay rent. The donation button on the site brings in roughly $200–300 a month. I didn't expect GitHub Sponsors to move that number much. The old sponsor program made almost nothing. I love this project and I want to work on it full-time — and for a while I couldn't justify it.
The token changed that overnight. It's the closest thing I've had to a salary in a long time, and it let me go full-time on Voicebox immediately. That's not a hypothetical — it's already happening, and you'll see it in the commits, the releases, and the posts on [@VoiceboxAI](https://x.com/VoiceboxAI).
## What the token is — and what it isn't
**$VOICEBOX is entirely optional.** Voicebox is, and always will be, free, open source, and local-first. Every feature works without ever touching the token. It exists for supporters who want to back the project and have some fun — nothing here is gated behind it, and nothing here is financial advice.
It's also the **only** token I will ever make. My other projects, including Spacedrive, will never have an official token. I thought carefully about this: the community was clearly most interested in Voicebox because of its existing traction, and if I'm going to have a token at all, I'd rather deploy it myself so I can lock liquidity and be accountable for its trajectory — rather than have an anonymous community coin I can't control. As of now I no longer claim fees on any other community tokens, either.
## Don't trust — verify
A token only earns trust through actions you can check on-chain. So:
- Liquidity and a portion of dev holdings were **locked at launch**, visible from day one.
- I've done **buyback and burns** — buying $VOICEBOX back from the market and burning it to a dead address, permanently. I've done this more than once, and I'll keep doing it.
- The plan is a balanced mix: lock more for trust, burn periodically, but keep enough flexibility to fund real expenses and add liquidity when it helps.
Every one of these is linked on the [token page](/token), so you never have to take my word for it. Verify the contract address there before you do anything — impersonators are common.
## What the money actually builds
Going full-time means the roadmap moves faster. The near-term priorities:
- **The mobile app.** I use the prototype every day for dictation on the go. It does both cloning and dictation, and it's nearly ready to ship.
- **Encrypted cloud backup & sync.** Generate on the go, keep your captures and generations safe, pick up on any device. This will be a paid service — around $12/year — and **free for token holders**.
- **More engines and broader hardware support.** More TTS engines, better GPU and OS coverage, so Voicebox runs great on whatever you've got.
That's the whole pitch. The app stays free forever, the token is an optional way to support it and unlock the cloud service, and the proof of good faith is on-chain and in the changelog. If you want to back the project, the [token page](/token) is here — but starring the repo and telling a friend helps just as much.
Thanks for being here. Now back to shipping.
-41
View File
@@ -1,41 +0,0 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android
-139
View File
@@ -1,139 +0,0 @@
# Voicebox Mobile — V1 plan
A companion app for the Voicebox desktop. iPhone-first, dictate-anywhere, Captures as the hero. Talks to a paired desktop over Tailscale or LAN with end-to-end encryption.
V1 is entirely local — no cloud, no account. The device key minted during pairing is the root of the user's lifetime encryption identity and gets reused by the cloud phases that follow; see [`docs/plans/CLOUD_ROADMAP.md`](../docs/plans/CLOUD_ROADMAP.md) for the post-mobile arc (backup & sync → private inference → marketplace).
---
## Repo layout
- New `mobile/` at repo root, sibling to `app/`, `backend/`, `tauri/`, `landing/`, `web/`
- Standalone Expo project — not a Bun workspace member (avoids React/Tauri/Bun version drag)
- Type-sharing via OpenAPI: generate `mobile/src/api/types.ts` from backend's `/openapi.json`, commit it, regenerate on demand
- Branch off `main` once 0.5.0 ships → `feat/mobile-app`
## Stack (verified 2026-04-25)
### The SDK 54 vs SDK 55 fork
Expo SDK 55 is the current `latest` (`[email protected]`, React Native 0.83, React 19.2, New Architecture only — Legacy Architecture was dropped in 55). It also ships Expo Router v7, Hermes v1 with bytecode diffing, and `expo-brownfield`. But: **NativeWind v5 is the only NativeWind line that targets SDK 55, and v5 is still pre-release** (`5.0.0-preview.3`, with explicit "not intended for production use" warning in the v5 docs). NativeWind v4 stable (`4.2.3`) is paired with SDK 54.
Two real options:
- **Option A — Ship-fast (recommended for V1):** Expo SDK 54 + NativeWind v4.2.3. Both stable, official pairing, well-documented. Loses SDK 55's bytecode-diff updates and Expo Router v7 sugar but everything works.
- **Option B — Cutting-edge:** Expo SDK 55.0.17 + NativeWind 5.0.0-preview.3. Latest everything, but the NativeWind v5 maintainer explicitly says it's for experimentation. More breakage during dev, brittle CI.
Recommendation: **Option A**. We're trying to ship a companion app, not stress-test pre-release styling layers. We can bump to SDK 55 + NativeWind v5 once both stabilize (Expo targets stable SDK 55 mid-2026).
### Pinned versions (assuming Option A)
| Package | Version | Notes |
| --- | --- | --- |
| `expo` | `~54` (latest 54.x) | New Architecture default since SDK 51 |
| `expo-router` | bundled with SDK | file-based, typed routes |
| `nativewind` | `4.2.3` | Tailwind class parity with `app/` |
| `expo-audio` | `~54` (bundled with SDK) | stable in SDK 54+, replaces `expo-av` recording |
| `@shopify/react-native-skia` | `2.6.2` | live waveform; WaveSurfer is DOM-only |
| `expo-camera` | `~54` (bundled) | QR scan (barcode scanning built in) |
| `expo-secure-store` | `~54` (bundled) | paired device key |
| `react-native-reanimated` | `4.3.0` | rewritten for new arch |
| `react-native-gesture-handler` | `2.31.1` | |
| `zustand` | `5.0.12` | mirrors desktop |
| `@tanstack/react-query` | `5.100.5` | mirrors desktop |
(Versions for `expo-*` packages are managed by `npx expo install`, which picks the patch that matches the SDK — don't pin them by hand.)
### Other stack decisions
- **TypeScript strict**
- Theme tokens copied straight from `app/`'s shadcn theme (`hsl(43 60% 50%)` for the gold accent, dark surfaces match)
- **EAS Build** + **Dev Client** from day one — Skia and SecureStore push us off Expo Go
## Pairing & transport — Tailscale-friendly
1. Desktop: new **Settings → Mobile** with "Pair device" → renders QR + 6-digit fallback
2. QR payload: `voicebox://pair?host=<url>&secret=<b64>&fp=<sha256>`
- `host` = whatever address the user can reach: LAN IP (`192.168.x.x:17493`), Tailscale 100.x address, or MagicDNS name (`mac.tail-xxxx.ts.net:17493`) — Tailscale Just Works with zero extra code
- `secret` = one-time pairing token; mobile exchanges it for a long-lived device key on first request
- `fp` = self-signed cert fingerprint we mint at pair time, pinned on mobile
3. Auth: bearer token + XChaCha20-Poly1305 payload encryption with HKDF per-session keys — E2E layer above HTTP, survives any future cloud relay swap
## Backend additions (desktop, separate PR before mobile work)
- `POST /pair/init` — mint pairing token, return QR payload
- `POST /pair/complete` — exchange token for device-bound long-lived key, persist `paired_devices` row
- `GET/DELETE /devices` — Settings → Mobile lists & revokes paired devices, with `last_seen_at`
- Bearer middleware on `/generate`, `/transcribe`, `/profiles`, `/captures`, `/speak` (loopback callers stay unauthenticated as today; paired-device callers use the bearer)
- `/captures` upload accepts `m4a` (expo-audio's iOS default) in addition to existing formats
## Screens
### First-run
Pair flow: scan QR → confirm desktop name + fingerprint → store creds in SecureStore.
### Tab 1 — Generate
- Profile cards horizontal scroll (top)
- Recent generations list (middle) — tap to play, long-press for version picker / regenerate / share
- Floating generate box (bottom): text input + engine indicator + speak button
### Tab 2 — Voices
- List of profiles (cloned + presets), grouped by engine compatibility
- Tap to inspect: samples (Skia waveform thumbs), language, last used
- **No** profile creation in V1
### Tab 3 — Captures (the hero)
- **Big gold mic button** bottom-front-and-center — same accent as the sponsor CTA
- Tap-to-toggle in V1 (push-to-talk fights iOS gestures, defer)
- Live mic waveform via Skia, amplitude polled at 30 Hz, scrolling buffer
- Transcript text area above the waveform — empty during recording, populated on stop
- State pill at top: `recording → uploading → transcribing → refining → done` (mirrors desktop pill semantics)
- Captures list below: each row has a Skia mini-waveform, tap to expand, scrub, edit transcript inline, "Play as voice profile"
- **Audio preserved + downloadable** — visually obvious in the UI; this is the USP
- Schema/UI built so resumable capture can land later without re-architecting (no "one capture = one continuous take" assumptions baked in)
## Out of scope for V1 (deliberately)
Stories editor · Voice profile creation / sample recording · Effects editor · Personality LLM controls · Streaming transcription (lands with resumable capture in V2) · Settings beyond pairing · Android (pipeline supports it; QA focus iOS first)
## V2+ candidates
- **Resumable capture** — pause/resume that actually appends audio AND transcript (Apple Voice Notes drops the second-half transcript on resume; Voicebox shouldn't)
- **Document mode** — refinement LLM writes/edits a markdown document live as you speak, including dictated edits ("change the second bullet to…")
- **Streaming transcription** — partial transcripts during recording
- **Voice profile creation on mobile** — record samples directly
- **Android**
- **Cloud relay** — once the Voicebox platform exists, the same E2E layer rides over a relay so pairing isn't tied to Tailscale/LAN
## Build/dev workflow
```bash
cd mobile
bun install
bunx expo prebuild # generate native projects (Skia + SecureStore need it)
bunx expo run:ios # device on the same Tailnet
eas build --profile development # distributable Dev Client for TestFlight
```
Bundle ID: `sh.voicebox.mobile` (need to register in App Store Connect).
## Order of attack
1. Backend: pairing endpoints + bearer middleware (desktop PR, can land before mobile)
2. Mobile: Expo scaffold + NativeWind + theme tokens + Pair screen
3. Mobile: Captures tab end-to-end — validates transport, E2E layer, audio upload, waveform, pill states all in one flow
4. Mobile: Generate tab
5. Mobile: Voices tab
6. EAS dev build + TestFlight internal track
## Open questions
1. **iOS only V1?** Default: yes.
2. **NativeWind or hand-rolled styles?** Default: NativeWind — class parity with `app/` is worth the small bundler tax.
3. **Crib patterns from the Spacedrive mobile app first**, or start clean from current Expo docs?
4. **Bundle ID + display name** — confirm `sh.voicebox.mobile` / "Voicebox" before EAS setup.
5. **Pair screen UX** — QR-only, or always offer the 6-digit code as an a11y/fallback?
-44
View File
@@ -1,44 +0,0 @@
{
"expo": {
"name": "Voicebox",
"slug": "voicebox",
"scheme": "voicebox",
"version": "0.1.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "dark",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#0F0F0F"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "sh.voicebox.mobile"
},
"android": {
"package": "sh.voicebox.mobile",
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0F0F0F"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
"expo-secure-store",
[
"expo-camera",
{
"cameraPermission": "Voicebox uses the camera to scan the pairing QR shown by your desktop."
}
],
"expo-audio"
]
}
}
-56
View File
@@ -1,56 +0,0 @@
import { Redirect, Tabs } from 'expo-router';
import { Mic, PenTool, Users } from 'lucide-react-native';
import { View } from 'react-native';
import { colors } from '@/lib/colors';
import { useSession } from '@/lib/session';
export default function TabsLayout() {
const { session, hydrated } = useSession();
// Wait for SecureStore hydration before deciding where to send the user.
// Without this we'd briefly render the tabs against a null session and
// every authenticated query would fire with no bearer.
if (!hydrated) {
return <View style={{ flex: 1, backgroundColor: colors.background }} />;
}
if (!session) return <Redirect href="/welcome" />;
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: {
backgroundColor: colors.card,
borderTopColor: colors.border,
borderTopWidth: 1,
},
tabBarActiveTintColor: colors.accent,
tabBarInactiveTintColor: colors.mutedForeground,
tabBarLabelStyle: { fontSize: 11, fontWeight: '600' },
sceneStyle: { backgroundColor: colors.background },
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Captures',
tabBarIcon: ({ color, size }) => <Mic size={size - 2} color={color} />,
}}
/>
<Tabs.Screen
name="generate"
options={{
title: 'Generate',
tabBarIcon: ({ color, size }) => <PenTool size={size - 2} color={color} />,
}}
/>
<Tabs.Screen
name="voices"
options={{
title: 'Voices',
tabBarIcon: ({ color, size }) => <Users size={size - 2} color={color} />,
}}
/>
</Tabs>
);
}
-323
View File
@@ -1,323 +0,0 @@
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
import { Loader2, Pause, Play, Sparkles, Volume2 } from 'lucide-react-native';
import { useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
FlatList,
Pressable,
RefreshControl,
ScrollView,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Button } from '@/components/ui/Button';
import {
authHeaders,
buildGenerationAudioUrl,
type HistoryResponse,
type VoiceProfileResponse,
} from '@/lib/api';
import { colors } from '@/lib/colors';
import { useGenerate, useGenerationPolling, useHistory, useProfiles } from '@/lib/hooks';
import { useSession } from '@/lib/session';
export default function GenerateTab() {
const session = useSession((s) => s.session);
const profiles = useProfiles();
const history = useHistory(20);
const generateMutation = useGenerate();
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
const [text, setText] = useState('');
const [pendingId, setPendingId] = useState<string | null>(null);
const [nowPlayingId, setNowPlayingId] = useState<string | null>(null);
const polling = useGenerationPolling(pendingId);
const playingSource = useMemo(() => {
if (!session || !nowPlayingId) return null;
return {
uri: buildGenerationAudioUrl(session, nowPlayingId),
headers: authHeaders(session),
};
}, [session, nowPlayingId]);
const player = useAudioPlayer(playingSource ?? null);
const playerStatus = useAudioPlayerStatus(player);
// Default-select first profile when the list arrives.
useEffect(() => {
if (selectedProfileId === null && profiles.data && profiles.data.length > 0) {
setSelectedProfileId(profiles.data[0].id);
}
}, [profiles.data, selectedProfileId]);
// When the polled generation completes, auto-play it and clear pending.
useEffect(() => {
const data = polling.data;
if (!data) return;
if (data.status === 'completed') {
setNowPlayingId(data.id);
setPendingId(null);
} else if (data.status === 'failed') {
setPendingId(null);
}
}, [polling.data]);
// Auto-play when a new source comes in.
useEffect(() => {
if (!playingSource) return;
const t = window.setTimeout(() => {
try {
player.play();
} catch {
// ignore — player may not be ready yet
}
}, 100);
return () => window.clearTimeout(t);
}, [playingSource, player]);
const selectedProfile = profiles.data?.find((p) => p.id === selectedProfileId) ?? null;
const isGenerating =
generateMutation.isPending ||
(pendingId !== null && polling.data?.status !== 'completed');
function handleGenerate() {
if (!selectedProfile || !text.trim() || isGenerating) return;
generateMutation.mutate(
{
profile_id: selectedProfile.id,
text: text.trim(),
language: selectedProfile.language,
engine: selectedProfile.default_engine ?? undefined,
},
{
onSuccess: (gen) => {
setPendingId(gen.id);
setText('');
},
},
);
}
function handlePlayHistory(item: HistoryResponse) {
if (item.status !== 'completed') return;
if (nowPlayingId === item.id) {
// Toggle play/pause
if (playerStatus.playing) player.pause();
else player.play();
return;
}
setNowPlayingId(item.id);
}
return (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
{/* Header */}
<View className="px-6 py-3">
<Text className="text-foreground text-2xl font-bold tracking-tight">Generate</Text>
</View>
{/* Profile picker */}
<View className="pb-3">
{profiles.isLoading ? (
<View className="px-6 h-24 justify-center">
<ActivityIndicator color={colors.accent} />
</View>
) : profiles.data && profiles.data.length > 0 ? (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 24, gap: 10 }}
>
{profiles.data.map((p) => (
<ProfileCard
key={p.id}
profile={p}
selected={p.id === selectedProfileId}
onPress={() => setSelectedProfileId(p.id)}
/>
))}
</ScrollView>
) : (
<View className="px-6 py-4">
<Text className="text-muted-foreground text-sm">
No profiles found on this desktop. Create one in Voicebox first.
</Text>
</View>
)}
</View>
{/* Text input */}
<View className="px-6 pb-3">
<View className="rounded-2xl border border-border bg-card p-4 gap-3">
<TextInput
value={text}
onChangeText={setText}
placeholder={
selectedProfile
? `Speak as ${selectedProfile.name}…`
: 'Pick a voice above'
}
placeholderTextColor={colors.mutedForeground}
multiline
editable={!!selectedProfile && !isGenerating}
className="text-foreground text-base min-h-[80px]"
style={{ textAlignVertical: 'top' }}
/>
<View className="flex-row items-center justify-between">
<Text className="text-muted-foreground text-xs">
{text.length > 0 ? `${text.length} chars` : ' '}
</Text>
<Button
label={isGenerating ? 'Generating' : 'Speak'}
size="sm"
loading={isGenerating}
disabled={!selectedProfile || !text.trim() || isGenerating}
onPress={handleGenerate}
leftSlot={
isGenerating ? null : <Sparkles size={14} color="#F2F2F2" />
}
/>
</View>
{generateMutation.isError ? (
<Text className="text-destructive text-xs">
{(generateMutation.error as Error)?.message ?? 'Generate failed'}
</Text>
) : null}
</View>
</View>
{/* Recent generations */}
<View className="px-6 pt-2 pb-1">
<Text className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
Recent
</Text>
</View>
<FlatList
data={history.data?.items ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingHorizontal: 24, paddingBottom: 80 }}
ItemSeparatorComponent={() => <View className="h-2" />}
refreshControl={
<RefreshControl
refreshing={history.isRefetching && !history.isLoading}
onRefresh={() => history.refetch()}
tintColor={colors.accent}
/>
}
ListEmptyComponent={
history.isLoading ? (
<View className="pt-6 items-center">
<ActivityIndicator color={colors.accent} />
</View>
) : (
<Text className="text-muted-foreground text-sm pt-4">
Nothing yet — your generations will appear here.
</Text>
)
}
renderItem={({ item }) => (
<HistoryRow
item={item}
isActive={nowPlayingId === item.id}
isPlaying={nowPlayingId === item.id && playerStatus.playing}
onPress={() => handlePlayHistory(item)}
/>
)}
/>
</SafeAreaView>
);
}
function ProfileCard({
profile,
selected,
onPress,
}: {
profile: VoiceProfileResponse;
selected: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
className={`w-[140px] rounded-2xl px-4 py-3 border ${
selected ? 'border-accent bg-accent/10' : 'border-border bg-card'
}`}
accessibilityRole="button"
accessibilityLabel={`Voice ${profile.name}`}
accessibilityState={{ selected }}
>
<View
className={`h-9 w-9 rounded-full mb-2 items-center justify-center ${
selected ? 'bg-accent' : 'bg-muted'
}`}
>
<Volume2 size={16} color={selected ? '#F2F2F2' : colors.mutedForeground} />
</View>
<Text
className="text-foreground text-sm font-semibold"
numberOfLines={1}
>
{profile.name}
</Text>
<Text className="text-muted-foreground text-[11px]" numberOfLines={1}>
{profile.language.toUpperCase()}
{profile.voice_type === 'preset' ? ' · preset' : ''}
</Text>
</Pressable>
);
}
function HistoryRow({
item,
isActive,
isPlaying,
onPress,
}: {
item: HistoryResponse;
isActive: boolean;
isPlaying: boolean;
onPress: () => void;
}) {
const isCompleted = item.status === 'completed';
const isFailed = item.status === 'failed';
const isPending = !isCompleted && !isFailed;
return (
<Pressable
onPress={onPress}
disabled={!isCompleted}
className={`flex-row items-center gap-3 rounded-xl px-3 py-3 border ${
isActive
? 'border-accent/50 bg-accent/10'
: 'border-border bg-card'
} ${!isCompleted ? 'opacity-70' : ''}`}
>
<View
className={`h-10 w-10 rounded-full items-center justify-center ${
isActive ? 'bg-accent' : 'bg-muted'
}`}
>
{isPending ? (
<Loader2 size={16} color={colors.mutedForeground} />
) : isPlaying ? (
<Pause size={16} color="#F2F2F2" fill="#F2F2F2" />
) : (
<Play size={16} color={isActive ? '#F2F2F2' : colors.mutedForeground} fill={isActive ? '#F2F2F2' : 'transparent'} />
)}
</View>
<View className="flex-1 min-w-0">
<Text className="text-foreground text-sm" numberOfLines={2}>
{item.text}
</Text>
<Text className="text-muted-foreground text-[11px] mt-0.5">
{item.profile_name}
{item.duration ? ` · ${item.duration.toFixed(1)}s` : ''}
{isFailed ? ' · failed' : ''}
{isPending ? ' · generating…' : ''}
</Text>
</View>
</Pressable>
);
}
-394
View File
@@ -1,394 +0,0 @@
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
import { useRouter } from 'expo-router';
import { LogOut, Mic, Pause, Play, Square, Trash2 } from 'lucide-react-native';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Animated,
FlatList,
Pressable,
RefreshControl,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { LiveWaveform } from '@/components/ui/LiveWaveform';
import {
authHeaders,
buildCaptureAudioUrl,
deleteCapture,
type CaptureResponse,
} from '@/lib/api';
import { colors } from '@/lib/colors';
import { useDictation, type DictationPhase } from '@/lib/dictation';
import { useCaptures, useInvalidateCaptures } from '@/lib/hooks';
import { useSession } from '@/lib/session';
function formatRelative(iso: string): string {
const sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (sec < 60) return 'just now';
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
if (sec < 86400 * 7) return `${Math.floor(sec / 86400)}d ago`;
return new Date(iso).toLocaleDateString();
}
function formatDuration(ms: number | null): string {
if (!ms) return '';
const total = Math.round(ms / 1000);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function formatTime(seconds: number): string {
const total = Math.max(0, Math.floor(seconds));
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function pillCopyFor(phase: DictationPhase, durationSec: number): string {
if (phase === 'recording') {
const m = Math.floor(durationSec / 60);
const s = durationSec % 60;
return `Recording ${m}:${s.toString().padStart(2, '0')}`;
}
if (phase === 'processing') return 'Transcribing…';
if (phase === 'error') return 'Error';
return '';
}
export default function CapturesTab() {
const router = useRouter();
const session = useSession((s) => s.session);
const clearSession = useSession((s) => s.clear);
const captures = useCaptures();
const invalidateCaptures = useInvalidateCaptures();
const dictation = useDictation();
const [expandedId, setExpandedId] = useState<string | null>(null);
// Single shared player instance for whichever capture is currently expanded.
const playerSource = useMemo(() => {
if (!session || !expandedId) return null;
return {
uri: buildCaptureAudioUrl(session, expandedId),
headers: authHeaders(session),
};
}, [session, expandedId]);
const player = useAudioPlayer(playerSource ?? null);
const status = useAudioPlayerStatus(player);
// Pulse the mic button while recording.
const pulse = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (dictation.phase !== 'recording') {
pulse.setValue(1);
return;
}
const loop = Animated.loop(
Animated.sequence([
Animated.timing(pulse, { toValue: 1.12, duration: 700, useNativeDriver: true }),
Animated.timing(pulse, { toValue: 1, duration: 700, useNativeDriver: true }),
]),
);
loop.start();
return () => loop.stop();
}, [dictation.phase, pulse]);
async function handleMicPress() {
if (dictation.phase === 'recording') {
const result = await dictation.stop();
if (result) invalidateCaptures();
return;
}
if (dictation.phase === 'processing') return;
void dictation.start();
}
async function handleSignOut() {
await clearSession();
router.replace('/welcome');
}
function toggleExpanded(captureId: string) {
if (expandedId === captureId) {
setExpandedId(null);
} else {
setExpandedId(captureId);
}
}
async function handleDelete(capture: CaptureResponse) {
if (!session) return;
if (expandedId === capture.id) setExpandedId(null);
try {
await deleteCapture(session, capture.id);
invalidateCaptures();
} catch (e) {
console.warn('Delete failed', e);
}
}
const items = captures.data?.items ?? [];
return (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
{/* Header */}
<View className="flex-row items-center justify-between px-6 py-3">
<Text className="text-foreground text-2xl font-bold tracking-tight">Captures</Text>
<Pressable
onPress={handleSignOut}
hitSlop={12}
accessibilityLabel="Sign out"
accessibilityRole="button"
className="h-9 w-9 rounded-full items-center justify-center"
>
<LogOut size={18} color={colors.mutedForeground} />
</Pressable>
</View>
{/* Recording HUD: live waveform + timer while recording. Falls back to a
compact pill for processing/error so we don't reserve the screen
space while transcription runs. */}
{dictation.phase === 'recording' ? (
<View className="px-6 pb-2 gap-2">
<View className="rounded-2xl bg-destructive/10 border border-destructive/30 px-4 py-3 gap-2">
<View className="flex-row items-center justify-between">
<View className="flex-row items-center gap-2">
<View className="h-2 w-2 rounded-full bg-destructive" />
<Text className="text-foreground text-xs font-semibold uppercase tracking-widest">
Recording
</Text>
</View>
<Text
className="text-foreground text-xs font-semibold"
style={{ fontFamily: 'Menlo' }}
>
{`${Math.floor(dictation.durationSec / 60)}:${(dictation.durationSec % 60).toString().padStart(2, '0')}`}
</Text>
</View>
<LiveWaveform active={true} levelDb={dictation.meteringDb} />
</View>
</View>
) : dictation.phase !== 'idle' ? (
<View className="px-6 pb-2">
<View
className={`self-center flex-row items-center gap-2 px-4 py-1.5 rounded-full ${
dictation.phase === 'error' ? 'bg-destructive/15' : 'bg-accent/15'
}`}
>
{dictation.phase === 'processing' ? (
<ActivityIndicator size="small" color={colors.accent} />
) : null}
<Text
className={`text-xs font-semibold ${
dictation.phase === 'error' ? 'text-destructive' : 'text-foreground'
}`}
>
{pillCopyFor(dictation.phase, dictation.durationSec)}
</Text>
</View>
{dictation.error ? (
<Text className="text-destructive text-xs text-center mt-2">{dictation.error}</Text>
) : null}
</View>
) : null}
{/* List */}
<FlatList
data={items}
keyExtractor={(c) => c.id}
contentContainerStyle={{ paddingHorizontal: 24, paddingTop: 8, paddingBottom: 180 }}
ItemSeparatorComponent={() => <View className="h-3" />}
refreshControl={
<RefreshControl
refreshing={captures.isRefetching && !captures.isLoading}
onRefresh={() => captures.refetch()}
tintColor={colors.accent}
/>
}
ListEmptyComponent={
captures.isLoading ? (
<View className="items-center pt-16">
<ActivityIndicator color={colors.accent} />
</View>
) : (
<EmptyState />
)
}
renderItem={({ item }) => (
<CaptureRow
capture={item}
expanded={expandedId === item.id}
isPlaying={expandedId === item.id && status.playing}
currentTime={expandedId === item.id ? status.currentTime ?? 0 : 0}
duration={
expandedId === item.id
? status.duration ?? (item.duration_ms ? item.duration_ms / 1000 : 0)
: item.duration_ms
? item.duration_ms / 1000
: 0
}
onPress={() => toggleExpanded(item.id)}
onPlayPause={() => {
if (status.playing) player.pause();
else player.play();
}}
onDelete={() => handleDelete(item)}
/>
)}
/>
{/* Floating mic */}
<View pointerEvents="box-none" className="absolute left-0 right-0" style={{ bottom: 24 }}>
<View className="items-center">
<Animated.View style={{ transform: [{ scale: pulse }] }}>
<Pressable
onPress={handleMicPress}
accessibilityRole="button"
accessibilityLabel={
dictation.phase === 'recording' ? 'Stop recording' : 'Start recording'
}
disabled={dictation.phase === 'processing'}
style={({ pressed }) => ({
width: 78,
height: 78,
borderRadius: 39,
backgroundColor:
dictation.phase === 'recording' ? colors.destructive : colors.accent,
alignItems: 'center',
justifyContent: 'center',
opacity: pressed ? 0.85 : 1,
shadowColor:
dictation.phase === 'recording' ? colors.destructive : colors.accent,
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.4,
shadowRadius: 18,
elevation: 10,
})}
>
{dictation.phase === 'processing' ? (
<ActivityIndicator color="#fff" />
) : dictation.phase === 'recording' ? (
<Square size={28} color="#fff" fill="#fff" />
) : (
<Mic size={32} color="#fff" />
)}
</Pressable>
</Animated.View>
</View>
</View>
</SafeAreaView>
);
}
function CaptureRow({
capture,
expanded,
isPlaying,
currentTime,
duration,
onPress,
onPlayPause,
onDelete,
}: {
capture: CaptureResponse;
expanded: boolean;
isPlaying: boolean;
currentTime: number;
duration: number;
onPress: () => void;
onPlayPause: () => void;
onDelete: () => void;
}) {
const transcript = capture.transcript_refined?.trim() || capture.transcript_raw.trim();
const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0;
return (
<Pressable
onPress={onPress}
className={`rounded-2xl border px-4 py-3 gap-2 ${
expanded ? 'border-accent/50 bg-accent/5' : 'border-border bg-card'
}`}
>
<View className="flex-row items-center justify-between">
<Text className="text-muted-foreground text-[11px] font-semibold uppercase tracking-widest">
{formatRelative(capture.created_at)}
{capture.duration_ms ? ` · ${formatDuration(capture.duration_ms)}` : ''}
</Text>
{capture.transcript_refined ? (
<View className="px-1.5 py-0.5 rounded-sm bg-accent/15">
<Text className="text-accent text-[10px] font-semibold">REFINED</Text>
</View>
) : null}
</View>
<Text
className="text-foreground text-sm leading-snug"
numberOfLines={expanded ? undefined : 6}
>
{transcript || '(empty transcript)'}
</Text>
{expanded ? (
<View className="pt-2 gap-2 border-t border-border/60">
{/* Progress bar */}
<View className="h-1.5 rounded-full bg-muted overflow-hidden">
<View
className="h-full bg-accent"
style={{ width: `${progress * 100}%` }}
/>
</View>
<View className="flex-row items-center justify-between">
<Text className="text-muted-foreground text-[11px]" style={{ fontFamily: 'Menlo' }}>
{formatTime(currentTime)} / {formatTime(duration)}
</Text>
<View className="flex-row items-center gap-2">
<Pressable
onPress={(e) => {
e.stopPropagation();
onDelete();
}}
hitSlop={8}
className="h-8 w-8 rounded-full items-center justify-center"
accessibilityLabel="Delete capture"
>
<Trash2 size={14} color={colors.mutedForeground} />
</Pressable>
<Pressable
onPress={(e) => {
e.stopPropagation();
onPlayPause();
}}
hitSlop={8}
className="h-9 w-9 rounded-full items-center justify-center bg-accent"
accessibilityLabel={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? (
<Pause size={16} color="#F2F2F2" fill="#F2F2F2" />
) : (
<Play size={16} color="#F2F2F2" fill="#F2F2F2" />
)}
</Pressable>
</View>
</View>
</View>
) : null}
</Pressable>
);
}
function EmptyState() {
return (
<View className="items-center pt-20 px-6 gap-3">
<View className="h-14 w-14 rounded-full bg-accent/10 items-center justify-center">
<Mic size={22} color={colors.accent} />
</View>
<Text className="text-foreground text-base font-semibold">No captures yet</Text>
<Text className="text-muted-foreground text-sm text-center max-w-[260px]">
Tap the mic to dictate. Audio and transcript both stay on your desktop — Voicebox keeps every recording.
</Text>
</View>
);
}
-178
View File
@@ -1,178 +0,0 @@
import { Mic, Sparkles, User2, Users, Volume2 } from 'lucide-react-native';
import { useMemo, useState } from 'react';
import {
ActivityIndicator,
FlatList,
Pressable,
RefreshControl,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { type VoiceProfileResponse } from '@/lib/api';
import { colors } from '@/lib/colors';
import { useProfiles } from '@/lib/hooks';
export default function VoicesTab() {
const profiles = useProfiles();
const [query, setQuery] = useState('');
const [expandedId, setExpandedId] = useState<string | null>(null);
const filtered = useMemo(() => {
const items = profiles.data ?? [];
if (!query.trim()) return items;
const q = query.trim().toLowerCase();
return items.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.description?.toLowerCase().includes(q) ||
p.language.toLowerCase().includes(q),
);
}, [profiles.data, query]);
return (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View className="px-6 py-3 gap-3">
<View className="flex-row items-center justify-between">
<Text className="text-foreground text-2xl font-bold tracking-tight">Voices</Text>
{profiles.data ? (
<Text className="text-muted-foreground text-xs">
{profiles.data.length} {profiles.data.length === 1 ? 'voice' : 'voices'}
</Text>
) : null}
</View>
<TextInput
value={query}
onChangeText={setQuery}
placeholder="Search voices…"
placeholderTextColor={colors.mutedForeground}
className="rounded-xl border border-border bg-card px-4 py-2.5 text-foreground"
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<FlatList
data={filtered}
keyExtractor={(p) => p.id}
contentContainerStyle={{ paddingHorizontal: 24, paddingBottom: 80 }}
ItemSeparatorComponent={() => <View className="h-2" />}
refreshControl={
<RefreshControl
refreshing={profiles.isRefetching && !profiles.isLoading}
onRefresh={() => profiles.refetch()}
tintColor={colors.accent}
/>
}
ListEmptyComponent={
profiles.isLoading ? (
<View className="pt-12 items-center">
<ActivityIndicator color={colors.accent} />
</View>
) : query ? (
<Text className="text-muted-foreground text-sm pt-6 text-center">
No voices match "{query}".
</Text>
) : (
<EmptyState />
)
}
renderItem={({ item }) => (
<ProfileRow
profile={item}
expanded={expandedId === item.id}
onPress={() => setExpandedId(expandedId === item.id ? null : item.id)}
/>
)}
/>
</SafeAreaView>
);
}
function ProfileRow({
profile,
expanded,
onPress,
}: {
profile: VoiceProfileResponse;
expanded: boolean;
onPress: () => void;
}) {
const kindIcon =
profile.voice_type === 'preset' ? Sparkles : profile.voice_type === 'designed' ? User2 : Mic;
const KindIcon = kindIcon;
return (
<Pressable
onPress={onPress}
className={`rounded-2xl border px-4 py-3 ${
expanded ? 'border-accent/40 bg-accent/5' : 'border-border bg-card'
}`}
>
<View className="flex-row items-center gap-3">
<View
className={`h-11 w-11 rounded-full items-center justify-center ${
profile.voice_type === 'preset' ? 'bg-accent/15' : 'bg-muted'
}`}
>
<Volume2 size={18} color={profile.voice_type === 'preset' ? colors.accent : colors.mutedForeground} />
</View>
<View className="flex-1 min-w-0">
<Text className="text-foreground text-sm font-semibold" numberOfLines={1}>
{profile.name}
</Text>
<View className="flex-row items-center gap-2 mt-0.5">
<Text className="text-muted-foreground text-[11px] uppercase tracking-wider">
{profile.language}
</Text>
<KindBadge kind={profile.voice_type} />
<Text className="text-muted-foreground text-[11px]">
{profile.generation_count} gens
{profile.voice_type === 'cloned' ? ` · ${profile.sample_count} samples` : ''}
</Text>
</View>
</View>
<KindIcon size={16} color={colors.mutedForeground} />
</View>
{expanded && profile.description ? (
<View className="mt-3 pt-3 border-t border-border/60">
<Text className="text-muted-foreground text-sm leading-snug">
{profile.description}
</Text>
{profile.default_engine ? (
<Text className="text-muted-foreground text-[11px] uppercase tracking-wider mt-2">
engine · {profile.default_engine}
</Text>
) : null}
</View>
) : null}
</Pressable>
);
}
function KindBadge({ kind }: { kind: VoiceProfileResponse['voice_type'] }) {
const label =
kind === 'preset' ? 'PRESET' : kind === 'designed' ? 'DESIGNED' : 'CLONED';
return (
<View className="px-1.5 py-0.5 rounded-sm bg-muted">
<Text className="text-muted-foreground text-[9px] font-semibold tracking-widest">
{label}
</Text>
</View>
);
}
function EmptyState() {
return (
<View className="items-center pt-16 px-6 gap-3">
<View className="h-14 w-14 rounded-full bg-accent/10 items-center justify-center">
<Users size={22} color={colors.accent} />
</View>
<Text className="text-foreground text-base font-semibold">No voices yet</Text>
<Text className="text-muted-foreground text-sm text-center max-w-[260px]">
Create voice profiles in your Voicebox desktop — they'll appear here automatically.
</Text>
</View>
);
}
-46
View File
@@ -1,46 +0,0 @@
import '../global.css';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { useEffect, useState } from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { colors } from '@/lib/colors';
import { useSession } from '@/lib/session';
export default function RootLayout() {
const hydrate = useSession((s) => s.hydrate);
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5_000,
retry: 1,
},
},
}),
);
useEffect(() => {
void hydrate();
}, [hydrate]);
return (
<QueryClientProvider client={queryClient}>
<GestureHandlerRootView style={{ flex: 1, backgroundColor: colors.background }}>
<SafeAreaProvider>
<StatusBar style="light" />
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background },
animation: 'fade',
}}
/>
</SafeAreaProvider>
</GestureHandlerRootView>
</QueryClientProvider>
);
}
-164
View File
@@ -1,164 +0,0 @@
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useRouter } from 'expo-router';
import { useRef, useState } from 'react';
import {
KeyboardAvoidingView,
Platform,
Pressable,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Button } from '@/components/ui/Button';
import { ApiError, completePair } from '@/lib/api';
import { colors } from '@/lib/colors';
import { parsePairUrl } from '@/lib/pairing';
import { useSession } from '@/lib/session';
export default function PairScreen() {
const router = useRouter();
const setSession = useSession((s) => s.setSession);
const [permission, requestPermission] = useCameraPermissions();
const [deviceName, setDeviceName] = useState('iPhone');
const [manualUrl, setManualUrl] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Guard against the camera firing onBarcodeScanned multiple times for the
// same code while the request is in flight.
const scannedRef = useRef(false);
async function handlePair(host: string, token: string) {
if (busy) return;
setBusy(true);
setError(null);
try {
const res = await completePair(host, token, deviceName.trim() || 'Mobile');
await setSession({
host,
bearer: res.bearer,
deviceId: res.device_id,
deviceName: res.device_name,
});
router.replace('/');
} catch (e) {
const msg =
e instanceof ApiError
? e.message
: e instanceof Error
? `${e.message} — is the desktop reachable at this address?`
: 'Pair failed';
setError(msg);
scannedRef.current = false;
setBusy(false);
}
}
function handleBarcode({ data }: { data: string }) {
if (scannedRef.current) return;
const payload = parsePairUrl(data);
if (!payload) return;
scannedRef.current = true;
void handlePair(payload.host, payload.token);
}
function handleManualPair() {
const payload = parsePairUrl(manualUrl);
if (!payload) {
setError("That doesn't look like a Voicebox pairing URL.");
return;
}
void handlePair(payload.host, payload.token);
}
return (
<SafeAreaView className="flex-1 bg-background" edges={['top', 'bottom']}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={{ flex: 1 }}
>
<View className="flex-row items-center justify-between px-6 pt-2 pb-4">
<Pressable
onPress={() => router.back()}
hitSlop={12}
accessibilityRole="button"
accessibilityLabel="Cancel pairing"
>
<Text className="text-muted-foreground text-sm">Cancel</Text>
</Pressable>
<Text className="text-foreground text-base font-semibold">Pair device</Text>
<View style={{ width: 60 }} />
</View>
<View className="px-6 gap-4">
<Text className="text-muted-foreground text-sm leading-snug">
On your desktop, open Voicebox → Settings → Mobile → Pair device, then point your camera at the QR code.
</Text>
<View className="gap-2">
<Text className="text-muted-foreground text-[11px] font-semibold uppercase tracking-widest">
Device name
</Text>
<TextInput
value={deviceName}
onChangeText={setDeviceName}
placeholder="iPhone"
placeholderTextColor={colors.mutedForeground}
className="bg-card border border-border rounded-lg px-4 py-3 text-foreground"
autoCapitalize="words"
/>
</View>
</View>
<View className="flex-1 mx-6 mt-4 rounded-2xl overflow-hidden bg-card border border-border items-center justify-center">
{permission?.granted ? (
<CameraView
style={{ width: '100%', height: '100%' }}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={busy ? undefined : handleBarcode}
/>
) : (
<View className="items-center gap-4 px-8">
<Text className="text-foreground text-base text-center">
Camera access lets you scan the pairing QR.
</Text>
<Button
label={permission ? 'Allow camera' : 'Allow camera'}
onPress={() => {
void requestPermission();
}}
/>
</View>
)}
</View>
<View className="px-6 pt-4 pb-2 gap-3 border-t border-border mt-4">
<Text className="text-muted-foreground text-[11px] font-semibold uppercase tracking-widest">
Or paste pairing URL
</Text>
<TextInput
value={manualUrl}
onChangeText={setManualUrl}
placeholder="voicebox://pair?host=…&token=…"
placeholderTextColor={colors.mutedForeground}
autoCapitalize="none"
autoCorrect={false}
multiline
className="bg-card border border-border rounded-lg px-4 py-3 text-foreground"
style={{ minHeight: 60, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace' }}
/>
{error ? (
<Text className="text-destructive text-sm">{error}</Text>
) : null}
<Button
label={busy ? 'Pairing…' : 'Pair'}
onPress={handleManualPair}
loading={busy}
disabled={!manualUrl.trim() || busy}
/>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
-7
View File
@@ -1,7 +0,0 @@
import { useRouter } from 'expo-router';
import { WelcomeScreen } from '@/screens/WelcomeScreen';
export default function WelcomeRoute() {
const router = useRouter();
return <WelcomeScreen onGetStarted={() => router.push('/pair')} />;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 KiB

-9
View File
@@ -1,9 +0,0 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
'nativewind/babel',
],
};
};
-1763
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

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