mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-28 06:35:18 -07:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c1e8512b9 | ||
|
|
bf58750447 | ||
|
|
2d56309bdd | ||
|
|
0445be295c | ||
|
|
8d550a5f7c | ||
|
|
795bd54381 | ||
|
|
a6ab5f3858 | ||
|
|
9d7e4a417e | ||
|
|
882cabc7d2 | ||
|
|
9c76b5de2c | ||
|
|
4560b7378a | ||
|
|
abd9943430 | ||
|
|
c8cb12f1bc | ||
|
|
54a3bf322e | ||
|
|
476abe07fc | ||
|
|
67bf8e906a |
+1
-1
@@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.4.0
|
||||
current_version = 0.4.1
|
||||
commit = True
|
||||
tag = True
|
||||
tag_name = v{new_version}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
frontend-quality:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Typecheck app + web
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Build web smoke test
|
||||
run: bun run build:web
|
||||
@@ -68,6 +68,15 @@ jobs:
|
||||
if: matrix.backend == 'mlx'
|
||||
run: |
|
||||
pip install -r backend/requirements-mlx.txt
|
||||
# mlx-audio>=0.3.1 and mlx-lm>=0.31.1 both declare transformers>=5.x,
|
||||
# which conflicts with our 4.57.x cap. The runtime APIs we use work
|
||||
# fine on transformers 4.57.x in practice (verified in dev), so install
|
||||
# them --no-deps. mlx-audio's other runtime deps (huggingface_hub,
|
||||
# librosa, numpy, numba, pyloudnorm) are already in requirements.txt;
|
||||
# the rest (sounddevice, miniaudio, protobuf, sentencepiece, pyyaml,
|
||||
# jinja2) are pulled in by other engines.
|
||||
pip install --no-deps mlx-lm==0.31.1
|
||||
pip install --no-deps mlx-audio==0.4.1
|
||||
|
||||
- name: Build Python server (Linux/macOS)
|
||||
if: matrix.platform != 'windows-latest'
|
||||
|
||||
@@ -63,3 +63,8 @@ nul
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# E2E test artifacts
|
||||
backend/tests/results/
|
||||
backend/tests/fixtures/reference_voice.wav
|
||||
backend/tests/fixtures/reference_voice.txt
|
||||
|
||||
+42
-1
@@ -7,6 +7,46 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.1] - 2026-04-18
|
||||
|
||||
A fast follow-up to 0.4.0 focused on making the new engines actually load in the production binary — plus generation cancellation, Linux system-audio capture, and the repo's first PR-time type check. Five first-time contributors shipped in this release.
|
||||
|
||||
0.4.0 introduced three new TTS engines, but the frozen PyInstaller binary tripped over several Python-ecosystem quirks that don't show up in the dev venv: `transformers` opening `.py` sources at runtime, `scipy.stats._distn_infrastructure` hitting a frozen-importer `NameError`, and `chatterbox-multilingual` failing to find its Chinese segmenter dictionary. This release patches all of those in one sweep.
|
||||
|
||||
### Frozen-Binary Reliability ([#438](https://github.com/jamiepine/voicebox/pull/438))
|
||||
- **Kokoro** now bundles `.py` sources alongside `.pyc` via `--collect-all kokoro` so `transformers`' `_can_set_attn_implementation` regex scan can read them — previously `FileNotFoundError: kokoro/modules.py` killed Kokoro loading in production builds
|
||||
- **Chatterbox Multilingual** now bundles `spacy_pkuseg/dicts/default.pkl` and the package's native `.so` extensions via `--collect-all spacy_pkuseg` — previously the Chinese word segmenter crashed with `FileNotFoundError` on first load
|
||||
- **scipy.stats._distn_infrastructure** — new runtime hook source-patches the trailing `del obj` (which raises `NameError` under PyInstaller's frozen importer because the preceding list comprehension evaluates empty) to `globals().pop('obj', None)`, unblocking `librosa` → `scipy.signal` → `scipy.stats` for every TTS engine that depends on librosa
|
||||
- **transformers.masking_utils** — same runtime hook forces `_is_torch_greater_or_equal_than_2_6 = False` so the older `sdpa_mask_older_torch` path is selected; the 2.6+ path uses `TransformGetItemToIndex()`, a real `torch._dynamo` graph transform our permissive stub can't reproduce
|
||||
- **torch._dynamo** — no-op stub replaces the real module before `transformers` imports it, preventing the `torch._numpy._ufuncs` import crash (`NameError: name 'name' is not defined`) that blocked Kokoro and every engine pulling in `flex_attention`
|
||||
- `.spec` paths are now repo-relative instead of absolute, so the generated spec is portable across machines and CI
|
||||
|
||||
### Generation
|
||||
- **Cancel queued or running generations** ([#444](https://github.com/jamiepine/voicebox/pull/444)) — new `/generate/{id}/cancel` endpoint and a Stop button on the history row while generating. The serial queue now tracks per-ID state (queued / running / cancelled) so queued jobs are skipped before the worker picks them up and running jobs are `.cancel()`-ed mid-flight; `run_generation` catches `CancelledError` and marks the row `failed` with a "cancelled" error.
|
||||
- **Legacy `data/` path prefix resolution** ([#440](https://github.com/jamiepine/voicebox/pull/440)) — generations stored with the old `data/` prefix under pre-0.4 installs now resolve correctly after the storage root moved, fixing 404s for historical audio.
|
||||
|
||||
### Model Migration
|
||||
- Migration dialog no longer hangs when the cache is empty ([#439](https://github.com/jamiepine/voicebox/pull/439)) — the backend now emits a completion SSE event even when zero models are moved.
|
||||
- Storage-change flow surfaces a toast when there's nothing to migrate ([#433](https://github.com/jamiepine/voicebox/pull/433)) instead of proceeding with a no-op move and restarting the server.
|
||||
- Deleting all generations from a voice profile now deletes the associated version files and DB rows too ([#447](https://github.com/jamiepine/voicebox/pull/447)) — previously orphaned versions accumulated in storage.
|
||||
|
||||
### Platform
|
||||
- **Linux system audio capture** ([#457](https://github.com/jamiepine/voicebox/pull/457)) — `cpal`'s ALSA backend doesn't expose PulseAudio/PipeWire monitor sources by name, so the previous device-name search never matched and silently fell back to the microphone. Detection now uses `pactl get-default-sink` + `pactl list short sources` and routes via `PULSE_SOURCE`, with the name-based search retained as a fallback when `pactl` is absent.
|
||||
|
||||
### Frontend CI
|
||||
- First PR-time quality gate ([#418](https://github.com/jamiepine/voicebox/pull/418)) — new `.github/workflows/ci.yml` runs `bun run typecheck` + `bun run build:web` on every PR. Fixed pre-existing type issues that were being suppressed with `@ts-expect-error`, cleaned up a dep-array typo (`[platform.metadata.isTauricheckOnMountcheckForUpdates]`) in `useAutoUpdater`, and removed 100+ lines of dead `ModelItem` code from `ModelManagement.tsx`.
|
||||
- Follow-up: widened `apiClient.migrateModels()` return type to include `moved` and `errors` so the storage-change handler typechecks against the real backend response ([#470](https://github.com/jamiepine/voicebox/pull/470)).
|
||||
|
||||
### Docs
|
||||
- Clarified in the Quick Start + README that paralinguistic tags (`[laugh]`, `[sigh]`) only work with Chatterbox Turbo; other engines read them as literal text ([#450](https://github.com/jamiepine/voicebox/pull/450)).
|
||||
|
||||
### New Contributors
|
||||
- [@Bortlesboat](https://github.com/Bortlesboat) — generation cancellation (#444)
|
||||
- [@gaojulong](https://github.com/gaojulong) — migration dialog hang fix (#439)
|
||||
- [@fuleinist](https://github.com/fuleinist) — migration no-op toast (#433)
|
||||
- [@erionjuniordeandrade-a11y](https://github.com/erionjuniordeandrade-a11y) — frontend CI + type hardening (#418)
|
||||
- [@estefrac](https://github.com/estefrac) — Linux pactl system-audio capture (#457)
|
||||
|
||||
## [0.4.0] - 2026-04-16
|
||||
|
||||
The biggest Voicebox release yet. Three new TTS engines bring the lineup to **seven** — HumeAI TADA, Kokoro 82M, and Qwen CustomVoice join Qwen3-TTS, LuxTTS, Chatterbox Multilingual, and Chatterbox Turbo. GPU support broadens to Intel Arc (XPU) and NVIDIA Blackwell (RTX 50-series), with runtime diagnostics that warn when your PyTorch build doesn't match your GPU. The CUDA backend is now split into independently versioned server and library archives, so upgrading no longer redownloads 4 GB of PyTorch/CUDA DLLs.
|
||||
@@ -555,7 +595,8 @@ The first public release of Voicebox — an open-source voice synthesis studio p
|
||||
|
||||
Tauri v2, React, TypeScript, Tailwind CSS, FastAPI, Qwen3-TTS, Whisper, SQLite
|
||||
|
||||
[Unreleased]: https://github.com/jamiepine/voicebox/compare/v0.4.0...HEAD
|
||||
[Unreleased]: https://github.com/jamiepine/voicebox/compare/v0.4.1...HEAD
|
||||
[0.4.1]: https://github.com/jamiepine/voicebox/compare/v0.4.0...v0.4.1
|
||||
[0.4.0]: https://github.com/jamiepine/voicebox/compare/v0.3.0...v0.4.0
|
||||
[0.3.0]: https://github.com/jamiepine/voicebox/compare/v0.2.3...v0.3.0
|
||||
[0.2.3]: https://github.com/jamiepine/voicebox/compare/v0.2.2...v0.2.3
|
||||
|
||||
@@ -105,7 +105,12 @@ Five TTS engines with different strengths, switchable per-generation:
|
||||
|
||||
### Emotions & Paralinguistic Tags
|
||||
|
||||
Type `/` in the text input to insert expressive tags that the model synthesizes inline with speech (Chatterbox Turbo):
|
||||
Only **Chatterbox Turbo** interprets paralinguistic tags like `[laugh]` and
|
||||
`[sigh]`. Qwen3-TTS, LuxTTS, Chatterbox Multilingual, and HumeAI TADA read them
|
||||
literally as text.
|
||||
|
||||
With **Chatterbox Turbo** selected, type `/` in the text input to open the tag
|
||||
inserter and add expressive tags inline with speech:
|
||||
|
||||
`[laugh]` `[chuckle]` `[gasp]` `[cough]` `[sigh]` `[groan]` `[sniff]` `[shush]` `[clear throat]`
|
||||
|
||||
|
||||
+3
-3
@@ -6,8 +6,8 @@ We release patches for security vulnerabilities. Which versions are eligible for
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 0.1.x | :white_check_mark: |
|
||||
| < 0.1 | :x: |
|
||||
| 0.3.x | :white_check_mark: |
|
||||
| < 0.3 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
@@ -82,7 +82,7 @@ Timeline may vary based on severity and complexity.
|
||||
## Security Updates
|
||||
|
||||
Security updates will be:
|
||||
- Released as patch versions (e.g., 0.1.1)
|
||||
- Released as patch versions (e.g., 0.3.2)
|
||||
- Documented in CHANGELOG.md
|
||||
- Announced via GitHub releases
|
||||
- Automatically delivered via auto-updater
|
||||
|
||||
+2
-1
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "@voicebox/app",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"preview": "vite preview",
|
||||
"lint": "biome lint src",
|
||||
"lint:fix": "biome lint --write src",
|
||||
|
||||
@@ -121,7 +121,6 @@ function App() {
|
||||
console.log('Dev mode: Skipping auto-start of server (run it separately)');
|
||||
setServerReady(true); // Mark as ready so UI doesn't show loading screen
|
||||
// Mark that server was not started by app (so we don't try to stop it on close)
|
||||
// @ts-expect-error - adding property to window
|
||||
window.__voiceboxServerStartedByApp = false;
|
||||
return;
|
||||
}
|
||||
@@ -144,13 +143,11 @@ function App() {
|
||||
useServerStore.getState().setServerUrl(serverUrl);
|
||||
setServerReady(true);
|
||||
// Mark that we started the server (so we know to stop it on close)
|
||||
// @ts-expect-error - adding property to window
|
||||
window.__voiceboxServerStartedByApp = true;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to auto-start server:', error);
|
||||
serverStartingRef.current = false;
|
||||
// @ts-expect-error - adding property to window
|
||||
window.__voiceboxServerStartedByApp = false;
|
||||
|
||||
// Only fall back to health-check polling when the error indicates the
|
||||
|
||||
@@ -124,7 +124,7 @@ export function AudioTab() {
|
||||
);
|
||||
}
|
||||
|
||||
const handleChannelDelete = async (e, channelId) => {
|
||||
const handleChannelDelete = async (e: React.MouseEvent, channelId: string) => {
|
||||
e.stopPropagation();
|
||||
if (await confirm('Delete this channel?')) {
|
||||
deleteChannel.mutate(channelId);
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
AlignCenter,
|
||||
AudioLines,
|
||||
AudioWaveform,
|
||||
Download,
|
||||
FileArchive,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Square,
|
||||
Star,
|
||||
Trash2,
|
||||
Wand2,
|
||||
@@ -130,6 +129,23 @@ export function HistoryTable() {
|
||||
const exportGeneration = useExportGeneration();
|
||||
const exportGenerationAudio = useExportGenerationAudio();
|
||||
const importGeneration = useImportGeneration();
|
||||
const cancelGeneration = useMutation({
|
||||
mutationFn: (generationId: string) => apiClient.cancelGeneration(generationId),
|
||||
onSuccess: async (data) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||
toast({
|
||||
title: 'Cancelling generation',
|
||||
description: data.message,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Cancel failed',
|
||||
description: error instanceof Error ? error.message : 'Could not cancel generation',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
|
||||
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
||||
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
|
||||
@@ -483,6 +499,8 @@ export function HistoryTable() {
|
||||
const isPlayable = !isGenerating && !isFailed;
|
||||
const hasVersions = gen.versions && gen.versions.length > 1;
|
||||
const isVersionsExpanded = expandedVersionsId === gen.id;
|
||||
const isCancelling =
|
||||
cancelGeneration.isPending && cancelGeneration.variables === gen.id;
|
||||
return (
|
||||
<div
|
||||
key={gen.id}
|
||||
@@ -631,60 +649,71 @@ export function HistoryTable() {
|
||||
<Trash2 className="h-2 w-2" />
|
||||
</Button>
|
||||
</>
|
||||
) : isGenerating ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
|
||||
aria-label="Cancel generation"
|
||||
disabled={isCancelling}
|
||||
onClick={() => cancelGeneration.mutate(gen.id)}
|
||||
>
|
||||
{isCancelling ? (
|
||||
<Loader2 className="h-2 w-2 animate-spin" />
|
||||
) : (
|
||||
<Square className="h-2 w-2" />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
|
||||
aria-label="Actions"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<MoreHorizontal className="h-2 w-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Play
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDownloadAudio(gen.id, gen.text)}
|
||||
disabled={exportGenerationAudio.isPending}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export Audio
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExportPackage(gen.id, gen.text)}
|
||||
disabled={exportGeneration.isPending}
|
||||
>
|
||||
<FileArchive className="mr-2 h-4 w-4" />
|
||||
Export Package
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleApplyEffects(gen.id)}>
|
||||
<Wand2 className="mr-2 h-4 w-4" />
|
||||
Apply Effects
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleRegenerate(gen.id)}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Regenerate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
||||
disabled={deleteGeneration.isPending}
|
||||
// className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground/50 hover:bg-muted-foreground/20 hover:text-muted-foreground"
|
||||
aria-label="Actions"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<MoreHorizontal className="h-2 w-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Play
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDownloadAudio(gen.id, gen.text)}
|
||||
disabled={exportGenerationAudio.isPending}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export Audio
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExportPackage(gen.id, gen.text)}
|
||||
disabled={exportGeneration.isPending}
|
||||
>
|
||||
<FileArchive className="mr-2 h-4 w-4" />
|
||||
Export Package
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleApplyEffects(gen.id)}>
|
||||
<Wand2 className="mr-2 h-4 w-4" />
|
||||
Apply Effects
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleRegenerate(gen.id)}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Regenerate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
||||
disabled={deleteGeneration.isPending}
|
||||
// className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -977,7 +977,19 @@ export function ModelManagement() {
|
||||
});
|
||||
try {
|
||||
// Start the migration (background task)
|
||||
await apiClient.migrateModels(newDir);
|
||||
const migrationResult = await apiClient.migrateModels(newDir);
|
||||
|
||||
// If no models to migrate, warn user and skip the change
|
||||
if (migrationResult.moved === 0) {
|
||||
setMigrating(false);
|
||||
setMigrationProgress(null);
|
||||
toast({
|
||||
title: 'No models to migrate',
|
||||
description: 'Download at least one model before changing the storage location.',
|
||||
});
|
||||
setPendingMigrateDir(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect to SSE for progress
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -1064,105 +1076,3 @@ export function ModelManagement() {
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelItemProps {
|
||||
model: {
|
||||
model_name: string;
|
||||
display_name: string;
|
||||
downloaded: boolean;
|
||||
downloading?: boolean; // From server - true if download in progress
|
||||
size_mb?: number;
|
||||
loaded: boolean;
|
||||
};
|
||||
onDownload: () => void;
|
||||
onDelete: () => void;
|
||||
isDownloading: boolean; // Local state - true if user just clicked download
|
||||
formatSize: (sizeMb?: number) => string;
|
||||
}
|
||||
|
||||
function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: ModelItemProps) {
|
||||
// Use server's downloading state OR local state (for immediate feedback before server updates)
|
||||
const showDownloading = model.downloading || isDownloading;
|
||||
|
||||
const statusText = model.loaded
|
||||
? 'Loaded'
|
||||
: showDownloading
|
||||
? 'Downloading'
|
||||
: model.downloaded
|
||||
? 'Downloaded'
|
||||
: 'Not downloaded';
|
||||
const sizeText =
|
||||
model.downloaded && model.size_mb && !showDownloading ? `, ${formatSize(model.size_mb)}` : '';
|
||||
const rowLabel = `${model.display_name}, ${statusText}${sizeText}. Use Tab to reach Download or Delete.`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
role="group"
|
||||
tabIndex={0}
|
||||
aria-label={rowLabel}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{model.display_name}</span>
|
||||
{model.loaded && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Loaded
|
||||
</Badge>
|
||||
)}
|
||||
{/* Only show Downloaded if actually downloaded AND not downloading */}
|
||||
{model.downloaded && !model.loaded && !showDownloading && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Downloaded
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{model.downloaded && model.size_mb && !showDownloading && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Size: {formatSize(model.size_mb)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{model.downloaded && !showDownloading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<span>Ready</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
variant="outline"
|
||||
disabled={model.loaded}
|
||||
title={model.loaded ? 'Unload model before deleting' : 'Delete model'}
|
||||
aria-label={
|
||||
model.loaded ? 'Unload model before deleting' : `Delete ${model.display_name}`
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : showDownloading ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
aria-label={`${model.display_name} downloading`}
|
||||
>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Downloading...
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onDownload}
|
||||
variant="outline"
|
||||
aria-label={`Download ${model.display_name}`}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -371,7 +371,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
}
|
||||
}, [isResizing, handleResizeMove, handleResizeEnd]);
|
||||
|
||||
const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const handleTimelineClick = (e: React.MouseEvent<HTMLElement>) => {
|
||||
if (!tracksRef.current || draggingItem || trimmingItem) return;
|
||||
const rect = tracksRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left + tracksRef.current.scrollLeft;
|
||||
|
||||
@@ -5,7 +5,15 @@ import type { UpdateStatus } from '@/platform/types';
|
||||
// Re-export UpdateStatus for backwards compatibility
|
||||
export type { UpdateStatus };
|
||||
|
||||
export function useAutoUpdater(checkOnMount = false) {
|
||||
interface UseAutoUpdaterOptions {
|
||||
checkOnMount?: boolean;
|
||||
showToast?: boolean;
|
||||
}
|
||||
|
||||
export function useAutoUpdater(options: boolean | UseAutoUpdaterOptions = false) {
|
||||
const { checkOnMount } =
|
||||
typeof options === 'boolean' ? { checkOnMount: options } : { checkOnMount: options.checkOnMount ?? false };
|
||||
|
||||
const platform = usePlatform();
|
||||
const [status, setStatus] = useState<UpdateStatus>(platform.updater.getStatus());
|
||||
const hasCheckedRef = useRef(false);
|
||||
@@ -38,10 +46,11 @@ export function useAutoUpdater(checkOnMount = false) {
|
||||
useEffect(() => {
|
||||
if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) {
|
||||
hasCheckedRef.current = true;
|
||||
checkForUpdates();
|
||||
checkForUpdates().catch((error) => {
|
||||
console.error('Auto update check failed:', error);
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.metadata.isTauricheckOnMountcheckForUpdates]);
|
||||
}, [checkOnMount, checkForUpdates, platform.metadata.isTauri]);
|
||||
|
||||
return {
|
||||
status,
|
||||
|
||||
@@ -73,7 +73,7 @@ export function useAutoUpdater(options: boolean | UseAutoUpdaterOptions = false)
|
||||
}
|
||||
// Empty dependency array - only run once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.metadata.isTauricheckOnMountcheckForUpdates]);
|
||||
}, [checkOnMount, checkForUpdates, platform.metadata.isTauri]);
|
||||
|
||||
// Show toast when update is available
|
||||
useEffect(() => {
|
||||
|
||||
@@ -234,6 +234,12 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async cancelGeneration(generationId: string): Promise<{ message: string }> {
|
||||
return this.request<{ message: string }>(`/generate/${generationId}/cancel`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async regenerateGeneration(generationId: string): Promise<GenerationResponse> {
|
||||
return this.request<GenerationResponse>(`/generate/${generationId}/regenerate`, {
|
||||
method: 'POST',
|
||||
@@ -384,7 +390,9 @@ class ApiClient {
|
||||
return this.request<{ path: string }>('/models/cache-dir');
|
||||
}
|
||||
|
||||
async migrateModels(destination: string): Promise<{ source: string; destination: string }> {
|
||||
async migrateModels(
|
||||
destination: string,
|
||||
): Promise<{ source: string; destination: string; moved: number; errors: string[] }> {
|
||||
return this.request('/models/migrate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ destination }),
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
# Backend package
|
||||
|
||||
__version__ = "0.4.0"
|
||||
__version__ = "0.4.1"
|
||||
|
||||
+23
-12
@@ -55,10 +55,23 @@ def build_server(cuda=False):
|
||||
# numpy 2.x / torch ABI mismatch fix: install memmove fallback for
|
||||
# torch.from_numpy() before the app starts. Runtime hooks run after
|
||||
# FrozenImporter is registered so frozen torch/numpy are importable.
|
||||
# Paths are passed relative to backend_dir because os.chdir(backend_dir)
|
||||
# runs before PyInstaller. Absolute paths would get baked into the
|
||||
# generated .spec, breaking reproducible builds on other machines / CI.
|
||||
args.extend(
|
||||
[
|
||||
"--runtime-hook",
|
||||
str(backend_dir / "pyi_rth_numpy_compat.py"),
|
||||
"pyi_rth_numpy_compat.py",
|
||||
# Stub torch.compiler.disable before transformers imports
|
||||
# flex_attention, which otherwise triggers torch._dynamo →
|
||||
# torch._numpy._ufuncs and crashes at module load under
|
||||
# PyInstaller. See pyi_rth_torch_compiler_disable.py.
|
||||
"--runtime-hook",
|
||||
"pyi_rth_torch_compiler_disable.py",
|
||||
# Per-module collection overrides (e.g. forcing scipy.stats._distn_infrastructure
|
||||
# to bundle .py source alongside .pyc so the runtime hook can source-patch it).
|
||||
"--additional-hooks-dir",
|
||||
"pyi_hooks",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -125,6 +138,11 @@ def build_server(cuda=False):
|
||||
"backend.backends.chatterbox_backend",
|
||||
"--hidden-import",
|
||||
"backend.backends.chatterbox_turbo_backend",
|
||||
# chatterbox multilingual uses spacy_pkuseg for Chinese word
|
||||
# segmentation, which ships pickled dict files (dicts/default.pkl)
|
||||
# and native .so extensions that --hidden-import alone won't bundle.
|
||||
"--collect-all",
|
||||
"spacy_pkuseg",
|
||||
"--hidden-import",
|
||||
"backend.backends.luxtts_backend",
|
||||
"--hidden-import",
|
||||
@@ -241,20 +259,13 @@ def build_server(cuda=False):
|
||||
"--collect-submodules",
|
||||
"tada",
|
||||
# Kokoro 82M — lightweight TTS engine using misaki G2P
|
||||
# collect-all is required because transformers introspects .py source
|
||||
# files at runtime (e.g. _can_set_attn_implementation opens the class
|
||||
# file); hidden-import alone only bundles bytecode.
|
||||
"--hidden-import",
|
||||
"backend.backends.kokoro_backend",
|
||||
"--hidden-import",
|
||||
"--collect-all",
|
||||
"kokoro",
|
||||
"--hidden-import",
|
||||
"kokoro.pipeline",
|
||||
"--hidden-import",
|
||||
"kokoro.model",
|
||||
"--hidden-import",
|
||||
"kokoro.istftnet",
|
||||
"--hidden-import",
|
||||
"kokoro.modules",
|
||||
"--hidden-import",
|
||||
"kokoro.custom_stft",
|
||||
# misaki ships G2P data files (dictionaries, phoneme tables)
|
||||
# that must be bundled for espeak/en/ja/zh G2P to work
|
||||
"--collect-all",
|
||||
|
||||
@@ -89,6 +89,14 @@ def resolve_storage_path(path: str | Path | None) -> Path | None:
|
||||
|
||||
return stored_path
|
||||
|
||||
# 0.3.0 records sometimes stored relative paths with the data-dir name
|
||||
# baked in (e.g. "data/profiles/..."). Joining those directly with
|
||||
# _data_dir produces a spurious "<data_dir>/data/profiles/..." nest.
|
||||
if stored_path.parts and stored_path.parts[0] == "data":
|
||||
stored_path = (
|
||||
Path(*stored_path.parts[1:]) if len(stored_path.parts) > 1 else Path()
|
||||
)
|
||||
|
||||
return (_data_dir / stored_path).resolve()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Force scipy.stats._distn_infrastructure to be bundled with its .py source file
|
||||
alongside the .pyc bytecode.
|
||||
|
||||
The runtime hook in backend/pyi_rth_torch_compiler_disable.py patches this
|
||||
module's source at load time (the module has a `del obj` at line 369 that
|
||||
raises NameError under PyInstaller's frozen importer). That patch reads the
|
||||
source via loader.get_source(), which only works if the .py file was
|
||||
actually collected into the bundle.
|
||||
"""
|
||||
|
||||
module_collection_mode = "pyz+py"
|
||||
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Force transformers.masking_utils to be bundled with its .py source alongside
|
||||
the .pyc bytecode so the runtime hook in
|
||||
backend/pyi_rth_torch_compiler_disable.py can source-patch it.
|
||||
|
||||
The patch forces the torch<2.6 code path, bypassing `with TransformGetItemToIndex()`
|
||||
which our torch._dynamo no-op stub can't implement for real — the real context
|
||||
manager uses dynamo graph transforms to avoid `.item()` calls inside vmap.
|
||||
"""
|
||||
|
||||
module_collection_mode = "pyz+py"
|
||||
@@ -0,0 +1,540 @@
|
||||
"""
|
||||
PyInstaller runtime hook: stub torch._dynamo to a no-op module.
|
||||
|
||||
Problem
|
||||
-------
|
||||
transformers triggers torch._dynamo import at module-load time (not just
|
||||
when torch.compile is called) via class-body decorators:
|
||||
|
||||
transformers/modeling_utils.py:1984
|
||||
@torch._dynamo.allow_in_graph
|
||||
class PreTrainedModel(...)
|
||||
|
||||
transformers/integrations/flex_attention.py:61
|
||||
@torch.compiler.disable(recursive=False)
|
||||
class WrappedFlexAttention...
|
||||
|
||||
The attribute access triggers torch.__getattr__ -> importlib.import_module
|
||||
-> torch._dynamo -> torch._dynamo.utils imports torch._numpy ->
|
||||
torch._numpy._ndarray imports torch._numpy._ufuncs, which crashes under
|
||||
PyInstaller with:
|
||||
|
||||
File "torch/_numpy/_ufuncs.py", line 235, in <module>
|
||||
vars()[name] = deco_binary_ufunc(ufunc)
|
||||
NameError: name 'name' is not defined
|
||||
|
||||
(The module-level `for name in _binary: vars()[name] = ...` pattern works
|
||||
in a regular venv but fails in the PyInstaller bundle. Root cause is in
|
||||
PyInstaller's importer / bytecode pipeline and not easily fixed upstream.)
|
||||
|
||||
Surfaces as Kokoro failing to load when `from transformers import AlbertModel`
|
||||
trips the decorator chain.
|
||||
|
||||
Fix
|
||||
---
|
||||
voicebox never uses torch.compile / torch._dynamo for inference, so we
|
||||
replace torch._dynamo with a no-op stub module before transformers is
|
||||
imported. Any attribute access on the stub returns a pass-through callable,
|
||||
so `@torch._dynamo.allow_in_graph`, `torch._dynamo.is_compiling()`,
|
||||
`torch._dynamo.mark_static_address(...)`, etc. all work.
|
||||
|
||||
This hook is pure sys.modules manipulation — we deliberately do NOT import
|
||||
torch here. Runtime hooks run before the app starts and before
|
||||
pyi_rth_numpy_compat has had a chance to patch torch.from_numpy (it runs
|
||||
in a background thread, waiting for torch to appear in sys.modules).
|
||||
Eager-importing torch at hook time would trip the numpy ABI issue and
|
||||
kill the server process at startup.
|
||||
|
||||
torch.compiler.disable does not need a separate stub: its implementation
|
||||
is effectively `import torch._dynamo; return torch._dynamo.disable(...)`,
|
||||
and since our stub is in sys.modules, that call resolves to our no-op
|
||||
_NoopDecorator pass-through.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
|
||||
|
||||
# Diagnostics — log hook activity to a file alongside the bundle so we can
|
||||
# see what's happening when the server is run as a sidecar (no stdout for
|
||||
# runtime hook prints). Safe no-op if the file can't be written.
|
||||
_DIAG_PATH = os.path.join(tempfile.gettempdir(), "voicebox_rt_hook.log")
|
||||
|
||||
|
||||
def _diag(msg: str) -> None:
|
||||
try:
|
||||
with open(_DIAG_PATH, "a", encoding="utf-8") as f:
|
||||
f.write(msg + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_HOOK_VERSION = "v6-masking-utils-finder"
|
||||
_diag(f"=== runtime hook load @ pid={os.getpid()} version={_HOOK_VERSION} ===")
|
||||
|
||||
|
||||
class _NoopDecorator:
|
||||
"""Multi-role no-op: decorator, falsey predicate, and context manager.
|
||||
|
||||
Returned from calls like `torch._dynamo.disable()` (decorator),
|
||||
`torch._dynamo.is_compiling()` (predicate used in `if not ...`), and
|
||||
`with torch._dynamo._trace_wrapped_higher_order_op.TransformGetItemToIndex():`
|
||||
(context manager used to scope an fx graph transformation).
|
||||
|
||||
By implementing __call__, __bool__, __enter__, __exit__, and __iter__ we
|
||||
cover every use pattern we've seen transformers/torch use on a stubbed
|
||||
object. Anything we haven't covered will raise a clearer error than a
|
||||
silent wrong-result.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def __call__(self, fn=None, *args, **kwargs):
|
||||
return fn
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return False
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
return False # don't suppress exceptions
|
||||
|
||||
def __iter__(self):
|
||||
return iter(())
|
||||
|
||||
|
||||
_noop_decorator_singleton = _NoopDecorator()
|
||||
|
||||
|
||||
def _noop_callable(*args, **kwargs):
|
||||
# Direct-decorator use: @torch._dynamo.foo (no parens) — fn is positional
|
||||
if len(args) == 1 and callable(args[0]) and not kwargs:
|
||||
return args[0]
|
||||
# Side-effect call with non-callable arg(s), e.g. mark_static_address(tensor)
|
||||
return _noop_decorator_singleton
|
||||
|
||||
|
||||
class _NoopDynamoModule(types.ModuleType):
|
||||
"""Permissive stub: every attribute is a pass-through callable.
|
||||
|
||||
Covers attributes transformers hits at import time (allow_in_graph) and
|
||||
runtime (is_compiling, mark_static_address, reset, disable, ...).
|
||||
|
||||
Dunder attributes (__file__, __spec__, __loader__, ...) raise
|
||||
AttributeError so probes like inspect.getmodule() — which does
|
||||
`hasattr(m, '__file__')` then `os.path.normpath(m.__file__)` — see the
|
||||
module as having no source file and fall through to its normal
|
||||
handling, instead of receiving a function and blowing up.
|
||||
"""
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
if name.startswith("__") and name.endswith("__"):
|
||||
raise AttributeError(name)
|
||||
return _noop_callable
|
||||
|
||||
|
||||
class _DynamoLoader:
|
||||
"""Loader used by _DynamoMetaPathFinder to materialise stub submodules."""
|
||||
|
||||
def create_module(self, spec):
|
||||
return _NoopDynamoModule(spec.name)
|
||||
|
||||
def exec_module(self, module):
|
||||
# Mark every stub submodule as a package so deeper submodule imports
|
||||
# (`from torch._dynamo.X.Y import Z`) keep working.
|
||||
module.__path__ = []
|
||||
|
||||
|
||||
class _DynamoMetaPathFinder:
|
||||
"""Resolve any `torch._dynamo.X[.Y...]` import to a no-op stub module.
|
||||
|
||||
Without this, `from torch._dynamo._trace_wrapped_higher_order_op import X`
|
||||
fails even with torch._dynamo pre-populated in sys.modules — Python's
|
||||
import machinery checks the parent's __path__ and then looks up the
|
||||
child, and we need to provide both.
|
||||
"""
|
||||
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
if fullname == "torch._dynamo":
|
||||
return None # handled by the pre-populated sys.modules entry
|
||||
if not fullname.startswith("torch._dynamo."):
|
||||
return None
|
||||
from importlib.machinery import ModuleSpec
|
||||
|
||||
return ModuleSpec(fullname, _DynamoLoader(), is_package=True)
|
||||
|
||||
|
||||
class _TransformersStubFinder:
|
||||
"""Replace specific transformers submodules with no-op stubs.
|
||||
|
||||
Two modules are targeted:
|
||||
|
||||
1. transformers.utils.auto_docstring
|
||||
The real @auto_docstring decorator loads
|
||||
transformers.models.auto.modeling_auto just to build example docstrings,
|
||||
which drags in GenerationMixin -> candidate_generator -> sklearn.metrics
|
||||
-> scipy.stats._distn_infrastructure and trips (2) below. Docstrings
|
||||
aren't functional for inference, so a pass-through decorator is safe.
|
||||
|
||||
2. transformers.generation.candidate_generator
|
||||
Imported at module scope by transformers.generation.utils. It does
|
||||
`from sklearn.metrics import roc_curve` at module load, which triggers:
|
||||
|
||||
File "scipy/stats/_distn_infrastructure.py", line 369, in <module>
|
||||
NameError: name 'obj' is not defined
|
||||
|
||||
This is a PyInstaller-specific module-load bug (same class as the
|
||||
torch._numpy._ufuncs crash) where a module-level `for obj in [s for s
|
||||
in dir() if ...]` loop evaluates to empty in the bundle, leaving `obj`
|
||||
unbound before `del obj`.
|
||||
|
||||
The exports (AssistedCandidateGenerator, EarlyExitCandidateGenerator,
|
||||
etc.) are speculative-decoding helpers voicebox's TTS engines do not
|
||||
use; a no-op stub module satisfies the imports.
|
||||
"""
|
||||
|
||||
_STUBBED_MODULES = frozenset(
|
||||
{
|
||||
"transformers.utils.auto_docstring",
|
||||
"transformers.generation.candidate_generator",
|
||||
}
|
||||
)
|
||||
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
if fullname not in self._STUBBED_MODULES:
|
||||
return None
|
||||
from importlib.machinery import ModuleSpec
|
||||
|
||||
return ModuleSpec(fullname, _NoopStubLoader(), is_package=False)
|
||||
|
||||
|
||||
class _NoopStubLoader:
|
||||
def create_module(self, spec):
|
||||
return _NoopDynamoModule(spec.name)
|
||||
|
||||
def exec_module(self, module):
|
||||
# _NoopDynamoModule.__getattr__ already answers every non-dunder
|
||||
# attribute with a pass-through callable, which satisfies
|
||||
# `from stubbed_module import X` for any X.
|
||||
pass
|
||||
|
||||
|
||||
def _patch_scipy_distn_source(source: str) -> str:
|
||||
"""Replace the unsafe `del obj` with a no-op that survives when obj is unbound.
|
||||
|
||||
Returns the input unchanged if the target line isn't found (e.g. scipy
|
||||
version has changed).
|
||||
"""
|
||||
target = "\ndel obj\n"
|
||||
replacement = "\nglobals().pop('obj', None)\n"
|
||||
if target in source:
|
||||
return source.replace(target, replacement, 1)
|
||||
return source
|
||||
|
||||
|
||||
def _patch_masking_utils_source(source: str) -> str:
|
||||
"""Force torch<2.6 code path in transformers.masking_utils.
|
||||
|
||||
The torch>=2.6 path uses `with TransformGetItemToIndex():` to allow
|
||||
`.item()` calls inside vmap. That context manager is implemented via
|
||||
torch._dynamo graph transforms, which our stub doesn't reproduce — it's
|
||||
a no-op. The inner `_vmap_for_bhqkv` then crashes with:
|
||||
|
||||
RuntimeError: vmap: It looks like you're calling .item() on a Tensor.
|
||||
|
||||
Forcing the torch<2.6 flag off selects sdpa_mask_older_torch which uses
|
||||
a different vmap pattern that does not hit .item() and does not need
|
||||
TransformGetItemToIndex.
|
||||
"""
|
||||
target = 'is_torch_greater_or_equal("2.6", accept_dev=True)'
|
||||
# Find the specific line that assigns _is_torch_greater_or_equal_than_2_6
|
||||
if "_is_torch_greater_or_equal_than_2_6 = " + target in source:
|
||||
return source.replace(
|
||||
"_is_torch_greater_or_equal_than_2_6 = " + target,
|
||||
"_is_torch_greater_or_equal_than_2_6 = False",
|
||||
1,
|
||||
)
|
||||
return source
|
||||
|
||||
|
||||
class _SourcePatchingFinder:
|
||||
"""Generic delegate-and-wrap meta-path finder that patches a module's
|
||||
source before exec'ing.
|
||||
|
||||
Subclasses declare `target` (module fullname) and `patch` (str->str).
|
||||
Requires the target module's .py source to be bundled (use a PyInstaller
|
||||
hook setting module_collection_mode = "pyz+py").
|
||||
"""
|
||||
|
||||
target: str
|
||||
patch_fn: callable = None
|
||||
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
if fullname != self.target:
|
||||
return None
|
||||
for finder in sys.meta_path:
|
||||
if finder is self:
|
||||
continue
|
||||
find = getattr(finder, "find_spec", None)
|
||||
if find is None:
|
||||
continue
|
||||
try:
|
||||
real_spec = find(fullname, path, target)
|
||||
except Exception:
|
||||
continue
|
||||
if real_spec is None or real_spec.loader is None:
|
||||
continue
|
||||
real_spec.loader = _SourcePatchLoader(real_spec.loader, self.patch_fn)
|
||||
return real_spec
|
||||
return None
|
||||
|
||||
|
||||
class _SourcePatchLoader:
|
||||
"""Delegate loader that reads source via get_source, applies a patch, and
|
||||
compile/exec's the patched text into module.__dict__.
|
||||
"""
|
||||
|
||||
def __init__(self, inner, patch_fn):
|
||||
self._inner = inner
|
||||
self._patch_fn = patch_fn
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._inner, name)
|
||||
|
||||
def create_module(self, spec):
|
||||
return self._inner.create_module(spec)
|
||||
|
||||
def exec_module(self, module):
|
||||
source = None
|
||||
try:
|
||||
source = self._inner.get_source(module.__name__)
|
||||
except Exception as e:
|
||||
_diag(f"[source-patch] get_source({module.__name__}) failed: {e!r}")
|
||||
|
||||
if not source:
|
||||
_diag(
|
||||
f"[source-patch] no source for {module.__name__}; "
|
||||
"falling back to inner exec_module (patch NOT applied)"
|
||||
)
|
||||
self._inner.exec_module(module)
|
||||
return
|
||||
|
||||
patched = self._patch_fn(source)
|
||||
_diag(
|
||||
f"[source-patch] {module.__name__}: "
|
||||
f"patched={patched is not source}, len={len(patched)}"
|
||||
)
|
||||
spec = module.__spec__
|
||||
if spec is not None and spec.submodule_search_locations is not None:
|
||||
module.__path__ = spec.submodule_search_locations
|
||||
filename = getattr(self._inner, "path", module.__name__)
|
||||
exec(compile(patched, filename, "exec"), module.__dict__)
|
||||
_diag(f"[source-patch] {module.__name__} OK")
|
||||
|
||||
|
||||
class _MaskingUtilsFinder(_SourcePatchingFinder):
|
||||
target = "transformers.masking_utils"
|
||||
patch_fn = staticmethod(_patch_masking_utils_source)
|
||||
|
||||
|
||||
class _ScipyDistnPatchingFinder:
|
||||
"""Delegate-and-wrap finder for scipy.stats._distn_infrastructure.
|
||||
|
||||
That module ends with:
|
||||
|
||||
for obj in [s for s in dir() if s.startswith('_doc_')]:
|
||||
exec('del ' + obj)
|
||||
del obj
|
||||
|
||||
In the PyInstaller bundle the list comprehension evaluates to empty
|
||||
(module-level dir() under the frozen importer returns a different scope
|
||||
than CPython's normal module-exec path — same class of bug as the
|
||||
torch._numpy._ufuncs crash). The for loop body doesn't run, `obj` is
|
||||
never bound, and the trailing `del obj` raises NameError at module load.
|
||||
|
||||
This kills every downstream module: librosa (needed by nearly every TTS
|
||||
engine for mel filters) -> scipy.signal -> scipy.stats -> here.
|
||||
|
||||
Workaround: delegate to the real loader, but pre-bind `obj = None` in the
|
||||
module namespace before its bytecode runs. If the for loop executes, each
|
||||
iteration overwrites the sentinel via STORE_NAME (normal behaviour). If it
|
||||
doesn't, `del obj` removes the sentinel and module load succeeds. The
|
||||
`_doc_*` cleanup this line was meant to do is purely cosmetic — those vars
|
||||
stay in the module namespace but nothing references them after this point.
|
||||
"""
|
||||
|
||||
_TARGET = "scipy.stats._distn_infrastructure"
|
||||
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
if fullname != self._TARGET:
|
||||
return None
|
||||
_diag(f"[scipy-finder] match: {fullname}, path={path!r}")
|
||||
# Delegate to the other finders to locate the real spec
|
||||
for finder in sys.meta_path:
|
||||
if finder is self:
|
||||
continue
|
||||
find = getattr(finder, "find_spec", None)
|
||||
if find is None:
|
||||
continue
|
||||
try:
|
||||
real_spec = find(fullname, path, target)
|
||||
except Exception as e:
|
||||
_diag(f"[scipy-finder] inner finder {type(finder).__name__} raised: {e}")
|
||||
continue
|
||||
if real_spec is None:
|
||||
continue
|
||||
if real_spec.loader is None:
|
||||
_diag(f"[scipy-finder] {type(finder).__name__} returned spec with loader=None")
|
||||
continue
|
||||
_diag(
|
||||
f"[scipy-finder] wrapped loader from "
|
||||
f"{type(finder).__name__} -> {type(real_spec.loader).__name__}"
|
||||
)
|
||||
real_spec.loader = _ScipyDistnPrebindLoader(real_spec.loader)
|
||||
return real_spec
|
||||
_diag("[scipy-finder] NO inner finder returned a spec")
|
||||
return None
|
||||
|
||||
|
||||
class _ScipyDistnPrebindLoader:
|
||||
"""Thin wrapper that pre-binds `obj = None` before delegating to the
|
||||
real PyInstaller loader.
|
||||
|
||||
Every other attribute/method delegates to the inner loader — PyiFrozenLoader
|
||||
is a rich FileLoader/ExecutionLoader with get_code/get_source/get_filename/
|
||||
is_package/get_resource_reader/etc., any of which Python's import machinery
|
||||
or 3rd-party code may call on spec.loader. Forwarding via __getattr__
|
||||
avoids breaking any of those paths (and preserves @_check_name contracts
|
||||
because the decorated methods run on the inner instance where self.name
|
||||
matches spec.name).
|
||||
"""
|
||||
|
||||
def __init__(self, inner):
|
||||
self._inner = inner
|
||||
|
||||
def __getattr__(self, name):
|
||||
# __getattr__ fires only for attrs not already on self, so delegate
|
||||
# everything that isn't create_module/exec_module (or __getattr__/init).
|
||||
return getattr(self._inner, name)
|
||||
|
||||
def create_module(self, spec):
|
||||
return self._inner.create_module(spec)
|
||||
|
||||
def exec_module(self, module):
|
||||
# Compile scipy's module source with the problematic line patched.
|
||||
#
|
||||
# The real module ends with:
|
||||
# for obj in [s for s in dir() if s.startswith('_doc_')]:
|
||||
# exec('del ' + obj)
|
||||
# del obj
|
||||
#
|
||||
# Under PyInstaller's frozen importer, `del obj` raises NameError
|
||||
# even when we pre-populate module.__dict__['obj'] — the pre-compiled
|
||||
# .pyc bytecode interacts with the frame setup differently than a
|
||||
# fresh compile() from source. Easiest robust fix: read the source
|
||||
# and replace `del obj` with a safe variant before compiling.
|
||||
#
|
||||
# Requires the .py source to be bundled alongside the .pyc — see
|
||||
# backend/pyi_hooks/hook-scipy.stats._distn_infrastructure.py.
|
||||
source = None
|
||||
try:
|
||||
source = self._inner.get_source(module.__name__)
|
||||
except Exception as e:
|
||||
_diag(f"[scipy-loader] get_source failed: {e!r}")
|
||||
|
||||
if source:
|
||||
patched = _patch_scipy_distn_source(source)
|
||||
_diag(
|
||||
f"[scipy-loader] source-patch path: patched={patched is not source}, "
|
||||
f"len={len(patched)}"
|
||||
)
|
||||
spec = module.__spec__
|
||||
if spec is not None and spec.submodule_search_locations is not None:
|
||||
module.__path__ = spec.submodule_search_locations
|
||||
filename = getattr(self._inner, "path", module.__name__)
|
||||
bytecode = compile(patched, filename, "exec")
|
||||
try:
|
||||
exec(bytecode, module.__dict__)
|
||||
except Exception as e:
|
||||
_diag(f"[scipy-loader] patched exec raised {type(e).__name__}: {e!r}")
|
||||
raise
|
||||
_diag(f"[scipy-loader] exec_module {module.__name__} OK (source-patched)")
|
||||
return
|
||||
|
||||
# No source available — fall back to the pre-bind approach. This is
|
||||
# best-effort; if the frozen .pyc really does see a different `obj`
|
||||
# slot, this will still crash, but we've done all we can without
|
||||
# source.
|
||||
_diag("[scipy-loader] no source available; falling back to pre-bind")
|
||||
module.__dict__["obj"] = None
|
||||
self._inner.exec_module(module)
|
||||
|
||||
|
||||
def _install_dynamo_stub() -> None:
|
||||
stub = _NoopDynamoModule("torch._dynamo")
|
||||
# Mark as a package so `from torch._dynamo.X import Y` imports work
|
||||
# (Python's import machinery checks parent.__path__ before looking up
|
||||
# the child).
|
||||
stub.__path__ = []
|
||||
# torch._dynamo.config is accessed as a nested attribute namespace
|
||||
# (e.g. `torch._dynamo.config.capture_scalar_outputs = True`), so use
|
||||
# a permissive module so any attr read returns a no-op and sets succeed.
|
||||
stub.config = _NoopDynamoModule("torch._dynamo.config")
|
||||
stub.config.__path__ = []
|
||||
sys.modules["torch._dynamo"] = stub
|
||||
sys.modules["torch._dynamo.config"] = stub.config
|
||||
|
||||
# Finders:
|
||||
# - torch._dynamo.* submodules -> no-op stubs
|
||||
# - transformers.utils.auto_docstring and
|
||||
# transformers.generation.candidate_generator -> no-op stubs (both
|
||||
# paths reach sklearn -> scipy.stats which trips a separate crash)
|
||||
# - scipy.stats._distn_infrastructure -> real load with `obj` pre-bound,
|
||||
# so librosa -> scipy.signal -> scipy.stats loads cleanly
|
||||
for _FinderCls in (
|
||||
_DynamoMetaPathFinder,
|
||||
_TransformersStubFinder,
|
||||
_ScipyDistnPatchingFinder,
|
||||
_MaskingUtilsFinder,
|
||||
):
|
||||
try:
|
||||
sys.meta_path.insert(0, _FinderCls())
|
||||
_diag(f"installed finder: {_FinderCls.__name__}")
|
||||
except Exception as e:
|
||||
_diag(f"FAILED to install {_FinderCls.__name__}: {e!r}")
|
||||
_diag(
|
||||
"final sys.meta_path head: "
|
||||
+ ", ".join(type(f).__name__ for f in sys.meta_path[:6])
|
||||
)
|
||||
|
||||
# If torch is already imported, also set the attribute on the package so
|
||||
# `torch._dynamo` resolves to our stub without triggering torch.__getattr__
|
||||
# (which would lazy-import the real module and crash).
|
||||
torch_mod = sys.modules.get("torch")
|
||||
if torch_mod is not None:
|
||||
torch_mod._dynamo = stub
|
||||
|
||||
|
||||
try:
|
||||
_install_dynamo_stub()
|
||||
except Exception as _e:
|
||||
# Best effort. If this fails the original NameError will surface when
|
||||
# transformers imports — no worse than not patching at all.
|
||||
_diag(f"_install_dynamo_stub FAILED: {_e!r}")
|
||||
|
||||
# NOTE: we deliberately do NOT import torch or torch.compiler here.
|
||||
# Runtime hooks run before the app starts and before pyi_rth_numpy_compat
|
||||
# has had a chance to patch torch.from_numpy (it runs in a background
|
||||
# thread, waiting for torch to appear in sys.modules). Importing torch
|
||||
# eagerly at hook time would trip the numpy ABI issue and kill the
|
||||
# server process at startup.
|
||||
#
|
||||
# torch.compiler.disable does not need an explicit stub: its
|
||||
# implementation is effectively `import torch._dynamo; return
|
||||
# torch._dynamo.disable(fn, recursive, reason=reason)`, and since our
|
||||
# stub is installed in sys.modules, that call resolves to our no-op
|
||||
# _NoopDecorator pass-through.
|
||||
@@ -2,4 +2,14 @@
|
||||
# These should only be installed on aarch64-apple-darwin platforms
|
||||
|
||||
mlx>=0.30.0
|
||||
mlx-audio>=0.3.1
|
||||
|
||||
# NOTE: mlx-audio is intentionally not listed here. From 0.3.1 onward it
|
||||
# declares `transformers==5.0.0rc3` / `>=5.0.0`, which conflicts with the
|
||||
# `transformers<=4.57.6` cap in requirements.txt and breaks CI's clean
|
||||
# resolver. The mlx-audio API surface we use (mlx_audio.tts.load,
|
||||
# mlx_audio.stt.load) works fine on transformers 4.57.x in practice.
|
||||
#
|
||||
# Install it via `pip install --no-deps mlx-audio==0.4.1` after this file
|
||||
# (see .github/workflows/release.yml). All other mlx-audio runtime deps
|
||||
# (huggingface_hub, librosa, miniaudio, mlx-lm, numba, numpy, protobuf,
|
||||
# pyloudnorm, sounddevice, tqdm) are already in requirements.txt.
|
||||
|
||||
@@ -14,7 +14,7 @@ from .. import models
|
||||
from ..services import history, profiles, tts
|
||||
from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db
|
||||
from ..services.generation import run_generation
|
||||
from ..services.task_queue import enqueue_generation
|
||||
from ..services.task_queue import cancel_generation as cancel_generation_job, enqueue_generation
|
||||
from ..utils.tasks import get_task_manager
|
||||
|
||||
router = APIRouter()
|
||||
@@ -82,6 +82,7 @@ async def generate_speech(
|
||||
pass
|
||||
|
||||
enqueue_generation(
|
||||
generation_id,
|
||||
run_generation(
|
||||
generation_id=generation_id,
|
||||
profile_id=data.profile_id,
|
||||
@@ -127,6 +128,7 @@ async def retry_generation(generation_id: str, db: Session = Depends(get_db)):
|
||||
)
|
||||
|
||||
enqueue_generation(
|
||||
generation_id,
|
||||
run_generation(
|
||||
generation_id=generation_id,
|
||||
profile_id=gen.profile_id,
|
||||
@@ -170,6 +172,7 @@ async def regenerate_generation(generation_id: str, db: Session = Depends(get_db
|
||||
version_id = str(uuid.uuid4())
|
||||
|
||||
enqueue_generation(
|
||||
generation_id,
|
||||
run_generation(
|
||||
generation_id=generation_id,
|
||||
profile_id=gen.profile_id,
|
||||
@@ -187,6 +190,34 @@ async def regenerate_generation(generation_id: str, db: Session = Depends(get_db
|
||||
return models.GenerationResponse.model_validate(gen)
|
||||
|
||||
|
||||
@router.post("/generate/{generation_id}/cancel")
|
||||
async def cancel_generation(generation_id: str, db: Session = Depends(get_db)):
|
||||
"""Cancel a queued or running generation."""
|
||||
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
|
||||
if not gen:
|
||||
raise HTTPException(status_code=404, detail="Generation not found")
|
||||
|
||||
if (gen.status or "completed") not in ("loading_model", "generating"):
|
||||
raise HTTPException(status_code=400, detail="Only active generations can be cancelled")
|
||||
|
||||
cancellation_state = cancel_generation_job(generation_id)
|
||||
if cancellation_state is None:
|
||||
raise HTTPException(status_code=409, detail="Generation is no longer cancellable")
|
||||
|
||||
if cancellation_state == "queued":
|
||||
task_manager = get_task_manager()
|
||||
task_manager.complete_generation(generation_id)
|
||||
await history.update_generation_status(
|
||||
generation_id=generation_id,
|
||||
status="failed",
|
||||
db=db,
|
||||
error="Generation cancelled",
|
||||
)
|
||||
return {"message": "Queued generation cancelled"}
|
||||
|
||||
return {"message": "Generation cancellation requested"}
|
||||
|
||||
|
||||
@router.get("/generate/{generation_id}/status")
|
||||
async def get_generation_status(generation_id: str, db: Session = Depends(get_db)):
|
||||
"""SSE endpoint that streams generation status updates."""
|
||||
|
||||
@@ -135,14 +135,15 @@ async def migrate_models(request: models.ModelMigrateRequest):
|
||||
if destination.resolve().is_relative_to(source.resolve()):
|
||||
raise HTTPException(status_code=400, detail="Destination cannot be inside the current cache directory")
|
||||
|
||||
progress_manager = get_progress_manager()
|
||||
model_dirs = [d for d in source.iterdir() if d.name.startswith("models--") and d.is_dir()]
|
||||
if not model_dirs:
|
||||
progress_manager.update_progress("migration", 1, 1, status="complete")
|
||||
progress_manager.mark_complete("migration")
|
||||
return {"moved": 0, "errors": [], "source": str(source), "destination": str(destination)}
|
||||
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
progress_manager = get_progress_manager()
|
||||
|
||||
same_fs = False
|
||||
try:
|
||||
same_fs = source.stat().st_dev == destination.stat().st_dev
|
||||
|
||||
@@ -16,6 +16,7 @@ Mode differences:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
from typing import Literal, Optional
|
||||
|
||||
@@ -126,6 +127,13 @@ async def run_generation(
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await history.update_generation_status(
|
||||
generation_id=generation_id,
|
||||
status="failed",
|
||||
db=bg_db,
|
||||
error="Generation cancelled",
|
||||
)
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
await history.update_generation_status(
|
||||
|
||||
@@ -319,6 +319,10 @@ async def delete_generations_by_profile(
|
||||
|
||||
count = 0
|
||||
for generation in generations:
|
||||
# Delete associated version files and rows first
|
||||
from . import versions as versions_mod
|
||||
versions_mod.delete_versions_for_generation(generation.id, db)
|
||||
|
||||
# Delete audio file
|
||||
audio_path = config.resolve_storage_path(generation.audio_path)
|
||||
if audio_path is not None and audio_path.exists():
|
||||
|
||||
@@ -5,12 +5,27 @@ to avoid GPU contention.
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from typing import Coroutine, Literal
|
||||
|
||||
# Keep references to fire-and-forget background tasks to prevent GC
|
||||
_background_tasks: set = set()
|
||||
|
||||
|
||||
@dataclass
|
||||
class GenerationJob:
|
||||
"""Queued generation work plus the generation ID it belongs to."""
|
||||
|
||||
generation_id: str
|
||||
coro: Coroutine
|
||||
|
||||
|
||||
# Generation queue — serializes TTS inference to avoid GPU contention
|
||||
_generation_queue: asyncio.Queue = None # type: ignore # initialized at startup
|
||||
_generation_worker_task: asyncio.Task | None = None
|
||||
_queued_generation_ids: set[str] = set()
|
||||
_running_generation_tasks: dict[str, asyncio.Task] = {}
|
||||
_cancelled_generation_ids: set[str] = set()
|
||||
|
||||
|
||||
def create_background_task(coro) -> asyncio.Task:
|
||||
@@ -24,25 +39,70 @@ def create_background_task(coro) -> asyncio.Task:
|
||||
async def _generation_worker():
|
||||
"""Worker that processes generation tasks one at a time."""
|
||||
while True:
|
||||
coro = await _generation_queue.get()
|
||||
job = await _generation_queue.get()
|
||||
try:
|
||||
await coro
|
||||
if job.generation_id in _cancelled_generation_ids:
|
||||
_cancelled_generation_ids.discard(job.generation_id)
|
||||
job.coro.close()
|
||||
continue
|
||||
|
||||
task = asyncio.create_task(job.coro)
|
||||
_running_generation_tasks[job.generation_id] = task
|
||||
_queued_generation_ids.discard(job.generation_id)
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
if not task.cancelled():
|
||||
raise
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
_running_generation_tasks.pop(job.generation_id, None)
|
||||
_queued_generation_ids.discard(job.generation_id)
|
||||
_generation_queue.task_done()
|
||||
|
||||
|
||||
def enqueue_generation(coro):
|
||||
def enqueue_generation(generation_id: str, coro):
|
||||
"""Add a generation coroutine to the serial queue."""
|
||||
_generation_queue.put_nowait(coro)
|
||||
if _generation_queue is None:
|
||||
raise RuntimeError("Generation queue has not been initialized")
|
||||
|
||||
_queued_generation_ids.add(generation_id)
|
||||
_generation_queue.put_nowait(GenerationJob(generation_id=generation_id, coro=coro))
|
||||
|
||||
|
||||
def init_queue():
|
||||
def cancel_generation(generation_id: str) -> Literal["queued", "running"] | None:
|
||||
"""Cancel a queued or running generation if it is still active."""
|
||||
running_task = _running_generation_tasks.get(generation_id)
|
||||
if running_task is not None:
|
||||
running_task.cancel()
|
||||
return "running"
|
||||
|
||||
if generation_id in _queued_generation_ids:
|
||||
_queued_generation_ids.discard(generation_id)
|
||||
_cancelled_generation_ids.add(generation_id)
|
||||
return "queued"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def init_queue(force: bool = False):
|
||||
"""Initialize the generation queue and start the worker.
|
||||
|
||||
Must be called once during application startup (inside a running event loop).
|
||||
"""
|
||||
global _generation_queue
|
||||
global _generation_queue, _generation_worker_task
|
||||
global _queued_generation_ids, _running_generation_tasks, _cancelled_generation_ids
|
||||
|
||||
if _generation_worker_task is not None and not _generation_worker_task.done():
|
||||
if not force:
|
||||
return
|
||||
_generation_worker_task.cancel()
|
||||
for task in list(_running_generation_tasks.values()):
|
||||
task.cancel()
|
||||
|
||||
_generation_queue = asyncio.Queue()
|
||||
create_background_task(_generation_worker())
|
||||
_queued_generation_ids = set()
|
||||
_running_generation_tasks = {}
|
||||
_cancelled_generation_ids = set()
|
||||
_generation_worker_task = create_background_task(_generation_worker())
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
# End-to-End Model Generation Test — Design
|
||||
|
||||
## Goal
|
||||
|
||||
A single script, runnable on macOS and Windows, that exercises every TTS model against the **frozen PyInstaller binary** (not the dev server), captures per-model pass/fail and error messages, and exits non-zero if any model fails. Generation is strictly sequential — one model loaded at a time.
|
||||
|
||||
## Test matrix (10 runs)
|
||||
|
||||
Derived from `backend/backends/__init__.py:185-316`. Each row maps to one `POST /generate` call.
|
||||
|
||||
| # | engine | model_size | profile kind | notes |
|
||||
|---|-----------------------|------------|--------------|-------|
|
||||
| 1 | `qwen` | `1.7B` | cloned | reference audio required |
|
||||
| 2 | `qwen` | `0.6B` | cloned | |
|
||||
| 3 | `qwen_custom_voice` | `1.7B` | preset | `preset_voice_id="Ryan"` |
|
||||
| 4 | `qwen_custom_voice` | `0.6B` | preset | `preset_voice_id="Ryan"` |
|
||||
| 5 | `luxtts` | — | cloned | English only |
|
||||
| 6 | `chatterbox` | — | cloned | |
|
||||
| 7 | `chatterbox_turbo` | — | cloned | English only |
|
||||
| 8 | `tada` | `1B` | cloned | tada-1b, English only |
|
||||
| 9 | `tada` | `3B` | cloned | tada-3b-ml, multilingual |
|
||||
| 10| `kokoro` | — | preset | `preset_voice_id="af_heart"` |
|
||||
|
||||
Cloned engines (1, 2, 5, 6, 7, 8, 9) share **one** profile created once with the reference WAV. Preset profiles are created separately, one for kokoro and one for qwen_custom_voice.
|
||||
|
||||
Language for every run: `en` (covers every engine's supported set).
|
||||
|
||||
## End-to-end flow
|
||||
|
||||
```
|
||||
1. Resolve paths → find binary, build if missing
|
||||
2. Launch binary → spawn with --port --data-dir --parent-pid
|
||||
3. Wait for /health → poll until status=="healthy" or 120s timeout
|
||||
4. Create profiles → 1 cloned + 2 preset, via /profiles (+ /samples)
|
||||
5. For each (engine, model_size) in matrix:
|
||||
a. Check cache → GET /models/status → cached? short timeout : long
|
||||
b. POST /generate → get generation_id
|
||||
c. Stream /status → consume SSE until completed/failed/timeout
|
||||
d. Record result → {engine, model_size, status, duration, error, elapsed}
|
||||
6. Write results → JSON + Markdown table to ./results/
|
||||
7. Shutdown binary → SIGTERM, fall back to kill, verify port freed
|
||||
8. Exit code → 0 if all passed, 1 otherwise
|
||||
```
|
||||
|
||||
## Binary resolution
|
||||
|
||||
Search order — **first hit wins**:
|
||||
|
||||
| Platform | Path | Build type |
|
||||
|----------|------|------------|
|
||||
| macOS | `backend/dist/voicebox-server-cuda/voicebox-server-cuda` | onedir (CUDA, rarely on Mac) |
|
||||
| macOS | `backend/dist/voicebox-server` | onefile (CPU) |
|
||||
| Windows | `backend\dist\voicebox-server-cuda\voicebox-server-cuda.exe` | onedir (CUDA) |
|
||||
| Windows | `backend\dist\voicebox-server.exe` | onefile (CPU) |
|
||||
|
||||
If none exist, run `python backend/build_binary.py` and wait for it to finish (can take 5-20 min). Fail with a clear error if the build itself fails. `--skip-build` flag forces "error out if no binary" instead of building.
|
||||
|
||||
## Spawn command
|
||||
|
||||
Mirrors Tauri's launch in `tauri/src-tauri/src/main.rs:369-388`:
|
||||
|
||||
```
|
||||
<binary> --host 127.0.0.1 --port <free-port> --data-dir <tempdir> --parent-pid <test-pid>
|
||||
```
|
||||
|
||||
- **Port**: bind to `0` first in Python to grab a free port, then pass that number.
|
||||
- **Data dir**: `tempfile.mkdtemp(prefix="voicebox-e2e-")`. Deleted after the run unless `--keep-data-dir`. Profiles and generated WAVs land here.
|
||||
- **Parent PID**: current Python PID — ensures the backend dies if the test crashes (watchdog in `server.py:102-224`).
|
||||
- **stdout/stderr**: tee to both a log file in `./results/server-<timestamp>.log` and a rolling in-memory buffer. On model failure, last 100 lines of the buffer are attached to that model's error record.
|
||||
|
||||
## Profile setup
|
||||
|
||||
One cloned profile shared across all cloning engines:
|
||||
|
||||
```http
|
||||
POST /profiles
|
||||
{
|
||||
"name": "e2e-cloned",
|
||||
"voice_type": "cloned",
|
||||
"language": "en"
|
||||
}
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```http
|
||||
POST /profiles/{id}/samples (multipart)
|
||||
file: <reference WAV>
|
||||
reference_text: <exact transcription>
|
||||
```
|
||||
|
||||
Two preset profiles:
|
||||
|
||||
```http
|
||||
POST /profiles
|
||||
{ "name": "e2e-kokoro", "voice_type": "preset", "language": "en",
|
||||
"preset_engine": "kokoro", "preset_voice_id": "af_heart" }
|
||||
|
||||
POST /profiles
|
||||
{ "name": "e2e-qwen-cv", "voice_type": "preset", "language": "en",
|
||||
"preset_engine": "qwen_custom_voice", "preset_voice_id": "Ryan" }
|
||||
```
|
||||
|
||||
## Generation request (per matrix row)
|
||||
|
||||
```http
|
||||
POST /generate
|
||||
{
|
||||
"profile_id": "<appropriate profile>",
|
||||
"text": "The quick brown fox jumps over the lazy dog.",
|
||||
"language": "en",
|
||||
"engine": "<engine>",
|
||||
"model_size": "<size or omitted>",
|
||||
"seed": 42,
|
||||
"normalize": true
|
||||
}
|
||||
```
|
||||
|
||||
Response `id` feeds into the SSE status loop (`GET /generate/{id}/status`, `routes/generations.py:190-227`). Loop reads lines until a payload with `status in ("completed", "failed")` arrives, then breaks.
|
||||
|
||||
## Timeout strategy (split)
|
||||
|
||||
Check `GET /models/status` for the target model **before** generation:
|
||||
|
||||
| Cached? | Per-model timeout | Rationale |
|
||||
|---------|-------------------|-----------|
|
||||
| Yes | **3 minutes** | Inference only; generous for CPU builds |
|
||||
| No | **20 minutes** | First-run HF download up to 8 GB (tada-3b-ml) |
|
||||
|
||||
On timeout: cancel the SSE stream, mark the row `timeout`, and continue to the next row. Don't abort the whole run on one timeout.
|
||||
|
||||
## Result format
|
||||
|
||||
`./results/e2e-<platform>-<arch>-<timestamp>.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"platform": "darwin-arm64",
|
||||
"binary": "/abs/path/voicebox-server",
|
||||
"binary_size_mb": 612,
|
||||
"started_at": "2026-04-16T12:34:56Z",
|
||||
"finished_at": "...",
|
||||
"results": [
|
||||
{
|
||||
"engine": "qwen",
|
||||
"model_size": "1.7B",
|
||||
"status": "passed|failed|timeout",
|
||||
"generation_id": "...",
|
||||
"was_cached": true,
|
||||
"elapsed_seconds": 12.4,
|
||||
"audio_duration": 3.1,
|
||||
"audio_path": "/tmp/.../gen.wav",
|
||||
"error": null,
|
||||
"server_log_tail": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Companion `./results/e2e-<...>.md`:
|
||||
|
||||
```
|
||||
# Voicebox E2E — darwin-arm64 — 2026-04-16 12:34
|
||||
|
||||
| Engine | Size | Status | Elapsed | Error |
|
||||
|---------------------|------|--------|---------|-------|
|
||||
| qwen | 1.7B | PASS | 12.4s | |
|
||||
| qwen | 0.6B | FAIL | 4.1s | CUDA OOM: ... |
|
||||
...
|
||||
```
|
||||
|
||||
## CLI flags
|
||||
|
||||
```
|
||||
python -m backend.tests.test_all_models_e2e [flags]
|
||||
|
||||
--binary PATH Use this binary instead of auto-detecting
|
||||
--skip-build Error if no binary found (no auto-build)
|
||||
--reference-wav PATH Reference audio (default: backend/tests/fixtures/reference_voice.wav)
|
||||
--reference-text STR Transcription (default: read from fixtures/reference_voice.txt)
|
||||
--only ENGINE[,...] Run only these engines (e.g. kokoro,qwen)
|
||||
--skip ENGINE[,...] Skip these engines
|
||||
--keep-data-dir Don't delete tempdir after run
|
||||
--timeout-cached SEC Override 180
|
||||
--timeout-download SEC Override 1200
|
||||
--port N Override auto-picked port
|
||||
--output-dir PATH Default: backend/tests/results/
|
||||
```
|
||||
|
||||
## File layout
|
||||
|
||||
```
|
||||
backend/tests/
|
||||
├── E2E_MODEL_TEST_DESIGN.md (this file)
|
||||
├── test_all_models_e2e.py (main script, ~400-500 LoC)
|
||||
├── fixtures/
|
||||
│ ├── reference_voice.wav (user-provided, ~5-15s clean speech)
|
||||
│ └── reference_voice.txt (exact transcription)
|
||||
└── results/ (gitignored)
|
||||
├── e2e-darwin-arm64-<ts>.json
|
||||
├── e2e-darwin-arm64-<ts>.md
|
||||
└── server-<ts>.log
|
||||
```
|
||||
|
||||
The script uses only stdlib + `httpx` (or `requests`) + `sseclient-py` — all already in `backend/requirements.txt`. No pytest to keep it invocable as a single command on fresh checkouts.
|
||||
|
||||
## Safety & cleanup
|
||||
|
||||
- Always kill the spawned binary in a `try/finally`. On Windows, `taskkill /F /T` the whole tree (Tauri does the same).
|
||||
- Verify the port is free on shutdown (Tauri port-reuse check in `main.rs:114-186` could otherwise pick up a ghost).
|
||||
- Don't touch the user's HF cache by default — let the server use `HF_HUB_CACHE` / `VOICEBOX_MODELS_DIR`. Passing `--isolated-cache` would point both env vars at the tempdir for a true cold-start run (opt-in only; would re-download every time).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Not validating audio quality (no WER, no waveform comparison). Pass = "endpoint returned `completed` and produced a non-empty WAV".
|
||||
- Not testing STT (Whisper), effects chains, channels, or streaming endpoints.
|
||||
- Not running on CI today — human-invoked on dev machines. CI integration is a follow-up once the script is stable.
|
||||
- No model unload between runs — models stay loaded; server manages its own eviction.
|
||||
- No version-drift check on the binary.
|
||||
- No `instruct` parameter exercised on qwen_custom_voice runs.
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
# E2E Test Fixtures
|
||||
|
||||
Place two files here before running `test_all_models_e2e.py`:
|
||||
|
||||
- `reference_voice.wav` — a clean speech sample, mono, 16–24 kHz, ~5–15 seconds.
|
||||
- `reference_voice.txt` — the **exact** transcription of the WAV (single line, no trailing newline required).
|
||||
|
||||
These are used to create a cloned voice profile for every cloning-capable engine (qwen, luxtts, chatterbox, chatterbox_turbo, tada). Keep them out of version control if they contain personal audio — this directory is not gitignored by default, so add them to `.gitignore` locally if needed.
|
||||
|
||||
You can point the test at different files with:
|
||||
|
||||
```
|
||||
python backend/tests/test_all_models_e2e.py \
|
||||
--reference-wav /path/to/your.wav \
|
||||
--reference-text "exact transcription here"
|
||||
```
|
||||
@@ -0,0 +1,630 @@
|
||||
"""
|
||||
End-to-end model generation test.
|
||||
|
||||
Exercises every TTS model against the frozen PyInstaller binary, captures
|
||||
per-model pass/fail, and writes a JSON + Markdown report.
|
||||
|
||||
Usage:
|
||||
python backend/tests/test_all_models_e2e.py [flags]
|
||||
|
||||
See E2E_MODEL_TEST_DESIGN.md for the full design.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
BACKEND_DIR = REPO_ROOT / "backend"
|
||||
DIST_DIR = BACKEND_DIR / "dist"
|
||||
FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures"
|
||||
RESULTS_DIR = Path(__file__).resolve().parent / "results"
|
||||
|
||||
|
||||
# ── Test matrix ──────────────────────────────────────────────────────
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MatrixRow:
|
||||
label: str # human-readable (appears in report)
|
||||
engine: str # /generate engine
|
||||
model_size: Optional[str] # /generate model_size (None = omit)
|
||||
profile_kind: str # "cloned" | "preset_kokoro" | "preset_qwen_cv"
|
||||
model_name: str # /models/status key for cache lookup
|
||||
|
||||
|
||||
MATRIX: list[MatrixRow] = [
|
||||
MatrixRow("qwen 1.7B", "qwen", "1.7B", "cloned", "qwen-tts-1.7B"),
|
||||
MatrixRow("qwen 0.6B", "qwen", "0.6B", "cloned", "qwen-tts-0.6B"),
|
||||
MatrixRow("qwen_custom_voice 1.7B", "qwen_custom_voice", "1.7B", "preset_qwen_cv", "qwen-custom-voice-1.7B"),
|
||||
MatrixRow("qwen_custom_voice 0.6B", "qwen_custom_voice", "0.6B", "preset_qwen_cv", "qwen-custom-voice-0.6B"),
|
||||
MatrixRow("luxtts", "luxtts", None, "cloned", "luxtts"),
|
||||
MatrixRow("chatterbox", "chatterbox", None, "cloned", "chatterbox-tts"),
|
||||
MatrixRow("chatterbox_turbo", "chatterbox_turbo", None, "cloned", "chatterbox-turbo"),
|
||||
MatrixRow("tada 1B", "tada", "1B", "cloned", "tada-1b"),
|
||||
MatrixRow("tada 3B", "tada", "3B", "cloned", "tada-3b-ml"),
|
||||
MatrixRow("kokoro", "kokoro", None, "preset_kokoro", "kokoro"),
|
||||
]
|
||||
|
||||
TEXT = "The quick brown fox jumps over the lazy dog."
|
||||
DEFAULT_TIMEOUT_CACHED = 180
|
||||
DEFAULT_TIMEOUT_DOWNLOAD = 1200
|
||||
HEALTH_TIMEOUT = 120
|
||||
|
||||
|
||||
# ── Result record ────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class ModelResult:
|
||||
label: str
|
||||
engine: str
|
||||
model_size: Optional[str]
|
||||
status: str # "passed" | "failed" | "timeout"
|
||||
was_cached: Optional[bool] = None
|
||||
generation_id: Optional[str] = None
|
||||
elapsed_seconds: float = 0.0
|
||||
audio_duration: Optional[float] = None
|
||||
audio_path: Optional[str] = None
|
||||
audio_bytes: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
http_status: Optional[int] = None
|
||||
server_log_tail: Optional[list[str]] = None
|
||||
|
||||
|
||||
# ── Binary resolution ────────────────────────────────────────────────
|
||||
|
||||
def find_binary() -> Optional[Path]:
|
||||
"""Return the first existing binary in priority order, or None."""
|
||||
is_win = platform.system() == "Windows"
|
||||
exe = ".exe" if is_win else ""
|
||||
candidates = [
|
||||
DIST_DIR / "voicebox-server-cuda" / f"voicebox-server-cuda{exe}",
|
||||
DIST_DIR / f"voicebox-server{exe}",
|
||||
]
|
||||
for c in candidates:
|
||||
if c.exists() and c.is_file():
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def build_binary() -> Path:
|
||||
"""Invoke build_binary.py and return the resulting binary path."""
|
||||
print("[build] No frozen binary found — invoking build_binary.py (this may take 5-20 minutes)...", flush=True)
|
||||
script = BACKEND_DIR / "build_binary.py"
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(script)],
|
||||
cwd=str(BACKEND_DIR),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"build_binary.py exited with code {result.returncode}")
|
||||
found = find_binary()
|
||||
if found is None:
|
||||
raise RuntimeError("build_binary.py finished but no binary was found in backend/dist/")
|
||||
return found
|
||||
|
||||
|
||||
# ── Server spawn + log capture ───────────────────────────────────────
|
||||
|
||||
class ServerProcess:
|
||||
def __init__(self, binary: Path, port: int, data_dir: Path, log_path: Path):
|
||||
self.binary = binary
|
||||
self.port = port
|
||||
self.data_dir = data_dir
|
||||
self.log_path = log_path
|
||||
self.proc: Optional[subprocess.Popen] = None
|
||||
self._log_buffer: deque[str] = deque(maxlen=500)
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
|
||||
def start(self) -> None:
|
||||
args = [
|
||||
str(self.binary),
|
||||
"--host", "127.0.0.1",
|
||||
"--port", str(self.port),
|
||||
"--data-dir", str(self.data_dir),
|
||||
"--parent-pid", str(os.getpid()),
|
||||
]
|
||||
print(f"[spawn] {' '.join(args)}", flush=True)
|
||||
self._log_fh = open(self.log_path, "w", encoding="utf-8", errors="replace")
|
||||
# Combine stderr into stdout so we get a single ordered stream.
|
||||
self.proc = subprocess.Popen(
|
||||
args,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
bufsize=1,
|
||||
text=True,
|
||||
errors="replace",
|
||||
)
|
||||
self._reader_thread = threading.Thread(target=self._pump_logs, daemon=True)
|
||||
self._reader_thread.start()
|
||||
|
||||
def _pump_logs(self) -> None:
|
||||
assert self.proc is not None and self.proc.stdout is not None
|
||||
for line in self.proc.stdout:
|
||||
self._log_buffer.append(line.rstrip("\n"))
|
||||
self._log_fh.write(line)
|
||||
self._log_fh.flush()
|
||||
|
||||
def log_tail(self, n: int = 100) -> list[str]:
|
||||
tail = list(self._log_buffer)[-n:]
|
||||
return tail
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
return self.proc is not None and self.proc.poll() is None
|
||||
|
||||
def stop(self) -> None:
|
||||
if self.proc is None:
|
||||
return
|
||||
if self.proc.poll() is not None:
|
||||
return
|
||||
try:
|
||||
if platform.system() == "Windows":
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/T", "/PID", str(self.proc.pid)],
|
||||
capture_output=True,
|
||||
)
|
||||
else:
|
||||
self.proc.send_signal(signal.SIGTERM)
|
||||
except Exception as e:
|
||||
print(f"[shutdown] signal failed: {e}", flush=True)
|
||||
try:
|
||||
self.proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
print("[shutdown] server didn't exit cleanly, killing", flush=True)
|
||||
self.proc.kill()
|
||||
try:
|
||||
self.proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
if self._reader_thread is not None:
|
||||
self._reader_thread.join(timeout=2)
|
||||
try:
|
||||
self._log_fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def pick_free_port() -> int:
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
return port
|
||||
|
||||
|
||||
# ── HTTP helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def wait_for_health(base_url: str, server: ServerProcess, timeout: int) -> None:
|
||||
deadline = time.time() + timeout
|
||||
with httpx.Client(timeout=5.0) as client:
|
||||
while time.time() < deadline:
|
||||
if not server.is_alive():
|
||||
raise RuntimeError("Server process exited before becoming healthy")
|
||||
try:
|
||||
r = client.get(f"{base_url}/health")
|
||||
if r.status_code == 200 and r.json().get("status") == "healthy":
|
||||
return
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
time.sleep(1.0)
|
||||
raise TimeoutError(f"Server did not become healthy within {timeout}s")
|
||||
|
||||
|
||||
def get_model_cached(client: httpx.Client, base_url: str, model_name: str) -> Optional[bool]:
|
||||
try:
|
||||
r = client.get(f"{base_url}/models/status", timeout=30.0)
|
||||
r.raise_for_status()
|
||||
for m in r.json().get("models", []):
|
||||
if m.get("model_name") == model_name:
|
||||
return bool(m.get("downloaded"))
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def create_cloned_profile(client: httpx.Client, base_url: str, wav_path: Path, reference_text: str) -> str:
|
||||
r = client.post(f"{base_url}/profiles", json={
|
||||
"name": "e2e-cloned",
|
||||
"voice_type": "cloned",
|
||||
"language": "en",
|
||||
})
|
||||
r.raise_for_status()
|
||||
profile_id = r.json()["id"]
|
||||
|
||||
with open(wav_path, "rb") as f:
|
||||
r = client.post(
|
||||
f"{base_url}/profiles/{profile_id}/samples",
|
||||
files={"file": (wav_path.name, f, "audio/wav")},
|
||||
data={"reference_text": reference_text},
|
||||
timeout=120.0,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return profile_id
|
||||
|
||||
|
||||
def create_preset_profile(client: httpx.Client, base_url: str, name: str, engine: str, voice_id: str) -> str:
|
||||
r = client.post(f"{base_url}/profiles", json={
|
||||
"name": name,
|
||||
"voice_type": "preset",
|
||||
"language": "en",
|
||||
"preset_engine": engine,
|
||||
"preset_voice_id": voice_id,
|
||||
})
|
||||
r.raise_for_status()
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def run_one_generation(
|
||||
client: httpx.Client,
|
||||
base_url: str,
|
||||
row: MatrixRow,
|
||||
profile_id: str,
|
||||
timeout_s: int,
|
||||
) -> tuple[str, dict]:
|
||||
"""Start a generation and stream its status until done/failed/timeout.
|
||||
|
||||
Returns (status, payload) where status is "completed" | "failed" | "timeout".
|
||||
"""
|
||||
body = {
|
||||
"profile_id": profile_id,
|
||||
"text": TEXT,
|
||||
"language": "en",
|
||||
"engine": row.engine,
|
||||
"seed": 42,
|
||||
"normalize": True,
|
||||
}
|
||||
if row.model_size is not None:
|
||||
body["model_size"] = row.model_size
|
||||
|
||||
r = client.post(f"{base_url}/generate", json=body, timeout=30.0)
|
||||
r.raise_for_status()
|
||||
gen = r.json()
|
||||
gen_id = gen["id"]
|
||||
|
||||
deadline = time.time() + timeout_s
|
||||
last_payload: dict = gen
|
||||
status_url = f"{base_url}/generate/{gen_id}/status"
|
||||
|
||||
while time.time() < deadline:
|
||||
remaining = max(1.0, deadline - time.time())
|
||||
try:
|
||||
with client.stream("GET", status_url, timeout=httpx.Timeout(remaining + 5, read=remaining + 5)) as resp:
|
||||
resp.raise_for_status()
|
||||
for line in resp.iter_lines():
|
||||
if not line or not line.startswith("data: "):
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(line[6:])
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
last_payload = payload
|
||||
status = payload.get("status")
|
||||
if status == "not_found":
|
||||
return "failed", {"error": "generation not found", **payload}
|
||||
if status in ("completed", "failed"):
|
||||
return status, payload
|
||||
if time.time() >= deadline:
|
||||
break
|
||||
except httpx.HTTPError:
|
||||
time.sleep(1.0)
|
||||
continue
|
||||
|
||||
return "timeout", last_payload
|
||||
|
||||
|
||||
def fetch_audio_info(
|
||||
client: httpx.Client, base_url: str, generation_id: str, data_dir: Path
|
||||
) -> tuple[Optional[str], Optional[int]]:
|
||||
"""Return (audio_path, audio_bytes) for a completed generation.
|
||||
|
||||
Server stores audio_path relative to data_dir; resolve it to get a size.
|
||||
"""
|
||||
try:
|
||||
r = client.get(f"{base_url}/history/{generation_id}", timeout=10.0)
|
||||
if r.status_code != 200:
|
||||
return None, None
|
||||
data = r.json()
|
||||
audio_path = data.get("audio_path")
|
||||
if not audio_path:
|
||||
return None, None
|
||||
p = Path(audio_path)
|
||||
if not p.is_absolute():
|
||||
p = data_dir / p
|
||||
if p.exists():
|
||||
return str(p), p.stat().st_size
|
||||
return audio_path, None
|
||||
except httpx.HTTPError:
|
||||
return None, None
|
||||
|
||||
|
||||
# ── Report writers ───────────────────────────────────────────────────
|
||||
|
||||
def write_reports(
|
||||
output_dir: Path,
|
||||
binary: Path,
|
||||
started_at: datetime,
|
||||
finished_at: datetime,
|
||||
results: list[ModelResult],
|
||||
) -> tuple[Path, Path]:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
plat = f"{platform.system().lower()}-{platform.machine().lower()}"
|
||||
ts = started_at.strftime("%Y%m%d-%H%M%S")
|
||||
json_path = output_dir / f"e2e-{plat}-{ts}.json"
|
||||
md_path = output_dir / f"e2e-{plat}-{ts}.md"
|
||||
|
||||
doc = {
|
||||
"platform": plat,
|
||||
"binary": str(binary),
|
||||
"binary_size_mb": round(binary.stat().st_size / (1024 * 1024), 1) if binary.exists() else None,
|
||||
"started_at": started_at.isoformat(),
|
||||
"finished_at": finished_at.isoformat(),
|
||||
"elapsed_seconds": (finished_at - started_at).total_seconds(),
|
||||
"results": [asdict(r) for r in results],
|
||||
}
|
||||
json_path.write_text(json.dumps(doc, indent=2))
|
||||
|
||||
lines = [
|
||||
f"# Voicebox E2E — {plat} — {started_at.strftime('%Y-%m-%d %H:%M UTC')}",
|
||||
"",
|
||||
f"Binary: `{binary}` ",
|
||||
f"Elapsed: {doc['elapsed_seconds']:.1f}s",
|
||||
"",
|
||||
"| Model | Status | Cached | Elapsed | Audio | Error |",
|
||||
"|-------|--------|--------|---------|-------|-------|",
|
||||
]
|
||||
for r in results:
|
||||
status_icon = {"passed": "PASS", "failed": "FAIL", "timeout": "TIMEOUT"}.get(r.status, r.status.upper())
|
||||
cached = "yes" if r.was_cached else ("no" if r.was_cached is False else "?")
|
||||
audio_col = f"{r.audio_duration:.2f}s" if r.audio_duration else ("—" if r.status != "passed" else "?")
|
||||
error_col = (r.error or "").replace("\n", " ")[:120]
|
||||
lines.append(f"| {r.label} | {status_icon} | {cached} | {r.elapsed_seconds:.1f}s | {audio_col} | {error_col} |")
|
||||
|
||||
failed_rows = [r for r in results if r.status != "passed"]
|
||||
if failed_rows:
|
||||
lines.append("")
|
||||
lines.append("## Failures")
|
||||
for r in failed_rows:
|
||||
lines.append("")
|
||||
lines.append(f"### {r.label} — {r.status}")
|
||||
if r.error:
|
||||
lines.append("")
|
||||
lines.append("```")
|
||||
lines.append(r.error)
|
||||
lines.append("```")
|
||||
if r.server_log_tail:
|
||||
lines.append("")
|
||||
lines.append("<details><summary>server log (last lines)</summary>")
|
||||
lines.append("")
|
||||
lines.append("```")
|
||||
lines.extend(r.server_log_tail)
|
||||
lines.append("```")
|
||||
lines.append("</details>")
|
||||
|
||||
md_path.write_text("\n".join(lines) + "\n")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="Voicebox E2E model generation test")
|
||||
p.add_argument("--binary", type=Path, help="Path to voicebox-server binary (overrides auto-detect)")
|
||||
p.add_argument("--skip-build", action="store_true", help="Error if binary missing instead of building")
|
||||
p.add_argument(
|
||||
"--reference-wav",
|
||||
type=Path,
|
||||
default=FIXTURES_DIR / "reference_voice.wav",
|
||||
help="Reference audio for cloning engines",
|
||||
)
|
||||
p.add_argument(
|
||||
"--reference-text",
|
||||
help="Transcription of reference-wav (default: read from fixtures/reference_voice.txt)",
|
||||
)
|
||||
p.add_argument("--only", help="Comma-separated engines to run (e.g. kokoro,qwen)")
|
||||
p.add_argument("--skip", help="Comma-separated engines to skip")
|
||||
p.add_argument("--keep-data-dir", action="store_true", help="Don't delete tempdir after run")
|
||||
p.add_argument("--timeout-cached", type=int, default=DEFAULT_TIMEOUT_CACHED)
|
||||
p.add_argument("--timeout-download", type=int, default=DEFAULT_TIMEOUT_DOWNLOAD)
|
||||
p.add_argument("--port", type=int, help="Override auto-picked port")
|
||||
p.add_argument("--output-dir", type=Path, default=RESULTS_DIR)
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def filter_matrix(args: argparse.Namespace) -> list[MatrixRow]:
|
||||
only = set(x.strip() for x in args.only.split(",")) if args.only else None
|
||||
skip = set(x.strip() for x in args.skip.split(",")) if args.skip else set()
|
||||
rows = []
|
||||
for r in MATRIX:
|
||||
if only is not None and r.engine not in only:
|
||||
continue
|
||||
if r.engine in skip:
|
||||
continue
|
||||
rows.append(r)
|
||||
return rows
|
||||
|
||||
|
||||
def resolve_reference(args: argparse.Namespace) -> tuple[Path, str]:
|
||||
wav = args.reference_wav
|
||||
if not wav.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Reference WAV not found: {wav}\n"
|
||||
f"Place a sample at {FIXTURES_DIR / 'reference_voice.wav'} or pass --reference-wav.\n"
|
||||
f"See backend/tests/fixtures/README.md."
|
||||
)
|
||||
if args.reference_text:
|
||||
text = args.reference_text
|
||||
else:
|
||||
txt_path = wav.with_suffix(".txt")
|
||||
if not txt_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Reference transcription not found: {txt_path}\n"
|
||||
f"Create it next to the WAV, or pass --reference-text."
|
||||
)
|
||||
text = txt_path.read_text().strip()
|
||||
if not text:
|
||||
raise ValueError("Reference transcription is empty")
|
||||
return wav, text
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
rows = filter_matrix(args)
|
||||
if not rows:
|
||||
print("No rows selected after --only/--skip filtering", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# Binary
|
||||
binary = args.binary or find_binary()
|
||||
if binary is None:
|
||||
if args.skip_build:
|
||||
print("No frozen binary found and --skip-build set. Run: python backend/build_binary.py", file=sys.stderr)
|
||||
return 2
|
||||
binary = build_binary()
|
||||
if not binary.exists():
|
||||
print(f"Binary path does not exist: {binary}", file=sys.stderr)
|
||||
return 2
|
||||
print(f"[binary] {binary}", flush=True)
|
||||
|
||||
# Reference audio (only required if any cloning row is in the matrix)
|
||||
needs_reference = any(r.profile_kind == "cloned" for r in rows)
|
||||
ref_wav: Optional[Path] = None
|
||||
ref_text: Optional[str] = None
|
||||
if needs_reference:
|
||||
try:
|
||||
ref_wav, ref_text = resolve_reference(args)
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
print(f"[fixture] {e}", file=sys.stderr)
|
||||
return 2
|
||||
print(f"[fixture] reference WAV: {ref_wav}", flush=True)
|
||||
print(f"[fixture] reference text: {ref_text!r}", flush=True)
|
||||
|
||||
# Tempdir + log path
|
||||
data_dir = Path(tempfile.mkdtemp(prefix="voicebox-e2e-"))
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
log_path = args.output_dir / f"server-{ts}.log"
|
||||
|
||||
port = args.port or pick_free_port()
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
|
||||
server = ServerProcess(binary=binary, port=port, data_dir=data_dir, log_path=log_path)
|
||||
started_at = datetime.now(timezone.utc)
|
||||
results: list[ModelResult] = []
|
||||
|
||||
try:
|
||||
server.start()
|
||||
print(f"[health] waiting for {base_url}/health ...", flush=True)
|
||||
wait_for_health(base_url, server, HEALTH_TIMEOUT)
|
||||
print("[health] ready", flush=True)
|
||||
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
# Profile setup (only create what's needed)
|
||||
cloned_profile_id: Optional[str] = None
|
||||
kokoro_profile_id: Optional[str] = None
|
||||
qwen_cv_profile_id: Optional[str] = None
|
||||
needed_kinds = {r.profile_kind for r in rows}
|
||||
if "cloned" in needed_kinds:
|
||||
assert ref_wav is not None and ref_text is not None
|
||||
print("[profile] creating cloned profile...", flush=True)
|
||||
cloned_profile_id = create_cloned_profile(client, base_url, ref_wav, ref_text)
|
||||
if "preset_kokoro" in needed_kinds:
|
||||
print("[profile] creating kokoro preset...", flush=True)
|
||||
kokoro_profile_id = create_preset_profile(client, base_url, "e2e-kokoro", "kokoro", "af_heart")
|
||||
if "preset_qwen_cv" in needed_kinds:
|
||||
print("[profile] creating qwen_custom_voice preset...", flush=True)
|
||||
qwen_cv_profile_id = create_preset_profile(client, base_url, "e2e-qwen-cv", "qwen_custom_voice", "Ryan")
|
||||
|
||||
profile_lookup = {
|
||||
"cloned": cloned_profile_id,
|
||||
"preset_kokoro": kokoro_profile_id,
|
||||
"preset_qwen_cv": qwen_cv_profile_id,
|
||||
}
|
||||
|
||||
# Matrix loop
|
||||
for row in rows:
|
||||
print(f"\n[run] {row.label} (engine={row.engine}, size={row.model_size})", flush=True)
|
||||
profile_id = profile_lookup[row.profile_kind]
|
||||
assert profile_id is not None
|
||||
was_cached = get_model_cached(client, base_url, row.model_name)
|
||||
timeout_s = args.timeout_cached if was_cached else args.timeout_download
|
||||
print(f"[run] cached={was_cached} timeout={timeout_s}s", flush=True)
|
||||
|
||||
t0 = time.time()
|
||||
result = ModelResult(
|
||||
label=row.label,
|
||||
engine=row.engine,
|
||||
model_size=row.model_size,
|
||||
status="failed",
|
||||
was_cached=was_cached,
|
||||
)
|
||||
try:
|
||||
status, payload = run_one_generation(client, base_url, row, profile_id, timeout_s)
|
||||
result.status = "passed" if status == "completed" else status
|
||||
result.generation_id = payload.get("id")
|
||||
result.audio_duration = payload.get("duration")
|
||||
result.error = payload.get("error")
|
||||
if status == "completed" and result.generation_id:
|
||||
audio_path, audio_bytes = fetch_audio_info(
|
||||
client, base_url, result.generation_id, data_dir
|
||||
)
|
||||
result.audio_path = audio_path
|
||||
result.audio_bytes = audio_bytes
|
||||
if audio_bytes is not None and audio_bytes == 0:
|
||||
result.status = "failed"
|
||||
result.error = (result.error or "") + " (audio file is empty)"
|
||||
except httpx.HTTPStatusError as e:
|
||||
result.status = "failed"
|
||||
result.http_status = e.response.status_code
|
||||
try:
|
||||
detail = e.response.json().get("detail")
|
||||
except Exception:
|
||||
detail = e.response.text
|
||||
result.error = f"HTTP {e.response.status_code}: {detail}"
|
||||
except Exception as e:
|
||||
result.status = "failed"
|
||||
result.error = f"{type(e).__name__}: {e}"
|
||||
|
||||
result.elapsed_seconds = round(time.time() - t0, 2)
|
||||
if result.status != "passed":
|
||||
result.server_log_tail = server.log_tail(100)
|
||||
print(f"[run] {row.label} → {result.status} in {result.elapsed_seconds}s"
|
||||
+ (f" ({result.error})" if result.error else ""), flush=True)
|
||||
results.append(result)
|
||||
finally:
|
||||
finished_at = datetime.now(timezone.utc)
|
||||
server.stop()
|
||||
if not args.keep_data_dir:
|
||||
shutil.rmtree(data_dir, ignore_errors=True)
|
||||
else:
|
||||
print(f"[cleanup] keeping data dir: {data_dir}", flush=True)
|
||||
|
||||
json_path, md_path = write_reports(args.output_dir, binary, started_at, finished_at, results)
|
||||
print(f"\n[report] {json_path}")
|
||||
print(f"[report] {md_path}")
|
||||
print(f"[report] server log: {log_path}")
|
||||
|
||||
passed = sum(1 for r in results if r.status == "passed")
|
||||
failed = len(results) - passed
|
||||
print(f"\n== {passed} passed, {failed} failed ==")
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,54 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.services import task_queue
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_queued_generation_skips_execution():
|
||||
task_queue.init_queue(force=True)
|
||||
|
||||
running_started = asyncio.Event()
|
||||
release_running = asyncio.Event()
|
||||
queued_ran = asyncio.Event()
|
||||
|
||||
async def running_job():
|
||||
running_started.set()
|
||||
await release_running.wait()
|
||||
|
||||
async def queued_job():
|
||||
queued_ran.set()
|
||||
|
||||
task_queue.enqueue_generation("gen-running", running_job())
|
||||
await asyncio.wait_for(running_started.wait(), timeout=1)
|
||||
|
||||
task_queue.enqueue_generation("gen-queued", queued_job())
|
||||
assert task_queue.cancel_generation("gen-queued") == "queued"
|
||||
|
||||
release_running.set()
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert not queued_ran.is_set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_running_generation_cancels_task():
|
||||
task_queue.init_queue(force=True)
|
||||
|
||||
running_started = asyncio.Event()
|
||||
running_cancelled = asyncio.Event()
|
||||
|
||||
async def running_job():
|
||||
running_started.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError:
|
||||
running_cancelled.set()
|
||||
raise
|
||||
|
||||
task_queue.enqueue_generation("gen-running", running_job())
|
||||
await asyncio.wait_for(running_started.wait(), timeout=1)
|
||||
|
||||
assert task_queue.cancel_generation("gen-running") == "running"
|
||||
await asyncio.wait_for(running_cancelled.wait(), timeout=1)
|
||||
@@ -5,7 +5,7 @@ from PyInstaller.utils.hooks import copy_metadata
|
||||
|
||||
datas = []
|
||||
binaries = []
|
||||
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.services.profiles', 'backend.services.history', 'backend.services.tts', 'backend.services.transcribe', 'backend.utils.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.backends.qwen_custom_voice_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.services.cuda', 'backend.services.effects', 'backend.utils.effects', 'backend.services.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.hume_backend', 'tada', 'tada.modules', 'tada.modules.tada', 'tada.modules.encoder', 'tada.modules.decoder', 'tada.modules.aligner', 'tada.modules.acoustic_spkr_verf', 'tada.nn', 'tada.nn.vibevoice', 'tada.utils', 'tada.utils.gray_code', 'tada.utils.text', 'backend.utils.dac_shim', 'torchaudio', 'backend.backends.kokoro_backend', 'kokoro', 'kokoro.pipeline', 'kokoro.model', 'kokoro.istftnet', 'kokoro.modules', 'kokoro.custom_stft', 'en_core_web_sm', 'loguru', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
|
||||
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.services.profiles', 'backend.services.history', 'backend.services.tts', 'backend.services.transcribe', 'backend.utils.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.backends.qwen_custom_voice_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.services.cuda', 'backend.services.effects', 'backend.utils.effects', 'backend.services.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.hume_backend', 'tada', 'tada.modules', 'tada.modules.tada', 'tada.modules.encoder', 'tada.modules.decoder', 'tada.modules.aligner', 'tada.modules.acoustic_spkr_verf', 'tada.nn', 'tada.nn.vibevoice', 'tada.utils', 'tada.utils.gray_code', 'tada.utils.text', 'backend.utils.dac_shim', 'torchaudio', 'backend.backends.kokoro_backend', 'en_core_web_sm', 'loguru', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
|
||||
datas += copy_metadata('qwen-tts')
|
||||
datas += copy_metadata('requests')
|
||||
datas += copy_metadata('transformers')
|
||||
@@ -18,6 +18,8 @@ hiddenimports += collect_submodules('jaraco')
|
||||
hiddenimports += collect_submodules('tada')
|
||||
hiddenimports += collect_submodules('mlx')
|
||||
hiddenimports += collect_submodules('mlx_audio')
|
||||
tmp_ret = collect_all('spacy_pkuseg')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('zipvoice')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('linacodec')
|
||||
@@ -34,6 +36,8 @@ tmp_ret = collect_all('perth')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('piper_phonemize')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('kokoro')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('misaki')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('language_tags')
|
||||
@@ -54,9 +58,9 @@ a = Analysis(
|
||||
binaries=binaries,
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hookspath=['pyi_hooks'],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
runtime_hooks=['pyi_rth_numpy_compat.py', 'pyi_rth_torch_compiler_disable.py'],
|
||||
excludes=['nvidia', 'nvidia.cublas', 'nvidia.cuda_cupti', 'nvidia.cuda_nvrtc', 'nvidia.cuda_runtime', 'nvidia.cudnn', 'nvidia.cufft', 'nvidia.curand', 'nvidia.cusolver', 'nvidia.cusparse', 'nvidia.nccl', 'nvidia.nvjitlink', 'nvidia.nvtx'],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
|
||||
@@ -5,17 +5,22 @@ description: "How voice profile management works in Voicebox"
|
||||
|
||||
## Overview
|
||||
|
||||
Voice profiles are the foundation of Voicebox's voice cloning capability. Each profile stores reference audio samples and metadata that the TTS model uses to clone a voice.
|
||||
Voice profiles are the unit of "a saved voice" in Voicebox. As of 0.4 they support two flavors backed by the same `profiles` table:
|
||||
|
||||
- **Cloned profiles** — store one or more reference audio samples; the cloning engine generates a voice embedding at use time
|
||||
- **Preset profiles** — store no audio; just a pointer to an engine-specific pre-built voice (e.g. Kokoro's `am_adam`, Qwen CustomVoice's `Ryan`)
|
||||
|
||||
The schema also reserves a third type, `designed`, for future text-described voices. Not currently used by any shipped engine.
|
||||
|
||||
## Architecture
|
||||
|
||||
The voice profile system consists of three main components:
|
||||
|
||||
**Database Layer:** SQLite tables store profile metadata and sample references.
|
||||
**Database Layer:** SQLite tables store profile metadata, sample references (cloned), and engine + voice ID (preset).
|
||||
|
||||
**File Storage:** Audio samples are stored on disk in a structured directory format.
|
||||
**File Storage:** Audio samples are stored on disk in a structured directory format. Preset profiles have no on-disk audio.
|
||||
|
||||
**Profile Module:** The `profiles.py` module provides the business logic for CRUD operations.
|
||||
**Profile Module:** `backend/services/profiles.py` provides the business logic for CRUD operations and dispatches to the appropriate engine based on `voice_type`.
|
||||
|
||||
## Data Model
|
||||
|
||||
@@ -24,27 +29,49 @@ The voice profile system consists of three main components:
|
||||
```python
|
||||
class VoiceProfile(Base):
|
||||
__tablename__ = "profiles"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String, unique=True, nullable=False)
|
||||
description = Column(Text)
|
||||
language = Column(String, default="en")
|
||||
created_at = Column(DateTime)
|
||||
updated_at = Column(DateTime)
|
||||
avatar_path = Column(String, nullable=True)
|
||||
effects_chain = Column(Text, nullable=True)
|
||||
|
||||
# Voice type system — added v0.3.x
|
||||
voice_type = Column(String, default="cloned") # "cloned" | "preset" | "designed"
|
||||
preset_engine = Column(String, nullable=True) # e.g. "kokoro" — only for preset
|
||||
preset_voice_id = Column(String, nullable=True) # e.g. "am_adam" — only for preset
|
||||
design_prompt = Column(Text, nullable=True) # text description — only for designed (reserved)
|
||||
default_engine = Column(String, nullable=True) # auto-selected engine, locked for preset
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
```
|
||||
|
||||
The `voice_type` column discriminates the three flavors:
|
||||
|
||||
| `voice_type` | `preset_engine` | `preset_voice_id` | Samples in `profile_samples` |
|
||||
| ------------ | --------------- | ----------------- | ---------------------------- |
|
||||
| `cloned` | NULL | NULL | Required (≥1 row) |
|
||||
| `preset` | engine name | voice ID string | None |
|
||||
| `designed` | NULL | NULL | None (uses `design_prompt`) |
|
||||
|
||||
The `default_engine` column is set automatically when the profile is created. For preset profiles it's locked to the source engine — switching engines at generation time will skip the profile (and the UI auto-switches back when the user clicks a greyed-out card; see the floating generate box and profile grid).
|
||||
|
||||
### ProfileSample Table
|
||||
|
||||
```python
|
||||
class ProfileSample(Base):
|
||||
__tablename__ = "profile_samples"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
profile_id = Column(String, ForeignKey("profiles.id"))
|
||||
audio_path = Column(String, nullable=False)
|
||||
reference_text = Column(Text, nullable=False)
|
||||
```
|
||||
|
||||
Only populated for cloned profiles. Preset and designed profiles have zero rows in this table.
|
||||
|
||||
## File Structure
|
||||
|
||||
Profiles are stored in the data directory:
|
||||
|
||||
@@ -1,32 +1,43 @@
|
||||
---
|
||||
title: "Creating Voice Profiles"
|
||||
description: "Advanced guide to creating high-quality voice profiles"
|
||||
description: "How to create voice profiles, both cloning-based and preset-based"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Voice profiles are the foundation of voice cloning in Voicebox. This guide covers best practices for creating professional-quality voice profiles.
|
||||
A **voice profile** is a saved voice you can reuse across generations, stories, and the API. As of 0.4, Voicebox profiles come in two flavors that map to two different ways of getting a voice:
|
||||
|
||||
## Quick Start
|
||||
| Profile type | What it stores | Use when… |
|
||||
| -------------- | ---------------------------------------------------- | -------------------------------------------------------- |
|
||||
| **Cloned** | One or more reference audio samples + a voice embedding | You want to replicate a specific person's voice |
|
||||
| **Preset** | A reference to a pre-built voice in a specific engine | You want a curated, production-ready voice with no audio prep |
|
||||
|
||||
Both types live in the same Profiles tab and behave the same way at generation time — pick the type that matches your goal and follow the workflow below.
|
||||
|
||||
<Callout type="info">
|
||||
Not sure which to use? Cloning gives you a *specific* voice but needs clean audio. Preset gives you *good* voices instantly but you don't get to choose who they sound like.
|
||||
</Callout>
|
||||
|
||||
## Workflow A — Cloned Profiles
|
||||
|
||||
Use this when you want to replicate a specific person's voice from a recording.
|
||||
|
||||
<Steps>
|
||||
<Step title="Prepare Audio">
|
||||
10-30 seconds of clear speech
|
||||
10-30 seconds of clear speech, minimal background noise. See [Voice Cloning](/overview/voice-cloning) for the engine catalog.
|
||||
</Step>
|
||||
<Step title="Create Profile">
|
||||
**Profiles** → **+ New Profile**
|
||||
**Profiles** → **+ New Profile** → choose a cloning engine (Qwen3-TTS, Chatterbox, LuxTTS, or TADA)
|
||||
</Step>
|
||||
<Step title="Upload Sample">
|
||||
Add your audio file
|
||||
<Step title="Upload or Record Sample">
|
||||
Drag in an audio file, or record directly with the in-app recorder
|
||||
</Step>
|
||||
<Step title="Generate">
|
||||
Use the profile to generate speech
|
||||
<Step title="Generate to Test">
|
||||
Use the profile to generate a test phrase. If quality is poor, add more samples
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Audio Requirements
|
||||
|
||||
### Ideal Sample Characteristics
|
||||
### Audio Requirements (Cloning Only)
|
||||
|
||||
<Cards>
|
||||
<Card title="Duration">
|
||||
@@ -44,7 +55,7 @@ Voice profiles are the foundation of voice cloning in Voicebox. This guide cover
|
||||
<Card title="Quality">
|
||||
**High fidelity**
|
||||
|
||||
44.1kHz or 48kHz sample rate
|
||||
44.1 kHz or 48 kHz sample rate
|
||||
Minimal compression
|
||||
</Card>
|
||||
<Card title="Content">
|
||||
@@ -58,18 +69,16 @@ Voice profiles are the foundation of voice cloning in Voicebox. This guide cover
|
||||
### File Formats
|
||||
|
||||
Supported formats:
|
||||
- **WAV** (recommended) - Lossless quality
|
||||
- **MP3** - Acceptable, minimal compression
|
||||
- **M4A** - Acceptable
|
||||
- **FLAC** - Lossless alternative
|
||||
- **WAV** (recommended) — Lossless quality
|
||||
- **MP3** — Acceptable, minimal compression
|
||||
- **M4A** — Acceptable
|
||||
- **FLAC** — Lossless alternative
|
||||
|
||||
<Callout type="info">
|
||||
Use WAV for best results. Avoid heavily compressed formats.
|
||||
</Callout>
|
||||
|
||||
## Recording Tips
|
||||
|
||||
### Environment
|
||||
### Recording Tips
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Quiet Space">
|
||||
@@ -87,27 +96,25 @@ Supported formats:
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Recording Settings">
|
||||
- 44.1kHz or 48kHz sample rate
|
||||
- 44.1 kHz or 48 kHz sample rate
|
||||
- 16-bit or 24-bit depth
|
||||
- Mono is fine (stereo will be converted)
|
||||
- Avoid automatic gain control
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### Speaking
|
||||
### Speaking Style
|
||||
|
||||
- **Natural pace** - Don't rush or speak too slowly
|
||||
- **Clear articulation** - Pronounce words clearly
|
||||
- **Consistent volume** - Maintain steady loudness
|
||||
- **Normal tone** - Speak as you normally would
|
||||
- **Complete sentences** - Avoid fragments or "ums"
|
||||
- **Natural pace** — Don't rush or speak too slowly
|
||||
- **Clear articulation** — Pronounce words clearly
|
||||
- **Consistent volume** — Maintain steady loudness
|
||||
- **Normal tone** — Speak as you normally would
|
||||
- **Complete sentences** — Avoid fragments or "ums"
|
||||
|
||||
## Multiple Samples
|
||||
### Multiple Samples
|
||||
|
||||
Adding multiple samples can significantly improve quality:
|
||||
|
||||
### Why Multiple Samples?
|
||||
|
||||
<Cards>
|
||||
<Card title="Robustness">
|
||||
Model learns a more complete representation
|
||||
@@ -123,110 +130,57 @@ Adding multiple samples can significantly improve quality:
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
### Sample Variety
|
||||
|
||||
Consider adding samples with:
|
||||
|
||||
1. **Different tones**
|
||||
- Casual conversation
|
||||
- Professional/formal
|
||||
- Excited/enthusiastic
|
||||
- Calm/serious
|
||||
|
||||
2. **Different content**
|
||||
- Narratives
|
||||
- Questions
|
||||
- Statements
|
||||
- Emotions (happy, sad, neutral)
|
||||
|
||||
3. **Different recording conditions**
|
||||
- Studio quality
|
||||
- Phone call quality (if needed)
|
||||
- Room acoustics
|
||||
1. **Different tones** — casual, formal, excited, calm
|
||||
2. **Different content** — narratives, questions, statements
|
||||
3. **Different recording conditions** — studio quality, room acoustics
|
||||
|
||||
<Callout type="warn">
|
||||
All samples should be from the **same speaker**. Mixing voices will produce poor results.
|
||||
</Callout>
|
||||
|
||||
## Processing Existing Audio
|
||||
### Processing Existing Audio
|
||||
|
||||
If you have existing audio (podcasts, videos, etc.):
|
||||
|
||||
### Extracting Clean Segments
|
||||
|
||||
<Steps>
|
||||
<Step title="Find Clean Speech">
|
||||
Look for segments with:
|
||||
- Just the target speaker
|
||||
- No background music
|
||||
- Minimal noise
|
||||
Look for segments with just the target speaker, no background music, minimal noise
|
||||
</Step>
|
||||
|
||||
<Step title="Use Audio Editor">
|
||||
Tools like Audacity or Adobe Audition:
|
||||
- Cut out clean 10-30s segments
|
||||
- Remove silence at start/end
|
||||
- Normalize volume if needed
|
||||
Tools like Audacity or Adobe Audition: cut clean 10-30s segments, remove silence at start/end, normalize volume
|
||||
</Step>
|
||||
|
||||
<Step title="Export as WAV">
|
||||
Save as high-quality WAV file
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### Noise Reduction
|
||||
For light background noise, use Audacity's noise reduction (gentle settings — over-processing introduces artifacts).
|
||||
|
||||
If you have light background noise:
|
||||
### Testing & Iteration
|
||||
|
||||
```
|
||||
1. Use noise reduction in Audacity:
|
||||
- Select noise-only section
|
||||
- Get Noise Profile
|
||||
- Select full audio
|
||||
- Apply noise reduction (gentle settings)
|
||||
|
||||
2. Avoid over-processing:
|
||||
- Can introduce artifacts
|
||||
- May reduce voice quality
|
||||
```
|
||||
|
||||
## Testing & Iteration
|
||||
|
||||
### Test Your Profile
|
||||
|
||||
After creating a profile:
|
||||
After creating a cloned profile:
|
||||
|
||||
<Steps>
|
||||
<Step title="Generate Test">
|
||||
Generate a simple phrase:
|
||||
```
|
||||
"Hello, this is a test of my voice profile."
|
||||
```
|
||||
Try a simple phrase: `"Hello, this is a test of my voice profile."`
|
||||
</Step>
|
||||
|
||||
<Step title="Evaluate Quality">
|
||||
Listen for:
|
||||
- Natural tone
|
||||
- Clear pronunciation
|
||||
- Proper prosody
|
||||
- Lack of artifacts
|
||||
Listen for natural tone, clear pronunciation, proper prosody, lack of artifacts
|
||||
</Step>
|
||||
|
||||
<Step title="Iterate">
|
||||
If quality is poor:
|
||||
- Add more samples
|
||||
- Try different source audio
|
||||
- Check sample quality
|
||||
If quality is poor: add more samples, try different source audio, check sample quality
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### Common Issues
|
||||
#### Common Issues
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Robotic Voice">
|
||||
**Cause**: Poor quality samples or too short
|
||||
|
||||
**Fix**: Use longer, higher quality samples
|
||||
**Fix**: Use longer, higher-quality samples
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Wrong Tone">
|
||||
@@ -242,51 +196,89 @@ After creating a profile:
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Workflow B — Preset Profiles
|
||||
|
||||
Use this when you want a ready-made voice without recording anything. Available engines: **Kokoro 82M** (50 voices) and **Qwen CustomVoice** (9 voices). See [Preset Voices](/overview/preset-voices) for the full catalog.
|
||||
|
||||
<Steps>
|
||||
<Step title="Create Profile">
|
||||
**Profiles** → **+ New Profile** → choose **Kokoro** or **Qwen CustomVoice** as the engine
|
||||
</Step>
|
||||
<Step title="Pick a Voice">
|
||||
The engine's voice catalog appears. Click any voice to preview it
|
||||
</Step>
|
||||
<Step title="Name and Save">
|
||||
Give the profile a name. No audio sample required
|
||||
</Step>
|
||||
<Step title="Generate">
|
||||
The profile is ready immediately — use it in the floating generate box or Generate page
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Callout type="info">
|
||||
Preset profiles are **locked to their source engine**. Switching to a different engine in the floating generate box greys out the profile, since the voice only exists in that engine. Clicking a greyed profile auto-switches the engine back.
|
||||
</Callout>
|
||||
|
||||
### Qwen CustomVoice + Instruct
|
||||
|
||||
Preset voices in Qwen CustomVoice support **delivery instructions** — natural-language style control over tone, pace, and emotion. The floating generate box shows a slider icon next to the generate button when a Qwen CustomVoice profile is selected; click it to reveal the instruct textarea.
|
||||
|
||||
See [Preset Voices → Using Instruct Mode](/overview/preset-voices#using-instruct-mode) for examples.
|
||||
|
||||
## Advanced Tips
|
||||
|
||||
### Celebrity/Character Voices
|
||||
### Celebrity / Character Voices (Cloning)
|
||||
|
||||
For cloning public figures or characters:
|
||||
|
||||
1. **Legal considerations** - Ensure you have rights or it's fair use
|
||||
2. **Source quality** - Find high-quality interview audio or clean clips
|
||||
3. **Consistency** - Use clips where they speak similarly
|
||||
4. **Multiple samples** - Very important for recognizable voices
|
||||
1. **Legal considerations** — Ensure you have rights or it's clearly fair use
|
||||
2. **Source quality** — Find high-quality interview audio or clean clips
|
||||
3. **Consistency** — Use clips where they speak similarly
|
||||
4. **Multiple samples** — Very important for recognizable voices
|
||||
|
||||
### Accent & Dialect
|
||||
### Accent & Dialect (Cloning)
|
||||
|
||||
The model will preserve accent and dialect:
|
||||
Cloning models preserve accent and dialect:
|
||||
|
||||
- British English will generate British English
|
||||
- Southern accent will produce Southern accent
|
||||
- Regional pronunciations will be maintained
|
||||
- British English samples generate British English output
|
||||
- Southern accent samples produce Southern accent output
|
||||
- Regional pronunciations are maintained
|
||||
|
||||
### Emotion Transfer
|
||||
### Emotion Transfer (Cloning)
|
||||
|
||||
The emotional tone of samples affects generation:
|
||||
|
||||
- Energetic samples → Energetic output
|
||||
- Calm samples → Calm output
|
||||
- Mix samples for versatile profile
|
||||
- Energetic samples → energetic output
|
||||
- Calm samples → calm output
|
||||
- Mix samples for a more versatile profile
|
||||
|
||||
For Qwen CustomVoice presets, use the **instruct** field instead of relying on sample emotion — that's exactly what it controls.
|
||||
|
||||
## Managing Profiles
|
||||
|
||||
### Organization
|
||||
|
||||
- **Descriptive names** - "John Smith - Professional Narrator"
|
||||
- **Add descriptions** - Note recording conditions, use cases
|
||||
- **Language tags** - Mark the primary language
|
||||
- **Archive unused** - Keep profile list manageable
|
||||
- **Descriptive names** — "John Smith - Professional Narrator"
|
||||
- **Add descriptions** — Note recording conditions, use cases, or which preset voice
|
||||
- **Language tags** — Mark the primary language
|
||||
- **Archive unused** — Keep profile list manageable
|
||||
|
||||
### Export/Import
|
||||
### Export / Import
|
||||
|
||||
- **Export** profiles to share or backup
|
||||
- **Import** from colleagues or teammates
|
||||
- Profiles include voice embeddings, not original audio
|
||||
- **Cloned profiles** export with their voice embeddings (not the original audio)
|
||||
- **Preset profiles** export as engine + voice ID metadata only — the importer must have that engine's model installed
|
||||
|
||||
## Next Steps
|
||||
|
||||
<Cards>
|
||||
<Card title="Voice Cloning" href="/overview/voice-cloning">
|
||||
Engine catalog and best practices for cloning
|
||||
</Card>
|
||||
<Card title="Preset Voices" href="/overview/preset-voices">
|
||||
Full catalog of Kokoro and Qwen CustomVoice voices
|
||||
</Card>
|
||||
<Card title="Generate Speech" href="/overview/generating-speech">
|
||||
Use your profile to generate speech
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
---
|
||||
title: "GPU Acceleration"
|
||||
description: "How Voicebox uses your GPU — auto-detection, manual setup, troubleshooting"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Voicebox auto-detects available accelerators on first launch and picks the fastest backend it can use. For most people this just works — open the app and you're already on the right backend.
|
||||
|
||||
This page is for the cases where it doesn't:
|
||||
|
||||
- You have a GPU but Voicebox is running on CPU
|
||||
- You upgraded GPUs (especially to RTX 50-series / Blackwell) and generation broke
|
||||
- You want to switch backends manually (e.g. force MLX over PyTorch on Apple Silicon)
|
||||
- You see `[UNSUPPORTED - see logs]` next to your GPU in Settings
|
||||
|
||||
## Backend Matrix
|
||||
|
||||
| Platform | Auto-selected backend | Notes |
|
||||
| --------------------------- | ------------------------- | ---------------------------------------------------- |
|
||||
| **macOS Apple Silicon** | MLX (Metal) | 4-5x faster than PyTorch via Apple Neural Engine |
|
||||
| **macOS Intel** | PyTorch CPU | No GPU acceleration available; PyTorch ≥ 2.2 only |
|
||||
| **Windows + NVIDIA** | PyTorch CUDA (cu128) | Auto-downloads the CUDA backend binary on first use |
|
||||
| **Windows + Intel Arc** | PyTorch XPU (IPEX) | New in 0.4 — works with Arc A-series and B-series |
|
||||
| **Windows generic GPU** | DirectML | Universal Windows GPU support; slower than CUDA |
|
||||
| **Linux + NVIDIA** | PyTorch CUDA (cu128) | Same auto-download flow as Windows |
|
||||
| **Linux + AMD** | PyTorch ROCm | Auto-configures `HSA_OVERRIDE_GFX_VERSION` |
|
||||
| **Linux + Intel Arc** | PyTorch XPU (IPEX) | |
|
||||
| **Any (no GPU)** | PyTorch CPU | Works everywhere; expect 5-50x slower than GPU |
|
||||
|
||||
The detected backend is shown in Settings → GPU. Logs at startup also print the chosen backend and the device name.
|
||||
|
||||
## Apple Silicon — MLX vs PyTorch
|
||||
|
||||
On M-series Macs, Voicebox ships an MLX-optimized backend that uses the Apple Neural Engine. It's **4-5x faster** than the PyTorch (CPU/Metal) path for supported engines.
|
||||
|
||||
| Engine | MLX support | Notes |
|
||||
| -------------------- | ----------- | ------------------------------------------- |
|
||||
| Qwen3-TTS | ✅ Native | Uses MLX exclusively when available |
|
||||
| Chatterbox / Turbo | PyTorch MPS | Falls back to Metal via PyTorch |
|
||||
| LuxTTS | PyTorch MPS | |
|
||||
| TADA | PyTorch MPS | |
|
||||
| Kokoro | PyTorch MPS | Requires `PYTORCH_ENABLE_MPS_FALLBACK=1` |
|
||||
| Qwen CustomVoice | PyTorch MPS | |
|
||||
| Whisper (transcribe) | ✅ Native | MLX-Whisper is the default on Apple Silicon |
|
||||
|
||||
The Whisper Turbo + MLX combo dropped transcription latency from ~20s to ~2-3s on M-series chips (see CHANGELOG entry for v0.1.10).
|
||||
|
||||
## Windows / Linux + NVIDIA — The CUDA Backend Swap
|
||||
|
||||
Voicebox doesn't bundle CUDA into the main installer (it would balloon downloads to multi-gigabyte territory for users who don't have an NVIDIA GPU). Instead, when you first need it, the app downloads a separate **CUDA backend binary** that contains the PyTorch + CUDA runtime.
|
||||
|
||||
<Steps>
|
||||
<Step title="Open Settings → GPU">
|
||||
If an NVIDIA GPU is detected, you'll see "Install CUDA backend" in the GPU panel
|
||||
</Step>
|
||||
<Step title="Click Install">
|
||||
The app downloads two archives separately:
|
||||
- **Server core** (~200-400 MB) — versioned with each Voicebox release
|
||||
- **CUDA libs** (~4 GB) — the heavy PyTorch + CUDA DLLs, versioned independently
|
||||
</Step>
|
||||
<Step title="Restart">
|
||||
Voicebox restarts to swap in the CUDA backend
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Callout type="info">
|
||||
The split-archive design (added in v0.4) means most Voicebox upgrades only redownload the small server-core archive. The 4 GB libs archive is only refreshed when the underlying CUDA toolkit or torch major version changes.
|
||||
</Callout>
|
||||
|
||||
### Auto-update
|
||||
|
||||
When a new Voicebox release ships, the GPU panel checks if the bundled server-core matches the installed CUDA version. If only the core changed (typical), it pulls the new core in the background. If the libs version changed (rare — only happens on cu126 → cu128 type bumps), you'll be prompted to confirm the larger download.
|
||||
|
||||
## RTX 50-series / Blackwell
|
||||
|
||||
Voicebox 0.4 added explicit RTX 50-series support:
|
||||
|
||||
- CUDA toolkit upgraded to **cu128** (previous releases used cu126 which lacks Blackwell kernels)
|
||||
- Build pinned with `TORCH_CUDA_ARCH_LIST=...12.0+PTX` for forward-compatibility
|
||||
|
||||
If you're on an RTX 5070 / 5080 / 5090 and you see "no kernel image is available" errors:
|
||||
|
||||
1. Make sure you're on Voicebox **≥ 0.4.0** (Settings → About)
|
||||
2. Reinstall the CUDA backend (Settings → GPU → Reinstall CUDA backend) — older installs may have stale cu126 libs
|
||||
3. If errors persist, see the GPU compatibility warnings section below
|
||||
|
||||
## Intel Arc (XPU)
|
||||
|
||||
New in 0.4. Works with both Arc A-series (Alchemist: A380, A580, A750, A770) and B-series (Battlemage).
|
||||
|
||||
### Setup
|
||||
|
||||
Voicebox auto-detects Arc GPUs and routes through Intel's PyTorch XPU backend (powered by IPEX — Intel Extension for PyTorch). No extra installation step beyond the standard Voicebox install.
|
||||
|
||||
Verify it's working:
|
||||
- Settings → GPU should show **XPU** followed by your Arc model name (e.g. `XPU (Intel Arc A770)`)
|
||||
- Startup logs print `Backend: PYTORCH` and `GPU: XPU (Intel Arc ...)`
|
||||
|
||||
### Engines on XPU
|
||||
|
||||
All PyTorch-based engines work on XPU. Performance is generally between CPU and CUDA — expect ~2-3x speedup over CPU for the larger models.
|
||||
|
||||
## DirectML
|
||||
|
||||
The fallback for Windows users with non-NVIDIA, non-Intel-Arc GPUs (older AMD discrete, integrated GPUs, etc.). Slower than CUDA and XPU but provides some acceleration over CPU.
|
||||
|
||||
Auto-selected when no other GPU backend is available.
|
||||
|
||||
## AMD ROCm (Linux)
|
||||
|
||||
ROCm provides PyTorch GPU acceleration on AMD discrete GPUs. Voicebox auto-configures `HSA_OVERRIDE_GFX_VERSION` for common cards that need the override.
|
||||
|
||||
### Verifying
|
||||
|
||||
```bash
|
||||
# In a terminal
|
||||
echo $HSA_OVERRIDE_GFX_VERSION
|
||||
# Should show e.g. 10.3.0 for RX 6000 series
|
||||
```
|
||||
|
||||
If detection fails, set the variable manually before launching Voicebox:
|
||||
|
||||
```bash
|
||||
export HSA_OVERRIDE_GFX_VERSION=10.3.0
|
||||
voicebox
|
||||
```
|
||||
|
||||
Common values:
|
||||
- `10.3.0` — RX 6000 series (RDNA 2)
|
||||
- `11.0.0` — RX 7000 series (RDNA 3)
|
||||
- `9.0.0` — Older Vega cards
|
||||
|
||||
## GPU Compatibility Warnings
|
||||
|
||||
Voicebox 0.4 added a runtime check that compares your GPU's compute capability against the architectures the bundled PyTorch was compiled for. If they don't match, you'll see:
|
||||
|
||||
- A startup log line: `WARNING: GPU COMPATIBILITY: <your GPU> is not supported by this PyTorch build...`
|
||||
- The GPU label in Settings shows `[UNSUPPORTED - see logs]`
|
||||
- The `/health` API returns a populated `gpu_compatibility_warning` field
|
||||
|
||||
### What to do
|
||||
|
||||
The most common trigger is a brand-new GPU architecture that pre-built PyTorch wheels don't yet cover natively. In order of preference:
|
||||
|
||||
1. **Update Voicebox** — newer releases ship newer PyTorch with broader arch support
|
||||
2. **Reinstall the CUDA backend** — Settings → GPU → Reinstall CUDA backend
|
||||
3. **For bleeding-edge GPUs (newer than current Blackwell):** install PyTorch nightly manually:
|
||||
```bash
|
||||
pip install torch --index-url https://download.pytorch.org/whl/nightly/cu128 --force-reinstall
|
||||
```
|
||||
Then point Voicebox at that environment via [Remote Mode](/overview/remote-mode) until stable PyTorch catches up.
|
||||
4. **Fall back to CPU** temporarily — set `VOICEBOX_FORCE_CPU=1` before launching
|
||||
|
||||
## CPU-Only Fallback
|
||||
|
||||
When no GPU is available (or you've forced it off), Voicebox runs the PyTorch CPU backend. Expect:
|
||||
|
||||
- 5-50x slower generation depending on engine and text length
|
||||
- Heavy CPU usage during generation
|
||||
- Some engines work better than others on CPU:
|
||||
- **Kokoro 82M** — runs at realtime on modern CPUs
|
||||
- **LuxTTS** — exceeds 150x realtime on CPU
|
||||
- **Chatterbox Turbo (350M)** — usable but slow
|
||||
- Larger models (Qwen 1.7B, Chatterbox Multilingual, TADA 3B) — painful
|
||||
|
||||
For CPU-bound use cases, prefer the smaller, lighter engines.
|
||||
|
||||
## Verifying Your Setup
|
||||
|
||||
Three places to check that the right backend is being used:
|
||||
|
||||
<Steps>
|
||||
<Step title="Settings → GPU">
|
||||
Shows the detected backend, GPU model, and VRAM (when applicable). Look for the `[UNSUPPORTED - see logs]` suffix
|
||||
</Step>
|
||||
<Step title="Settings → Logs">
|
||||
The "Server logs" tab shows the startup banner with `Backend: <type>` and `GPU: <name>`
|
||||
</Step>
|
||||
<Step title="Health endpoint">
|
||||
`curl http://localhost:17493/health` returns a JSON payload with `backend_type`, `backend_variant`, and `gpu_compatibility_warning` (when applicable)
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Settings shows CPU instead of my GPU">
|
||||
- On NVIDIA: install the CUDA backend (Settings → GPU)
|
||||
- On Intel Arc: confirm IPEX detection in startup logs; restart the app after a driver update
|
||||
- On AMD Linux: check `HSA_OVERRIDE_GFX_VERSION` is set
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="'no kernel image is available' / 'CUDA error'">
|
||||
Almost always means the bundled PyTorch doesn't have kernels for your GPU's compute capability.
|
||||
|
||||
1. Update to Voicebox ≥ 0.4.0 (Blackwell support added there)
|
||||
2. Reinstall the CUDA backend
|
||||
3. If still broken, install PyTorch nightly via Remote Mode
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Out of memory (CUDA)">
|
||||
- Switch to a smaller model size (e.g. Qwen3 0.6B instead of 1.7B)
|
||||
- Use Settings → Models to unload other engines you're not using
|
||||
- Enable `low_cpu_mem_usage` is already on for CPU; for CUDA, the engine's `device_map` handles offload automatically
|
||||
- Close other GPU applications
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="MPS fallback errors on macOS">
|
||||
Some operations don't have a Metal implementation. Voicebox sets `PYTORCH_ENABLE_MPS_FALLBACK=1` for engines that need it (notably Kokoro), but if you launch from a custom env, set it manually:
|
||||
```bash
|
||||
export PYTORCH_ENABLE_MPS_FALLBACK=1
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Generation works but is slow on my GPU">
|
||||
- Check Settings → GPU shows your GPU (not CPU)
|
||||
- Check VRAM usage — you may be paging to system memory
|
||||
- Try a smaller model
|
||||
- For NVIDIA: confirm cu128 is installed (Settings → GPU → version)
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Next Steps
|
||||
|
||||
<Cards>
|
||||
<Card title="Remote Mode" href="/overview/remote-mode">
|
||||
Run the backend on a different machine with a stronger GPU
|
||||
</Card>
|
||||
<Card title="Model Management" href="/developer/model-management">
|
||||
Unload models to free GPU memory
|
||||
</Card>
|
||||
<Card title="Troubleshooting" href="/overview/troubleshooting">
|
||||
General troubleshooting beyond GPU
|
||||
</Card>
|
||||
</Cards>
|
||||
@@ -6,7 +6,9 @@
|
||||
"installation",
|
||||
"docker",
|
||||
"quick-start",
|
||||
"gpu-acceleration",
|
||||
"voice-cloning",
|
||||
"preset-voices",
|
||||
"stories-editor",
|
||||
"recording-transcription",
|
||||
"generation-history",
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
---
|
||||
title: "Preset Voices"
|
||||
description: "Use built-in, ready-made voices without recording audio samples"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Some Voicebox engines ship with a curated set of pre-built voices. Instead of cloning from your own audio sample, you pick a voice from a fixed catalog and the model speaks in that voice. No recording, no upload, no per-voice training required.
|
||||
|
||||
Two engines in 0.4 ship preset voices:
|
||||
|
||||
| Engine | Voices | Languages | Strengths |
|
||||
| --------------------- | ----------------------- | --------- | ------------------------------------------------------- |
|
||||
| **Kokoro 82M** | 50 | 9 | Tiny model, CPU-friendly, lowest VRAM of any engine |
|
||||
| **Qwen CustomVoice** | 9 (premium curated) | 4 | Natural-language style control over tone, emotion, pace |
|
||||
|
||||
<Callout type="info">
|
||||
Looking for cloning a specific person's voice instead? See [Voice Cloning](/overview/voice-cloning).
|
||||
</Callout>
|
||||
|
||||
## When to Use Preset Voices
|
||||
|
||||
<Cards>
|
||||
<Card title="No reference audio">
|
||||
You don't have (or don't want to provide) a recording of the target voice
|
||||
</Card>
|
||||
<Card title="Production reliability">
|
||||
Curated voices have predictable quality across any text input
|
||||
</Card>
|
||||
<Card title="Speed">
|
||||
Skip the audio cleanup, sample preparation, and quality iteration loop
|
||||
</Card>
|
||||
<Card title="Lightweight setup">
|
||||
Kokoro runs at CPU realtime with ~150 MB on disk — no GPU needed
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
## Creating a Preset-Voice Profile
|
||||
|
||||
<Steps>
|
||||
<Step title="Open Profiles → New Profile">
|
||||
Same entry point as cloning profiles
|
||||
</Step>
|
||||
<Step title="Choose the engine">
|
||||
Select **Kokoro** or **Qwen CustomVoice** from the engine dropdown
|
||||
</Step>
|
||||
<Step title="Pick a preset voice">
|
||||
The voice catalog for the chosen engine appears — preview each by clicking it
|
||||
</Step>
|
||||
<Step title="Name and save">
|
||||
Give the profile a name. No audio sample needed — just save
|
||||
</Step>
|
||||
<Step title="Generate">
|
||||
Use the profile like any other in the floating generate box or the Generate page
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Callout type="info">
|
||||
Preset profiles are locked to their source engine — switching engines won't work since the voice exists only for that model. The profile grid greys out preset profiles when you switch to a different engine, and clicking one auto-switches the engine back to the right one.
|
||||
</Callout>
|
||||
|
||||
## Kokoro 82M — 50 Voices Across 9 Languages
|
||||
|
||||
Kokoro is the smallest engine in Voicebox at 82M parameters. It runs at CPU realtime with negligible VRAM, making it the best option for lightweight local inference. Voices are pre-built style vectors trained into the model — there's no concept of cloning here.
|
||||
|
||||
**Repository:** [`hexgrad/Kokoro-82M`](https://huggingface.co/hexgrad/Kokoro-82M) · Apache 2.0 licensed
|
||||
|
||||
### American English
|
||||
|
||||
| Female | Male |
|
||||
| ------- | ------- |
|
||||
| Alloy | Adam |
|
||||
| Aoede | Echo |
|
||||
| Bella | Eric |
|
||||
| Heart | Fenrir |
|
||||
| Jessica | Liam |
|
||||
| Kore | Michael |
|
||||
| Nicole | Onyx |
|
||||
| Nova | Puck |
|
||||
| River | Santa |
|
||||
| Sarah | |
|
||||
| Sky | |
|
||||
|
||||
### British English
|
||||
|
||||
| Female | Male |
|
||||
| -------- | ------ |
|
||||
| Alice | Daniel |
|
||||
| Emma | Fable |
|
||||
| Isabella | George |
|
||||
| Lily | Lewis |
|
||||
|
||||
### Other Languages
|
||||
|
||||
| Language | Voices |
|
||||
| ----------------- | ------------------------------------------- |
|
||||
| Spanish (`es`) | Dora (f), Alex (m), Santa (m) |
|
||||
| French (`fr`) | Siwis (f) |
|
||||
| Hindi (`hi`) | Alpha (f), Beta (f), Omega (m), Psi (m) |
|
||||
| Italian (`it`) | Sara (f), Nicola (m) |
|
||||
| Japanese (`ja`) | Alpha (f), Gongitsune (f), Nezumi (f), Tebukuro (f), Kumo (m) |
|
||||
| Portuguese (`pt`) | Dora (f), Alex (m), Santa (m) |
|
||||
| Chinese (`zh`) | Xiaobei (f), Xiaoni (f), Xiaoxiao (f), Xiaoyi (f) |
|
||||
|
||||
### Kokoro at a Glance
|
||||
|
||||
| Property | Value |
|
||||
| --------------- | -------------------------------------------- |
|
||||
| Parameters | 82M |
|
||||
| Sample rate | 24 kHz |
|
||||
| VRAM | ~150 MB (negligible on CPU) |
|
||||
| Speed | Realtime on CPU, faster on GPU |
|
||||
| Instruct | Not supported (preset voice carries the style) |
|
||||
| License | Apache 2.0 |
|
||||
|
||||
## Qwen CustomVoice — 9 Premium Voices with Instruct Control
|
||||
|
||||
Qwen CustomVoice ships with 9 curated speakers and supports **natural-language style control** — you tell the model how to deliver the line ("speak slowly with warmth", "authoritative and clear") and it adapts tone, emotion, and pace.
|
||||
|
||||
Two model sizes:
|
||||
- **1.7B** — full quality, recommended default
|
||||
- **0.6B** — lighter, faster, lower-end hardware
|
||||
|
||||
**Repository:** [`Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice`](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice) (and 0.6B variant) · by Alibaba
|
||||
|
||||
### Voice Catalog
|
||||
|
||||
| Speaker | Gender | Language | Description |
|
||||
| --------- | ------ | -------- | ------------------------------------------------------------ |
|
||||
| Vivian | female | Chinese | Bright, slightly edgy young female voice |
|
||||
| Serena | female | Chinese | Warm, gentle young female voice |
|
||||
| Uncle Fu | male | Chinese | Seasoned male voice with a low, mellow timbre |
|
||||
| Dylan | male | Chinese | Youthful Beijing male voice with a clear, natural timbre |
|
||||
| Eric | male | Chinese | Lively Chengdu male voice with a slightly husky brightness |
|
||||
| Ryan | male | English | Dynamic male voice with strong rhythmic drive (default) |
|
||||
| Aiden | male | English | Sunny American male voice with a clear midrange |
|
||||
| Ono Anna | female | Japanese | Playful Japanese female voice with a light, nimble timbre |
|
||||
| Sohee | female | Korean | Warm Korean female voice with rich emotion |
|
||||
|
||||
### Using Instruct Mode
|
||||
|
||||
In the floating generate box, switch to a Qwen CustomVoice profile and click the **delivery instructions** toggle (slider icon, left of the generate button). A second textarea appears below the main text:
|
||||
|
||||
- Main text → what you want the voice to say
|
||||
- Instruct text → how you want it delivered
|
||||
|
||||
Examples of effective instruct prompts:
|
||||
|
||||
```
|
||||
Speak slowly with emphasis, like reading bedtime stories
|
||||
Warm and friendly, conversational tone
|
||||
Professional and authoritative, broadcast quality
|
||||
Whisper, intimate and close
|
||||
Excited and energetic, like sports commentary
|
||||
```
|
||||
|
||||
The full Generate page also surfaces the instruct field as a separate input.
|
||||
|
||||
### Qwen CustomVoice at a Glance
|
||||
|
||||
| Property | Value |
|
||||
| --------------- | -------------------------------------------------- |
|
||||
| Parameters | 1.7B / 0.6B |
|
||||
| Languages | Chinese, English, Japanese, Korean (10 supported) |
|
||||
| Voices | 9 curated preset speakers |
|
||||
| VRAM | ~3.5 GB (1.7B), ~1.2 GB (0.6B) |
|
||||
| Instruct | Yes — natural-language style control |
|
||||
| Cloning | No — paired Base Qwen3-TTS engine handles cloning |
|
||||
|
||||
## Cloning vs Preset — Quick Decision
|
||||
|
||||
| You want… | Use |
|
||||
| -------------------------------------------------- | ----------------------------------------- |
|
||||
| To replicate a specific person's voice | [Voice Cloning](/overview/voice-cloning) |
|
||||
| Production-ready voices with no audio prep | Kokoro or Qwen CustomVoice |
|
||||
| The smallest possible footprint (CPU-only) | Kokoro |
|
||||
| Fine control over delivery (tone, pace, emotion) | Qwen CustomVoice |
|
||||
| The broadest language coverage | [Voice Cloning](/overview/voice-cloning) via Chatterbox Multilingual (23 langs) |
|
||||
|
||||
## Limitations
|
||||
|
||||
<Callout type="warn">
|
||||
Preset voices are fixed — you can't fine-tune or modify the underlying voice. If you want a specific voice that isn't in the catalog, use a cloning engine and provide a reference sample.
|
||||
</Callout>
|
||||
|
||||
- Preset voices can't be exported to use in other Voicebox installations as audio (only as profile metadata pointing to the same engine + voice ID)
|
||||
- The Kokoro voice catalog is set by the upstream model — new voices appear only when hexgrad publishes new model releases
|
||||
- Qwen CustomVoice's 9 speakers are part of the model checkpoint — same constraint
|
||||
|
||||
## Next Steps
|
||||
|
||||
<Cards>
|
||||
<Card title="Voice Cloning" href="/overview/voice-cloning">
|
||||
Clone a specific voice from your own audio
|
||||
</Card>
|
||||
<Card title="Generate Speech" href="/overview/generating-speech">
|
||||
Use a profile to generate audio
|
||||
</Card>
|
||||
<Card title="Build Stories" href="/overview/building-stories">
|
||||
Compose multi-voice narratives
|
||||
</Card>
|
||||
</Cards>
|
||||
@@ -69,6 +69,16 @@ Now let's use your new voice profile to generate speech.
|
||||
```
|
||||
Hello! This is my first voice generation with Voicebox.
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
Paralinguistic tags like `[laugh]`, `[sigh]`, and `[gasp]` only work with
|
||||
**Chatterbox Turbo**. Qwen3-TTS, LuxTTS, Chatterbox Multilingual, and
|
||||
HumeAI TADA will read those tags literally instead of turning them into
|
||||
expressive sounds.
|
||||
</Callout>
|
||||
|
||||
To insert supported tags, select **Chatterbox Turbo** and type `/` in the
|
||||
text input to open the tag inserter.
|
||||
</Step>
|
||||
|
||||
<Step title="Generate">
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
---
|
||||
title: "Voice Cloning"
|
||||
description: "Clone any voice from just a few seconds of audio"
|
||||
description: "Clone any voice from a few seconds of reference audio"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Voicebox uses **Qwen3-TTS** from Alibaba to achieve near-perfect voice cloning from just a few seconds of audio. The model captures prosody, emotion, and natural cadence.
|
||||
Voicebox can replicate a specific person's voice from a short audio sample — known as **zero-shot voice cloning**. You provide 10-30 seconds of clear speech, the model extracts a voice embedding, and from then on you can generate any text in that voice.
|
||||
|
||||
Five engines in 0.4 support cloning:
|
||||
|
||||
| Engine | Languages | Strengths |
|
||||
| --------------------------- | --------- | -------------------------------------------------------------------------- |
|
||||
| **Qwen3-TTS** (0.6B / 1.7B) | 10 | High-quality multilingual, supports delivery instructions on the same kwarg |
|
||||
| **Chatterbox Multilingual** | 23 | Broadest language coverage — Arabic, Hindi, Swahili, Hebrew, more |
|
||||
| **Chatterbox Turbo** | English | Fast 350M model with paralinguistic emotion tags (`[laugh]`, `[sigh]`) |
|
||||
| **LuxTTS** | English | Lightweight (~1 GB VRAM), 48 kHz output, 150x realtime on CPU |
|
||||
| **TADA** (1B / 3B) | 10 | Speech-language model with 700s+ coherent long-form generation |
|
||||
|
||||
<Callout type="info">
|
||||
Don't want to record audio? Use a curated voice from Kokoro or Qwen CustomVoice instead — see [Preset Voices](/overview/preset-voices).
|
||||
</Callout>
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -13,17 +27,30 @@ Voicebox uses **Qwen3-TTS** from Alibaba to achieve near-perfect voice cloning f
|
||||
<Step title="Upload or Record Sample">
|
||||
Provide 10-30 seconds of clear speech from the target voice
|
||||
</Step>
|
||||
<Step title="Model Analysis">
|
||||
Qwen3-TTS analyzes vocal characteristics, tone, and speaking patterns
|
||||
<Step title="Engine Analysis">
|
||||
The selected engine analyzes vocal characteristics, tone, and speaking patterns
|
||||
</Step>
|
||||
<Step title="Voice Profile Created">
|
||||
The model generates a voice embedding for synthesis
|
||||
A voice embedding is generated and stored with your profile
|
||||
</Step>
|
||||
<Step title="Generate Speech">
|
||||
Use the profile to generate any text in the cloned voice
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Choosing an Engine for Cloning
|
||||
|
||||
Different engines suit different use cases. The profile grid greys out unsupported engines so you can switch easily.
|
||||
|
||||
| If you want… | Pick |
|
||||
| -------------------------------------------------- | --------------------- |
|
||||
| Best overall quality on a few common languages | **Qwen3-TTS 1.7B** |
|
||||
| Faster generation, slightly lower quality | **Qwen3-TTS 0.6B** |
|
||||
| Languages outside Qwen's 10 (Arabic, Hindi, etc.) | **Chatterbox Multilingual** |
|
||||
| Expressive English with `[laugh]` `[sigh]` tags | **Chatterbox Turbo** |
|
||||
| CPU-only or GPU-light setup, English | **LuxTTS** |
|
||||
| Long-form generation (audiobooks, full chapters) | **TADA 3B** |
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Sample Quality
|
||||
@@ -52,24 +79,40 @@ Adding multiple samples from the same speaker can improve quality:
|
||||
- Different recording conditions
|
||||
|
||||
<Callout type="info">
|
||||
The model will learn a more robust representation from diverse samples.
|
||||
The model will learn a more robust representation from diverse samples. Especially helpful for distinctive voices the model might otherwise smooth over.
|
||||
</Callout>
|
||||
|
||||
## Supported Languages
|
||||
## Supported Languages by Engine
|
||||
|
||||
Currently supported:
|
||||
- English
|
||||
- Chinese (Mandarin)
|
||||
- **Qwen3-TTS** — English, Chinese, Japanese, Korean, German, French, Russian, Portuguese, Spanish, Italian (10)
|
||||
- **Chatterbox Multilingual** — Arabic, Chinese, Danish, Dutch, English, Finnish, French, German, Greek, Hebrew, Hindi, Italian, Japanese, Korean, Malay, Norwegian, Polish, Portuguese, Russian, Spanish, Swahili, Swedish, Turkish (23)
|
||||
- **Chatterbox Turbo** — English
|
||||
- **LuxTTS** — English
|
||||
- **TADA 3B** — 10 multilingual; **TADA 1B** — English
|
||||
|
||||
More languages coming soon.
|
||||
For complete language tables and engine-specific notes, see the [TTS Engines developer guide](/developer/tts-engines).
|
||||
|
||||
## Limitations
|
||||
|
||||
<Callout type="warn">
|
||||
Voice cloning should only be used with consent. Ensure you have permission to clone someone's voice.
|
||||
Voice cloning should only be used with consent. Ensure you have permission to clone someone's voice. See the project's [SECURITY.md](https://github.com/jamiepine/voicebox/blob/main/SECURITY.md) and your local laws on synthetic voice content.
|
||||
</Callout>
|
||||
|
||||
- Quality depends on sample clarity
|
||||
- Works best with consistent speaking tone
|
||||
- Quality depends on sample clarity — noisy samples produce noisy clones
|
||||
- Works best with consistent speaking tone within a sample
|
||||
- May struggle with extreme accents or speech impediments
|
||||
- Background noise reduces quality
|
||||
- Background noise reduces quality and can introduce artifacts
|
||||
|
||||
## Next Steps
|
||||
|
||||
<Cards>
|
||||
<Card title="Creating Voice Profiles" href="/overview/creating-voice-profiles">
|
||||
Step-by-step guide to creating profiles
|
||||
</Card>
|
||||
<Card title="Preset Voices" href="/overview/preset-voices">
|
||||
Use built-in voices instead of cloning
|
||||
</Card>
|
||||
<Card title="Generating Speech" href="/overview/generating-speech">
|
||||
Use a profile to generate audio
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
@@ -295,6 +295,15 @@ fix-python: _ensure-venv
|
||||
test: _ensure-venv
|
||||
{{ venv_bin }}/python -m pytest {{ backend_dir }}/tests -v
|
||||
|
||||
# E2E: generate with every TTS model against the frozen binary (pass extra flags like --only kokoro)
|
||||
[unix]
|
||||
test-models *ARGS: _ensure-venv
|
||||
{{ venv_bin }}/python {{ backend_dir }}/tests/test_all_models_e2e.py {{ ARGS }}
|
||||
|
||||
[windows]
|
||||
test-models *ARGS: _ensure-venv
|
||||
& "{{ python }}" {{ backend_dir }}/tests/test_all_models_e2e.py {{ ARGS }}
|
||||
|
||||
# ─── Database ─────────────────────────────────────────────────────────
|
||||
|
||||
# Initialize SQLite database
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@voicebox/landing",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"description": "Landing page for voicebox.sh",
|
||||
"scripts": {
|
||||
"dev": "bun --bun next dev --turbo",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
+454
-300
@@ -1,327 +1,481 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { Github, Globe, Languages, MessageSquare, Zap } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ControlUI } from '@/components/ControlUI';
|
||||
import { Features } from '@/components/Features';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { Navbar } from '@/components/Navbar';
|
||||
import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/PlatformIcons';
|
||||
import { VoiceCreator } from '@/components/VoiceCreator';
|
||||
import { DOWNLOAD_LINKS, GITHUB_REPO } from '@/lib/constants';
|
||||
import type { DownloadLinks } from '@/lib/releases';
|
||||
import {
|
||||
Github,
|
||||
Globe,
|
||||
Languages,
|
||||
MessageSquare,
|
||||
SlidersHorizontal,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {ApiSection} from "@/components/ApiSection";
|
||||
import {ControlUI} from "@/components/ControlUI";
|
||||
import {Features} from "@/components/Features";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {AppleIcon, LinuxIcon, WindowsIcon} from "@/components/PlatformIcons";
|
||||
import {TutorialsSection} from "@/components/TutorialsSection";
|
||||
import {VoiceCreator} from "@/components/VoiceCreator";
|
||||
import {DOWNLOAD_LINKS, GITHUB_REPO} from "@/lib/constants";
|
||||
import type {DownloadLinks} from "@/lib/releases";
|
||||
|
||||
export default function Home() {
|
||||
const [downloadLinks, setDownloadLinks] = useState<DownloadLinks>(DOWNLOAD_LINKS);
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
const [totalDownloads, setTotalDownloads] = useState<number | null>(null);
|
||||
const [downloadLinks, setDownloadLinks] =
|
||||
useState<DownloadLinks>(DOWNLOAD_LINKS);
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
const [totalDownloads, setTotalDownloads] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/releases')
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to fetch releases');
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.downloadLinks) setDownloadLinks(data.downloadLinks);
|
||||
if (data.version) setVersion(data.version);
|
||||
if (data.totalDownloads != null) setTotalDownloads(data.totalDownloads);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch release info:', error);
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
fetch("/api/releases")
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Failed to fetch releases");
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.downloadLinks) setDownloadLinks(data.downloadLinks);
|
||||
if (data.version) setVersion(data.version);
|
||||
if (data.totalDownloads != null) setTotalDownloads(data.totalDownloads);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to fetch release info:", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
{/* ── Hero Section ─────────────────────────────────────────────── */}
|
||||
<section className="relative pt-32 pb-16">
|
||||
{/* Background glow */}
|
||||
<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-[800px] h-[600px] rounded-full bg-accent/15 blur-[150px]" />
|
||||
<div className="absolute left-1/2 top-12 -translate-x-1/2 w-[500px] h-[400px] rounded-full bg-accent/10 blur-[80px]" />
|
||||
</div>
|
||||
{/* ── Hero Section ─────────────────────────────────────────────── */}
|
||||
<section className="relative pt-32 pb-16">
|
||||
{/* Background glow */}
|
||||
<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-[800px] h-[600px] rounded-full bg-accent/15 blur-[150px]" />
|
||||
<div className="absolute left-1/2 top-12 -translate-x-1/2 w-[500px] h-[400px] rounded-full bg-accent/10 blur-[80px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-7xl px-6 text-center">
|
||||
{/* Logo */}
|
||||
<div
|
||||
className="fade-in mx-auto mb-8 h-[120px] w-[120px] md:h-[160px] md:w-[160px]"
|
||||
style={{ animationDelay: '0ms' }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/voicebox-logo-app.webp"
|
||||
alt="Voicebox"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative mx-auto max-w-7xl px-6 text-center">
|
||||
{/* Logo */}
|
||||
<div
|
||||
className="fade-in mx-auto mb-8 h-[120px] w-[120px] md:h-[160px] md:w-[160px]"
|
||||
style={{animationDelay: "0ms"}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/voicebox-logo-app.webp"
|
||||
alt="Voicebox"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Headline */}
|
||||
<div className="fade-in relative" style={{ animationDelay: '100ms' }}>
|
||||
<h1 className="text-5xl font-bold tracking-tighter leading-[0.9] text-foreground md:text-7xl lg:text-8xl">
|
||||
Your voice, your machine.
|
||||
</h1>
|
||||
</div>
|
||||
{/* Headline */}
|
||||
<div className="fade-in relative" style={{animationDelay: "100ms"}}>
|
||||
<h1 className="text-5xl font-bold tracking-tighter leading-[0.9] text-foreground md:text-7xl lg:text-8xl">
|
||||
Clone any voice, in seconds.
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p
|
||||
className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl"
|
||||
style={{ animationDelay: '200ms' }}
|
||||
>
|
||||
Open source voice cloning studio with support for multiple TTS engines. Clone any voice,
|
||||
generate natural speech, and compose multi-voice projects — all running locally.
|
||||
</p>
|
||||
{/* Subtitle */}
|
||||
<p
|
||||
className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl"
|
||||
style={{animationDelay: "200ms"}}
|
||||
>
|
||||
Open source voice cloning studio with support for multiple TTS
|
||||
engines. Clone any voice, generate natural speech, and compose
|
||||
multi-voice projects. All running{" "}
|
||||
<b className="text-white">locally on your machine.</b>
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
<div
|
||||
className="fade-in mt-10 flex flex-col sm:flex-row items-center justify-center gap-4"
|
||||
style={{ animationDelay: '300ms' }}
|
||||
>
|
||||
<a
|
||||
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)]"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<a
|
||||
href={GITHUB_REPO}
|
||||
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"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
{/* CTAs */}
|
||||
<div
|
||||
className="fade-in mt-10 flex flex-col sm:flex-row items-center justify-center gap-4"
|
||||
style={{animationDelay: "300ms"}}
|
||||
>
|
||||
<a
|
||||
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)]"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<a
|
||||
href={GITHUB_REPO}
|
||||
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"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Version + downloads */}
|
||||
<p
|
||||
className="fade-in mt-4 text-xs text-muted-foreground/50"
|
||||
style={{ animationDelay: '400ms' }}
|
||||
>
|
||||
{version ?? ''}
|
||||
{version && totalDownloads != null ? ' \u00b7 ' : ''}
|
||||
{totalDownloads != null ? `${totalDownloads.toLocaleString()} downloads` : ''}
|
||||
{version || totalDownloads != null ? ' \u00b7 ' : ''}
|
||||
macOS, Windows, Linux
|
||||
</p>
|
||||
</div>
|
||||
{/* Version + downloads */}
|
||||
<p
|
||||
className="fade-in mt-4 text-xs text-muted-foreground/50"
|
||||
style={{animationDelay: "400ms"}}
|
||||
>
|
||||
{version ?? ""}
|
||||
{version && totalDownloads != null ? " \u00b7 " : ""}
|
||||
{totalDownloads != null
|
||||
? `${totalDownloads.toLocaleString()} downloads`
|
||||
: ""}
|
||||
{version || totalDownloads != null ? " \u00b7 " : ""}
|
||||
macOS, Windows, Linux
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── ControlUI mockup ─────────────────────────────────────── */}
|
||||
<div className="mt-16">
|
||||
<ControlUI />
|
||||
</div>
|
||||
</section>
|
||||
{/* ── ControlUI mockup ─────────────────────────────────────── */}
|
||||
<div className="mt-16">
|
||||
<ControlUI />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Features ─────────────────────────────────────────────── */}
|
||||
<Features />
|
||||
{/* ── Features ─────────────────────────────────────────────── */}
|
||||
<Features />
|
||||
|
||||
{/* ── Voice Creator ────────────────────────────────────────── */}
|
||||
<VoiceCreator />
|
||||
{/* ── Voice Creator ────────────────────────────────────────── */}
|
||||
<VoiceCreator />
|
||||
|
||||
{/* ── Models ─────────────────────────────────────────────────── */}
|
||||
<section id="about" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-5xl px-6">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
||||
Multi-Engine Architecture
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Choose the right model for every job. All models run locally on your hardware —
|
||||
download once, use forever.
|
||||
</p>
|
||||
</div>
|
||||
{/* ── Tutorials ────────────────────────────────────────────── */}
|
||||
<TutorialsSection />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Qwen3-TTS */}
|
||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">Qwen3-TTS</h3>
|
||||
<span className="text-xs text-muted-foreground/60">by Alibaba</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||
1.7B
|
||||
</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||
0.6B
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||
High-quality multilingual voice cloning with natural prosody. The only engine with
|
||||
delivery instructions — control tone, pace, and emotion with natural language.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<Globe className="h-3 w-3" />
|
||||
10 languages
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
Delivery instructions
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* ── API Section ──────────────────────────────────────────── */}
|
||||
<ApiSection />
|
||||
|
||||
{/* Chatterbox */}
|
||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">Chatterbox</h3>
|
||||
<span className="text-xs text-muted-foreground/60">by Resemble AI</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||
Production-grade voice cloning with the broadest language support. 23 languages with
|
||||
zero-shot cloning and emotion exaggeration control.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<Languages className="h-3 w-3" />
|
||||
23 languages
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* ── Models ─────────────────────────────────────────────────── */}
|
||||
<section id="about" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-5xl px-6">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
||||
Multi-Engine Architecture
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Choose the right model for every job. All models run locally on
|
||||
your hardware — download once, use forever.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Chatterbox Turbo */}
|
||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">Chatterbox Turbo</h3>
|
||||
<span className="text-xs text-muted-foreground/60">by Resemble AI</span>
|
||||
</div>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||
350M
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||
Lightweight and fast. Supports paralinguistic tags — embed [laugh], [sigh], [gasp]
|
||||
and more directly in your text for expressive, natural speech.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<Zap className="h-3 w-3" />
|
||||
350M params
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
[laugh] [sigh] tags
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Qwen3-TTS */}
|
||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
Qwen3-TTS
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
by Alibaba
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||
1.7B
|
||||
</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||
0.6B
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||
High-quality multilingual voice cloning with natural prosody.
|
||||
The only engine with delivery instructions — control tone, pace,
|
||||
and emotion with natural language.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<Globe className="h-3 w-3" />
|
||||
10 languages
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
Delivery instructions
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LuxTTS */}
|
||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">LuxTTS</h3>
|
||||
<span className="text-xs text-muted-foreground/60">by ZipVoice</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||
Ultra-fast, CPU-friendly voice cloning at 48kHz. Exceeds 150x realtime on CPU with
|
||||
~1GB VRAM. The fastest engine for quick iterations.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<Zap className="h-3 w-3" />
|
||||
150x realtime
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
48kHz output
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/* Chatterbox */}
|
||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
Chatterbox
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
by Resemble AI
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||
Production-grade voice cloning with the broadest language
|
||||
support. 23 languages with zero-shot cloning and emotion
|
||||
exaggeration control.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<Languages className="h-3 w-3" />
|
||||
23 languages
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Download Section ─────────────────────────────────────── */}
|
||||
<section id="download" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-4xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
||||
Download Voicebox
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Available for macOS, Windows, and Linux. No dependencies required.
|
||||
</p>
|
||||
</div>
|
||||
{/* Chatterbox Turbo */}
|
||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
Chatterbox Turbo
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
by Resemble AI
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||
350M
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||
Lightweight and fast. Supports paralinguistic tags — embed
|
||||
[laugh], [sigh], [gasp] and more directly in your text for
|
||||
expressive, natural speech.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<Zap className="h-3 w-3" />
|
||||
350M params
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
[laugh] [sigh] tags
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl mx-auto">
|
||||
{/* macOS ARM */}
|
||||
<a
|
||||
href={downloadLinks.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"
|
||||
>
|
||||
<AppleIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium">macOS</div>
|
||||
<div className="text-xs text-muted-foreground">Apple Silicon (ARM)</div>
|
||||
</div>
|
||||
</a>
|
||||
{/* LuxTTS */}
|
||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
LuxTTS
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
by ZipVoice
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||
Ultra-fast, CPU-friendly voice cloning at 48kHz. Exceeds 150x
|
||||
realtime on CPU with ~1GB VRAM. The fastest engine for quick
|
||||
iterations.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<Zap className="h-3 w-3" />
|
||||
150x realtime
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
48kHz output
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* macOS Intel */}
|
||||
<a
|
||||
href={downloadLinks.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"
|
||||
>
|
||||
<AppleIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium">macOS</div>
|
||||
<div className="text-xs text-muted-foreground">Intel (x64)</div>
|
||||
</div>
|
||||
</a>
|
||||
{/* Qwen CustomVoice */}
|
||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
Qwen CustomVoice
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
by Alibaba
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||
1.7B
|
||||
</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||
0.6B
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||
Nine premium preset speakers with natural-language style
|
||||
control. Tell the model how to deliver — "speak slowly with
|
||||
warmth", "authoritative and clear" — and it adapts tone,
|
||||
emotion, and pace.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<SlidersHorizontal className="h-3 w-3" />
|
||||
Instruct control
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<Globe className="h-3 w-3" />
|
||||
10 languages
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
9 preset voices
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Windows */}
|
||||
<a
|
||||
href={downloadLinks.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"
|
||||
>
|
||||
<WindowsIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium">Windows</div>
|
||||
<div className="text-xs text-muted-foreground">64-bit (MSI)</div>
|
||||
</div>
|
||||
</a>
|
||||
{/* HumeAI TADA */}
|
||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
TADA
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
by Hume AI
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||
3B
|
||||
</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||
1B
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||
Speech-language model with text-acoustic dual alignment. Built
|
||||
for long-form generation — produces 700s+ of coherent audio
|
||||
without drift. Multilingual at 3B, English-focused at 1B.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<Globe className="h-3 w-3" />
|
||||
10 languages
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
Long-form coherent
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Linux */}
|
||||
<a
|
||||
href="/linux-install"
|
||||
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"
|
||||
>
|
||||
<LinuxIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium">Linux</div>
|
||||
<div className="text-xs text-muted-foreground">Build from source</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{/* Kokoro 82M */}
|
||||
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6 transition-colors hover:border-accent/30">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
Kokoro
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
by hexgrad · Apache 2.0
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border bg-background text-muted-foreground">
|
||||
82M
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||
Tiny 82M-parameter TTS that runs at CPU realtime with negligible
|
||||
VRAM. Pre-built voice styles instead of cloning — pick a voice,
|
||||
type, generate. Smallest footprint of any engine.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
<Zap className="h-3 w-3" />
|
||||
CPU realtime
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||
Preset voices
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* GitHub link */}
|
||||
<div className="mt-6 text-center">
|
||||
<a
|
||||
href={`${GITHUB_REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
View all releases on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/* ── Download Section ─────────────────────────────────────── */}
|
||||
<section id="download" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-4xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
||||
Download Voicebox
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Available for macOS, Windows, and Linux. No dependencies required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Footer ───────────────────────────────────────────────── */}
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl mx-auto">
|
||||
{/* macOS ARM */}
|
||||
<a
|
||||
href={downloadLinks.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"
|
||||
>
|
||||
<AppleIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium">macOS</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Apple Silicon (ARM)
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* macOS Intel */}
|
||||
<a
|
||||
href={downloadLinks.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"
|
||||
>
|
||||
<AppleIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium">macOS</div>
|
||||
<div className="text-xs text-muted-foreground">Intel (x64)</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Windows */}
|
||||
<a
|
||||
href={downloadLinks.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"
|
||||
>
|
||||
<WindowsIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium">Windows</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
64-bit (MSI)
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Linux */}
|
||||
<a
|
||||
href="/linux-install"
|
||||
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"
|
||||
>
|
||||
<LinuxIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium">Linux</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Build from source
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* GitHub link */}
|
||||
<div className="mt-6 text-center">
|
||||
<a
|
||||
href={`${GITHUB_REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
View all releases on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Footer ───────────────────────────────────────────────── */}
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import {AppWindow, Code2, Gamepad2, Terminal, Wrench} from "lucide-react";
|
||||
|
||||
type Endpoint = {
|
||||
method: "POST" | "GET" | "DELETE" | "PATCH";
|
||||
path: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const ENDPOINTS: Endpoint[] = [
|
||||
{method: "POST", path: "/generate", label: "Generate speech"},
|
||||
{method: "POST", path: "/generate/{id}/cancel", label: "Cancel a generation"},
|
||||
{method: "GET", path: "/profiles", label: "List voice profiles"},
|
||||
{method: "POST", path: "/profiles", label: "Create a new profile"},
|
||||
{method: "GET", path: "/models/status", label: "Model catalog & state"},
|
||||
{method: "GET", path: "/history", label: "Past generations"},
|
||||
{method: "GET", path: "/health", label: "Server health"},
|
||||
];
|
||||
|
||||
const METHOD_STYLES: Record<Endpoint["method"], string> = {
|
||||
POST: "bg-accent/10 text-accent border-accent/20",
|
||||
GET: "bg-muted text-muted-foreground border-border",
|
||||
DELETE: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||
PATCH: "bg-blue-500/10 text-blue-400 border-blue-500/20",
|
||||
};
|
||||
|
||||
const CURL_SNIPPET = `curl -X POST http://127.0.0.1:17493/generate \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"text": "Welcome to the game, player one.",
|
||||
"profile_id": "morgan-freeman",
|
||||
"engine": "qwen",
|
||||
"instruct": "warm, slow, cinematic"
|
||||
}' \\
|
||||
--output line.wav`;
|
||||
|
||||
const USE_CASES = [
|
||||
{
|
||||
icon: Gamepad2,
|
||||
title: "Games",
|
||||
description:
|
||||
"Generate NPC dialogue on the fly, localize characters into new languages, or ship expressive voice lines without a studio.",
|
||||
},
|
||||
{
|
||||
icon: AppWindow,
|
||||
title: "Apps & agents",
|
||||
description:
|
||||
"Give your app or AI agent a voice. Real-time narration, accessibility readouts, voice replies — all running on the user's machine.",
|
||||
},
|
||||
{
|
||||
icon: Wrench,
|
||||
title: "Scripts & tools",
|
||||
description:
|
||||
"Batch-generate audiobook chapters, automate podcast intros, or wire Voicebox into your Stream Deck. It's just a localhost URL.",
|
||||
},
|
||||
];
|
||||
|
||||
export function ApiSection() {
|
||||
return (
|
||||
<section id="api" 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">
|
||||
<Code2 className="h-3 w-3 text-accent" />
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Built-in REST API
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
||||
Your local voice API
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Every engine you download becomes a REST endpoint on your machine.
|
||||
Build apps, games, and voice tools with full programmatic control —
|
||||
no API keys, no rate limits, no per-character fees.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main panel: endpoints + code snippet */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-5 mb-14">
|
||||
{/* Endpoint reference */}
|
||||
<div className="lg:col-span-3 rounded-xl border border-border bg-card/60 backdrop-blur-sm overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-border/60 bg-card/40">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
<div className="h-2 w-2 rounded-full bg-muted-foreground/30" />
|
||||
<div className="h-2 w-2 rounded-full bg-muted-foreground/30" />
|
||||
<div className="h-2 w-2 rounded-full bg-muted-foreground/30" />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-foreground ml-2">
|
||||
API Reference
|
||||
</span>
|
||||
</div>
|
||||
<code className="text-[10px] bg-background border border-border px-1.5 py-0.5 rounded font-mono text-muted-foreground">
|
||||
http://127.0.0.1:17493
|
||||
</code>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-1">
|
||||
{ENDPOINTS.map((ep) => (
|
||||
<div
|
||||
key={`${ep.method}-${ep.path}`}
|
||||
className="flex items-center gap-3 py-1.5 group"
|
||||
>
|
||||
<span
|
||||
className={`text-[10px] font-mono font-semibold w-12 text-center rounded px-1 py-0.5 border ${METHOD_STYLES[ep.method]}`}
|
||||
>
|
||||
{ep.method}
|
||||
</span>
|
||||
<code className="text-xs font-mono text-foreground/90">
|
||||
{ep.path}
|
||||
</code>
|
||||
<span className="text-xs text-muted-foreground/60 ml-auto">
|
||||
{ep.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-border/60 px-5 py-3 bg-card/40">
|
||||
<a
|
||||
href="http://127.0.0.1:17493/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-accent hover:underline"
|
||||
>
|
||||
See the full OpenAPI reference at{" "}
|
||||
<code className="font-mono">/docs</code> when Voicebox is running
|
||||
→
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code snippet */}
|
||||
<div className="lg:col-span-2 rounded-xl border border-border bg-card/60 backdrop-blur-sm overflow-hidden flex flex-col">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-border/60 bg-card/40">
|
||||
<Terminal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
Generate a line
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] text-muted-foreground/50 font-mono">
|
||||
curl
|
||||
</span>
|
||||
</div>
|
||||
<pre className="flex-1 p-4 text-[11px] font-mono text-muted-foreground/90 leading-relaxed overflow-x-auto whitespace-pre">
|
||||
<code>{CURL_SNIPPET}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Use cases */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{USE_CASES.map((uc) => {
|
||||
const Icon = uc.icon;
|
||||
return (
|
||||
<div
|
||||
key={uc.title}
|
||||
className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5 transition-colors hover:border-accent/30"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className="h-4 w-4 text-accent" />
|
||||
<h3 className="text-[15px] font-medium text-foreground">
|
||||
{uc.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{uc.description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bottom bar: key selling points */}
|
||||
<div className="mt-10 flex flex-wrap items-center justify-center gap-x-8 gap-y-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
No API keys
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
No rate limits
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
No per-character fees
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
Works offline
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
Your audio, your machine
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Coffee } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { GITHUB_REPO } from '@/lib/constants';
|
||||
import { DONATE_URL, GITHUB_REPO } from '@/lib/constants';
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
@@ -19,9 +20,19 @@ export function Footer() {
|
||||
/>
|
||||
<span className="text-sm font-semibold">Voicebox</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-4">
|
||||
Open source voice cloning studio. Local-first, free forever.
|
||||
</p>
|
||||
<a
|
||||
href={DONATE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border/60 bg-card/60 px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground hover:border-[#FFDD00]/40"
|
||||
aria-label="Donate via Buy Me a Coffee"
|
||||
>
|
||||
<Coffee className="h-4 w-4 text-[#FFDD00]" />
|
||||
<span className="text-[13px] font-medium">Donate</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Product */}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { Github } from 'lucide-react';
|
||||
import { Coffee, Github } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { GITHUB_REPO } from '@/lib/constants';
|
||||
import { DONATE_URL, GITHUB_REPO } from '@/lib/constants';
|
||||
|
||||
function formatStarCount(count: number): string {
|
||||
if (count >= 1000) {
|
||||
@@ -57,7 +57,13 @@ export function Navbar() {
|
||||
href="#about"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
About
|
||||
Models
|
||||
</a>
|
||||
<a
|
||||
href="#api"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
API
|
||||
</a>
|
||||
<a
|
||||
href="#download"
|
||||
@@ -75,21 +81,33 @@ export function Navbar() {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* GitHub star button */}
|
||||
<a
|
||||
href={GITHUB_REPO}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 justify-self-end 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-border"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
<span className="text-[13px] font-medium">Star</span>
|
||||
{starCount !== null && (
|
||||
<span className="border-l border-border/60 pl-2 text-[13px] font-semibold text-foreground">
|
||||
{formatStarCount(starCount)}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
{/* Donate + GitHub star buttons */}
|
||||
<div className="flex items-center gap-2 justify-self-end">
|
||||
<a
|
||||
href={DONATE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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-[#FFDD00]/40"
|
||||
aria-label="Donate via Buy Me a Coffee"
|
||||
>
|
||||
<Coffee className="h-4 w-4 text-[#FFDD00]" />
|
||||
<span className="text-[13px] font-medium">Donate</span>
|
||||
</a>
|
||||
<a
|
||||
href={GITHUB_REPO}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="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-border"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
<span className="text-[13px] font-medium">Star</span>
|
||||
{starCount !== null && (
|
||||
<span className="border-l border-border/60 pl-2 text-[13px] font-semibold text-foreground">
|
||||
{formatStarCount(starCount)}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import {Play, Youtube} from "lucide-react";
|
||||
|
||||
type Tutorial = {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
thumbnail: string;
|
||||
};
|
||||
|
||||
const TUTORIALS: (Tutorial | null)[] = [
|
||||
{
|
||||
id: "sisnzgc73zc",
|
||||
title: "Free AI Voice Generator on Your PC (Clones Any Voice)",
|
||||
author: "Kevin Stratvert",
|
||||
thumbnail: "/tutorials/sisnzgc73zc.jpg",
|
||||
},
|
||||
{
|
||||
id: "woQe90k7g3c",
|
||||
title: "NEW Voicebox DESTROYS ElevenLabs?",
|
||||
author: "Julian Goldie SEO",
|
||||
thumbnail: "/tutorials/woQe90k7g3c.jpg",
|
||||
},
|
||||
{
|
||||
id: "kqxqjRsdD5E",
|
||||
title: "This Open-Source TTS App Sounds Scary Good (And It's Free)",
|
||||
author: "Dave Swift",
|
||||
thumbnail: "/tutorials/kqxqjRsdD5E.jpg",
|
||||
},
|
||||
];
|
||||
|
||||
function TutorialCard({tutorial}: {tutorial: Tutorial}) {
|
||||
return (
|
||||
<a
|
||||
href={`https://www.youtube.com/watch?v=${tutorial.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group rounded-xl border border-border bg-card/60 backdrop-blur-sm overflow-hidden transition-all hover:border-accent/30 hover:bg-card"
|
||||
>
|
||||
<div className="relative aspect-video overflow-hidden bg-muted">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={tutorial.thumbnail}
|
||||
alt={tutorial.title}
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
{/* Gradient overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0" />
|
||||
{/* Play button overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-black/50 backdrop-blur-md border border-white/20 transition-all group-hover:scale-110 group-hover:bg-accent/90 group-hover:border-accent">
|
||||
<Play className="h-5 w-5 text-white fill-white ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
{/* YouTube badge */}
|
||||
<div className="absolute top-3 right-3 flex items-center gap-1 rounded bg-black/60 backdrop-blur-sm px-2 py-1">
|
||||
<Youtube className="h-3 w-3 text-white" />
|
||||
<span className="text-[10px] font-medium text-white uppercase tracking-wider">
|
||||
YouTube
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm font-medium text-foreground line-clamp-2 leading-snug mb-1.5 group-hover:text-accent transition-colors">
|
||||
{tutorial.title}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">{tutorial.author}</p>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function TutorialPlaceholder() {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-border/60 bg-card/30 backdrop-blur-sm overflow-hidden">
|
||||
<div className="relative aspect-video overflow-hidden bg-gradient-to-br from-card via-muted/20 to-card">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full border border-border/40 bg-card/40">
|
||||
<Play className="h-5 w-5 text-muted-foreground/40 fill-muted-foreground/40 ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="h-3 w-3/4 rounded bg-muted-foreground/10 mb-2" />
|
||||
<div className="h-2.5 w-1/3 rounded bg-muted-foreground/10" />
|
||||
<p className="text-[11px] text-muted-foreground/50 mt-3 uppercase tracking-wider">
|
||||
Coming soon
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TutorialsSection() {
|
||||
return (
|
||||
<section id="tutorials" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
<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">
|
||||
<Youtube className="h-3 w-3 text-accent" />
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Video tutorials
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-foreground md:text-4xl mb-4">
|
||||
Learn by watching
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Walkthroughs from the community covering setup, voice cloning, and
|
||||
production workflows.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{TUTORIALS.map((tutorial, i) =>
|
||||
tutorial ? (
|
||||
<TutorialCard key={tutorial.id} tutorial={tutorial} />
|
||||
) : (
|
||||
<TutorialPlaceholder key={`placeholder-${i}`} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export const LATEST_VERSION = 'v0.1.0';
|
||||
|
||||
export const GITHUB_REPO = 'https://github.com/jamiepine/voicebox';
|
||||
export const GITHUB_RELEASES_PAGE = `${GITHUB_REPO}/releases`;
|
||||
export const DONATE_URL = 'https://buymeacoffee.com/jamiepine';
|
||||
|
||||
export const DOWNLOAD_LINKS = {
|
||||
macArm: GITHUB_RELEASES_PAGE,
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "voicebox",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"app",
|
||||
@@ -24,12 +24,13 @@
|
||||
"update:icons": "./scripts/update-icons.sh",
|
||||
"convert:assets": "./scripts/convert-assets.sh",
|
||||
"lint": "biome lint .",
|
||||
"typecheck": "bunx tsc -p app/tsconfig.json --noEmit && cd web && bunx tsc --noEmit",
|
||||
"lint:fix": "biome lint --write .",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome format .",
|
||||
"check": "biome check .",
|
||||
"check:fix": "biome check --write .",
|
||||
"ci": "biome ci ."
|
||||
"ci": "bun run typecheck && bun run build:web"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.12",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@voicebox/tauri",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -5041,7 +5041,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "voicebox"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"core-foundation-sys",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "voicebox"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
|
||||
@@ -8,12 +8,71 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
/// Try to find a PulseAudio/PipeWire monitor source using `pactl`.
|
||||
/// Returns the source name (e.g. "alsa_output.pci-0000_0d_00.6.analog-stereo.monitor") if found.
|
||||
fn find_monitor_source_via_pactl() -> Option<String> {
|
||||
let output = std::process::Command::new("pactl")
|
||||
.args(["list", "short", "sources"])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// First, try to find the monitor of the default sink
|
||||
let default_sink = std::process::Command::new("pactl")
|
||||
.args(["get-default-sink"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
if o.status.success() {
|
||||
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
// If we know the default sink, look for its .monitor specifically
|
||||
if let Some(sink_name) = &default_sink {
|
||||
let monitor_name = format!("{}.monitor", sink_name);
|
||||
for line in stdout.lines() {
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.len() >= 2 && parts[1] == monitor_name {
|
||||
eprintln!(
|
||||
"Linux audio capture: Found default sink monitor via pactl: {}",
|
||||
monitor_name
|
||||
);
|
||||
return Some(monitor_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: find any .monitor source
|
||||
for line in stdout.lines() {
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.len() >= 2 && parts[1].ends_with(".monitor") {
|
||||
let name = parts[1].to_string();
|
||||
eprintln!(
|
||||
"Linux audio capture: Found monitor source via pactl: {}",
|
||||
name
|
||||
);
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Start capturing system audio on Linux using PulseAudio monitor sources.
|
||||
///
|
||||
/// PulseAudio exposes "monitor" devices that mirror the output of each sink,
|
||||
/// allowing us to capture whatever audio is currently playing on the system.
|
||||
/// We use `cpal` with the default host (which will be PulseAudio or PipeWire
|
||||
/// on modern Linux) and look for monitor input devices.
|
||||
/// On modern Linux with PulseAudio or PipeWire, we first try to detect the
|
||||
/// monitor source via `pactl` and set the `PULSE_SOURCE` environment variable.
|
||||
/// This tells PulseAudio's ALSA plugin to use the monitor as the default input
|
||||
/// source for this process. If `pactl` is unavailable, we fall back to searching
|
||||
/// cpal device names for "monitor".
|
||||
pub async fn start_capture(
|
||||
state: &AudioCaptureState,
|
||||
max_duration_secs: u32,
|
||||
@@ -42,30 +101,62 @@ pub async fn start_capture(
|
||||
|
||||
// Spawn capture on a dedicated thread
|
||||
thread::spawn(move || {
|
||||
// Try to set PULSE_SOURCE to a monitor before initializing cpal.
|
||||
// This tells PulseAudio/PipeWire's ALSA plugin to use the monitor
|
||||
// as the default input source for this process.
|
||||
let monitor_source = find_monitor_source_via_pactl();
|
||||
if let Some(ref source_name) = monitor_source {
|
||||
eprintln!(
|
||||
"Linux audio capture: Setting PULSE_SOURCE={}",
|
||||
source_name
|
||||
);
|
||||
std::env::set_var("PULSE_SOURCE", source_name);
|
||||
}
|
||||
|
||||
let host = cpal::default_host();
|
||||
|
||||
// Try to find a monitor device for system audio capture.
|
||||
// On PulseAudio/PipeWire, monitor sources have "monitor" in their name.
|
||||
let device = {
|
||||
// Select the capture device.
|
||||
// If PULSE_SOURCE was set, the default input device IS the monitor.
|
||||
// Otherwise, fall back to searching device names for "monitor".
|
||||
let device = if monitor_source.is_some() {
|
||||
// PULSE_SOURCE was set — default input IS the monitor now
|
||||
match host.default_input_device() {
|
||||
Some(d) => {
|
||||
let name = d.name().unwrap_or_default();
|
||||
eprintln!(
|
||||
"Linux audio capture: Using PULSE_SOURCE monitor device: {}",
|
||||
name
|
||||
);
|
||||
d
|
||||
}
|
||||
None => {
|
||||
let error_msg = "No audio input device available".to_string();
|
||||
eprintln!("{}", error_msg);
|
||||
*error_arc.lock().unwrap() = Some(error_msg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// pactl not available — try to find monitor by name (original approach)
|
||||
let mut monitor_device = None;
|
||||
|
||||
if let Ok(devices) = host.input_devices() {
|
||||
for d in devices {
|
||||
if let Ok(name) = d.name() {
|
||||
let name_lower = name.to_lowercase();
|
||||
if name_lower.contains("monitor") {
|
||||
eprintln!("Linux audio capture: Found monitor device: {}", name);
|
||||
eprintln!(
|
||||
"Linux audio capture: Found monitor device by name: {}",
|
||||
name
|
||||
);
|
||||
monitor_device = Some(d);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match monitor_device {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
// Fallback to default input device (microphone)
|
||||
eprintln!("Linux audio capture: No monitor device found, falling back to default input");
|
||||
match host.default_input_device() {
|
||||
Some(d) => d,
|
||||
@@ -266,7 +357,11 @@ pub async fn stop_capture(state: &AudioCaptureState) -> Result<String, String> {
|
||||
}
|
||||
|
||||
pub fn is_supported() -> bool {
|
||||
// Check if we can find a monitor device for system audio capture
|
||||
// Check via pactl first (most reliable on modern Linux)
|
||||
if find_monitor_source_via_pactl().is_some() {
|
||||
return true;
|
||||
}
|
||||
// Fallback: check cpal devices
|
||||
let host = cpal::default_host();
|
||||
if let Ok(devices) = host.input_devices() {
|
||||
for d in devices {
|
||||
@@ -277,7 +372,6 @@ pub fn is_supported() -> bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Even without a monitor, basic input capture is available
|
||||
host.default_input_device().is_some()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Voicebox",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"identifier": "sh.voicebox.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@voicebox/web",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -11,10 +11,6 @@ class WebUpdater implements PlatformUpdater {
|
||||
|
||||
private subscribers: Set<(status: UpdateStatus) => void> = new Set();
|
||||
|
||||
private notifySubscribers() {
|
||||
this.subscribers.forEach((callback) => callback(this.status));
|
||||
}
|
||||
|
||||
subscribe(callback: (status: UpdateStatus) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
callback(this.status);
|
||||
|
||||
+3
-2
@@ -18,8 +18,9 @@
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["../app/src/*"]
|
||||
}
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"include": ["src", "../app/src/global.d.ts"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user