Compare commits

..
Author SHA1 Message Date
James PineandClaude Opus 4.7 10247851d4 test(offline): make concurrency test deterministic and bounded
Replace the `sleep(0.15)` ordering hack with an explicit `threading.Event`
the fast thread sets in `finally`. The slow thread waits on that event
(bounded), then observes the flag — so we deterministically verify the
slow thread still sees offline mode after the fast thread has exited.

Also add timeouts to `barrier.wait()` and assert `not thread.is_alive()`
after the joins so the test can't hang on an unexpected failure path.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-19 19:27:21 -07:00
James PineandClaude Opus 4.7 79b70b8970 fix(offline): atomic entry rollback + tidy test assertions
Review follow-up:

- Wrap the `_offline_refcount == 0` setup in a try/except so any failure
  during the cached-constant mutation (including unexpected non-ImportError
  like RuntimeError or AttributeError from a half-initialized module)
  rolls back *all* partial state before re-raising. Without this, a
  mid-setup crash could leave `huggingface_hub.constants.HF_HUB_OFFLINE`
  mutated but the refcount at 0 — a persistent offline flag outliving
  the process.
- Swap ruff-flagged Yoda comparisons in the new test file (SIM300) and
  add a module-level note warning that these tests mutate global state
  and are not safe under cross-process parallelism.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-19 19:18:43 -07:00
James PineandClaude Opus 4.7 de15d8fdc6 fix(offline): mutate cached HF constants + threadsafe refcount
Review feedback on the initial fix surfaced two real issues:

1. ``os.environ`` toggles alone don't flip offline mode.
   ``huggingface_hub.constants.HF_HUB_OFFLINE`` is read once at import
   time into a module-level bool; ``transformers.utils.hub._is_offline_mode``
   mirrors that bool at its own import time. The hot paths
   (``_http._default_backend_factory`` in huggingface_hub,
   ``is_offline_mode`` in transformers) read the cached bools — not the
   env — so mutating only ``os.environ`` was a no-op.

2. Race condition on concurrent inference. Two threads running inside
   ``force_offline_if_cached`` via ``asyncio.to_thread`` could have
   thread A's ``finally`` strip thread B's offline protection mid-run.

Rewrite the helper to:
  - mutate ``huggingface_hub.constants.HF_HUB_OFFLINE`` and
    ``transformers.utils.hub._is_offline_mode`` directly
  - refcount concurrent users under a single ``threading.RLock`` so a
    shared offline window is restored only when the last caller exits
  - still write ``os.environ`` for anything that reads it dynamically

Also addresses the unused-variable ruff flag on the Whisper transcribe
path (``audio, sr`` → ``audio, _sr``).

New unit tests cover the cached-constant mutation, env propagation,
no-op on ``is_cached=False``, nested contexts, and a threaded race
where a slow thread must retain offline mode after a peer exits.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-19 18:24:48 -07:00
James PineandClaude Opus 4.7 f3ed312cf2 fix(offline): guard inference paths with HF_HUB_OFFLINE (#462)
PR #443 wrapped the model *load* path with `force_offline_if_cached` so
cached models don't phone home at startup. The context manager restores
`HF_HUB_OFFLINE` on exit, which left inference paths (generate,
transcribe, voice-prompt creation) unguarded — and `qwen_tts`,
`mlx_audio`, and `transformers` perform lazy tokenizer/processor/config
lookups during inference. With internet on, those lookups are
near-instant and invisible; with internet off, `requests` hangs on DNS
or connect until the network returns. This is exactly what users in
#462 describe: model shows "Loaded", internet drops, generation
"thinks" forever, internet comes back, generation completes.

Chatterbox and LuxTTS don't exhibit this because their engine libs
resolve everything through already-cached paths at load time.

Fix: wrap each inference-sync body with `force_offline_if_cached(True,
...)`. Since inference only runs after a successful load, weights are
known to be on disk, so `is_cached=True` is unconditional.

Also adds the load-time guard that was missing from
`qwen_custom_voice_backend.py` — CustomVoice previously had no offline
protection at all.

Paths patched:
  - PyTorchTTSBackend.create_voice_prompt (create_voice_clone_prompt)
  - PyTorchTTSBackend.generate (generate_voice_clone)
  - PyTorchSTTBackend.transcribe (Whisper generate + decoder-prompt-ids)
  - MLXTTSBackend.generate (mlx_audio generate, all branches)
  - MLXSTTBackend.transcribe (mlx_audio whisper generate)
  - QwenCustomVoiceBackend._load_model_sync + generate

Does not address the secondary `check_model_inputs() missing 'func'`
error reported in the same issue — that's a `transformers` 5.x
version-skew bug on the install path, separate concern.

Fixes #462.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-19 16:56:28 -07:00
Shekhar KumarandGitHub e3f7cd9d00 fix(landing): use public origin for download redirects behind proxies (#498)
Prefer x-forwarded host/proto for redirect URL construction so users are not sent to internal localhost origins.

Fixes #496
2026-04-19 15:57:58 -07:00
27a5a62581 fix(landing): API example + new /download page (no more dumping users on GitHub) (#487)
* fix(landing): use qwen_custom_voice in API example (instruct is CustomVoice-only)

The curl snippet showed engine: "qwen" alongside an instruct field, but base
Qwen3-TTS has no instruct path — that's a Qwen CustomVoice feature.

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

* fix(landing): use a realistic UUID for profile_id in API example

Profile IDs are str(uuid.uuid4()), not slugs (see backend/services/profiles.py:175).

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

* feat(landing): add polished /download page — no more dumping users on GitHub

Users were clicking download, landing on the GitHub releases page, and filing
confused comments along the lines of "I ended up on some blog site called
GitHub." We now route every download CTA through a dedicated /download page
that auto-triggers the platform-specific download and gives users a polished
post-click experience with donate + docs + AI help prompts.

- New /download page:
  - Big app logo + "Your download has started" messaging.
  - Auto-detects platform from ?platform=X or navigator.userAgent.
  - Programmatically clicks a hidden anchor to trigger the file download
    without leaving the page.
  - Platform-specific buttons as a visible fallback for "download not
    working" / manual-pick.
  - Personal donate spiel + Buy Me a Coffee button.
  - Resources grid: docs, DeepWiki ("got questions? ask AI"), GitHub.
- Landing page download section cards now link to /download?platform=X
  instead of the asset URL directly.
- /download/[platform] (used by README/docs links) now redirects to the
  /download page rather than straight to the asset or to GitHub on error.
- Drops unused downloadLinks state from the landing page.

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

* fix(landing): use official platform brand icons via simple-icons

The hand-rolled Linux SVG path wasn't actually Tux — it was a symmetric
placeholder shape. Apple/Windows were close but not canonical either.

- Apple + Linux: pulled from @icons-pack/react-simple-icons (SiApple, SiLinux).
- Windows: simple-icons drops the Microsoft mark over trademark policy, so
  the Windows 11 flag is inlined from Microsoft's public brand guidance.

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

* fix(landing): route Download CTAs to /download page, not the section anchor

Hero CTA, navbar link, and footer link were all scrolling to #download
(the section at the bottom of the page) instead of going to the new
/download page that triggers the actual download.

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

* chore(landing): run dev server on Node instead of Bun runtime

Bun runtime + Next 16 Turbopack dev server intermittently trips a
JavaScriptCore allocator panic ('pas panic: deallocation did fail ...
Alloc bit not set') after a few requests. Dropping --bun keeps Bun as
the package manager but runs next dev on Node, which is stable.

Build + start keep --bun since one-shot invocations don't exhibit the
allocator drift.

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

* fix(landing): route Linux users to /linux-install instead of attempting download

No prebuilt Linux binary exists yet (see /linux-install for build-from-source
instructions). The /download page previously treated Linux like the other
platforms — auto-triggering a non-existent AppImage and offering a dead
manual button.

- /download page: if platform resolves to 'linux' via ?platform or UA detect,
  window.location.replace('/linux-install') — never try to auto-download.
- Manual Linux card: label changed to "Build from source" and links to
  /linux-install (no download attribute, no asset URL).
- /download/linux pretty URL: 307s straight to /linux-install.

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

* docs: consolidate troubleshooting into the MDX docs site + status updates

- Delete docs/TROUBLESHOOTING.md; the canonical troubleshooting guide now
  lives under docs/content/docs/overview/troubleshooting.mdx so it's served
  from docs.voicebox.sh alongside the rest of the docs.
- CONTRIBUTING.md + README.md: repoint "Troubleshooting" references to the
  new MDX path. README gets a top-level callout so users hit the guide
  before filing an issue.
- PROJECT_STATUS.md: refresh issue/PR counts, document the flash-attn
  warning (cosmetic on all platforms; CUDA-only, fallback is PyTorch SDPA
  which is near-FA2 on Ampere+) with per-platform context + community
  Windows wheels + SageAttention/xformers alternatives, add WebAudio
  audio-session bug note (tracked separately in PR #486), and expand the
  Qwen 0.6B→1.7B MLX fallback explanation for triage.

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

* fix(landing): address PR #487 review feedback

- Preserve canonical camelCase platform aliases (macArm, macIntel) in the
  /download/[platform] redirect so those URLs don't lose their platform param.
- Add accessible title + role="img" to the inline Windows SVG so it passes
  Biome's a11y rule and announces to screen readers.
- On /api/releases fetch failure, show an explicit error state with a single
  intentional link to GitHub releases — no more silent GitHub fallback or
  disabled-button UX lie. Keeps normies off GitHub unless they opt in.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-18 23:32:32 -07:00
d3a44338a2 fix(audio): prevent WKWebView audio session teardown after backgrounding (#41) (#486)
Keep a silent looping <audio> element mounted at the app root so macOS
never tears down the CoreAudio session. Without this, backgrounding the
app long enough leaves WaveSurfer's AudioContext in a state where play()
resolves and timeupdate fires, but no audio reaches the output — and not
even cmd+R (full JS reload) restores it, only a full app relaunch.

Uses a zero-PCM WAV blob at full volume rather than a muted element,
since WebKit can optimize muted media away and defeat the purpose.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-18 22:43:59 -07:00
James Pine 28aa963b09 readme update 2026-04-18 21:19:51 -07:00
ae91aa9a88 docs: audit mdx docs against multi-engine backend (#484)
* docs: audit mdx docs against multi-engine backend and refresh stale content

Rewrote developer-facing docs that predated the TTSBackend Protocol /
ModelConfig registry refactor (architecture, tts-generation,
model-management, transcription). Updated user-facing docs to reflect all
seven shipped engines (Qwen, Qwen CustomVoice, LuxTTS, Chatterbox,
Chatterbox Turbo, TADA, Kokoro) instead of the outdated "5 engines" claim.

Also fixes:
- Stale app identifier (com.voicebox.app → sh.voicebox.app)
- CUDA backend update flow (now two-archive split, not N-way chunks)
- Whisper model list (removed tiny, added turbo)
- Broken /development/ and /guides/ route links
- Stale just commands and install steps (missing --no-deps chatterbox/tada)
- Removed ASCII art diagrams from README and stories.mdx
- History Generation schema sync with DB model

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

* docs: add DeepWiki badge to README

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

* docs: address PR review feedback

- architecture.mdx: fix backends/ file list (remove nonexistent qwen_backend.py, rename tada_backend.py → hume_backend.py)
- model-management.mdx: Kokoro language count 9 → 8 (matches ModelConfig)
- model-management.mdx: ProgressManager path services/ → utils/
- tts-generation.mdx: ModelConfig example uses field(default_factory=...) — mutable default would raise at runtime
- tts-generation.mdx: "1080p samples" → "on CUDA" (1080p is video, not audio)
- PROJECT_STATUS.md: replace ASCII architecture diagram with prose (matches no-ASCII-art rule)

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

* fix(app): guard against undefined engine in FloatingGenerateBox preset check

form.getValues('engine') returns string | undefined; Set<string>.has()
rejects undefined under strict mode. Added a truthy guard before the
preset lookup.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-18 21:06:06 -07:00
22 changed files with 995 additions and 498 deletions
+2 -2
View File
@@ -359,7 +359,7 @@ Releases are managed by maintainers:
## Troubleshooting ## Troubleshooting
See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues and solutions. See [docs/content/docs/overview/troubleshooting.mdx](docs/content/docs/overview/troubleshooting.mdx) for common issues and solutions.
**Quick fixes:** **Quick fixes:**
@@ -372,7 +372,7 @@ See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues and sol
- Open an issue for bugs or feature requests - Open an issue for bugs or feature requests
- Check existing issues and discussions - Check existing issues and discussions
- Review the codebase to understand patterns - Review the codebase to understand patterns
- See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues - See [docs/content/docs/overview/troubleshooting.mdx](docs/content/docs/overview/troubleshooting.mdx) for common issues
## Additional Resources ## Additional Resources
+12 -6
View File
@@ -33,7 +33,8 @@
<a href="https://docs.voicebox.sh">Docs</a> • <a href="https://docs.voicebox.sh">Docs</a> •
<a href="#download">Download</a> • <a href="#download">Download</a> •
<a href="#features">Features</a> • <a href="#features">Features</a> •
<a href="#api">API</a> <a href="#api">API</a> •
<a href="docs/content/docs/overview/troubleshooting.mdx">Troubleshooting</a>
</p> </p>
<br/> <br/>
@@ -62,13 +63,14 @@
## What is Voicebox? ## What is Voicebox?
Voicebox is a **local-first voice cloning studio** — a free and open-source alternative to ElevenLabs. Clone voices from a few seconds of audio, generate speech in 23 languages across 5 TTS engines, apply post-processing effects, and compose multi-voice projects with a timeline editor. Voicebox is a **local-first voice cloning studio** — a free and open-source alternative to ElevenLabs. Clone voices from a few seconds of audio or pick from 50+ preset voices, generate speech in 23 languages across 7 TTS engines, apply post-processing effects, and compose multi-voice projects with a timeline editor.
- **Complete privacy** — models and voice data stay on your machine - **Complete privacy** — models and voice data stay on your machine
- **5 TTS engines** — Qwen3-TTS, LuxTTS, Chatterbox Multilingual, Chatterbox Turbo, and HumeAI TADA - **7 TTS engines** — Qwen3-TTS, Qwen CustomVoice, LuxTTS, Chatterbox Multilingual, Chatterbox Turbo, HumeAI TADA, and Kokoro
- **Cloning and preset voices** — zero-shot cloning from a reference sample, or curated preset voices via Kokoro (50 voices) and Qwen CustomVoice (9 voices)
- **23 languages** — from English to Arabic, Japanese, Hindi, Swahili, and more - **23 languages** — from English to Arabic, Japanese, Hindi, Swahili, and more
- **Post-processing effects** — pitch shift, reverb, delay, chorus, compression, and filters - **Post-processing effects** — pitch shift, reverb, delay, chorus, compression, and filters
- **Expressive speech** — paralinguistic tags like `[laugh]`, `[sigh]`, `[gasp]` via Chatterbox Turbo - **Expressive speech** — paralinguistic tags like `[laugh]`, `[sigh]`, `[gasp]` via Chatterbox Turbo; natural-language delivery control via Qwen CustomVoice
- **Unlimited length** — auto-chunking with crossfade for scripts, articles, and chapters - **Unlimited length** — auto-chunking with crossfade for scripts, articles, and chapters
- **Stories editor** — multi-track timeline for conversations, podcasts, and narratives - **Stories editor** — multi-track timeline for conversations, podcasts, and narratives
- **API-first** — REST API for integrating voice synthesis into your own projects - **API-first** — REST API for integrating voice synthesis into your own projects
@@ -90,21 +92,25 @@ Voicebox is a **local-first voice cloning studio** — a free and open-source al
> **Linux** — Pre-built binaries are not yet available. See [voicebox.sh/linux-install](https://voicebox.sh/linux-install) for build-from-source instructions. > **Linux** — Pre-built binaries are not yet available. See [voicebox.sh/linux-install](https://voicebox.sh/linux-install) for build-from-source instructions.
> **Having trouble?** See the [Troubleshooting Guide](docs/content/docs/overview/troubleshooting.mdx) for common install, generation, model-download, and GPU issues.
--- ---
## Features ## Features
### Multi-Engine Voice Cloning ### Multi-Engine Voice Cloning
Five TTS engines with different strengths, switchable per-generation: Seven TTS engines with different strengths, switchable per-generation:
| Engine | Languages | Strengths | | Engine | Languages | Strengths |
| --------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| **Qwen3-TTS** (0.6B / 1.7B) | 10 | High-quality multilingual cloning, delivery instructions ("speak slowly", "whisper") | | **Qwen3-TTS** (0.6B / 1.7B) | 10 | High-quality multilingual cloning, delivery instructions ("speak slowly", "whisper") |
| **Qwen CustomVoice** | 10 | 9 curated preset voices with natural-language delivery control — no reference audio required |
| **LuxTTS** | English | Lightweight (~1GB VRAM), 48kHz output, 150x realtime on CPU | | **LuxTTS** | English | Lightweight (~1GB VRAM), 48kHz output, 150x realtime on CPU |
| **Chatterbox Multilingual** | 23 | Broadest language coverage — Arabic, Danish, Finnish, Greek, Hebrew, Hindi, Malay, Norwegian, Polish, Swahili, Swedish, Turkish and more | | **Chatterbox Multilingual** | 23 | Broadest language coverage — Arabic, Danish, Finnish, Greek, Hebrew, Hindi, Malay, Norwegian, Polish, Swahili, Swedish, Turkish and more |
| **Chatterbox Turbo** | English | Fast 350M model with paralinguistic emotion/sound tags | | **Chatterbox Turbo** | English | Fast 350M model with paralinguistic emotion/sound tags |
| **TADA** (1B / 3B) | 10 | HumeAI speech-language model — 700s+ coherent audio, text-acoustic dual alignment | | **TADA** (1B / 3B) | 10 | HumeAI speech-language model — 700s+ coherent audio, text-acoustic dual alignment |
| **Kokoro** | 8 | 50 curated preset voices, tiny 82M model, fast CPU inference |
### Emotions & Paralinguistic Tags ### Emotions & Paralinguistic Tags
@@ -239,7 +245,7 @@ Full API documentation available at `http://localhost:17493/docs`.
| Frontend | React, TypeScript, Tailwind CSS | | Frontend | React, TypeScript, Tailwind CSS |
| State | Zustand, React Query | | State | Zustand, React Query |
| Backend | FastAPI (Python) | | Backend | FastAPI (Python) |
| TTS Engines | Qwen3-TTS, LuxTTS, Chatterbox, Chatterbox Turbo, TADA | | TTS Engines | Qwen3-TTS, Qwen CustomVoice, LuxTTS, Chatterbox, Chatterbox Turbo, TADA, Kokoro |
| Effects | Pedalboard (Spotify) | | Effects | Pedalboard (Spotify) |
| Transcription | Whisper / Whisper Turbo (PyTorch or MLX) | | Transcription | Whisper / Whisper Turbo (PyTorch or MLX) |
| Inference | MLX (Apple Silicon) / PyTorch (CUDA/ROCm/XPU/CPU) | | Inference | MLX (Apple Silicon) / PyTorch (CUDA/ROCm/XPU/CPU) |
+2
View File
@@ -1,5 +1,6 @@
import { useRouterState } from '@tanstack/react-router'; import { useRouterState } from '@tanstack/react-router';
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion'; import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
import { AudioKeepAlive } from '@/components/AudioPlayer/AudioKeepAlive';
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer'; import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
import { StoryTrackEditor } from '@/components/StoriesTab/StoryTrackEditor'; import { StoryTrackEditor } from '@/components/StoriesTab/StoryTrackEditor';
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
@@ -26,6 +27,7 @@ export function AppFrame({ children }: AppFrameProps) {
className={cn('h-screen bg-background flex flex-col overflow-hidden', TOP_SAFE_AREA_PADDING)} className={cn('h-screen bg-background flex flex-col overflow-hidden', TOP_SAFE_AREA_PADDING)}
> >
<TitleBarDragRegion /> <TitleBarDragRegion />
<AudioKeepAlive />
{children} {children}
{showTrackEditor ? ( {showTrackEditor ? (
<StoryTrackEditor storyId={story.id} items={story.items} /> <StoryTrackEditor storyId={story.id} items={story.items} />
@@ -0,0 +1,85 @@
import { useEffect, useRef } from 'react';
import { debug } from '@/lib/utils/debug';
// WKWebView tears down the app's CoreAudio output when idle for long enough,
// and a JS-level reload (cmd+R) does NOT restore it — only relaunching the
// Tauri app does. Keeping a silent <audio> element looping forever prevents
// the OS audio session from ever going dormant.
//
// Real silence (zero PCM samples) at full volume is preferred over a muted
// element: browsers/WebKit can optimize muted media away, which defeats the
// purpose of holding the session open.
function buildSilentWavUrl(seconds = 1, sampleRate = 8000): string {
const numSamples = seconds * sampleRate;
const bytes = 44 + numSamples * 2;
const buffer = new ArrayBuffer(bytes);
const view = new DataView(buffer);
const write = (offset: number, str: string) => {
for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
};
write(0, 'RIFF');
view.setUint32(4, bytes - 8, true);
write(8, 'WAVE');
write(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
write(36, 'data');
view.setUint32(40, numSamples * 2, true);
return URL.createObjectURL(new Blob([buffer], { type: 'audio/wav' }));
}
export function AudioKeepAlive() {
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
const url = buildSilentWavUrl(1, 8000);
const el = new Audio(url);
el.loop = true;
el.volume = 1;
el.preload = 'auto';
audioRef.current = el;
const tryPlay = () => {
if (!audioRef.current) return;
if (!audioRef.current.paused) return;
audioRef.current.play().catch((err) => {
debug.log('[AudioKeepAlive] play blocked (will retry on next gesture):', err);
});
};
tryPlay();
// Autoplay may be blocked until first user interaction — re-attempt then.
const onGesture = () => tryPlay();
window.addEventListener('pointerdown', onGesture, { once: false });
window.addEventListener('keydown', onGesture, { once: false });
// If the webview ever pauses the element on background, resume on return.
const onWake = () => {
if (!document.hidden) tryPlay();
};
document.addEventListener('visibilitychange', onWake);
window.addEventListener('focus', onWake);
window.addEventListener('pageshow', onWake);
return () => {
window.removeEventListener('pointerdown', onGesture);
window.removeEventListener('keydown', onGesture);
document.removeEventListener('visibilitychange', onWake);
window.removeEventListener('focus', onWake);
window.removeEventListener('pageshow', onWake);
el.pause();
el.src = '';
URL.revokeObjectURL(url);
audioRef.current = null;
};
}, []);
return null;
}
+12
View File
@@ -195,6 +195,8 @@ class MLXTTSBackend:
logger.info("Generating audio for text: %s", text) logger.info("Generating audio for text: %s", text)
model_name = f"qwen-tts-{self._current_model_size}"
def _generate_sync(): def _generate_sync():
"""Run synchronous generation in thread pool.""" """Run synchronous generation in thread pool."""
# MLX generate() returns a generator yielding GenerationResult objects # MLX generate() returns a generator yielding GenerationResult objects
@@ -220,6 +222,10 @@ class MLXTTSBackend:
logger.warning("Regenerating without voice prompt.") logger.warning("Regenerating without voice prompt.")
ref_audio = None ref_audio = None
# Model is loaded → weights are on disk. Force offline so
# lazy tokenizer/config lookups inside mlx_audio don't hang
# when the user is disconnected (issue #462).
with force_offline_if_cached(True, model_name):
# Check if model supports voice cloning via generate method # Check if model supports voice cloning via generate method
# MLX API may support ref_audio parameter directly # MLX API may support ref_audio parameter directly
try: try:
@@ -343,6 +349,8 @@ class MLXSTTBackend:
""" """
await self.load_model_async(model_size) await self.load_model_async(model_size)
progress_model_name = f"whisper-{self.model_size}"
def _transcribe_sync(): def _transcribe_sync():
"""Run synchronous transcription in thread pool.""" """Run synchronous transcription in thread pool."""
# MLX Whisper transcription using generate method # MLX Whisper transcription using generate method
@@ -351,6 +359,10 @@ class MLXSTTBackend:
if language: if language:
decode_options["language"] = language decode_options["language"] = language
# Model is loaded → weights are on disk. Force offline so
# lazy tokenizer/config lookups don't hang when the user is
# disconnected (issue #462).
with force_offline_if_cached(True, progress_model_name):
result = self.model.generate(str(audio_path), **decode_options) result = self.model.generate(str(audio_path), **decode_options)
# Extract text from result # Extract text from result
+19 -2
View File
@@ -172,8 +172,14 @@ class PyTorchTTSBackend:
# This shouldn't happen in practice, but handle it # This shouldn't happen in practice, but handle it
return {"prompt": cached_prompt}, True return {"prompt": cached_prompt}, True
model_name = f"qwen-tts-{self._current_model_size}"
def _create_prompt_sync(): def _create_prompt_sync():
"""Run synchronous voice prompt creation in thread pool.""" """Run synchronous voice prompt creation in thread pool."""
# Model is loaded → weights are on disk. Force offline so
# lazy tokenizer/config lookups inside qwen_tts don't hang
# when the user is disconnected (issue #462).
with force_offline_if_cached(True, model_name):
return self.model.create_voice_clone_prompt( return self.model.create_voice_clone_prompt(
ref_audio=str(audio_path), ref_audio=str(audio_path),
ref_text=reference_text, ref_text=reference_text,
@@ -221,13 +227,18 @@ class PyTorchTTSBackend:
# Load model # Load model
await self.load_model_async(None) await self.load_model_async(None)
model_name = f"qwen-tts-{self._current_model_size}"
def _generate_sync(): def _generate_sync():
"""Run synchronous generation in thread pool.""" """Run synchronous generation in thread pool."""
# Set seed if provided # Set seed if provided
if seed is not None: if seed is not None:
manual_seed(seed, self.device) manual_seed(seed, self.device)
# Generate audio - this is the blocking operation # Model is loaded → weights are on disk. Force offline so
# lazy tokenizer/config lookups inside qwen_tts don't hang
# when the user is disconnected (issue #462).
with force_offline_if_cached(True, model_name):
wavs, sample_rate = self.model.generate_voice_clone( wavs, sample_rate = self.model.generate_voice_clone(
text=text, text=text,
voice_clone_prompt=voice_prompt, voice_clone_prompt=voice_prompt,
@@ -331,11 +342,17 @@ class PyTorchSTTBackend:
""" """
await self.load_model_async(model_size) await self.load_model_async(model_size)
progress_model_name = f"whisper-{self.model_size}"
def _transcribe_sync(): def _transcribe_sync():
"""Run synchronous transcription in thread pool.""" """Run synchronous transcription in thread pool."""
# Load audio # Load audio
audio, sr = load_audio(audio_path, sample_rate=16000) audio, _sr = load_audio(audio_path, sample_rate=16000)
# Model is loaded → weights are on disk. Force offline so
# `get_decoder_prompt_ids` and any lazy tokenizer lookups
# don't hang when the user is disconnected (issue #462).
with force_offline_if_cached(True, progress_model_name):
# Process audio # Process audio
inputs = self.processor( inputs = self.processor(
audio, audio,
@@ -28,6 +28,7 @@ from .base import (
combine_voice_prompts as _combine_voice_prompts, combine_voice_prompts as _combine_voice_prompts,
model_load_progress, model_load_progress,
) )
from ..utils.hf_offline_patch import force_offline_if_cached
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -104,6 +105,7 @@ class QwenCustomVoiceBackend:
model_path = self._get_model_path(model_size) model_path = self._get_model_path(model_size)
logger.info("Loading Qwen CustomVoice %s on %s...", model_size, self.device) logger.info("Loading Qwen CustomVoice %s on %s...", model_size, self.device)
with force_offline_if_cached(is_cached, model_name):
if self.device == "cpu": if self.device == "cpu":
self.model = Qwen3TTSModel.from_pretrained( self.model = Qwen3TTSModel.from_pretrained(
model_path, model_path,
@@ -184,6 +186,7 @@ class QwenCustomVoiceBackend:
await self.load_model_async(None) await self.load_model_async(None)
speaker = voice_prompt.get("preset_voice_id") or QWEN_CV_DEFAULT_SPEAKER speaker = voice_prompt.get("preset_voice_id") or QWEN_CV_DEFAULT_SPEAKER
model_name = f"qwen-custom-voice-{self._current_model_size}"
def _generate_sync(): def _generate_sync():
if seed is not None: if seed is not None:
@@ -203,6 +206,10 @@ class QwenCustomVoiceBackend:
if instruct: if instruct:
kwargs["instruct"] = instruct kwargs["instruct"] = instruct
# Model is loaded → weights are on disk. Force offline so
# lazy tokenizer/config lookups inside qwen_tts don't hang
# when the user is disconnected (issue #462).
with force_offline_if_cached(True, model_name):
wavs, sample_rate = self.model.generate_custom_voice(**kwargs) wavs, sample_rate = self.model.generate_custom_voice(**kwargs)
return wavs[0], sample_rate return wavs[0], sample_rate
+118
View File
@@ -0,0 +1,118 @@
"""
Unit tests for the ``force_offline_if_cached`` helper.
Verifies that the helper mutates the cached module constants in
``huggingface_hub.constants`` and ``transformers.utils.hub`` — not just
``os.environ`` — and that concurrent users are refcount-coordinated so
one thread's exit can't strip another thread's offline protection.
NOTE: These tests mutate process-global state in ``huggingface_hub.constants``
and ``transformers.utils.hub``. They are not safe under cross-process
parallelism (e.g. ``pytest-xdist`` with ``--dist=loadfile``/``loadscope``);
run this file serially.
"""
import os
import sys
import threading
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from utils.hf_offline_patch import force_offline_if_cached # noqa: E402
def _hf_const():
import huggingface_hub.constants as hf_const
return hf_const
def _tf_hub():
import transformers.utils.hub as tf_hub
return tf_hub
def test_mutates_cached_huggingface_hub_constant():
original = _hf_const().HF_HUB_OFFLINE
with force_offline_if_cached(True, "t"):
assert _hf_const().HF_HUB_OFFLINE is True
assert original == _hf_const().HF_HUB_OFFLINE
def test_mutates_cached_transformers_constant():
original = _tf_hub()._is_offline_mode
with force_offline_if_cached(True, "t"):
assert _tf_hub()._is_offline_mode is True
assert original == _tf_hub()._is_offline_mode
def test_sets_env_variable():
original = os.environ.get("HF_HUB_OFFLINE")
with force_offline_if_cached(True, "t"):
assert "1" == os.environ.get("HF_HUB_OFFLINE")
assert original == os.environ.get("HF_HUB_OFFLINE")
def test_noop_when_not_cached():
before = _hf_const().HF_HUB_OFFLINE
with force_offline_if_cached(False, "t"):
assert before == _hf_const().HF_HUB_OFFLINE
def test_nested_contexts_respect_refcount():
original = _hf_const().HF_HUB_OFFLINE
with force_offline_if_cached(True, "outer"):
assert _hf_const().HF_HUB_OFFLINE is True
with force_offline_if_cached(True, "inner"):
assert _hf_const().HF_HUB_OFFLINE is True
# inner exit must not restore while outer is still active
assert _hf_const().HF_HUB_OFFLINE is True
assert original == _hf_const().HF_HUB_OFFLINE
def test_concurrent_threads_share_offline_window():
"""A slow thread must keep seeing offline mode even if a peer exits first."""
original = _hf_const().HF_HUB_OFFLINE
observations: list[bool] = []
errors: list[Exception] = []
barrier = threading.Barrier(2)
fast_exited = threading.Event()
def slow():
try:
with force_offline_if_cached(True, "slow"):
barrier.wait(timeout=5)
assert fast_exited.wait(timeout=5), "fast thread did not exit"
observations.append(_hf_const().HF_HUB_OFFLINE)
except Exception as exc: # noqa: BLE001
errors.append(exc)
def fast():
try:
with force_offline_if_cached(True, "fast"):
barrier.wait(timeout=5)
except Exception as exc: # noqa: BLE001
errors.append(exc)
finally:
fast_exited.set()
t_slow = threading.Thread(target=slow)
t_fast = threading.Thread(target=fast)
t_slow.start()
t_fast.start()
t_slow.join(timeout=5)
t_fast.join(timeout=5)
assert not t_slow.is_alive(), "slow thread did not finish"
assert not t_fast.is_alive(), "fast thread did not finish"
assert not errors, errors
assert observations == [True], "slow thread lost offline protection"
assert original == _hf_const().HF_HUB_OFFLINE
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+104 -21
View File
@@ -6,6 +6,7 @@ are already downloaded. Must be imported BEFORE mlx_audio.
import logging import logging
import os import os
import threading
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Optional, Union from typing import Optional, Union
@@ -13,13 +14,33 @@ from typing import Optional, Union
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# huggingface_hub reads ``HF_HUB_OFFLINE`` once at import time into
# ``huggingface_hub.constants.HF_HUB_OFFLINE``; transformers mirrors that into
# ``transformers.utils.hub._is_offline_mode`` at *its* import time. Toggling
# ``os.environ`` after either module is imported does not flip those cached
# bools, and the hot paths (``_http._default_backend_factory``,
# ``transformers.utils.hub.is_offline_mode``) read the bools — not the env.
# We mutate the cached constants directly, guarded by a refcount so
# concurrent inference threads share a single offline window safely.
_offline_lock = threading.RLock()
_offline_refcount = 0
_saved_env: Optional[str] = None
_saved_hf_const: Optional[bool] = None
_saved_transformers_const: Optional[bool] = None
@contextmanager @contextmanager
def force_offline_if_cached(is_cached: bool, model_label: str = ""): def force_offline_if_cached(is_cached: bool, model_label: str = ""):
"""Context manager that sets ``HF_HUB_OFFLINE=1`` while loading a cached model. """Force offline mode for the duration of a cached-model operation.
Flips ``HF_HUB_OFFLINE`` in the process env **and** in the cached bools
inside ``huggingface_hub.constants`` / ``transformers.utils.hub`` so HTTP
adapters and offline-mode checks actually see the change. Uses a refcount
so multiple concurrent inference threads share a single offline window
and the last one to exit restores state.
If *is_cached* is ``False`` the block runs normally (network allowed). If *is_cached* is ``False`` the block runs normally (network allowed).
If the offline load raises an error containing "offline" we automatically
retry with network access so a partially-cached model still works.
Args: Args:
is_cached: Whether the model weights are already on disk. is_cached: Whether the model weights are already on disk.
@@ -29,34 +50,96 @@ def force_offline_if_cached(is_cached: bool, model_label: str = ""):
yield yield
return return
original_value = os.environ.get("HF_HUB_OFFLINE") global _offline_refcount, _saved_env, _saved_hf_const, _saved_transformers_const
with _offline_lock:
if _offline_refcount == 0:
# Snapshot prior state, apply new state, roll back on *any*
# failure. Catching only ImportError here would let a partially
# broken install (RuntimeError, AttributeError from a half-init
# module, etc.) leave the cached HF constants mutated without
# bumping the refcount — a persistent offline leak that outlives
# the process and is miserable to debug.
prev_env = os.environ.get("HF_HUB_OFFLINE")
prev_hf: Optional[bool] = None
prev_tf: Optional[bool] = None
try:
try:
import huggingface_hub.constants as hf_const
prev_hf = hf_const.HF_HUB_OFFLINE
hf_const.HF_HUB_OFFLINE = True
except ImportError:
prev_hf = None
try:
import transformers.utils.hub as tf_hub
prev_tf = tf_hub._is_offline_mode
tf_hub._is_offline_mode = True
except ImportError:
prev_tf = None
os.environ["HF_HUB_OFFLINE"] = "1" os.environ["HF_HUB_OFFLINE"] = "1"
except BaseException:
# Roll back whatever we already changed, then re-raise so
# the caller sees the real failure.
if prev_hf is not None:
try:
import huggingface_hub.constants as hf_const
hf_const.HF_HUB_OFFLINE = prev_hf
except ImportError:
pass
if prev_tf is not None:
try:
import transformers.utils.hub as tf_hub
tf_hub._is_offline_mode = prev_tf
except ImportError:
pass
if prev_env is not None:
os.environ["HF_HUB_OFFLINE"] = prev_env
else:
os.environ.pop("HF_HUB_OFFLINE", None)
raise
_saved_env = prev_env
_saved_hf_const = prev_hf
_saved_transformers_const = prev_tf
logger.info( logger.info(
"[offline-guard] %s is cached — forcing HF_HUB_OFFLINE=1", "[offline-guard] %s is cached — forcing offline mode",
model_label or "model", model_label or "model",
) )
_offline_refcount += 1
try: try:
yield yield
except Exception as exc:
if "offline" in str(exc).lower():
logger.warning(
"[offline-guard] Offline load failed for %s, retrying with network: %s",
model_label or "model",
exc,
)
# Restore original env and retry — caller must wrap the load
# inside force_offline_if_cached so retrying here isn't possible.
# Instead, propagate a flag via the exception so the caller can
# decide. For simplicity we just let it fall through to the
# finally block and re-raise.
raise
raise
finally: finally:
if original_value is not None: with _offline_lock:
os.environ["HF_HUB_OFFLINE"] = original_value _offline_refcount -= 1
if _offline_refcount == 0:
if _saved_env is not None:
os.environ["HF_HUB_OFFLINE"] = _saved_env
else: else:
os.environ.pop("HF_HUB_OFFLINE", None) os.environ.pop("HF_HUB_OFFLINE", None)
if _saved_hf_const is not None:
try:
import huggingface_hub.constants as hf_const
hf_const.HF_HUB_OFFLINE = _saved_hf_const
except ImportError:
pass
if _saved_transformers_const is not None:
try:
import transformers.utils.hub as tf_hub
tf_hub._is_offline_mode = _saved_transformers_const
except ImportError:
pass
_saved_env = None
_saved_hf_const = None
_saved_transformers_const = None
def patch_huggingface_hub_offline(): def patch_huggingface_hub_offline():
+7 -4
View File
@@ -17,7 +17,7 @@
}, },
"app": { "app": {
"name": "@voicebox/app", "name": "@voicebox/app",
"version": "0.2.0", "version": "0.4.1",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -72,9 +72,10 @@
}, },
"landing": { "landing": {
"name": "@voicebox/landing", "name": "@voicebox/landing",
"version": "0.2.0", "version": "0.4.1",
"dependencies": { "dependencies": {
"@fontsource/space-grotesk": "^5.2.10", "@fontsource/space-grotesk": "^5.2.10",
"@icons-pack/react-simple-icons": "^13.13.0",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"autoprefixer": "^10.4.17", "autoprefixer": "^10.4.17",
@@ -100,7 +101,7 @@
}, },
"tauri": { "tauri": {
"name": "@voicebox/tauri", "name": "@voicebox/tauri",
"version": "0.2.0", "version": "0.4.1",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0",
@@ -123,7 +124,7 @@
}, },
"web": { "web": {
"name": "@voicebox/web", "name": "@voicebox/web",
"version": "0.2.0", "version": "0.4.1",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"react": "^18.3.0", "react": "^18.3.0",
@@ -287,6 +288,8 @@
"@humanwhocodes/object-schema": ["@humanwhocodes/[email protected]", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="], "@humanwhocodes/object-schema": ["@humanwhocodes/[email protected]", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="],
"@icons-pack/react-simple-icons": ["@icons-pack/[email protected]", "", { "peerDependencies": { "react": "^16.13 || ^17 || ^18 || ^19" } }, "sha512-B5HhQMIpcSH4z8IZ8HFhD59CboHceKYMpPC9kAwGyKntvPdyJJv26DLu4Z1wAjcCLyrJhf11tMhiQGom9Rxb9g=="],
"@img/colour": ["@img/[email protected]", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], "@img/colour": ["@img/[email protected]", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
"@img/sharp-darwin-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], "@img/sharp-darwin-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
+44 -10
View File
@@ -1,6 +1,6 @@
# Voicebox Project Status & Roadmap # Voicebox Project Status & Roadmap
> Last updated: 2026-04-18 | Current version: **v0.4.1** | ~155 open issues | 12 open PRs > Last updated: 2026-04-18 | Current version: **v0.4.1** | 232 open issues | 12 open PRs
--- ---
@@ -224,6 +224,8 @@ POST /generate
- **Blackwell (RTX 50-series) CUDA**: cu128 + sm_120 kernel support shipped (PR #401, #316), but users still report `cudaErrorNoKernelImageForDevice` (#417, #400, #396, #395, #390, #362) — likely a stale CUDA binary on upgraded installs. Needs a follow-up diagnostic / forced re-download path. - **Blackwell (RTX 50-series) CUDA**: cu128 + sm_120 kernel support shipped (PR #401, #316), but users still report `cudaErrorNoKernelImageForDevice` (#417, #400, #396, #395, #390, #362) — likely a stale CUDA binary on upgraded installs. Needs a follow-up diagnostic / forced re-download path.
- **Long text 50k character limit** (#464, #365, #354): Still hit on GPU despite chunking (PR #266). Chunking reliability needs another pass. - **Long text 50k character limit** (#464, #365, #354): Still hit on GPU despite chunking (PR #266). Chunking reliability needs another pass.
- **ROCm on RDNA 3/4** (#469): `HSA_OVERRIDE_GFX_VERSION` is hardcoded and harms newer cards. - **ROCm on RDNA 3/4** (#469): `HSA_OVERRIDE_GFX_VERSION` is hardcoded and harms newer cards.
- **`flash-attn is not installed` warning on every platform (cosmetic, common user complaint)**: Our transformer-based engines (Chatterbox / Qwen) emit `Warning: flash-attn is not installed. Will only run the manual PyTorch version. Please install flash-attn for faster inference.` on every startup, on every platform — we don't pin `flash-attn` in requirements because installing it is fragile and version-sensitive. Fallback is PyTorch SDPA, which is near-FA2 throughput on Ampere+ and is what actually runs. **Per-platform reality:** (a) **macOS/Apple Silicon** — FlashAttention is CUDA-only, irrelevant here; MLX has its own attention kernels. (b) **Linux** — `pip install flash-attn --no-build-isolation` works but takes 20+ min to compile. (c) **Windows** — no official support (Dao-AILab README still says only "Might work"; source builds routinely fail on recent CUDA/MSVC, issues #1715, #1828, #2395). Windows users can install community prebuilt wheels from `kingbri1/flash-attention` or `bdashore3/flash-attention` (latest v2.8.3, Aug 2025; `win_amd64` wheels for CUDA 12.4/12.8, Torch 2.6–2.9, Python 3.10–3.13) matching their exact CUDA/Torch/Python, or use WSL2. **Native-Windows alternatives worth considering as a build-time swap:** SageAttention (thu-ml, Apache 2.0, claims 2–5× over FA2) and xformers (official Windows wheels). **Action for us:** troubleshooting doc now covers it (see `docs/content/docs/overview/troubleshooting.mdx`), and we should optionally suppress the warning via `logging.getLogger(...).setLevel(ERROR)` at backend import since the fallback is functionally fine.
- **WebAudio playback dies after audio-session interruption** (#41, plus an internal repro where the app is backgrounded long enough): WaveSurfer's `AudioContext` gets suspended by macOS — either because another app grabs the audio output, or because the WKWebView throttles when backgrounded. `play()` resolves and `timeupdate` can still fire, but no audio reaches the output. Only app restart fixes it. **Things already tried that didn't work:** (a) swapping WaveSurfer backend away from WebAudio — introduced more bugs, not an option; (b) remount hook on the player — doesn't help because a freshly-created `AudioContext` is born suspended and only resumes on a user gesture. PR #293 was a prior partial fix that doesn't cover this path. **Next thing to try** (not yet attempted — confirmed via grep of `AudioPlayer.tsx`): call `wavesurfer.getMediaElement().getGainNode().context.resume()` on the play button click (the click itself is a valid user gesture), plus a `visibilitychange` + `statechange` listener as belt-and-suspenders. The `ctx.resume()` pattern already exists in the codebase at `useStoryPlayback.ts:52` — just not wired into the main player.
--- ---
@@ -303,9 +305,15 @@ POST /generate
Still reported. Users get stuck downloads, can't resume, offline mode edge cases. Still reported. Users get stuck downloads, can't resume, offline mode edge cases.
**Key issues:** #475 (MAC CustomVoice install error), #449 (infinite loading macOS), #445 (can't download CustomVoice), #462 (Qwen requires internet even when loaded — regression from #150), #434 (infinite retry loop offline — PR #443 open), #432 (storage location change hangs when empty — partly fixed by PR #439/#433), #181, #180. **Key issues:** #475 (MAC CustomVoice install error), #449 (infinite loading macOS), #445 (can't download CustomVoice), #462 (Qwen requires internet even when loaded — regression from #150), #434 (infinite retry loop offline — PR #443 open), #432 (storage location change hangs when empty — partly fixed by PR #439/#433), #348 (TADA 3B Multilingual download fails), #336 (TADA model not listed in app), #275 (`No module named 'chatterbox'` on download), #304 (whisper-base feature extractor load error), #287 (macOS ARM `check_model_inputs` ImportError on new version), #181, #180.
**Fix path:** PR #443 addresses infinite offline retry. CustomVoice-specific download failures (#475, #445) need triage — likely related to frozen-binary import fixes in PR #438. **Fix path:** PR #443 addresses infinite offline retry. CustomVoice-specific download failures (#475, #445) need triage — likely related to frozen-binary import fixes in PR #438. TADA cluster (#336, #348) and macOS ARM import regressions (#287, #275, #304) need a dedicated triage pass.
**Qwen 0.6B-downloads-1.7B reports:** **#485** (2026-04-19), **#423** (macOS M1), **#329**. Platform-dependent:
- **On MLX (Apple Silicon) — not a bug.** `mlx-community` only publishes 1.7B-Base-bf16 weights, so the 0.6B Base option intentionally resolves to the same repo (`backend/backends/__init__.py:180` — `# 0.6B not available in MLX, falls back`). UX gap: the selector offers a size that doesn't exist on the active backend. Fix: (a) hide the 0.6B option on MLX, or (b) label it "0.6B (uses 1.7B on Apple Silicon)".
- **On PyTorch (Windows/Linux/CUDA/ROCm/XPU/CPU) — real bug if reported.** Both 0.6B and 1.7B have distinct repos (`Qwen/Qwen3-TTS-12Hz-0.6B-Base` vs `-1.7B-Base`). Triage each report by platform before merging into the MLX cluster.
- **Qwen CustomVoice (either platform)** — no fallback, both sizes always have dedicated repos.
### Language Requests (ongoing) ### Language Requests (ongoing)
@@ -364,6 +372,9 @@ Notable:
- **#383** — Concatenate partial reference audio into generated audio - **#383** — Concatenate partial reference audio into generated audio
- **#382** — Lightning.ai support - **#382** — Lightning.ai support
- **#376** — Remote mode - **#376** — Remote mode
- **#353** — Audio transcoding
- **#317** — Voice pitch control
- **#189** — "Auto" language option
- **#173** — Vocal intonation/inflection control - **#173** — Vocal intonation/inflection control
- **#165, #270** — Audiobook mode (PR #154 open) - **#165, #270** — Audiobook mode (PR #154 open)
- **#242** — Seed value pinning - **#242** — Seed value pinning
@@ -371,17 +382,40 @@ Notable:
- **#235** — Finetuned Qwen3-TTS tokenizer (PR #253 open) - **#235** — Finetuned Qwen3-TTS tokenizer (PR #253 open)
- **#144** — Copy text to clipboard - **#144** — Copy text to clipboard
### Housekeeping / Triage Needed
| Issue | Reason |
|-------|--------|
| **#431**, **#408** | Spam — Chinese "free Claude API" promos. Close. |
| **#398** ("Excelente") | Non-issue. Close. |
| **#357** | Informational — project featured in Awesome MLX. Close after acknowledgement. |
| **#374**, **#377** | Version-release questions, no bug. Close. |
| **#306** ("voice model"), **#389** ("New model"), **#473** ("New functionality") | Title-only issues, no content. Request details or close. |
| **#309** | Uninstall/cleanup question. Answer and close. |
| **#241** | "How to use in Colab" — support question, not a bug. |
| **#423** / **#485** / **#329** | Platform-dependent. On MLX: not a bug (0.6B weights don't exist upstream, fallback is intentional — fix UX). On PyTorch: real bug if reproducible. Classify each by reporter's platform before deduping. |
| **#336** / **#348** | TADA download/registration cluster — triage together. |
| **#287** / **#275** / **#304** | macOS ARM import regressions on new version — likely one root cause. |
| **#292**, **#349** | Possibly already fixed by merged PRs (#321/#412 and #345). Verify + close. |
**~70 older issues (pre-#170) not individually categorized above.** Most are long-tail support questions or duplicates of problems now addressed by the multi-engine / model-registry work. A dedicated backlog-sweep pass is overdue.
### Bugs (ongoing) ### Bugs (ongoing)
| Category | Issues | | Category | Issues |
|----------|--------| |----------|--------|
| Generation failures | #476, #467, #452, #459 (voice clone fetch error), #468 (tada-1b marked error), #437, #282 | | Generation failures | #476, #467, #452, #459 (voice clone fetch error), #468 (tada-1b marked error), #437, #300, #301, #282 |
| Audio quality | #456 (clipping errors v0.4.0), #436 (emotion labels), #333 (pitch/echo), #307 (by-model breakdown) | | Audio quality | #456 (clipping errors v0.4.0), #436 (emotion labels), #333 (pitch/echo), #307 (by-model breakdown), #340 (all generations say "www...") |
| File ops | #477 (spacy_pkuseg dict missing on frozen Windows build), #472 (storage location change) | | Transcription | #371 (fails every time), #291 (extract transcription from generated audio) |
| Windows | #466 (install problem), #273 (port 8000 conflict) | | Effects / presets | #349 ("Failed to save" when creating effects presets — possibly fixed by merged #345) |
| Linux | #471 (thread-safe PULSE_SOURCE), #413 (Arch build), #409 (Kubuntu build), #341 | | File ops | #477 (spacy_pkuseg dict missing on frozen Windows build), #472 (storage location change), #283 (allow longer files for voice creation + in-app trim), #350 (failed to add sample) |
| macOS | #441 (older macOS), #369 (malware flag), #171 (ARM64 binary won't open) | | History | #292 (can't delete failed generations — possibly fixed by merged #321/#412) |
| Profile/UI | #360 (Kokoro profile hides others — partly addressed by auto-switch), #299 (drag-drop on Win11), #329 (size selector state bug) | | Windows | #466 (install problem), #375 (WinError 5 access denied), #273 (port 8000 conflict), #201 (model doesn't stay loaded) |
| Linux | #471 (thread-safe PULSE_SOURCE), #413 (Arch build), #409 (Kubuntu build), #351, #341 |
| macOS | #441 (older macOS), #369 (malware flag), #334 (microphone permission), #287 (`check_model_inputs` ImportError — regression), #171 (ARM64 binary won't open) |
| Profile/UI | #360 (Kokoro profile hides others — partly addressed by auto-switch), #299 (drag-drop on Win11), #329 (size selector state bug), #393 (stuck loading screen after reinstall to new dir) |
| Integrations | #397 (SAMMI-bot 422 Unprocessable Entity) |
| Audio playback / session | **#41** (macOS: Voicebox goes silent after another app takes audio output; restart restores it) — see deep-dive below |
| Database | #174 (sqlite3 IntegrityError) | | Database | #174 (sqlite3 IntegrityError) |
--- ---
-311
View File
@@ -1,311 +0,0 @@
---
title: "Troubleshooting Guide"
description: "Common issues and solutions for Voicebox"
---
Common issues and solutions for Voicebox.
## Installation Issues
### macOS: "Voicebox cannot be opened because it is from an unidentified developer"
**Solution:**
1. Right-click the `.dmg` file
2. Select "Open"
3. Click "Open" in the security dialog
4. Alternatively, go to System Settings → Privacy & Security → Allow Voicebox
### Windows: "Windows protected your PC"
**Solution:**
1. Click "More info"
2. Click "Run anyway"
3. Windows Defender may flag new software; this is normal for unsigned apps
### Linux: AppImage won't run
**Solution:**
```bash
chmod +x voicebox-*.AppImage
./voicebox-*.AppImage
```
## Runtime Issues
### Server won't start
**Symptoms:** App opens but shows "Server not connected"
**Solutions:**
1. **Check Python installation**
```bash
python --version # Should be 3.11+
```
2. **Check server binary exists**
- Look in `tauri/src-tauri/binaries/` for your platform
- Binary should match your system architecture
3. **Check permissions**
```bash
# macOS/Linux
chmod +x tauri/src-tauri/binaries/voicebox-server-*
```
4. **Check logs**
- macOS: Open Console.app and search for "voicebox"
- Linux: Check `~/.local/share/voicebox/` for logs
- Windows: Check Event Viewer
### "Model download failed"
**Symptoms:** First generation fails with download error
**Solutions:**
1. **Check internet connection**
- Models download from HuggingFace Hub (~2-4GB)
- First download may take several minutes
2. **Check disk space**
- Models are cached in `~/.cache/huggingface/`
- Ensure at least 5GB free space
3. **Manual download** (if automatic fails)
```bash
pip install huggingface_hub
huggingface-cli download Qwen/Qwen3-TTS-12Hz-1.7B-Base
```
### "Out of memory" errors
**Symptoms:** Generation fails with CUDA/VRAM errors
**Solutions:**
1. **Use smaller model**
- Switch to 0.6B model instead of 1.7B
- Settings → Model Management → Load 0.6B
2. **Close other applications**
- Free up GPU memory
- Close browser tabs, other ML apps
3. **Use CPU mode**
- Slower but works without GPU
- Backend automatically falls back to CPU
### MLX "Failed to load the default metallib" error (Apple Silicon)
**Symptoms:** Generation fails with "library not found" or "metallib" errors
**Solutions:**
1. **Rebuild server binary**
```bash
just build-server
```
The build script automatically includes MLX Metal shader libraries on Apple Silicon.
2. **Check MLX installation**
```bash
pip install -r backend/requirements-mlx.txt
```
3. **Verify backend detection**
- Check server logs for "Backend: MLX"
- If showing "Backend: PYTORCH", MLX may not be installed correctly
### Audio playback issues
**Symptoms:** Generated audio won't play
**Solutions:**
1. **Check audio format**
- Audio is saved as WAV files
- Ensure your system supports WAV playback
2. **Try downloading audio**
- Right-click → Download
- Play in external player
3. **Check browser permissions** (web version)
- Allow audio autoplay in browser settings
### Slow generation
**Symptoms:** Generation takes >30 seconds
**Solutions:**
1. **Check backend type** (Apple Silicon)
- Check Settings → Server Status
- Should show "Backend: MLX" on Apple Silicon
- If showing "Backend: PYTORCH", install MLX: `pip install -r backend/requirements-mlx.txt`
- MLX provides 4-5x faster inference on Apple Silicon
2. **Use GPU** (if available)
- Check Settings → Server Status
- Should show "GPU available: true"
- Apple Silicon: Should show "Metal (Apple Silicon via MLX)"
- Windows/Linux: Should show "CUDA" if GPU available
3. **Enable caching**
- Voice prompts are cached automatically
- Second generation with same voice should be faster
4. **Use smaller model**
- 0.6B model is faster than 1.7B
- Quality difference is minimal for most voices
5. **Check system resources**
- Close other CPU/GPU intensive apps
- Ensure adequate RAM (8GB+ recommended)
## API Issues
### "Connection refused" when using API
**Solutions:**
1. **Check server is running**
```bash
curl http://localhost:17493/health
```
2. **Check remote mode**
- If connecting remotely, ensure server is started with `--host 0.0.0.0`
- Check firewall settings
3. **Check port availability**
- The current local app and dev workflow uses port 17493 by default
- Ensure no other service is using it
### CORS errors in browser
**Solutions:**
1. **Use desktop app** (recommended)
- Desktop app doesn't have CORS restrictions
2. **Configure CORS** (for web deployment)
- Update `backend/main.py` CORS settings
- Add your domain to allowed origins
## Update Issues
### "Update check failed"
**Solutions:**
1. **Check internet connection**
- Updates are fetched from GitHub releases
2. **Check GitHub access**
- Ensure `github.com` is accessible
- Check firewall/proxy settings
3. **Manual update**
- Download latest release from GitHub
- Install manually
### "Invalid signature" error
**Solutions:**
1. **Re-download installer**
- Signature may be corrupted
- Download fresh copy from GitHub
2. **Check release integrity**
- Verify `.sig` file matches installer
- Report issue if signature is invalid
## Data Issues
### Profiles disappeared
**Solutions:**
1. **Check data directory**
- macOS: `~/Library/Application Support/sh.voicebox.app/`
- Windows: `%APPDATA%/sh.voicebox.app/`
- Linux: `~/.config/sh.voicebox.app/`
2. **Check database**
- Database: `data/voicebox.db`
- Ensure file exists and is readable
3. **Restore from backup**
- Profiles can be exported/imported
- Check for backup files
### "Database locked" error
**Solutions:**
1. **Close other instances**
- Ensure only one Voicebox instance is running
2. **Restart app**
- Close and reopen Voicebox
3. **Check file permissions**
- Ensure database file is writable
- Check directory permissions
## Development Issues
### Build fails
**Solutions:**
1. **Check Rust installation**
```bash
rustc --version
rustup update
```
2. **Check Tauri dependencies**
```bash
cd tauri
bun install
```
3. **Clean build**
```bash
cd tauri/src-tauri
cargo clean
cd ../..
just build
```
### API client generation fails
**Solutions:**
1. **Start backend server**
```bash
just dev-backend
```
2. **Check OpenAPI endpoint**
```bash
curl http://localhost:17493/openapi.json
```
3. **Regenerate client**
```bash
just generate-api
```
## Still Having Issues?
1. **Check existing issues**
- Search GitHub issues for similar problems
- Check closed issues for solutions
2. **Create new issue**
- Include:
- OS and version
- Voicebox version
- Steps to reproduce
- Error messages/logs
- Screenshots (if applicable)
3. **Get help**
- Check documentation in `docs/`
- Review `backend/README.md` for API details
- See `CONTRIBUTING.md` for development help
---
For more help, open an issue on [GitHub](https://github.com/jamiepine/voicebox/issues).
+127 -3
View File
@@ -29,6 +29,14 @@ Windows SmartScreen may warn that the app is unrecognized.
This is expected for unsigned applications. We're working on code signing for future releases. This is expected for unsigned applications. We're working on code signing for future releases.
</Callout> </Callout>
### Linux: AppImage Won't Run
**Solution:**
```bash
chmod +x voicebox-*.AppImage
./voicebox-*.AppImage
```
## Server Issues ## Server Issues
### Backend Server Won't Start ### Backend Server Won't Start
@@ -85,6 +93,52 @@ Windows SmartScreen may warn that the app is unrecognized.
</Accordion> </Accordion>
</AccordionGroup> </AccordionGroup>
### `flash-attn is not installed` Warning in Server Logs
**Symptoms:**
```
Warning: flash-attn is not installed. Will only run the manual PyTorch version.
Please install flash-attn for faster inference.
```
**This is harmless.** The warning is emitted by our transformer-based engines (Chatterbox / Qwen) on every startup. FlashAttention is an optional acceleration library — when it's not present, PyTorch's built-in scaled-dot-product attention (SDPA) runs instead, which is near-FA2 throughput on modern GPUs. Generation works normally.
**Why it shows up on every platform:**
- **Windows:** `flash-attn` has no official Windows support. The upstream project (Dao-AILab/flash-attention) still only says it *might* work, and source builds typically fail on recent CUDA/MSVC combinations.
- **macOS (Apple Silicon):** FlashAttention is CUDA-only and doesn't apply here at all. MLX has its own optimized attention kernels.
- **Linux:** It's not pinned in our requirements because installing it is fragile and version-sensitive; users who want it install it themselves.
**Solutions (all optional):**
<AccordionGroup>
<Accordion title="Ignore it (recommended)">
PyTorch SDPA is what actually runs the model, and on Ampere/Ada/Hopper GPUs it's within a few percent of FA2 for our workloads. You won't notice a meaningful speed difference.
</Accordion>
<Accordion title="Install flash-attn on Linux">
```bash
pip install flash-attn --no-build-isolation
```
Requires a matching CUDA toolkit. Build can take 20+ minutes.
</Accordion>
<Accordion title="Install flash-attn on Windows (community wheels)">
Official builds don't exist, but community maintainers publish prebuilt wheels:
- [kingbri1/flash-attention releases](https://github.com/kingbri1/flash-attention/releases)
- [bdashore3/flash-attention releases](https://github.com/bdashore3/flash-attention/releases)
Pick the wheel matching your exact CUDA + PyTorch + Python combination. Example:
```bash
pip install https://github.com/kingbri1/flash-attention/releases/download/v2.8.3/flash_attn-2.8.3+cu128torch2.8.0cxx11abiFALSE-cp312-cp312-win_amd64.whl
```
Alternatively, run Voicebox's backend inside WSL2 and use the standard Linux wheels.
</Accordion>
</AccordionGroup>
### Connection Timeout ### Connection Timeout
**Symptoms:** **Symptoms:**
@@ -174,6 +228,34 @@ This is expected behavior. The first generation downloads the selected TTS engin
</Accordion> </Accordion>
</AccordionGroup> </AccordionGroup>
### MLX "Failed to load the default metallib" (Apple Silicon)
**Symptoms:**
- Generation fails with "library not found" or "metallib" errors
- Server logs reference missing Metal shader libraries
**Solutions:**
<AccordionGroup>
<Accordion title="Rebuild the Server Binary">
```bash
just build-server
```
The build script bundles MLX Metal shader libraries on Apple Silicon automatically.
</Accordion>
<Accordion title="Reinstall MLX Dependencies">
```bash
pip install -r backend/requirements-mlx.txt
```
</Accordion>
<Accordion title="Verify Backend Detection">
Check Settings → Server Status. Should show **Backend: MLX** on Apple Silicon. If it shows **Backend: PYTORCH**, MLX isn't installed correctly.
</Accordion>
</AccordionGroup>
## Audio Issues ## Audio Issues
### No Audio Playback ### No Audio Playback
@@ -357,7 +439,26 @@ Restart the app to create a fresh database.
- Check your internet connection - Check your internet connection
- Check HuggingFace Hub status - Check HuggingFace Hub status
- Try using a VPN if HuggingFace is blocked in your region - Try using a VPN if HuggingFace is blocked in your region
- Manually download and place in cache directory - Manually download via the HuggingFace CLI and place in the cache directory:
```bash
pip install huggingface_hub
huggingface-cli download Qwen/Qwen3-TTS-12Hz-1.7B-Base
```
### Qwen 0.6B Downloads the Same Files as 1.7B on Apple Silicon
**Symptoms:**
- You select Qwen 0.6B on an Apple Silicon Mac and the download is the same size as 1.7B
- Generation speed and VRAM usage match 1.7B, not the expected smaller model
**Explanation:**
This is intentional, not a bug. The MLX community only publishes `mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16` — there is no 0.6B MLX build. Voicebox's model registry falls back to the 1.7B weights when 0.6B is selected on MLX (see `backend/backends/__init__.py`).
**Solution:**
- On Apple Silicon, both size options use the 1.7B model — pick either.
- If you specifically need a smaller model, switch to **Kokoro 82M** (~350 MB) or **LuxTTS** (~300 MB) — both CPU-realtime.
- On Windows/Linux with PyTorch, 0.6B and 1.7B are distinct repos and behave differently.
### Wrong Model Version ### Wrong Model Version
@@ -404,6 +505,16 @@ rmdir /s %USERPROFILE%\.cache\huggingface\hub\models--Qwen*
<Accordion title="Update GPU Drivers"> <Accordion title="Update GPU Drivers">
Outdated drivers can cause performance issues. Update to the latest NVIDIA drivers. Outdated drivers can cause performance issues. Update to the latest NVIDIA drivers.
</Accordion> </Accordion>
<Accordion title="Apple Silicon: Confirm MLX Backend">
Check Settings → Server Status. Should show **Backend: MLX** on Apple Silicon — MLX is 4–5× faster than PyTorch here. If it shows **Backend: PYTORCH**, reinstall MLX:
```bash
pip install -r backend/requirements-mlx.txt
```
GPU availability should read "Metal (Apple Silicon via MLX)".
</Accordion>
</AccordionGroup> </AccordionGroup>
### High Memory Usage ### High Memory Usage
@@ -417,6 +528,21 @@ rmdir /s %USERPROFILE%\.cache\huggingface\hub\models--Qwen*
- Clear generation history - Clear generation history
- Restart the app periodically - Restart the app periodically
## Update Issues
### "Update Check Failed"
**Solutions:**
- Confirm your internet connection — updates are fetched from GitHub releases.
- Ensure `github.com` is accessible and not blocked by a firewall or proxy.
- As a fallback, download the latest release from GitHub and install manually.
### "Invalid Signature" Error
**Solutions:**
- Re-download the installer — the signature may have been corrupted in transit.
- Verify the `.sig` file matches the installer; if it doesn't, file an issue.
## Remote Mode Issues ## Remote Mode Issues
### Can't Connect to Remote Server ### Can't Connect to Remote Server
@@ -482,5 +608,3 @@ python --version
# GPU info (if generation issues) # GPU info (if generation issues)
nvidia-smi # NVIDIA GPUs nvidia-smi # NVIDIA GPUs
``` ```
For more detailed troubleshooting, see the [TROUBLESHOOTING.md](https://github.com/jamiepine/voicebox/blob/main/docs/TROUBLESHOOTING.md) file in the repository.
+2 -1
View File
@@ -3,13 +3,14 @@
"version": "0.4.1", "version": "0.4.1",
"description": "Landing page for voicebox.sh", "description": "Landing page for voicebox.sh",
"scripts": { "scripts": {
"dev": "bun --bun next dev --turbo", "dev": "next dev --turbo",
"build": "bun --bun next build", "build": "bun --bun next build",
"start": "bun --bun next start", "start": "bun --bun next start",
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@fontsource/space-grotesk": "^5.2.10", "@fontsource/space-grotesk": "^5.2.10",
"@icons-pack/react-simple-icons": "^13.13.0",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"autoprefixer": "^10.4.17", "autoprefixer": "^10.4.17",
+31 -27
View File
@@ -1,42 +1,46 @@
import { type NextRequest, NextResponse } from 'next/server'; import { type NextRequest, NextResponse } from 'next/server';
import { getLatestRelease } from '@/lib/releases';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
const PLATFORM_MAP: Record< // Pretty URLs from README / docs (e.g. /download/mac-arm) are kept for
string, // compatibility, but we now always route through the /download page so users
keyof Awaited<ReturnType<typeof getLatestRelease>>['downloadLinks'] // see context + a donate prompt + resources while the download kicks off.
> = { // The page handles the actual file trigger itself — no more silent redirects
// to GitHub or direct asset URLs.
const PLATFORM_ALIAS: Record<string, string> = {
'mac-arm': 'macArm', 'mac-arm': 'macArm',
macArm: 'macArm',
'mac-intel': 'macIntel', 'mac-intel': 'macIntel',
macIntel: 'macIntel',
windows: 'windows', windows: 'windows',
linux: 'linux',
}; };
function getPublicOrigin(request: NextRequest): string {
const forwardedHost = request.headers.get('x-forwarded-host');
const forwardedProto = request.headers.get('x-forwarded-proto');
if (forwardedHost && forwardedProto) {
// Behind reverse proxies/CDNs, request.url can be an internal origin
// (for example localhost:8080). Prefer forwarded headers so redirects
// keep users on the public domain.
return `${forwardedProto}://${forwardedHost}`;
}
return new URL(request.url).origin;
}
export async function GET( export async function GET(
_request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ platform: string }> }, { params }: { params: Promise<{ platform: string }> },
) { ) {
const origin = getPublicOrigin(request);
const { platform } = await params; const { platform } = await params;
const key = PLATFORM_MAP[platform]; // No prebuilt Linux binary yet — send straight to the build-from-source page.
if (platform === 'linux') {
if (!key) { return NextResponse.redirect(new URL('/linux-install', origin), 307);
return NextResponse.json(
{ error: `Unknown platform: ${platform}. Use: ${Object.keys(PLATFORM_MAP).join(', ')}` },
{ status: 404 },
);
}
try {
const release = await getLatestRelease();
const url = release.downloadLinks[key];
if (!url) {
return NextResponse.json({ error: `No download available for ${platform}` }, { status: 404 });
}
return NextResponse.redirect(url);
} catch {
return NextResponse.redirect(`https://github.com/jamiepine/voicebox/releases/latest`);
} }
const normalized = PLATFORM_ALIAS[platform];
const target = new URL('/download', origin);
if (normalized) target.searchParams.set('platform', normalized);
return NextResponse.redirect(target, 307);
} }
+313
View File
@@ -0,0 +1,313 @@
'use client';
import {
ArrowLeft,
Bot,
Coffee,
Download as DownloadIcon,
FileText,
Github,
} from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/PlatformIcons';
import { Button } from '@/components/ui/button';
import { DONATE_URL, GITHUB_RELEASES_PAGE, GITHUB_REPO } from '@/lib/constants';
import type { DownloadLinks } from '@/lib/releases';
type Platform = keyof DownloadLinks;
type PlatformMeta = {
key: Platform;
label: string;
description: string;
icon: React.ComponentType<{ className?: string }>;
};
const PLATFORMS: PlatformMeta[] = [
{ key: 'macArm', label: 'macOS', description: 'Apple Silicon', icon: AppleIcon },
{ key: 'macIntel', label: 'macOS', description: 'Intel (x64)', icon: AppleIcon },
{ key: 'windows', label: 'Windows', description: '64-bit (MSI)', icon: WindowsIcon },
{ key: 'linux', label: 'Linux', description: 'Build from source', icon: LinuxIcon },
];
function detectPlatform(): Platform | null {
if (typeof navigator === 'undefined') return null;
const ua = navigator.userAgent;
if (/Windows/i.test(ua)) return 'windows';
if (/Linux/i.test(ua) && !/Android/i.test(ua)) return 'linux';
if (/Mac/i.test(ua)) {
// Apple Silicon Safari reports "Intel" for compat; default to ARM since
// M-series is the majority. Users can click the Intel button if needed.
return 'macArm';
}
return null;
}
function parseQueryPlatform(search: string): Platform | null {
const params = new URLSearchParams(search);
const raw = params.get('platform');
if (!raw) return null;
// Accept both camelCase and hyphenated forms (/download/mac-arm → ?platform=mac-arm).
const normalized = raw
.toLowerCase()
.replace(/[-_\s]/g, '')
.replace('macarm', 'macArm')
.replace('macintel', 'macIntel');
const valid: Platform[] = ['macArm', 'macIntel', 'windows', 'linux'];
return (valid as string[]).includes(normalized) ? (normalized as Platform) : null;
}
export default function DownloadPage() {
const [links, setLinks] = useState<DownloadLinks | null>(null);
const [linksError, setLinksError] = useState(false);
const [platform, setPlatform] = useState<Platform | null>(null);
const [triggered, setTriggered] = useState(false);
useEffect(() => {
const fromQuery = parseQueryPlatform(window.location.search);
const resolved = fromQuery ?? detectPlatform();
// No prebuilt Linux binary yet — send Linux users to the build-from-source
// instructions instead of sitting on /download trying to trigger a
// download that doesn't exist.
if (resolved === 'linux') {
window.location.replace('/linux-install');
return;
}
setPlatform(resolved);
}, []);
useEffect(() => {
let cancelled = false;
fetch('/api/releases')
.then((r) => {
if (!r.ok) throw new Error(`releases ${r.status}`);
return r.json();
})
.then((data) => {
if (cancelled) return;
if (data.downloadLinks) setLinks(data.downloadLinks as DownloadLinks);
})
.catch(() => {
if (!cancelled) setLinksError(true);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (triggered || !links || !platform) return;
const url = links[platform];
if (!url) return;
const a = document.createElement('a');
a.href = url;
a.rel = 'noopener';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTriggered(true);
}, [triggered, links, platform]);
const activeMeta = useMemo(
() => PLATFORMS.find((p) => p.key === platform) ?? null,
[platform],
);
return (
<div className="min-h-screen bg-background">
{/* Minimal branded header */}
<header className="border-b border-border/50">
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
<Link href="/" className="flex items-center gap-2.5">
<Image
src="/voicebox-logo-app.webp"
alt="Voicebox"
width={28}
height={28}
className="h-7 w-7"
/>
<span className="text-[15px] font-semibold text-foreground">Voicebox</span>
</Link>
<Link
href="/"
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-3.5 w-3.5" />
Back to voicebox.sh
</Link>
</div>
</header>
<main className="mx-auto max-w-5xl px-6 py-16 md:py-24">
{/* Hero */}
<div className="flex flex-col md:flex-row md:items-center gap-10 md:gap-14">
<Image
src="/voicebox-logo-app.webp"
alt="Voicebox"
width={200}
height={200}
priority
className="h-32 w-32 md:h-44 md:w-44 shrink-0 drop-shadow-2xl"
/>
<div className="flex-1 min-w-0 text-center md:text-left">
{triggered ? (
<>
<h1 className="text-4xl md:text-5xl font-semibold tracking-tight text-foreground mb-4">
Your download has started.
</h1>
<p className="text-lg text-muted-foreground">
{activeMeta
? `Downloading Voicebox for ${activeMeta.label} (${activeMeta.description}). Check your downloads folder.`
: 'Check your downloads folder for Voicebox.'}
</p>
</>
) : (
<>
<h1 className="text-4xl md:text-5xl font-semibold tracking-tight text-foreground mb-4">
{linksError ? "We couldn't load the latest release." : 'Download Voicebox'}
</h1>
<p className="text-lg text-muted-foreground">
{linksError
? 'Our release server is temporarily unreachable. Please try again in a moment.'
: 'Pick your platform to get started.'}
</p>
</>
)}
</div>
</div>
{/* Platform buttons — always visible as a fallback */}
{linksError ? (
<div className="mt-12 rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 text-center">
<p className="text-sm text-muted-foreground mb-4">
If this keeps happening, you can{' '}
<a
href={`${GITHUB_RELEASES_PAGE}/latest`}
target="_blank"
rel="noopener noreferrer"
className="text-accent underline underline-offset-2 hover:text-accent/80"
>
browse releases on GitHub
</a>
{' '}and grab the build for your platform manually.
</p>
</div>
) : (
<div className="mt-12 rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6">
<h2 className="text-sm font-medium text-foreground mb-4">
{triggered ? 'Download not working?' : 'Choose your platform'}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{PLATFORMS.map((meta) => {
const isLinux = meta.key === 'linux';
const url = isLinux ? '/linux-install' : links?.[meta.key];
const isActive = meta.key === platform;
const disabled = !isLinux && !url;
return (
<a
key={meta.key}
href={url ?? '#'}
{...(isLinux ? {} : { download: true })}
aria-disabled={disabled}
onClick={(e) => {
if (disabled) e.preventDefault();
}}
className={`flex items-center rounded-xl border px-5 py-4 transition-all group ${
isActive
? 'border-accent/40 bg-accent/5 hover:border-accent/60'
: 'border-border bg-card/40 hover:border-accent/30 hover:bg-card'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<meta.icon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
<div className="ml-4 flex-1">
<div className="text-sm font-medium text-foreground">{meta.label}</div>
<div className="text-xs text-muted-foreground">{meta.description}</div>
</div>
<DownloadIcon className="h-4 w-4 text-muted-foreground/60 group-hover:text-accent transition-colors" />
</a>
);
})}
</div>
</div>
)}
{/* Donate — prominent, heartfelt, post-click context */}
<div className="mt-16 rounded-2xl border border-border bg-gradient-to-br from-card via-card/80 to-background backdrop-blur-sm p-8 md:p-10 overflow-hidden relative">
<div className="absolute top-0 right-0 w-64 h-64 bg-[#FFDD00]/5 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2 pointer-events-none" />
<div className="relative">
<div className="inline-flex items-center gap-2 rounded-full border border-[#FFDD00]/30 bg-[#FFDD00]/10 px-3 py-1 mb-4">
<Coffee className="h-3 w-3 text-[#FFDD00]" />
<span className="text-[11px] font-medium uppercase tracking-wider text-[#FFDD00]">
Hi from the maintainer
</span>
</div>
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground mb-4">
Jamie here — Voicebox is a side project.
</h2>
<p className="text-muted-foreground leading-relaxed mb-6 max-w-2xl">
I build and maintain Voicebox in my spare time. It's completely
free, open source, runs entirely on your machine — no accounts, no
cloud, no subscriptions, no upsells. If it saves you an ElevenLabs
bill or just made your day, a coffee genuinely helps me keep
shipping updates, adding new models, and fixing bugs. Every little
bit keeps the lights on.
</p>
<Button asChild size="lg" className="bg-[#FFDD00]/10 border-[#FFDD00]/30 text-[#FFDD00] hover:bg-[#FFDD00]/20 hover:border-[#FFDD00]/50">
<a href={DONATE_URL} target="_blank" rel="noopener noreferrer">
<Coffee className="h-4 w-4 mr-2" />
Buy me a coffee
</a>
</Button>
</div>
</div>
{/* Resources */}
<div className="mt-10">
<h2 className="text-sm font-medium text-foreground mb-4">While you wait</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<a
href="https://docs.voicebox.sh"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5 hover:border-accent/30 hover:bg-card transition-all group"
>
<FileText className="h-5 w-5 text-accent mb-3" />
<h3 className="text-sm font-medium text-foreground mb-1">Read the docs</h3>
<p className="text-xs text-muted-foreground leading-relaxed">
Get familiar with Voicebox — setup, voice cloning, the REST API.
</p>
</a>
<a
href="https://deepwiki.com/jamiepine/voicebox"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5 hover:border-accent/30 hover:bg-card transition-all group"
>
<Bot className="h-5 w-5 text-accent mb-3" />
<h3 className="text-sm font-medium text-foreground mb-1">Got questions? Ask AI.</h3>
<p className="text-xs text-muted-foreground leading-relaxed">
DeepWiki is an AI that knows Voicebox inside-out. Ask anything.
</p>
</a>
<a
href={GITHUB_REPO}
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5 hover:border-accent/30 hover:bg-card transition-all group"
>
<Github className="h-5 w-5 text-accent mb-3" />
<h3 className="text-sm font-medium text-foreground mb-1">Source on GitHub</h3>
<p className="text-xs text-muted-foreground leading-relaxed">
Star the repo, file issues, or contribute a PR.
</p>
</a>
</div>
</div>
</main>
</div>
);
}
+5 -12
View File
@@ -17,12 +17,9 @@ import {Navbar} from "@/components/Navbar";
import {AppleIcon, LinuxIcon, WindowsIcon} from "@/components/PlatformIcons"; import {AppleIcon, LinuxIcon, WindowsIcon} from "@/components/PlatformIcons";
import {TutorialsSection} from "@/components/TutorialsSection"; import {TutorialsSection} from "@/components/TutorialsSection";
import {VoiceCreator} from "@/components/VoiceCreator"; import {VoiceCreator} from "@/components/VoiceCreator";
import {DOWNLOAD_LINKS, GITHUB_REPO} from "@/lib/constants"; import {GITHUB_REPO} from "@/lib/constants";
import type {DownloadLinks} from "@/lib/releases";
export default function Home() { export default function Home() {
const [downloadLinks, setDownloadLinks] =
useState<DownloadLinks>(DOWNLOAD_LINKS);
const [version, setVersion] = useState<string | null>(null); const [version, setVersion] = useState<string | null>(null);
const [totalDownloads, setTotalDownloads] = useState<number | null>(null); const [totalDownloads, setTotalDownloads] = useState<number | null>(null);
@@ -33,7 +30,6 @@ export default function Home() {
return res.json(); return res.json();
}) })
.then((data) => { .then((data) => {
if (data.downloadLinks) setDownloadLinks(data.downloadLinks);
if (data.version) setVersion(data.version); if (data.version) setVersion(data.version);
if (data.totalDownloads != null) setTotalDownloads(data.totalDownloads); if (data.totalDownloads != null) setTotalDownloads(data.totalDownloads);
}) })
@@ -92,7 +88,7 @@ export default function Home() {
style={{animationDelay: "300ms"}} style={{animationDelay: "300ms"}}
> >
<a <a
href="#download" href="/download"
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)]" 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)]"
> >
Download Download
@@ -403,8 +399,7 @@ export default function Home() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl mx-auto"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl mx-auto">
{/* macOS ARM */} {/* macOS ARM */}
<a <a
href={downloadLinks.macArm} href="/download?platform=macArm"
download
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group" className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group"
> >
<AppleIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" /> <AppleIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
@@ -418,8 +413,7 @@ export default function Home() {
{/* macOS Intel */} {/* macOS Intel */}
<a <a
href={downloadLinks.macIntel} href="/download?platform=macIntel"
download
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group" className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group"
> >
<AppleIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" /> <AppleIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
@@ -431,8 +425,7 @@ export default function Home() {
{/* Windows */} {/* Windows */}
<a <a
href={downloadLinks.windows} href="/download?platform=windows"
download
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group" className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group"
> >
<WindowsIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" /> <WindowsIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
+2 -2
View File
@@ -29,8 +29,8 @@ const CURL_SNIPPET = `curl -X POST http://127.0.0.1:17493/generate \\
-H "Content-Type: application/json" \\ -H "Content-Type: application/json" \\
-d '{ -d '{
"text": "Welcome to the game, player one.", "text": "Welcome to the game, player one.",
"profile_id": "morgan-freeman", "profile_id": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"engine": "qwen", "engine": "qwen_custom_voice",
"instruct": "warm, slow, cinematic" "instruct": "warm, slow, cinematic"
}' \\ }' \\
--output line.wav`; --output line.wav`;
+1 -1
View File
@@ -45,7 +45,7 @@ export function Footer() {
</a> </a>
</li> </li>
<li> <li>
<a href="#download" className="hover:text-foreground transition-colors"> <a href="/download" className="hover:text-foreground transition-colors">
Download Download
</a> </a>
</li> </li>
+1 -1
View File
@@ -66,7 +66,7 @@ export function Navbar() {
API API
</a> </a>
<a <a
href="#download" href="/download"
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground" className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
> >
Download Download
+21 -15
View File
@@ -1,23 +1,29 @@
import { SiApple, SiLinux } from '@icons-pack/react-simple-icons';
// Official brand icons via Simple Icons (apple/linux). Simple Icons drops
// Microsoft's mark due to trademark policy, so the Windows 11 flag is
// inlined from Microsoft's public brand guidance.
export function AppleIcon({ className }: { className?: string }) { export function AppleIcon({ className }: { className?: string }) {
return ( return <SiApple className={className} color="currentColor" />;
<svg className={className} viewBox="0 0 24 24" fill="currentColor"> }
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
</svg> export function LinuxIcon({ className }: { className?: string }) {
); return <SiLinux className={className} color="currentColor" />;
} }
export function WindowsIcon({ className }: { className?: string }) { export function WindowsIcon({ className }: { className?: string }) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor"> <svg
<path d="M3 12V6.75l6-1.32v6.48L3 12zm17-9v8.75l-10 .15V5.21L20 3zM3 13l6 .09v7.81l-6-1.15V13zm17 .25V22l-10-1.8v-7.15l10 .15z" /> className={className}
</svg> viewBox="0 0 24 24"
); fill="currentColor"
} xmlns="http://www.w3.org/2000/svg"
role="img"
export function LinuxIcon({ className }: { className?: string }) { aria-label="Windows"
return ( >
<svg className={className} viewBox="0 0 24 24" fill="currentColor"> <title>Windows</title>
<path d="M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.26-.195.69-.133 1.001.054.27.112.553.077.784-.12.794-.3 1.593-.3 2.406 0 .599.18 1.193.3 1.791.12.599.3 1.193.3 1.792 0 .812.18 1.611.3 2.405.035.23-.023.514-.077.783-.062.312-.127.742.133 1.002a.424.424 0 00.11.135c-.123.805.01 1.657.287 2.489.589 1.771 1.831 3.47 2.716 4.521.75 1.067 0.974 1.928 1.05 3.02.065 1.491-1.056 5.965 3.17 6.298.165.013.325.021.48.021.155 0 .315-.008.48-.021 4.226-.333 3.105-4.807 3.17-6.298.076-1.092.3-1.953 1.05-3.02.885-1.051 2.127-2.75 2.716-4.521.278-.832.41-1.684.287-2.489a.424.424 0 00.11-.135c.26-.26.195-.69.133-1.001-.054-.27-.112-.553-.077-.784.12-.794.3-1.593.3-2.406 0-.599-.18-1.193-.3-1.791-.12-.599-.3-1.193-.3-1.792 0-.812-.18-1.611-.3-2.405-.035-.23.023-.514.077-.783.062-.312.127-.742-.133-1.002a.424.424 0 00-.11-.135c.123-.805-.01-1.657-.287-2.489-.589-1.771-1.831-3.47-2.716-4.521-.75-1.067-.974-1.928-1.05-3.02-.065-1.491 1.056-5.965-3.17-6.298C12.819.008 12.659 0 12.504 0z" /> <path d="M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.4l-13.051.149M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-12.9-1.801" />
</svg> </svg>
); );
} }
+1 -1
View File
@@ -5041,7 +5041,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "voicebox" name = "voicebox"
version = "0.4.0" version = "0.4.1"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"core-foundation-sys", "core-foundation-sys",