Compare commits

...
Author SHA1 Message Date
James Pine 53f640a027 merge: resolve conflicts with latest main 2026-03-13 02:41:28 -07:00
Jamie PineandGitHub 3e6513c0fb Merge pull request #257 from jamiepine/feat/chatterbox
feat: Chatterbox TTS engine with multilingual voice cloning
2026-03-13 02:12:56 -07:00
James Pine c54ee14173 fix: model loaded icon uses accent-colored CircleCheck, show size for loaded models, fix generate box overlapping player on stories route 2026-03-13 02:09:32 -07:00
James Pine cc07d4d3c9 fix: download progress tracking for all engines and inline progress UI
- Add HFProgressTracker to LuxTTS and Chatterbox backends so tqdm-based
  file-level download progress reaches the frontend (previously only Qwen
  had this, LuxTTS/Chatterbox showed a static spinner)
- Add progress/current/total/filename fields to ActiveDownloadTask so the
  /tasks/active polling endpoint carries progress data
- Show inline progress bar + bytes in the model list and detail modal,
  poll at 1s during active downloads (5s otherwise)
- Fix GpuAcceleration crash: cudaStatusLoading was referenced before
  initialization in its own useQuery declaration
2026-03-13 02:09:32 -07:00
James Pine 9beb9d7fec fix: install chatterbox-tts with --no-deps to avoid numpy pin conflict
chatterbox-tts 0.1.6 pins numpy<1.26 and torch==2.6 which are
incompatible with Python 3.12+. Install with --no-deps and list
its sub-dependencies explicitly in requirements.txt.

Also removes HFProgressTracker from chatterbox backend to avoid
'generator didn't stop after throw()' errors from tqdm patching.
2026-03-13 02:09:32 -07:00
James Pine 76bb207b2b feat: add Chatterbox TTS engine for multilingual voice cloning
- New ChatterboxTTSBackend wrapping ChatterboxMultilingualTTS (ResembleAI/chatterbox)
- Supports 23 languages including Hebrew, forces CPU on macOS (MPS issue)
- Monkey-patches torch.load for CPU loading, forces eager attention for compatibility
- trim_tts_output utility cuts trailing silence/hallucination from Chatterbox output
- Full engine integration: /generate, /generate/stream, model status/download/delete
- Hebrew (he) added to supported languages in frontend and backend validation
- Single flat model dropdown extended with Chatterbox option in both generation UIs
- ModelManagement UI groups LuxTTS and Chatterbox under 'Other Voice Models' section
2026-03-13 02:09:32 -07:00
Jamie PineandGitHub 3576521d62 Merge pull request #254 from jamiepine/feat/luxtts
feat: LuxTTS integration — multi-engine TTS support
2026-03-13 02:04:46 -07:00
Jamie PineandGitHub 2df4ece388 Merge pull request #210 from ieguiguren/fix/linux-nvidia-gbm-buffer
fix: Linux NVIDIA GBM buffer crash + WebKitGTK microphone access
2026-03-13 01:55:58 -07:00
Jamie PineandGitHub cbb4979ed6 Merge pull request #175 from Vaibhavee89/fix/profile-duplicate-name-validation
Fix #134: Add validation for duplicate profile names
2026-03-13 01:55:30 -07:00
James Pine 753158c1c9 fix: address review feedback — race condition, GPU safety, task GC
- Add threading lock to get_tts_backend_for_engine() to prevent race
  condition where concurrent requests could create duplicate backend
  instances (double-checked locking pattern)
- Fix LuxTTS generate: call .detach().cpu() before .numpy() so it
  works on GPU/MPS devices, not just CPU
- Store background download tasks in a module-level set to prevent
  garbage collection before completion (asyncio.create_task fire-and-
  forget pattern)
- Deduplicate cache_key computation in LuxTTS create_voice_prompt
- Prefix unused sr variable with underscore
2026-03-13 01:54:09 -07:00
Jamie PineandGitHub 573f82a7e6 Merge pull request #250 from pandego/fix/docs-align-local-port-17493
docs: align local API port examples with current dev flow
2026-03-13 01:53:28 -07:00
James Pine 1e5afc2bef fix: LuxTTS generation and preserve model selection after generate
- Fix silent Zod validation failure when LuxTTS selected (modelSize was
  set to 'default' which failed enum validation, preventing form submit)
- Preserve engine, model size, and language after successful generation
  instead of resetting to defaults
2026-03-13 00:21:43 -07:00
James Pine 163528bf69 fix: single flat model dropdown, linacodec dep, quiet sidecar script
- Combine engine + model size into one flat dropdown (Qwen3-TTS 1.7B,
  Qwen3-TTS 0.6B, LuxTTS) in both FloatingGenerateBox and GenerationForm
- Add linacodec git dep to requirements.txt (uv-only source, pip can't
  resolve it from Zipvoice's pyproject.toml)
- Remove redundant transitive deps from requirements.txt
- Quiet the sidecar setup script (was printing misleading instructions)
2026-03-13 00:21:43 -07:00
James Pine e1ad7a6e73 fix: add piper-phonemize find-links for LuxTTS install
piper-phonemize has no PyPI wheels — needs custom find-links URL
from k2-fsa.github.io. Removed redundant transitive deps that
Zipvoice already declares.
2026-03-13 00:21:43 -07:00
James Pine 411e91bb19 docs: add just commands to README dev quick start 2026-03-13 00:21:43 -07:00
James Pine 05cf163744 chore: add justfile for streamlined dev setup and workflow
Adds 'just' as the recommended dev tool: 'just setup' for one-time
install, 'just dev' to run backend + frontend in one terminal.
Updates CONTRIBUTING.md to document just as the primary setup method.
2026-03-13 00:21:43 -07:00
James Pine d46eb5bcc6 feat: add LuxTTS as second TTS engine with multi-engine support
Introduce LuxTTS (ZipVoice) alongside Qwen TTS, enabling users to choose
between engines at generation time. LuxTTS offers fast, English-focused
voice cloning at 48kHz with ~1GB VRAM.

Backend:
- Add LuxTTSBackend with encode_prompt/generate_speech integration
- Multi-engine registry (get_tts_backend_for_engine) replacing singleton
- Engine-prefixed voice prompt cache keys to avoid collisions
- Engine field on GenerationRequest (default 'qwen' for backward compat)
- Engine dispatch in /generate and /generate/stream endpoints
- LuxTTS in model status, download, and delete maps

Frontend:
- TTS Engine selector dropdown in GenerationForm (Qwen TTS / LuxTTS)
- Conditionally hide Model Size and Delivery Instructions for LuxTTS
- Engine field added to TypeScript types and Zod schema
- LuxTTS section in Model Management page
2026-03-13 00:21:43 -07:00
Jamie PineandGitHub 6359dee406 Merge pull request #252 from jamiepine/feat/cuda-backend-swap
feat: CUDA backend swap via binary download and restart
2026-03-13 00:20:40 -07:00
pandego 3d2506767d docs: address review nits for API generator 2026-03-13 05:08:25 +01:00
pandego cdef2163c1 docs: align local API port examples with current dev flow 2026-03-12 12:17:50 +01:00
IvanandClaude Opus 4.6 30ee07c2e3 fix: scope DMABUF workaround to Linux+NVIDIA, add origin validation
Address CodeRabbit review feedback:
- Makefile: only set WEBKIT_DISABLE_DMABUF_RENDERER=1 when running on
  Linux with an NVIDIA GPU detected via lspci
- main.rs: validate webview origin before auto-granting microphone
  permission — only allow for trusted local origins (tauri://, localhost,
  127.0.0.1)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-27 07:01:32 +01:00
IvanandClaude Opus 4.6 d21c63b52c fix: enable microphone access on Linux via WebKitGTK
WebKitGTK denies getUserMedia by default. This adds webkit2gtk as a
Linux dependency and configures the webview to enable media streams
and auto-grant UserMediaPermissionRequest for microphone access.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-27 06:49:08 +01:00
IvanandClaude Opus 4.6 5ad67d7ecb fix: disable DMABUF renderer for NVIDIA GPUs on Linux
WebKitGTK fails to create GBM buffers with NVIDIA proprietary drivers,
resulting in an empty/blank Tauri window. Set WEBKIT_DISABLE_DMABUF_RENDERER=1
in the dev target to work around this.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-27 06:32:02 +01:00
Vaibhavee Singh 6cc96c2614 Fix #134: Add validation for duplicate profile names
- Add validation in create_profile() to check for existing names before insert
- Add validation in update_profile() to prevent renaming to duplicate names
- Improve error handling in API endpoints with user-friendly messages
- Add comprehensive test suite for duplicate name validation
- Update CHANGELOG.md with fix details

This fix prevents database constraint violations and provides clear
error messages when users attempt to create or update profiles with
names that already exist in the database.
2026-02-24 10:17:39 +05:30
Omer Celik 831a50cf61 Prepare feature branch for PR 2026-02-22 21:29:55 +00:00
39 changed files with 4234 additions and 567 deletions
+18
View File
@@ -5,6 +5,14 @@ All notable changes to Voicebox will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Fixed
- **Profile Name Validation** - Added proper validation to prevent duplicate profile names ([#134](https://github.com/jamiepine/voicebox/issues/134))
- Users now receive clear error messages when attempting to create or update profiles with duplicate names
- Improved error handling in create and update profile API endpoints
- Added comprehensive test suite for duplicate name validation
## [0.1.0] - 2026-01-25
### Added
@@ -55,6 +63,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Audio export failing when Tauri save dialog returns object instead of string path
- OpenAPI client generator script now documents the local backend port and avoids an unused loop variable warning
### Added
- **Makefile** - Comprehensive development workflow automation with commands for setup, development, building, testing, and code quality checks
@@ -62,9 +71,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Self-documenting help system with `make help`
- Colored output for better readability
- Supports parallel development server execution
- **Audiobook Tab** - New long-form narration workflow in the app
- Import/paste `.txt` book content and review/edit before generation
- Generate a quick 5-sentence preview before full run
- Chunk long text automatically and process chunk-by-chunk with retry support
- Auto-create and update a Story during generation, with export shortcut
- **Text chunking utility** - Added reusable sentence-aware chunking for large text inputs (`app/src/lib/utils/textChunking.ts`)
### Changed
- **README** - Added Makefile reference and updated Quick Start with Makefile-based setup instructions alongside manual setup
- **Navigation** - Added Audiobook route/tab to the app sidebar
- **Generation API types** - Added optional `instruct` field to `GenerationRequest`
- **App styling** - Added `scrollbar-visible` utility styles for long-scroll panels/editors
---
+21 -2
View File
@@ -32,7 +32,26 @@ Thank you for your interest in contributing to Voicebox! This document provides
### Development Setup
**Using the Makefile (recommended for macOS/Linux):** Run `make setup` to install all dependencies, then `make dev` to start development servers. See `make help` for all available commands.
**Using `just` (recommended):**
Install [just](https://github.com/casey/just) (`brew install just` or `cargo install just`), then:
```bash
just setup # creates venv, installs Python + JS deps
just dev # starts backend + desktop app in one terminal
```
Other useful commands:
```bash
just dev-web # backend + web app (no Tauri/Rust build)
just dev-backend # backend only
just kill # stop all dev processes
just clean-all # nuke everything and start fresh
just --list # see all available commands
```
**Using the Makefile:** Run `make setup` then `make dev`. See `make help` for all commands.
**Manual setup (required for Windows):**
@@ -407,7 +426,7 @@ See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues and sol
- **Backend won't start:** Check Python version (3.11+), ensure venv is activated, install dependencies
- **Tauri build fails:** Ensure Rust is installed, clean build with `cd tauri/src-tauri && cargo clean`
- **OpenAPI client generation fails:** Ensure backend is running, check `curl http://localhost:8000/openapi.json`
- **OpenAPI client generation fails:** Ensure backend is running, check `curl http://localhost:17493/openapi.json`
## Questions?
+6 -1
View File
@@ -48,6 +48,7 @@ setup-python: $(VENV)/bin/activate ## Set up Python virtual environment and depe
@echo -e "$(BLUE)Installing Python dependencies...$(NC)"
$(PIP) install --upgrade pip
$(PIP) install -r $(BACKEND_DIR)/requirements.txt
$(PIP) install --no-deps chatterbox-tts
@if [ "$$(uname -m)" = "arm64" ] && [ "$$(uname)" = "Darwin" ]; then \
echo -e "$(BLUE)Detected Apple Silicon - installing MLX dependencies...$(NC)"; \
$(PIP) install -r $(BACKEND_DIR)/requirements-mlx.txt; \
@@ -79,7 +80,11 @@ dev: ## Start backend + desktop app (parallel)
@echo -e "$(YELLOW)Note: If Tauri fails, run 'make build-server' first or use separate terminals$(NC)"
@trap 'kill 0' EXIT; \
$(MAKE) dev-backend & \
sleep 2 && $(MAKE) dev-frontend & \
sleep 2 && if [ "$$(uname)" = "Linux" ] && lspci 2>/dev/null | grep -qi nvidia; then \
WEBKIT_DISABLE_DMABUF_RENDERER=1 $(MAKE) dev-frontend; \
else \
$(MAKE) dev-frontend; \
fi & \
wait
dev-backend: ## Start FastAPI backend server
+12 -30
View File
@@ -147,17 +147,20 @@ Create multi-voice narratives, podcasts, and conversations with a timeline-based
Voicebox exposes a full REST API, so you can integrate voice synthesis into your own apps.
For the current local app and development workflow, the backend is typically available at `http://localhost:17493`.
If you launch the backend manually with a different host or port, use that address instead.
```bash
# Generate speech
curl -X POST http://localhost:8000/generate \
curl -X POST http://localhost:17493/generate \
-H "Content-Type: application/json" \
-d '{"text": "Hello world", "profile_id": "abc123", "language": "en"}'
# List voice profiles
curl http://localhost:8000/profiles
curl http://localhost:17493/profiles
# Create a profile
curl -X POST http://localhost:8000/profiles \
curl -X POST http://localhost:17493/profiles \
-H "Content-Type: application/json" \
-d '{"name": "My Voice", "language": "en"}'
```
@@ -170,7 +173,7 @@ curl -X POST http://localhost:8000/profiles \
- Voice assistants
- Content creation automation
Full API documentation available at `http://localhost:8000/docs` when running.
Full API documentation is available at `http://localhost:17493/docs` in the default local workflow, or at `/docs` on whatever server address you configured.
---
@@ -225,42 +228,21 @@ Voicebox aims to be the **one-stop shop for everything voice** — cloning, synt
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed setup and contribution guidelines.
**Using the Makefile (recommended):** Run `make help` to see all available commands for setup, development, building, and testing.
### Quick Start
**With Makefile (Unix/macOS/Linux):**
```bash
# Clone the repo
git clone https://github.com/jamiepine/voicebox.git
cd voicebox
# Setup everything
make setup
# Start development
make dev
just setup # creates Python venv, installs all deps
just dev # starts backend + desktop app
```
**Manual setup (all platforms):**
Install [just](https://github.com/casey/just): `brew install just` or `cargo install just`. Run `just --list` to see all commands.
```bash
# Clone the repo
git clone https://github.com/jamiepine/voicebox.git
cd voicebox
Also available via Makefile: `make setup && make dev` (run `make help` for all commands).
# Install dependencies
bun install
# Install Python dependencies
cd backend && pip install -r requirements.txt && cd ..
# Start development
bun run dev
```
**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org). [XCode on macOS](https://developer.apple.com/xcode/).
**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org), [XCode on macOS](https://developer.apple.com/xcode/).
**Performance:**
- **Apple Silicon (M1/M2/M3)**: Uses MLX backend with native Metal acceleration for 4-5x faster inference
File diff suppressed because it is too large Load Diff
@@ -316,7 +316,7 @@ export function FloatingGenerateBox({
</span>
</div>
<AnimatePresence>
{isExpanded && (
{isExpanded && form.watch('engine') === 'qwen' && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
@@ -402,30 +402,48 @@ export function FloatingGenerateBox({
)}
/>
<FormField
control={form.control}
name="modelSize"
render={({ field }) => (
<FormItem className="flex-1 space-y-0">
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="1.7B" className="text-xs text-muted-foreground">
Qwen3-TTS 1.7B
</SelectItem>
<SelectItem value="0.6B" className="text-xs text-muted-foreground">
Qwen3-TTS 0.6B
</SelectItem>
</SelectContent>
</Select>
<FormMessage className="text-xs" />
</FormItem>
)}
/>
<FormItem className="flex-1 space-y-0">
<Select
value={
form.watch('engine') === 'luxtts'
? 'luxtts'
: form.watch('engine') === 'chatterbox'
? 'chatterbox'
: `qwen:${form.watch('modelSize') || '1.7B'}`
}
onValueChange={(value) => {
if (value === 'luxtts') {
form.setValue('engine', 'luxtts');
} else if (value === 'chatterbox') {
form.setValue('engine', 'chatterbox');
} else {
const [, modelSize] = value.split(':');
form.setValue('engine', 'qwen');
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
}
}}
>
<FormControl>
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="qwen:1.7B" className="text-xs text-muted-foreground">
Qwen3-TTS 1.7B
</SelectItem>
<SelectItem value="qwen:0.6B" className="text-xs text-muted-foreground">
Qwen3-TTS 0.6B
</SelectItem>
<SelectItem value="luxtts" className="text-xs text-muted-foreground">
LuxTTS
</SelectItem>
<SelectItem value="chatterbox" className="text-xs text-muted-foreground">
Chatterbox
</SelectItem>
</SelectContent>
</Select>
</FormItem>
</div>
</motion.div>
</AnimatePresence>
@@ -76,29 +76,74 @@ export function GenerationForm() {
)}
/>
<FormField
control={form.control}
name="instruct"
render={({ field }) => (
<FormItem>
<FormLabel>Delivery Instructions (optional)</FormLabel>
<FormControl>
<Textarea
placeholder="e.g. Speak slowly with emphasis, Warm and friendly tone, Professional and authoritative..."
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormDescription>
Natural language instructions to control speech delivery (tone, emotion, pace).
Max 500 characters
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch('engine') === 'qwen' && (
<FormField
control={form.control}
name="instruct"
render={({ field }) => (
<FormItem>
<FormLabel>Delivery Instructions (optional)</FormLabel>
<FormControl>
<Textarea
placeholder="e.g. Speak slowly with emphasis, Warm and friendly tone, Professional and authoritative..."
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormDescription>
Natural language instructions to control speech delivery (tone, emotion,
pace). Max 500 characters
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="grid gap-4 md:grid-cols-3">
<FormItem>
<FormLabel>Model</FormLabel>
<Select
value={
form.watch('engine') === 'luxtts'
? 'luxtts'
: form.watch('engine') === 'chatterbox'
? 'chatterbox'
: `qwen:${form.watch('modelSize') || '1.7B'}`
}
onValueChange={(value) => {
if (value === 'luxtts') {
form.setValue('engine', 'luxtts');
} else if (value === 'chatterbox') {
form.setValue('engine', 'chatterbox');
} else {
const [, modelSize] = value.split(':');
form.setValue('engine', 'qwen');
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
}
}}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="qwen:1.7B">Qwen3-TTS 1.7B</SelectItem>
<SelectItem value="qwen:0.6B">Qwen3-TTS 0.6B</SelectItem>
<SelectItem value="luxtts">LuxTTS</SelectItem>
<SelectItem value="chatterbox">Chatterbox</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{form.watch('engine') === 'luxtts'
? 'Fast, English-focused'
: form.watch('engine') === 'chatterbox'
? 'Multilingual, incl. Hebrew'
: 'Multi-language, two sizes'}
</FormDescription>
</FormItem>
<FormField
control={form.control}
name="language"
@@ -124,29 +169,6 @@ export function GenerationForm() {
)}
/>
<FormField
control={form.control}
name="modelSize"
render={({ field }) => (
<FormItem>
<FormLabel>Model Size</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="1.7B">Qwen TTS 1.7B (Higher Quality)</SelectItem>
<SelectItem value="0.6B">Qwen TTS 0.6B (Faster)</SelectItem>
</SelectContent>
</Select>
<FormDescription>Larger models produce better quality</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="seed"
@@ -170,11 +192,7 @@ export function GenerationForm() {
/>
</div>
<Button
type="submit"
className="w-full"
disabled={isPending || !selectedProfileId}
>
<Button type="submit" className="w-full" disabled={isPending || !selectedProfileId}>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
+1 -1
View File
@@ -2,7 +2,7 @@ import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
export function ModelsTab() {
return (
<div className="space-y-4 overflow-y-auto flex flex-col">
<div className="h-full flex flex-col p-4">
<ModelManagement />
</div>
);
@@ -32,7 +32,7 @@ export function GpuAcceleration() {
} = useQuery({
queryKey: ['cuda-status', serverUrl],
queryFn: () => apiClient.getCudaStatus(),
refetchInterval: cudaStatusLoading ? false : 10000,
refetchInterval: (query) => (query.state.status === 'pending' ? false : 10000),
retry: 1,
enabled: !!health, // Only fetch when backend is reachable
});
@@ -1,6 +1,21 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ChevronDown, ChevronUp, Download, Loader2, RotateCcw, Trash2, X } from 'lucide-react';
import { useCallback, useState } from 'react';
import {
ChevronDown,
ChevronRight,
ChevronUp,
CircleCheck,
CircleX,
Download,
ExternalLink,
HardDrive,
Heart,
Loader2,
RotateCcw,
Scale,
Trash2,
X,
} from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import {
AlertDialog,
AlertDialogAction,
@@ -13,12 +28,59 @@ import {
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Progress } from '@/components/ui/progress';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type { ActiveDownloadTask } from '@/lib/api/types';
import type { ActiveDownloadTask, HuggingFaceModelInfo, ModelStatus } from '@/lib/api/types';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
async function fetchHuggingFaceModelInfo(repoId: string): Promise<HuggingFaceModelInfo> {
const response = await fetch(`https://huggingface.co/api/models/${repoId}`);
if (!response.ok) throw new Error(`Failed to fetch model info: ${response.status}`);
return response.json();
}
function formatDownloads(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return n.toString();
}
function formatLicense(license: string): string {
const map: Record<string, string> = {
'apache-2.0': 'Apache 2.0',
mit: 'MIT',
'cc-by-4.0': 'CC BY 4.0',
'cc-by-sa-4.0': 'CC BY-SA 4.0',
'cc-by-nc-4.0': 'CC BY-NC 4.0',
'openrail++': 'OpenRAIL++',
openrail: 'OpenRAIL',
};
return map[license] || license;
}
function formatPipelineTag(tag: string): string {
return tag
.split('-')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
}
export function ModelManagement() {
const { toast } = useToast();
const queryClient = useQueryClient();
@@ -28,36 +90,48 @@ export function ModelManagement() {
const [dismissedErrors, setDismissedErrors] = useState<Set<string>>(new Set());
const [localErrors, setLocalErrors] = useState<Map<string, string>>(new Map());
// Modal state
const [selectedModel, setSelectedModel] = useState<ModelStatus | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const { data: modelStatus, isLoading } = useQuery({
queryKey: ['modelStatus'],
queryFn: async () => {
console.log('[Query] Fetching model status');
const result = await apiClient.getModelStatus();
console.log('[Query] Model status fetched:', result);
return result;
},
refetchInterval: 5000, // Refresh every 5 seconds
refetchInterval: 5000,
});
const { data: activeTasks } = useQuery({
queryKey: ['activeTasks'],
queryFn: () => apiClient.getActiveTasks(),
refetchInterval: 5000,
refetchInterval: (query) => {
const data = query.state.data;
const hasActive = data?.downloads.some((d) => d.status === 'downloading');
return hasActive ? 1000 : 5000;
},
});
// HuggingFace model card query - only fetches when modal is open and model has a repo ID
const { data: hfModelInfo, isLoading: hfLoading } = useQuery({
queryKey: ['hfModelInfo', selectedModel?.hf_repo_id],
queryFn: () => fetchHuggingFaceModelInfo(selectedModel!.hf_repo_id!),
enabled: detailOpen && !!selectedModel?.hf_repo_id,
staleTime: 1000 * 60 * 30, // Cache for 30 minutes
retry: 1,
});
// Build a map of errored downloads for quick lookup, excluding dismissed ones
// Merge server errors with locally captured SSE errors
const erroredDownloads = new Map<string, ActiveDownloadTask>();
if (activeTasks?.downloads) {
for (const dl of activeTasks.downloads) {
if (dl.status === 'error' && !dismissedErrors.has(dl.model_name)) {
// Prefer locally captured error (from SSE) over server error
const localErr = localErrors.get(dl.model_name);
erroredDownloads.set(dl.model_name, localErr ? { ...dl, error: localErr } : dl);
}
}
}
// Also add locally captured errors that aren't in server response yet
for (const [modelName, error] of localErrors) {
if (!erroredDownloads.has(modelName) && !dismissedErrors.has(modelName)) {
erroredDownloads.set(modelName, {
@@ -71,27 +145,39 @@ export function ModelManagement() {
const errorCount = erroredDownloads.size;
// Callbacks for download completion
// Build progress map from active tasks for inline display
const downloadProgressMap = useMemo(() => {
const map = new Map<string, ActiveDownloadTask>();
if (activeTasks?.downloads) {
for (const dl of activeTasks.downloads) {
if (dl.status === 'downloading') {
map.set(dl.model_name, dl);
}
}
}
return map;
}, [activeTasks]);
const handleDownloadComplete = useCallback(() => {
console.log('[ModelManagement] Download complete, clearing state');
setDownloadingModel(null);
setDownloadingDisplayName(null);
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
queryClient.invalidateQueries({ queryKey: ['activeTasks'] });
}, [queryClient]);
const handleDownloadError = useCallback((error: string) => {
console.log('[ModelManagement] Download error, clearing state');
if (downloadingModel) {
setLocalErrors((prev) => new Map(prev).set(downloadingModel, error));
setConsoleOpen(true);
}
setDownloadingModel(null);
setDownloadingDisplayName(null);
queryClient.invalidateQueries({ queryKey: ['activeTasks'] });
}, [queryClient, downloadingModel]);
const handleDownloadError = useCallback(
(error: string) => {
if (downloadingModel) {
setLocalErrors((prev) => new Map(prev).set(downloadingModel, error));
setConsoleOpen(true);
}
setDownloadingModel(null);
setDownloadingDisplayName(null);
queryClient.invalidateQueries({ queryKey: ['activeTasks'] });
},
[queryClient, downloadingModel],
);
// Use progress toast hook for the downloading model
useModelDownloadToast({
modelName: downloadingModel || '',
displayName: downloadingDisplayName || '',
@@ -108,36 +194,24 @@ export function ModelManagement() {
} | null>(null);
const handleDownload = async (modelName: string) => {
console.log('[Download] Button clicked for:', modelName, 'at', new Date().toISOString());
// Clear any previous dismissal so fresh errors can appear
setDismissedErrors((prev) => {
const next = new Set(prev);
next.delete(modelName);
return next;
});
// Find display name
const model = modelStatus?.models.find((m) => m.model_name === modelName);
const displayName = model?.display_name || modelName;
try {
// IMPORTANT: Call the API FIRST before setting state
// Setting state enables the SSE EventSource in useModelDownloadToast,
// which can block/delay the download fetch due to HTTP/1.1 connection limits
console.log('[Download] Calling download API for:', modelName);
const result = await apiClient.triggerModelDownload(modelName);
console.log('[Download] Download API responded:', result);
await apiClient.triggerModelDownload(modelName);
// NOW set state to enable SSE tracking (after download has started on backend)
setDownloadingModel(modelName);
setDownloadingDisplayName(displayName);
// Download initiated successfully - state will be cleared when SSE reports completion
// or by the polling interval detecting the model is downloaded
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
queryClient.invalidateQueries({ queryKey: ['activeTasks'] });
} catch (error) {
console.error('[Download] Download failed:', error);
setDownloadingModel(null);
setDownloadingDisplayName(null);
toast({
@@ -157,15 +231,17 @@ export function ModelManagement() {
});
const handleCancel = (modelName: string) => {
// Snapshot previous state for rollback
const prevDismissed = dismissedErrors;
const prevLocalErrors = localErrors;
const prevDownloadingModel = downloadingModel;
const prevDownloadingDisplayName = downloadingDisplayName;
// Optimistically hide the error and suppress downloading state in UI
setDismissedErrors((prev) => new Set(prev).add(modelName));
setLocalErrors((prev) => { const next = new Map(prev); next.delete(modelName); return next; });
setLocalErrors((prev) => {
const next = new Map(prev);
next.delete(modelName);
return next;
});
if (downloadingModel === modelName) {
setDownloadingModel(null);
setDownloadingDisplayName(null);
@@ -173,12 +249,15 @@ export function ModelManagement() {
cancelMutation.mutate(modelName, {
onError: () => {
// Rollback optimistic updates on failure
setDismissedErrors(prevDismissed);
setLocalErrors(prevLocalErrors);
setDownloadingModel(prevDownloadingModel);
setDownloadingDisplayName(prevDownloadingDisplayName);
toast({ title: 'Cancel failed', description: 'Could not cancel the download task.', variant: 'destructive' });
toast({
title: 'Cancel failed',
description: 'Could not cancel the download task.',
variant: 'destructive',
});
},
});
};
@@ -197,30 +276,22 @@ export function ModelManagement() {
const deleteMutation = useMutation({
mutationFn: async (modelName: string) => {
console.log('[Delete] Deleting model:', modelName);
const result = await apiClient.deleteModel(modelName);
console.log('[Delete] Model deleted successfully:', modelName);
return result;
},
onSuccess: async (_data, _modelName) => {
console.log('[Delete] onSuccess - showing toast and invalidating queries');
onSuccess: async () => {
toast({
title: 'Model deleted',
description: `${modelToDelete?.displayName || 'Model'} has been deleted successfully.`,
});
setDeleteDialogOpen(false);
setModelToDelete(null);
console.log('[Delete] Invalidating modelStatus query');
await queryClient.invalidateQueries({
queryKey: ['modelStatus'],
refetchType: 'all',
});
console.log('[Delete] Explicitly refetching modelStatus query');
setDetailOpen(false);
setSelectedModel(null);
await queryClient.invalidateQueries({ queryKey: ['modelStatus'], refetchType: 'all' });
await queryClient.refetchQueries({ queryKey: ['modelStatus'] });
console.log('[Delete] Query refetched');
},
onError: (error: Error) => {
console.log('[Delete] onError:', error);
toast({
title: 'Delete failed',
description: error.message,
@@ -230,149 +301,438 @@ export function ModelManagement() {
});
const formatSize = (sizeMb?: number): string => {
if (!sizeMb) return 'Unknown';
if (!sizeMb) return 'Unknown size';
if (sizeMb < 1024) return `${sizeMb.toFixed(1)} MB`;
return `${(sizeMb / 1024).toFixed(2)} GB`;
};
const getModelState = (model: ModelStatus) => {
const isDownloading =
(model.downloading || downloadingModel === model.model_name) &&
!erroredDownloads.has(model.model_name) &&
!dismissedErrors.has(model.model_name);
const hasError = erroredDownloads.has(model.model_name);
return { isDownloading, hasError };
};
const openModelDetail = (model: ModelStatus) => {
setSelectedModel(model);
setDetailOpen(true);
};
const ttsModels = modelStatus?.models.filter((m) => m.model_name.startsWith('qwen-tts')) ?? [];
const otherTtsModels =
modelStatus?.models.filter(
(m) => m.model_name.startsWith('luxtts') || m.model_name.startsWith('chatterbox'),
) ?? [];
const whisperModels = modelStatus?.models.filter((m) => m.model_name.startsWith('whisper')) ?? [];
// Build sections
const sections: { label: string; models: ModelStatus[] }[] = [
{ label: 'Voice Generation', models: ttsModels },
...(otherTtsModels.length > 0 ? [{ label: 'Other Voice Models', models: otherTtsModels }] : []),
{ label: 'Transcription', models: whisperModels },
];
// Get detail modal state for selected model
const selectedState = selectedModel ? getModelState(selectedModel) : null;
const selectedError = selectedModel ? erroredDownloads.get(selectedModel.model_name) : undefined;
// Keep selectedModel data fresh from query results
const freshSelectedModel =
selectedModel && modelStatus
? modelStatus.models.find((m) => m.model_name === selectedModel.model_name) || selectedModel
: selectedModel;
// Derive license from HF data
const license =
hfModelInfo?.cardData?.license ||
hfModelInfo?.tags?.find((t) => t.startsWith('license:'))?.replace('license:', '');
return (
<Card>
<CardHeader>
<CardTitle>Model Management</CardTitle>
<CardDescription>
<div className="flex flex-col h-full">
{/* Header */}
<div className="shrink-0 pb-4">
<h1 className="text-lg font-semibold">Models</h1>
<p className="text-sm text-muted-foreground">
Download and manage AI models for voice generation and transcription
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : modelStatus ? (
<div className="space-y-4">
{/* TTS Models */}
<div>
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">
Voice Generation Models
</h3>
<div className="space-y-2">
{modelStatus.models
.filter((m) => m.model_name.startsWith('qwen-tts'))
.map((model) => (
<ModelItem
key={model.model_name}
model={model}
onDownload={() => handleDownload(model.model_name)}
onDelete={() => {
setModelToDelete({
name: model.model_name,
displayName: model.display_name,
sizeMb: model.size_mb,
});
setDeleteDialogOpen(true);
}}
onCancel={() => handleCancel(model.model_name)}
isDownloading={downloadingModel === model.model_name}
isCancelling={cancelMutation.isPending && cancelMutation.variables === model.model_name}
isDismissed={dismissedErrors.has(model.model_name)}
erroredDownload={erroredDownloads.get(model.model_name)}
formatSize={formatSize}
/>
))}
</div>
</div>
</p>
</div>
{/* Whisper Models */}
<div>
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">
Transcription Models
</h3>
<div className="space-y-2">
{modelStatus.models
.filter((m) => m.model_name.startsWith('whisper'))
.map((model) => (
<ModelItem
{/* Model list */}
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : modelStatus ? (
<div className="flex-1 min-h-0 overflow-y-auto space-y-6">
{sections.map((section) => (
<div key={section.label}>
<h2 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">
{section.label}
</h2>
<div className="border rounded-lg divide-y overflow-hidden">
{section.models.map((model) => {
const { isDownloading, hasError } = getModelState(model);
return (
<button
key={model.model_name}
model={model}
onDownload={() => handleDownload(model.model_name)}
onDelete={() => {
setModelToDelete({
name: model.model_name,
displayName: model.display_name,
sizeMb: model.size_mb,
});
setDeleteDialogOpen(true);
}}
onCancel={() => handleCancel(model.model_name)}
isDownloading={downloadingModel === model.model_name}
isCancelling={cancelMutation.isPending && cancelMutation.variables === model.model_name}
isDismissed={dismissedErrors.has(model.model_name)}
erroredDownload={erroredDownloads.get(model.model_name)}
formatSize={formatSize}
/>
))}
</div>
</div>
{/* Console Panel */}
{errorCount > 0 && (
<div className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-1.5 bg-muted/50 text-xs font-medium text-muted-foreground">
<button
type="button"
onClick={() => setConsoleOpen((v) => !v)}
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
{consoleOpen ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
<span>Problems</span>
<Badge variant="destructive" className="text-[10px] h-4 px-1.5 rounded-full">
{errorCount}
</Badge>
</button>
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => clearAllMutation.mutate()}
disabled={clearAllMutation.isPending}
>
<RotateCcw className="h-3 w-3 mr-1" />
Clear All
</Button>
</div>
{consoleOpen && (
<div className="bg-[#1e1e1e] text-[#d4d4d4] p-3 max-h-48 overflow-auto font-mono text-xs leading-relaxed">
{Array.from(erroredDownloads.entries()).map(([modelName, dl]) => (
<div key={modelName} className="mb-2 last:mb-0">
<span className="text-[#f44747]">[error]</span>{' '}
<span className="text-[#569cd6]">{modelName}</span>
{dl.error ? (
<>
{': '}
<span className="text-[#ce9178] whitespace-pre-wrap break-all">{dl.error}</span>
</>
type="button"
onClick={() => openModelDetail(model)}
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-muted/50 transition-colors group"
>
{/* Status indicator */}
<div className="shrink-0">
{hasError ? (
<CircleX className="h-4 w-4 text-destructive" />
) : isDownloading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : model.loaded ? (
<CircleCheck className="h-4 w-4 text-accent" />
) : model.downloaded ? (
<CircleCheck className="h-4 w-4 text-emerald-500" />
) : (
<>
{': '}
<span className="text-[#808080]">No error details available. Try downloading again.</span>
</>
<Download className="h-4 w-4 text-muted-foreground/50" />
)}
<div className="text-[#6a9955] mt-0.5">
started at {new Date(dl.started_at).toLocaleString()}
</div>
</div>
))}
{/* Name + inline progress */}
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">{model.display_name}</span>
{isDownloading &&
(() => {
const dl = downloadProgressMap.get(model.model_name);
const pct = dl?.progress ?? 0;
const hasProgress = dl && dl.total && dl.total > 0;
return (
<div className="mt-1 space-y-0.5">
<Progress value={hasProgress ? pct : undefined} className="h-1" />
<div className="text-[10px] text-muted-foreground truncate">
{hasProgress
? `${formatBytes(dl.current ?? 0)} / ${formatBytes(dl.total!)} (${pct.toFixed(0)}%)`
: dl?.filename || 'Connecting...'}
</div>
</div>
);
})()}
</div>
{/* Right side info */}
<div className="shrink-0 flex items-center gap-2">
{hasError && (
<Badge variant="destructive" className="text-[10px] h-5">
Error
</Badge>
)}
{model.loaded && (
<Badge className="text-[10px] h-5 bg-accent/15 text-accent border-accent/30 hover:bg-accent/15">
Loaded
</Badge>
)}
{model.downloaded && !isDownloading && !hasError && (
<span className="text-xs text-muted-foreground">
{formatSize(model.size_mb)}
</span>
)}
{!model.downloaded && !isDownloading && !hasError && (
<span className="text-xs text-muted-foreground/60">Not downloaded</span>
)}
<ChevronRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
</div>
</button>
);
})}
</div>
</div>
))}
{/* Error console */}
{errorCount > 0 && (
<div className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-1.5 bg-muted/50 text-xs font-medium text-muted-foreground">
<button
type="button"
onClick={() => setConsoleOpen((v) => !v)}
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
{consoleOpen ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
<span>Problems</span>
<Badge variant="destructive" className="text-[10px] h-4 px-1.5 rounded-full">
{errorCount}
</Badge>
</button>
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => clearAllMutation.mutate()}
disabled={clearAllMutation.isPending}
>
<RotateCcw className="h-3 w-3 mr-1" />
Clear All
</Button>
</div>
{consoleOpen && (
<div className="bg-[#1e1e1e] text-[#d4d4d4] p-3 max-h-48 overflow-auto font-mono text-xs leading-relaxed">
{Array.from(erroredDownloads.entries()).map(([modelName, dl]) => (
<div key={modelName} className="mb-2 last:mb-0">
<span className="text-[#f44747]">[error]</span>{' '}
<span className="text-[#569cd6]">{modelName}</span>
{dl.error ? (
<>
{': '}
<span className="text-[#ce9178] whitespace-pre-wrap break-all">
{dl.error}
</span>
</>
) : (
<>
{': '}
<span className="text-[#808080]">
No error details available. Try downloading again.
</span>
</>
)}
<div className="text-[#6a9955] mt-0.5">
started at {new Date(dl.started_at).toLocaleString()}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
) : null}
{/* Model Detail Modal */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="sm:max-w-md">
{freshSelectedModel && (
<>
<DialogHeader>
<DialogTitle>{freshSelectedModel.display_name}</DialogTitle>
<DialogDescription className="flex items-center gap-1.5">
{freshSelectedModel.hf_repo_id ? (
<a
href={`https://huggingface.co/${freshSelectedModel.hf_repo_id}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 hover:underline"
>
{freshSelectedModel.hf_repo_id}
<ExternalLink className="h-3 w-3" />
</a>
) : (
freshSelectedModel.model_name
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-2">
{/* Status badges */}
<div className="flex items-center gap-2 flex-wrap">
{freshSelectedModel.loaded && (
<Badge className="text-xs bg-accent/15 text-accent border-accent/30 hover:bg-accent/15">
<CircleCheck className="h-3 w-3 mr-1" />
Loaded
</Badge>
)}
{freshSelectedModel.downloaded && !freshSelectedModel.loaded && (
<Badge variant="secondary" className="text-xs">
<CircleCheck className="h-3 w-3 mr-1" />
Downloaded
</Badge>
)}
{selectedState?.hasError && (
<Badge variant="destructive" className="text-xs">
<CircleX className="h-3 w-3 mr-1" />
Error
</Badge>
)}
{!freshSelectedModel.downloaded &&
!selectedState?.isDownloading &&
!selectedState?.hasError && (
<Badge variant="outline" className="text-xs text-muted-foreground">
Not downloaded
</Badge>
)}
</div>
{/* HuggingFace model card info */}
{hfLoading && freshSelectedModel.hf_repo_id && (
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
<Loader2 className="h-3 w-3 animate-spin" />
Loading model info...
</div>
)}
{hfModelInfo && (
<div className="space-y-3">
{/* Stats row */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1" title="Downloads">
<Download className="h-3.5 w-3.5" />
{formatDownloads(hfModelInfo.downloads)}
</span>
<span className="flex items-center gap-1" title="Likes">
<Heart className="h-3.5 w-3.5" />
{formatDownloads(hfModelInfo.likes)}
</span>
{license && (
<span className="flex items-center gap-1" title="License">
<Scale className="h-3.5 w-3.5" />
{formatLicense(license)}
</span>
)}
</div>
{/* Pipeline tag + author */}
<div className="flex flex-wrap gap-1.5">
{hfModelInfo.pipeline_tag && (
<Badge variant="outline" className="text-[10px]">
{formatPipelineTag(hfModelInfo.pipeline_tag)}
</Badge>
)}
{hfModelInfo.library_name && (
<Badge variant="outline" className="text-[10px]">
{hfModelInfo.library_name}
</Badge>
)}
{hfModelInfo.author && (
<Badge variant="outline" className="text-[10px]">
by {hfModelInfo.author}
</Badge>
)}
</div>
{/* Languages */}
{hfModelInfo.cardData?.language && hfModelInfo.cardData.language.length > 0 && (
<div>
<span className="text-xs text-muted-foreground">
{hfModelInfo.cardData.language.length > 10
? `${hfModelInfo.cardData.language.length} languages supported`
: `Languages: ${hfModelInfo.cardData.language.join(', ')}`}
</span>
</div>
)}
</div>
)}
{/* Disk size */}
{freshSelectedModel.downloaded && freshSelectedModel.size_mb && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<HardDrive className="h-4 w-4" />
<span>{formatSize(freshSelectedModel.size_mb)} on disk</span>
</div>
)}
{/* Error detail */}
{selectedError?.error && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-3 text-xs text-destructive">
{selectedError.error}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-2 border-t">
{selectedState?.hasError ? (
<>
<Button
size="sm"
onClick={() => handleDownload(freshSelectedModel.model_name)}
variant="outline"
className="flex-1"
>
<Download className="h-4 w-4 mr-2" />
Retry Download
</Button>
<Button
size="sm"
onClick={() => handleCancel(freshSelectedModel.model_name)}
variant="ghost"
disabled={
cancelMutation.isPending &&
cancelMutation.variables === freshSelectedModel.model_name
}
>
<X className="h-4 w-4" />
</Button>
</>
) : selectedState?.isDownloading ? (
<>
<div className="flex-1 space-y-2">
{(() => {
const dl = freshSelectedModel
? downloadProgressMap.get(freshSelectedModel.model_name)
: undefined;
const pct = dl?.progress ?? 0;
const hasProgress = dl && dl.total && dl.total > 0;
return (
<>
<Progress value={hasProgress ? pct : undefined} className="h-2" />
<div className="text-xs text-muted-foreground">
{hasProgress
? `${formatBytes(dl.current ?? 0)} / ${formatBytes(dl.total!)} (${pct.toFixed(1)}%)`
: dl?.filename || 'Connecting to HuggingFace...'}
</div>
</>
);
})()}
</div>
<Button
size="sm"
onClick={() => handleCancel(freshSelectedModel.model_name)}
variant="ghost"
disabled={
cancelMutation.isPending &&
cancelMutation.variables === freshSelectedModel.model_name
}
>
<X className="h-4 w-4" />
</Button>
</>
) : freshSelectedModel.downloaded ? (
<Button
size="sm"
onClick={() => {
setModelToDelete({
name: freshSelectedModel.model_name,
displayName: freshSelectedModel.display_name,
sizeMb: freshSelectedModel.size_mb,
});
setDeleteDialogOpen(true);
}}
variant="outline"
disabled={freshSelectedModel.loaded}
title={
freshSelectedModel.loaded ? 'Unload model before deleting' : 'Delete model'
}
className="flex-1"
>
<Trash2 className="h-4 w-4 mr-2" />
{freshSelectedModel.loaded ? 'Unload to Delete' : 'Delete Model'}
</Button>
) : (
<Button
size="sm"
onClick={() => handleDownload(freshSelectedModel.model_name)}
className="flex-1"
>
<Download className="h-4 w-4 mr-2" />
Download
</Button>
)}
</div>
</div>
)}
</div>
) : null}
</CardContent>
</>
)}
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
@@ -413,116 +773,6 @@ export function ModelManagement() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
);
}
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;
onCancel: () => void;
isDownloading: boolean; // Local state - true if user just clicked download
isCancelling: boolean;
isDismissed: boolean;
erroredDownload?: ActiveDownloadTask;
formatSize: (sizeMb?: number) => string;
}
function ModelItem({ model, onDownload, onDelete, onCancel, isDownloading, isCancelling, isDismissed, erroredDownload, formatSize }: ModelItemProps) {
// Use server's downloading state OR local state (for immediate feedback before server updates)
// Suppress downloading if user just dismissed/cancelled this model
const showDownloading = (model.downloading || isDownloading) && !erroredDownload && !isDismissed;
return (
<div className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex-1 min-w-0">
<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>
)}
{model.downloaded && !model.loaded && !showDownloading && !erroredDownload && (
<Badge variant="secondary" className="text-xs">
Downloaded
</Badge>
)}
{erroredDownload && (
<Badge variant="destructive" className="text-xs">
Error
</Badge>
)}
</div>
{model.downloaded && model.size_mb && !showDownloading && !erroredDownload && (
<div className="text-xs text-muted-foreground mt-1">
Size: {formatSize(model.size_mb)}
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0 ml-2">
{erroredDownload ? (
<div className="flex items-center gap-2">
<Button size="sm" onClick={onDownload} variant="outline">
<Download className="h-4 w-4 mr-2" />
Retry
</Button>
<Button
size="sm"
onClick={onCancel}
variant="ghost"
disabled={isCancelling}
title="Dismiss error"
>
<X className="h-4 w-4" />
</Button>
</div>
) : 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'}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : showDownloading ? (
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" disabled>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Downloading...
</Button>
<Button
size="sm"
onClick={onCancel}
variant="ghost"
disabled={isCancelling}
title="Cancel download"
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<Button size="sm" onClick={onDownload} variant="outline">
<Download className="h-4 w-4 mr-2" />
Download
</Button>
)}
</div>
</div>
);
}
+7 -6
View File
@@ -1,5 +1,5 @@
import { Link, useMatchRoute } from '@tanstack/react-router';
import { Box, BookOpen, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react';
import { Link, useMatchRoute, useRouterState } from '@tanstack/react-router';
import { BookOpen, BookText, Box, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react';
import voiceboxLogo from '@/assets/voicebox-logo.png';
import { cn } from '@/lib/utils/cn';
import { useGenerationStore } from '@/stores/generationStore';
@@ -12,6 +12,7 @@ interface SidebarProps {
const tabs = [
{ id: 'main', path: '/', icon: Volume2, label: 'Generate' },
{ id: 'stories', path: '/stories', icon: BookOpen, label: 'Stories' },
{ id: 'audiobook', path: '/audiobook', icon: BookText, label: 'Audiobook' },
{ id: 'voices', path: '/voices', icon: Mic, label: 'Voices' },
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
{ id: 'models', path: '/models', icon: Box, label: 'Models' },
@@ -23,6 +24,9 @@ export function Sidebar({ isMacOS }: SidebarProps) {
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
const matchRoute = useMatchRoute();
const pathname = useRouterState({
select: (state) => state.location.pathname,
});
return (
<div
@@ -41,10 +45,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
{tabs.map((tab) => {
const Icon = tab.icon;
// For index route, use exact match; for others, use default matching
const isActive =
tab.path === '/'
? matchRoute({ to: '/', exact: true })
: matchRoute({ to: tab.path });
const isActive = tab.path === '/' ? pathname === '/' : matchRoute({ to: tab.path });
return (
<Link
+4 -1
View File
@@ -1,8 +1,11 @@
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
import { usePlayerStore } from '@/stores/playerStore';
import { StoryContent } from './StoryContent';
import { StoryList } from './StoryList';
export function StoriesTab() {
const audioUrl = usePlayerStore((state) => state.audioUrl);
return (
<div className="flex flex-col h-full min-h-0 overflow-hidden">
{/* Main content area */}
@@ -18,7 +21,7 @@ export function StoriesTab() {
</div>
{/* Floating Generate Box - position is managed via storyStore.trackEditorHeight */}
<FloatingGenerateBox showVoiceSelector />
<FloatingGenerateBox showVoiceSelector isPlayerOpen={!!audioUrl} />
</div>
</div>
);
+26
View File
@@ -125,6 +125,32 @@
text-orientation: mixed;
letter-spacing: 0.1em;
}
.scrollbar-visible {
scrollbar-width: thin;
-ms-overflow-style: auto;
scrollbar-color: #d8ab4f #2b2b2b;
}
.scrollbar-visible::-webkit-scrollbar {
display: block;
width: 10px;
height: 10px;
}
.scrollbar-visible::-webkit-scrollbar-track {
background: #2b2b2b;
}
.scrollbar-visible::-webkit-scrollbar-thumb {
background: #d8ab4f;
border-radius: 9999px;
border: 2px solid #131313;
}
.scrollbar-visible::-webkit-scrollbar-thumb:hover {
background: #e2b85e;
}
}
@keyframes fadeInScale {
+23
View File
@@ -34,6 +34,8 @@ export interface GenerationRequest {
language: LanguageCode;
seed?: number;
model_size?: '1.7B' | '0.6B';
engine?: 'qwen' | 'luxtts' | 'chatterbox';
instruct?: string;
}
export interface GenerationResponse {
@@ -117,12 +119,29 @@ export interface ModelProgress {
export interface ModelStatus {
model_name: string;
display_name: string;
hf_repo_id?: string; // HuggingFace repository ID
downloaded: boolean;
downloading: boolean; // True if download is in progress
size_mb?: number;
loaded: boolean;
}
export interface HuggingFaceModelInfo {
id: string;
author: string;
lastModified: string;
pipeline_tag?: string;
library_name?: string;
downloads: number;
likes: number;
tags: string[];
cardData?: {
license?: string;
language?: string[];
pipeline_tag?: string;
};
}
export interface ModelStatusListResponse {
models: ModelStatus[];
}
@@ -136,6 +155,10 @@ export interface ActiveDownloadTask {
status: string;
started_at: string;
error?: string;
progress?: number; // 0-100 percentage
current?: number; // bytes downloaded
total?: number; // total bytes
filename?: string; // current file being downloaded
}
export interface ActiveGenerationTask {
+3 -2
View File
@@ -1,6 +1,6 @@
/**
* Supported languages for Qwen3-TTS
* Based on: https://github.com/QwenLM/Qwen3-TTS
* Supported languages for voice generation.
* Most languages use Qwen3-TTS; Hebrew uses Chatterbox TTS.
*/
export const SUPPORTED_LANGUAGES = {
@@ -14,6 +14,7 @@ export const SUPPORTED_LANGUAGES = {
pt: 'Portuguese',
es: 'Spanish',
it: 'Italian',
he: 'Hebrew',
} as const;
export type LanguageCode = keyof typeof SUPPORTED_LANGUAGES;
+29 -5
View File
@@ -16,6 +16,7 @@ const generationSchema = z.object({
seed: z.number().int().optional(),
modelSize: z.enum(['1.7B', '0.6B']).optional(),
instruct: z.string().max(500).optional(),
engine: z.enum(['qwen', 'luxtts', 'chatterbox']).optional(),
});
export type GenerationFormValues = z.infer<typeof generationSchema>;
@@ -47,6 +48,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
seed: undefined,
modelSize: '1.7B',
instruct: '',
engine: 'qwen',
...options.defaultValues,
},
});
@@ -67,8 +69,21 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
try {
setIsGenerating(true);
const modelName = `qwen-tts-${data.modelSize}`;
const displayName = data.modelSize === '1.7B' ? 'Qwen TTS 1.7B' : 'Qwen TTS 0.6B';
const engine = data.engine || 'qwen';
const modelName =
engine === 'luxtts'
? 'luxtts'
: engine === 'chatterbox'
? 'chatterbox-tts'
: `qwen-tts-${data.modelSize}`;
const displayName =
engine === 'luxtts'
? 'LuxTTS'
: engine === 'chatterbox'
? 'Chatterbox TTS'
: data.modelSize === '1.7B'
? 'Qwen TTS 1.7B'
: 'Qwen TTS 0.6B';
try {
const modelStatus = await apiClient.getModelStatus();
@@ -82,13 +97,15 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
console.error('Failed to check model status:', error);
}
const isQwen = engine === 'qwen';
const result = await generation.mutateAsync({
profile_id: selectedProfileId,
text: data.text,
language: data.language,
seed: data.seed,
model_size: data.modelSize,
instruct: data.instruct || undefined,
model_size: isQwen ? data.modelSize : undefined,
engine,
instruct: isQwen ? data.instruct || undefined : undefined,
});
toast({
@@ -99,7 +116,14 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
const audioUrl = apiClient.getAudioUrl(result.id);
setAudioWithAutoPlay(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
form.reset();
form.reset({
text: '',
language: data.language,
seed: undefined,
modelSize: data.modelSize,
instruct: '',
engine: data.engine,
});
options.onSuccess?.(result.id);
} catch (error) {
toast({
+99
View File
@@ -0,0 +1,99 @@
export interface TextChunk {
id: string;
text: string;
charCount: number;
wordCount: number;
}
function normalizeText(text: string): string {
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
}
function splitParagraphIntoSentences(paragraph: string): string[] {
const trimmed = paragraph.trim();
if (!trimmed) {
return [];
}
const matches = trimmed.match(/[^.!?]+[.!?]+(?:["')\]]+)?|[^.!?]+$/g);
if (!matches || matches.length === 0) {
return [trimmed];
}
return matches.map((sentence) => sentence.trim()).filter(Boolean);
}
export function chunkText(
rawText: string,
targetChunkSize: number,
maxChunkSize: number,
): TextChunk[] {
const text = normalizeText(rawText);
if (!text) {
return [];
}
const safeTarget = Math.max(200, Math.min(targetChunkSize, maxChunkSize));
const paragraphs = text
.split(/\n{2,}/)
.map((paragraph) => paragraph.trim())
.filter(Boolean);
const chunks: string[] = [];
let current = '';
const pushCurrent = () => {
const normalized = current.trim();
if (!normalized) {
return;
}
chunks.push(normalized);
current = '';
};
for (const paragraph of paragraphs) {
const sentences = splitParagraphIntoSentences(paragraph);
for (const sentence of sentences) {
// Keep sentence integrity. If one sentence exceeds maxChunkSize,
// keep it as a single oversized chunk and let UI ask for manual edit.
if (sentence.length > maxChunkSize) {
pushCurrent();
chunks.push(sentence);
continue;
}
if (!current) {
current = sentence;
continue;
}
const candidate = `${current} ${sentence}`;
if (candidate.length <= safeTarget) {
current = candidate;
continue;
}
if (candidate.length <= maxChunkSize && current.length < Math.floor(safeTarget * 0.75)) {
current = candidate;
continue;
}
pushCurrent();
current = sentence;
}
if (current.length >= Math.floor(safeTarget * 0.8)) {
pushCurrent();
}
}
pushCurrent();
return chunks.map((chunkTextValue, index) => ({
id: `chunk-${index + 1}`,
text: chunkTextValue,
charCount: chunkTextValue.length,
wordCount: chunkTextValue.split(/\s+/).filter(Boolean).length,
}));
}
+10
View File
@@ -1,5 +1,6 @@
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router';
import { AppFrame } from '@/components/AppFrame/AppFrame';
import { AudiobookTab } from '@/components/AudiobookTab/AudiobookTab';
import { AudioTab } from '@/components/AudioTab/AudioTab';
import { MainEditor } from '@/components/MainEditor/MainEditor';
import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
@@ -10,6 +11,7 @@ import { Toaster } from '@/components/ui/toaster';
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
// Simple platform check that works in both web and Tauri
const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
@@ -86,6 +88,13 @@ const storiesRoute = createRoute({
component: StoriesTab,
});
// Audiobook route
const audiobookRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/audiobook',
component: AudiobookTab,
});
// Voices route
const voicesRoute = createRoute({
getParentRoute: () => rootRoute,
@@ -118,6 +127,7 @@ const serverRoute = createRoute({
const routeTree = rootRoute.addChildren([
indexRoute,
storiesRoute,
audiobookRoute,
voicesRoute,
audioRoute,
modelsRoute,
+12 -9
View File
@@ -334,18 +334,21 @@ python -m backend.main --host 0.0.0.0 --port 8000
## Usage Examples
The desktop app, web client, and current development workflow use `http://localhost:17493` by default.
If you launch the backend manually with a different host or port, substitute that address in the examples below.
### Creating a Voice Profile
```bash
# 1. Create profile
curl -X POST http://localhost:8000/profiles \
curl -X POST http://localhost:17493/profiles \
-H "Content-Type: application/json" \
-d '{"name": "My Voice", "language": "en"}'
# Response: {"id": "abc-123", ...}
# 2. Add sample
curl -X POST http://localhost:8000/profiles/abc-123/samples \
curl -X POST http://localhost:17493/profiles/abc-123/samples \
-F "[email protected]" \
-F "reference_text=This is my voice sample"
```
@@ -353,7 +356,7 @@ curl -X POST http://localhost:8000/profiles/abc-123/samples \
### Generating Speech
```bash
curl -X POST http://localhost:8000/generate \
curl -X POST http://localhost:17493/generate \
-H "Content-Type: application/json" \
-d '{
"profile_id": "abc-123",
@@ -365,13 +368,13 @@ curl -X POST http://localhost:8000/generate \
# Response: {"id": "gen-456", "audio_path": "/path/to/audio.wav", ...}
# Download audio
curl http://localhost:8000/audio/gen-456 -o output.wav
curl http://localhost:17493/audio/gen-456 -o output.wav
```
### Transcribing Audio
```bash
curl -X POST http://localhost:8000/transcribe \
curl -X POST http://localhost:17493/transcribe \
-F "[email protected]" \
-F "language=en"
@@ -386,12 +389,12 @@ Add multiple samples to a profile for better quality:
```bash
# Add first sample
curl -X POST http://localhost:8000/profiles/abc-123/samples \
curl -X POST http://localhost:17493/profiles/abc-123/samples \
-F "[email protected]" \
-F "reference_text=First sample"
# Add second sample
curl -X POST http://localhost:8000/profiles/abc-123/samples \
curl -X POST http://localhost:17493/profiles/abc-123/samples \
-F "[email protected]" \
-F "reference_text=Second sample"
@@ -412,10 +415,10 @@ Models are lazy-loaded and can be manually unloaded:
```bash
# Unload TTS model
curl -X POST http://localhost:8000/models/unload
curl -X POST http://localhost:17493/models/unload
# Load specific model size
curl -X POST "http://localhost:8000/models/load?model_size=0.6B"
curl -X POST "http://localhost:17493/models/load?model_size=0.6B"
```
## Error Handling
+54 -12
View File
@@ -4,6 +4,7 @@ Backend abstraction layer for TTS and STT.
Provides a unified interface for MLX and PyTorch backends.
"""
import threading
from typing import Protocol, Optional, Tuple, List
from typing_extensions import runtime_checkable
import numpy as np
@@ -112,29 +113,69 @@ class STTBackend(Protocol):
# Global backend instances
_tts_backend: Optional[TTSBackend] = None
_tts_backends: dict[str, TTSBackend] = {}
_tts_backends_lock = threading.Lock()
_stt_backend: Optional[STTBackend] = None
# Supported TTS engines
TTS_ENGINES = {
"qwen": "Qwen TTS",
"luxtts": "LuxTTS",
"chatterbox": "Chatterbox TTS",
}
def get_tts_backend() -> TTSBackend:
"""
Get or create TTS backend instance based on platform.
Get or create the default (Qwen) TTS backend instance based on platform.
Returns:
TTS backend instance (MLX or PyTorch)
"""
global _tts_backend
return get_tts_backend_for_engine("qwen")
def get_tts_backend_for_engine(engine: str) -> TTSBackend:
"""
Get or create a TTS backend for the given engine.
if _tts_backend is None:
backend_type = get_backend_type()
Args:
engine: Engine name ("qwen" or "luxtts")
Returns:
TTS backend instance
"""
global _tts_backends
# Fast path: check without lock
if engine in _tts_backends:
return _tts_backends[engine]
# Slow path: create with lock to avoid duplicate instantiation
with _tts_backends_lock:
# Double-check after acquiring lock
if engine in _tts_backends:
return _tts_backends[engine]
if backend_type == "mlx":
from .mlx_backend import MLXTTSBackend
_tts_backend = MLXTTSBackend()
if engine == "qwen":
backend_type = get_backend_type()
if backend_type == "mlx":
from .mlx_backend import MLXTTSBackend
backend = MLXTTSBackend()
else:
from .pytorch_backend import PyTorchTTSBackend
backend = PyTorchTTSBackend()
elif engine == "luxtts":
from .luxtts_backend import LuxTTSBackend
backend = LuxTTSBackend()
elif engine == "chatterbox":
from .chatterbox_backend import ChatterboxTTSBackend
backend = ChatterboxTTSBackend()
else:
from .pytorch_backend import PyTorchTTSBackend
_tts_backend = PyTorchTTSBackend()
return _tts_backend
raise ValueError(f"Unknown TTS engine: {engine}. Supported: {list(TTS_ENGINES.keys())}")
_tts_backends[engine] = backend
return backend
def get_stt_backend() -> STTBackend:
@@ -161,6 +202,7 @@ def get_stt_backend() -> STTBackend:
def reset_backends():
"""Reset backend instances (useful for testing)."""
global _tts_backend, _stt_backend
global _tts_backend, _tts_backends, _stt_backend
_tts_backend = None
_tts_backends.clear()
_stt_backend = None
+326
View File
@@ -0,0 +1,326 @@
"""
Chatterbox TTS backend implementation.
Wraps ChatterboxMultilingualTTS from chatterbox-tts for zero-shot
voice cloning. Supports 23 languages including Hebrew. Forces CPU
on macOS due to known MPS tensor issues.
"""
import asyncio
import logging
import platform
import threading
from pathlib import Path
from typing import ClassVar, List, Optional, Tuple
import numpy as np
from . import TTSBackend
from ..utils.audio import normalize_audio, load_audio
from ..utils.progress import get_progress_manager
from ..utils.tasks import get_task_manager
logger = logging.getLogger(__name__)
CHATTERBOX_HF_REPO = "ResembleAI/chatterbox"
# Files that must be present for the multilingual model
_MTL_WEIGHT_FILES = [
"t3_mtl23ls_v2.safetensors",
"s3gen.pt",
"ve.pt",
]
class ChatterboxTTSBackend:
"""Chatterbox Multilingual TTS backend for voice cloning."""
# Class-level lock for torch.load monkey-patching
_load_lock: ClassVar[threading.Lock] = threading.Lock()
def __init__(self):
self.model = None
self.model_size = "default"
self._device = None
self._model_load_lock = asyncio.Lock()
def _get_device(self) -> str:
"""Get the best available device. Forces CPU on macOS (MPS issue)."""
if platform.system() == "Darwin":
return "cpu"
try:
import torch
if torch.cuda.is_available():
return "cuda"
except ImportError:
pass
return "cpu"
def is_loaded(self) -> bool:
return self.model is not None
def _get_model_path(self, model_size: str = "default") -> str:
return CHATTERBOX_HF_REPO
def _is_model_cached(self, model_size: str = "default") -> bool:
"""Check if the Chatterbox multilingual model is cached locally."""
try:
from huggingface_hub import constants as hf_constants
repo_cache = Path(hf_constants.HF_HUB_CACHE) / (
"models--" + CHATTERBOX_HF_REPO.replace("/", "--")
)
if not repo_cache.exists():
return False
blobs_dir = repo_cache / "blobs"
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
return False
# Check for multilingual weight files
snapshots_dir = repo_cache / "snapshots"
if snapshots_dir.exists():
for fname in _MTL_WEIGHT_FILES:
if not any(snapshots_dir.rglob(fname)):
return False
return True
return False
except Exception as e:
logger.warning(f"Error checking Chatterbox cache: {e}")
return False
async def load_model(self, model_size: str = "default") -> None:
"""Load the Chatterbox multilingual model."""
if self.model is not None:
return
async with self._model_load_lock:
if self.model is not None:
return
await asyncio.to_thread(self._load_model_sync)
def _load_model_sync(self):
"""Synchronous model loading."""
from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
progress_manager = get_progress_manager()
task_manager = get_task_manager()
model_name = "chatterbox-tts"
is_cached = self._is_model_cached()
# Set up HF progress tracking (intercepts tqdm for file-level progress)
progress_callback = create_hf_progress_callback(model_name, progress_manager)
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
tracker_context = tracker.patch_download()
tracker_context.__enter__()
if not is_cached:
task_manager.start_download(model_name)
progress_manager.update_progress(
model_name=model_name,
current=0,
total=0,
filename="Connecting to HuggingFace...",
status="downloading",
)
try:
device = self._get_device()
self._device = device
logger.info(f"Loading Chatterbox Multilingual TTS on {device}...")
import torch
from chatterbox.mtl_tts import ChatterboxMultilingualTTS
# Monkey-patch torch.load for CPU loading. The model's .pt files
# were saved on CUDA; from_pretrained() doesn't pass map_location
# so loading on CPU fails without this.
try:
if device == "cpu":
_orig_torch_load = torch.load
def _patched_load(*args, **kwargs):
kwargs.setdefault("map_location", "cpu")
return _orig_torch_load(*args, **kwargs)
with ChatterboxTTSBackend._load_lock:
torch.load = _patched_load
try:
self.model = ChatterboxMultilingualTTS.from_pretrained(
device=device,
)
finally:
torch.load = _orig_torch_load
else:
self.model = ChatterboxMultilingualTTS.from_pretrained(
device=device,
)
finally:
tracker_context.__exit__(None, None, None)
# Fix: transformers >= 4.36 defaults LlamaModel to sdpa attention
# which doesn't support output_attentions=True (needed by
# Chatterbox's AlignmentStreamAnalyzer). Force eager attention.
t3_tfmr = self.model.t3.tfmr
if hasattr(t3_tfmr, "config") and hasattr(
t3_tfmr.config, "_attn_implementation"
):
t3_tfmr.config._attn_implementation = "eager"
for layer in getattr(t3_tfmr, "layers", []):
if hasattr(layer, "self_attn"):
layer.self_attn._attn_implementation = "eager"
if not is_cached:
progress_manager.mark_complete(model_name)
task_manager.complete_download(model_name)
logger.info("Chatterbox Multilingual TTS loaded successfully")
except ImportError as e:
logger.error(
"chatterbox-tts package not found. "
"Install with: pip install chatterbox-tts"
)
if not is_cached:
progress_manager.mark_error(model_name, str(e))
task_manager.error_download(model_name, str(e))
raise
except Exception as e:
logger.error(f"Failed to load Chatterbox: {e}")
if not is_cached:
progress_manager.mark_error(model_name, str(e))
task_manager.error_download(model_name, str(e))
raise
def unload_model(self) -> None:
"""Unload model to free memory."""
if self.model is not None:
device = self._device
del self.model
self.model = None
self._device = None
if device == "cuda":
import torch
torch.cuda.empty_cache()
logger.info("Chatterbox unloaded")
async def create_voice_prompt(
self,
audio_path: str,
reference_text: str,
use_cache: bool = True,
) -> Tuple[dict, bool]:
"""
Create voice prompt from reference audio.
Chatterbox processes reference audio at generation time, so the
prompt just stores the file path. The actual audio is loaded by
model.generate() via audio_prompt_path.
"""
voice_prompt = {
"ref_audio": str(audio_path),
"ref_text": reference_text,
}
return voice_prompt, False
async def combine_voice_prompts(
self,
audio_paths: List[str],
reference_texts: List[str],
) -> Tuple[np.ndarray, str]:
"""Combine multiple reference samples."""
combined_audio = []
for path in audio_paths:
audio, _sr = load_audio(path)
audio = normalize_audio(audio)
combined_audio.append(audio)
mixed = np.concatenate(combined_audio)
mixed = normalize_audio(mixed)
combined_text = " ".join(reference_texts)
return mixed, combined_text
# Per-language generation defaults. Lower temp + higher cfg = clearer speech.
_LANG_DEFAULTS: ClassVar[dict] = {
"he": {
"exaggeration": 0.4,
"cfg_weight": 0.7,
"temperature": 0.65,
"repetition_penalty": 2.5,
},
}
_GLOBAL_DEFAULTS: ClassVar[dict] = {
"exaggeration": 0.5,
"cfg_weight": 0.5,
"temperature": 0.8,
"repetition_penalty": 2.0,
}
async def generate(
self,
text: str,
voice_prompt: dict,
language: str = "en",
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> Tuple[np.ndarray, int]:
"""
Generate audio using Chatterbox Multilingual TTS.
Args:
text: Text to synthesize
voice_prompt: Dict with ref_audio path
language: BCP-47 language code
seed: Random seed for reproducibility
instruct: Unused (protocol compatibility)
Returns:
Tuple of (audio_array, sample_rate)
"""
await self.load_model()
ref_audio = voice_prompt.get("ref_audio")
if ref_audio and not Path(ref_audio).exists():
logger.warning(f"Reference audio not found: {ref_audio}")
ref_audio = None
# Merge language-specific defaults with global defaults
lang_defaults = self._LANG_DEFAULTS.get(language, self._GLOBAL_DEFAULTS)
def _generate_sync():
import torch
if seed is not None:
torch.manual_seed(seed)
logger.info(f"[Chatterbox] Generating: lang={language}")
wav = self.model.generate(
text,
language_id=language,
audio_prompt_path=ref_audio,
exaggeration=lang_defaults["exaggeration"],
cfg_weight=lang_defaults["cfg_weight"],
temperature=lang_defaults["temperature"],
repetition_penalty=lang_defaults["repetition_penalty"],
)
# Convert tensor -> numpy
if isinstance(wav, torch.Tensor):
audio = wav.squeeze().cpu().numpy().astype(np.float32)
else:
audio = np.asarray(wav, dtype=np.float32)
sample_rate = (
getattr(self.model, "sr", None)
or getattr(self.model, "sample_rate", 24000)
)
return audio, sample_rate
return await asyncio.to_thread(_generate_sync)
+275
View File
@@ -0,0 +1,275 @@
"""
LuxTTS backend implementation.
Wraps the LuxTTS (ZipVoice) model for zero-shot voice cloning.
~1GB VRAM, 48kHz output, 150x realtime on CPU.
"""
import asyncio
import logging
from pathlib import Path
from typing import List, Optional, Tuple
import numpy as np
from . import TTSBackend
from ..utils.audio import normalize_audio, load_audio
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
from ..utils.progress import get_progress_manager
from ..utils.tasks import get_task_manager
logger = logging.getLogger(__name__)
# HuggingFace repo for model weight detection
LUXTTS_HF_REPO = "YatharthS/LuxTTS"
class LuxTTSBackend:
"""LuxTTS backend for zero-shot voice cloning."""
def __init__(self):
self.model = None
self.model_size = "default" # LuxTTS has only one model size
self._device = None
def _get_device(self) -> str:
"""Get the best available device."""
import torch
if torch.cuda.is_available():
return "cuda"
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "mps"
return "cpu"
def is_loaded(self) -> bool:
return self.model is not None
@property
def device(self) -> str:
if self._device is None:
self._device = self._get_device()
return self._device
def _get_model_path(self, model_size: str) -> str:
return LUXTTS_HF_REPO
def _is_model_cached(self, model_size: str = "default") -> bool:
"""Check if LuxTTS model weights are cached locally."""
try:
from huggingface_hub import constants as hf_constants
repo_cache = (
Path(hf_constants.HF_HUB_CACHE)
/ ("models--" + LUXTTS_HF_REPO.replace("/", "--"))
)
if not repo_cache.exists():
return False
blobs_dir = repo_cache / "blobs"
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
return False
snapshots_dir = repo_cache / "snapshots"
if snapshots_dir.exists():
has_weights = any(snapshots_dir.rglob("*.pt")) or any(
snapshots_dir.rglob("*.safetensors")
) or any(snapshots_dir.rglob("*.onnx")) or any(
snapshots_dir.rglob("*.bin")
)
return has_weights
return False
except Exception as e:
logger.warning(f"Error checking LuxTTS cache: {e}")
return False
async def load_model(self, model_size: str = "default") -> None:
"""Load the LuxTTS model."""
if self.model is not None:
return
await asyncio.to_thread(self._load_model_sync)
def _load_model_sync(self):
"""Synchronous model loading."""
from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
progress_manager = get_progress_manager()
task_manager = get_task_manager()
model_name = "luxtts"
is_cached = self._is_model_cached()
# Set up HF progress tracking (intercepts tqdm for file-level progress)
progress_callback = create_hf_progress_callback(model_name, progress_manager)
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
tracker_context = tracker.patch_download()
tracker_context.__enter__()
if not is_cached:
task_manager.start_download(model_name)
progress_manager.update_progress(
model_name=model_name,
current=0,
total=0,
filename="Connecting to HuggingFace...",
status="downloading",
)
try:
from zipvoice.luxvoice import LuxTTS
device = self.device
logger.info(f"Loading LuxTTS on {device}...")
# LuxTTS constructor downloads model and loads everything
try:
if device == "cpu":
import os
threads = os.cpu_count() or 4
self.model = LuxTTS(
model_path=LUXTTS_HF_REPO,
device="cpu",
threads=min(threads, 8),
)
else:
self.model = LuxTTS(
model_path=LUXTTS_HF_REPO,
device=device,
)
finally:
tracker_context.__exit__(None, None, None)
if not is_cached:
progress_manager.mark_complete(model_name)
task_manager.complete_download(model_name)
logger.info("LuxTTS loaded successfully")
except Exception as e:
logger.error(f"Failed to load LuxTTS: {e}")
if not is_cached:
progress_manager.mark_error(model_name, str(e))
task_manager.error_download(model_name, str(e))
raise
def unload_model(self) -> None:
"""Unload model to free memory."""
if self.model is not None:
del self.model
self.model = None
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
logger.info("LuxTTS unloaded")
async def create_voice_prompt(
self,
audio_path: str,
reference_text: str,
use_cache: bool = True,
) -> Tuple[dict, bool]:
"""
Create voice prompt from reference audio.
LuxTTS uses its own encode_prompt() which runs Whisper ASR internally
to transcribe the reference. The reference_text parameter is not used
by LuxTTS itself, but we include it in the cache key for consistency.
"""
await self.load_model()
# Compute cache key once for both lookup and storage
cache_key = ("luxtts_" + get_cache_key(audio_path, reference_text)) if use_cache else None
if cache_key:
cached = get_cached_voice_prompt(cache_key)
if cached is not None and isinstance(cached, dict):
return cached, True
def _encode_sync():
return self.model.encode_prompt(
prompt_audio=str(audio_path),
duration=5,
rms=0.01,
)
encoded = await asyncio.to_thread(_encode_sync)
if cache_key:
cache_voice_prompt(cache_key, encoded)
return encoded, False
async def combine_voice_prompts(
self,
audio_paths: List[str],
reference_texts: List[str],
) -> Tuple[np.ndarray, str]:
"""
Combine multiple reference samples.
LuxTTS doesn't have native multi-prompt support, so we concatenate
the audio and let encode_prompt handle the combined clip.
"""
combined_audio = []
for path in audio_paths:
audio, _sr = load_audio(path, sample_rate=24000)
audio = normalize_audio(audio)
combined_audio.append(audio)
mixed = np.concatenate(combined_audio)
mixed = normalize_audio(mixed)
combined_text = " ".join(reference_texts)
return mixed, combined_text
async def generate(
self,
text: str,
voice_prompt: dict,
language: str = "en",
seed: Optional[int] = None,
instruct: Optional[str] = None,
) -> Tuple[np.ndarray, int]:
"""
Generate audio from text using LuxTTS.
Args:
text: Text to synthesize
voice_prompt: Encoded prompt dict from encode_prompt()
language: Language code (LuxTTS is English-focused)
seed: Random seed for reproducibility
instruct: Not supported by LuxTTS (ignored)
Returns:
Tuple of (audio_array, sample_rate)
"""
await self.load_model()
def _generate_sync():
import torch
if seed is not None:
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed(seed)
wav = self.model.generate_speech(
text=text,
encode_dict=voice_prompt,
num_steps=4,
guidance_scale=3.0,
t_shift=0.5,
speed=1.0,
return_smooth=False, # 48kHz output
)
# LuxTTS returns a tensor (may be on GPU/MPS), move to CPU first
audio = wav.detach().cpu().numpy().squeeze()
return audio, 48000
return await asyncio.to_thread(_generate_sync)
+215 -46
View File
@@ -48,6 +48,18 @@ from .utils.tasks import get_task_manager
from .utils.cache import clear_voice_prompt_cache
from .platform_detect import get_backend_type
# Keep references to fire-and-forget background tasks to prevent GC
_background_tasks: set = set()
def _create_background_task(coro) -> asyncio.Task:
"""Create a background task and prevent it from being garbage collected."""
task = asyncio.create_task(coro)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
return task
app = FastAPI(
title="voicebox API",
description="Production-quality Qwen3-TTS voice cloning API",
@@ -222,7 +234,10 @@ async def create_profile(
"""Create a new voice profile."""
try:
return await profiles.create_profile(data, db)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
# Fallback for unexpected errors
raise HTTPException(status_code=400, detail=str(e))
@@ -278,10 +293,13 @@ async def update_profile(
db: Session = Depends(get_db),
):
"""Update a voice profile."""
profile = await profiles.update_profile(profile_id, data, db)
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
return profile
try:
profile = await profiles.update_profile(profile_id, data, db)
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
return profile
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.delete("/profiles/{profile_id}")
@@ -602,47 +620,92 @@ async def generate_speech(
raise HTTPException(status_code=404, detail="Profile not found")
# Generate audio
from .backends import get_tts_backend_for_engine
# Resolve model size and load the correct model FIRST.
# This must happen before create_voice_prompt_for_profile because that
# function calls load_model_async(None), which falls back to self.model_size.
# If the model is already loaded with the right size at that point, it
# returns immediately and the voice prompt is created by the correct model.
tts_model = tts.get_tts_model()
engine = data.engine or "qwen"
tts_model = get_tts_backend_for_engine(engine)
# Resolve model size (only relevant for Qwen engine)
model_size = data.model_size or "1.7B"
# Check if model needs to be downloaded first
model_path = tts_model._get_model_path(model_size)
if not tts_model._is_model_cached(model_size):
# Model is not fully cached — kick off a background download and tell
# the client to retry once it's ready.
model_name = f"qwen-tts-{model_size}"
if engine == "qwen":
if not tts_model._is_model_cached(model_size):
model_name = f"qwen-tts-{model_size}"
async def download_model_background():
try:
await tts_model.load_model_async(model_size)
except Exception as e:
task_manager.error_download(model_name, str(e))
async def download_model_background():
try:
await tts_model.load_model_async(model_size)
except Exception as e:
task_manager.error_download(model_name, str(e))
task_manager.start_download(model_name)
asyncio.create_task(download_model_background())
task_manager.start_download(model_name)
_create_background_task(download_model_background())
raise HTTPException(
status_code=202,
detail={
"message": f"Model {model_size} is being downloaded. Please wait and try again.",
"model_name": model_name,
"downloading": True,
},
)
raise HTTPException(
status_code=202,
detail={
"message": f"Model {model_size} is being downloaded. Please wait and try again.",
"model_name": model_name,
"downloading": True,
},
)
# Load (or switch to) the requested model before building the voice prompt
await tts_model.load_model_async(model_size)
# Load (or switch to) the requested model
await tts_model.load_model_async(model_size)
elif engine == "luxtts":
if not tts_model._is_model_cached():
model_name = "luxtts"
# Create voice prompt from profile (model is already loaded with correct size)
async def download_luxtts_background():
try:
await tts_model.load_model()
except Exception as e:
task_manager.error_download(model_name, str(e))
task_manager.start_download(model_name)
_create_background_task(download_luxtts_background())
raise HTTPException(
status_code=202,
detail={
"message": "LuxTTS model is being downloaded. Please wait and try again.",
"model_name": model_name,
"downloading": True,
},
)
await tts_model.load_model()
elif engine == "chatterbox":
if not tts_model._is_model_cached():
model_name = "chatterbox-tts"
async def download_chatterbox_background():
try:
await tts_model.load_model()
except Exception as e:
task_manager.error_download(model_name, str(e))
task_manager.start_download(model_name)
asyncio.create_task(download_chatterbox_background())
raise HTTPException(
status_code=202,
detail={
"message": "Chatterbox model is being downloaded. Please wait and try again.",
"model_name": model_name,
"downloading": True,
},
)
await tts_model.load_model()
# Create voice prompt from profile
voice_prompt = await profiles.create_voice_prompt_for_profile(
data.profile_id,
db,
use_cache=True,
engine=engine,
)
audio, sample_rate = await tts_model.generate(
@@ -653,6 +716,11 @@ async def generate_speech(
data.instruct,
)
# Trim trailing silence/hallucination for Chatterbox output
if engine == "chatterbox":
from .utils.audio import trim_tts_output
audio = trim_tts_output(audio, sample_rate)
# Calculate duration
duration = len(audio) / sample_rate
@@ -699,23 +767,41 @@ async def stream_speech(
playing audio before the entire file has been received. This endpoint
does NOT create a history entry — use /generate for that.
"""
from .backends import get_tts_backend_for_engine
profile = await profiles.get_profile(data.profile_id, db)
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
tts_model = tts.get_tts_model()
engine = data.engine or "qwen"
tts_model = get_tts_backend_for_engine(engine)
model_size = data.model_size or "1.7B"
if not tts_model._is_model_cached(model_size):
raise HTTPException(
status_code=400,
detail=f"Model {model_size} is not downloaded yet. Use /generate to trigger a download.",
)
if engine == "qwen":
if not tts_model._is_model_cached(model_size):
raise HTTPException(
status_code=400,
detail=f"Model {model_size} is not downloaded yet. Use /generate to trigger a download.",
)
await tts_model.load_model_async(model_size)
elif engine == "luxtts":
if not tts_model._is_model_cached():
raise HTTPException(
status_code=400,
detail="LuxTTS model is not downloaded yet. Use /generate to trigger a download.",
)
await tts_model.load_model()
elif engine == "chatterbox":
if not tts_model._is_model_cached():
raise HTTPException(
status_code=400,
detail="Chatterbox model is not downloaded yet. Use /generate to trigger a download.",
)
await tts_model.load_model()
# Load the correct model before building the voice prompt (fixes issue #96)
await tts_model.load_model_async(model_size)
voice_prompt = await profiles.create_voice_prompt_for_profile(data.profile_id, db)
voice_prompt = await profiles.create_voice_prompt_for_profile(
data.profile_id, db, engine=engine,
)
audio, sample_rate = await tts_model.generate(
data.text,
@@ -725,6 +811,11 @@ async def stream_speech(
data.instruct,
)
# Trim trailing silence/hallucination for Chatterbox output
if engine == "chatterbox":
from .utils.audio import trim_tts_output
audio = trim_tts_output(audio, sample_rate)
wav_bytes = tts.audio_to_wav_bytes(audio, sample_rate)
async def _wav_stream():
@@ -953,7 +1044,7 @@ async def transcribe_audio(
get_task_manager().error_download(progress_model_name, str(e))
get_task_manager().start_download(progress_model_name)
asyncio.create_task(download_whisper_background())
_create_background_task(download_whisper_background())
# Return 202 Accepted
raise HTTPException(
@@ -1324,6 +1415,24 @@ async def get_model_status():
whisper_medium_id = "openai/whisper-medium"
whisper_large_id = "openai/whisper-large-v3"
# Check if LuxTTS backend is loaded
def check_luxtts_loaded():
try:
from .backends import get_tts_backend_for_engine
backend = get_tts_backend_for_engine("luxtts")
return backend.is_loaded()
except Exception:
return False
# Check if Chatterbox backend is loaded
def check_chatterbox_loaded():
try:
from .backends import get_tts_backend_for_engine
backend = get_tts_backend_for_engine("chatterbox")
return backend.is_loaded()
except Exception:
return False
model_configs = [
{
"model_name": "qwen-tts-1.7B",
@@ -1339,6 +1448,20 @@ async def get_model_status():
"model_size": "0.6B",
"check_loaded": lambda: check_tts_loaded("0.6B"),
},
{
"model_name": "luxtts",
"display_name": "LuxTTS (Fast, CPU-friendly)",
"hf_repo_id": "YatharthS/LuxTTS",
"model_size": "default",
"check_loaded": check_luxtts_loaded,
},
{
"model_name": "chatterbox-tts",
"display_name": "Chatterbox TTS (Multilingual)",
"hf_repo_id": "ResembleAI/chatterbox",
"model_size": "default",
"check_loaded": check_chatterbox_loaded,
},
{
"model_name": "whisper-base",
"display_name": "Whisper Base",
@@ -1490,6 +1613,7 @@ async def get_model_status():
statuses.append(models.ModelStatus(
model_name=config["model_name"],
display_name=config["display_name"],
hf_repo_id=config["hf_repo_id"],
downloaded=downloaded,
downloading=is_downloading,
size_mb=size_mb,
@@ -1508,6 +1632,7 @@ async def get_model_status():
statuses.append(models.ModelStatus(
model_name=config["model_name"],
display_name=config["display_name"],
hf_repo_id=config["hf_repo_id"],
downloaded=False, # Assume not downloaded if check failed
downloading=is_downloading,
size_mb=None,
@@ -1521,6 +1646,7 @@ async def get_model_status():
async def trigger_model_download(request: models.ModelDownloadRequest):
"""Trigger download of a specific model."""
import asyncio
from .backends import get_tts_backend_for_engine
task_manager = get_task_manager()
progress_manager = get_progress_manager()
@@ -1534,6 +1660,14 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
"model_size": "0.6B",
"load_func": lambda: tts.get_tts_model().load_model("0.6B"),
},
"luxtts": {
"model_size": "default",
"load_func": lambda: get_tts_backend_for_engine("luxtts").load_model(),
},
"chatterbox-tts": {
"model_size": "default",
"load_func": lambda: get_tts_backend_for_engine("chatterbox").load_model(),
},
"whisper-base": {
"model_size": "base",
"load_func": lambda: transcribe.get_whisper_model().load_model("base"),
@@ -1585,7 +1719,7 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
)
# Start download in background task (don't await)
asyncio.create_task(download_in_background())
_create_background_task(download_in_background())
# Return immediately - frontend should poll progress endpoint
return {"message": f"Model {request.model_name} download started"}
@@ -1646,6 +1780,16 @@ async def delete_model(model_name: str):
"model_size": "0.6B",
"model_type": "tts",
},
"luxtts": {
"hf_repo_id": "YatharthS/LuxTTS",
"model_size": "default",
"model_type": "luxtts",
},
"chatterbox-tts": {
"hf_repo_id": "ResembleAI/chatterbox",
"model_size": "default",
"model_type": "chatterbox",
},
"whisper-base": {
"hf_repo_id": "openai/whisper-base",
"model_size": "base",
@@ -1680,6 +1824,16 @@ async def delete_model(model_name: str):
tts_model = tts.get_tts_model()
if tts_model.is_loaded() and tts_model.model_size == config["model_size"]:
tts.unload_tts_model()
elif config["model_type"] == "luxtts":
from .backends import get_tts_backend_for_engine
luxtts = get_tts_backend_for_engine("luxtts")
if luxtts.is_loaded():
luxtts.unload_model()
elif config["model_type"] == "chatterbox":
from .backends import get_tts_backend_for_engine
chatterbox = get_tts_backend_for_engine("chatterbox")
if chatterbox.is_loaded():
chatterbox.unload_model()
elif config["model_type"] == "whisper":
whisper_model = transcribe.get_whisper_model()
if whisper_model.is_loaded() and whisper_model.model_size == config["model_size"]:
@@ -1758,11 +1912,22 @@ async def get_active_tasks():
pm_data = progress_manager._progress.get(model_name)
if pm_data:
error = pm_data.get("error")
# Include progress data if available
prog = progress or {}
if not prog:
with progress_manager._lock:
pm_data = progress_manager._progress.get(model_name)
if pm_data:
prog = pm_data
active_downloads.append(models.ActiveDownloadTask(
model_name=model_name,
status=task.status,
started_at=task.started_at,
error=error,
progress=prog.get("progress"),
current=prog.get("current"),
total=prog.get("total"),
filename=prog.get("filename"),
))
elif progress:
# Progress exists but no task - create from progress data
@@ -1780,6 +1945,10 @@ async def get_active_tasks():
status=progress.get("status", "downloading"),
started_at=started_at,
error=progress.get("error"),
progress=progress.get("progress"),
current=progress.get("current"),
total=progress.get("total"),
filename=progress.get("filename"),
))
# Get active generations
@@ -1825,7 +1994,7 @@ async def download_cuda_backend():
import logging
logging.getLogger(__name__).error(f"CUDA download failed: {e}")
asyncio.create_task(_download())
_create_background_task(_download())
return {"message": "CUDA backend download started", "progress_key": "cuda-backend"}
+8 -2
View File
@@ -11,7 +11,7 @@ class VoiceProfileCreate(BaseModel):
"""Request model for creating a voice profile."""
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it)$")
language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he)$")
class VoiceProfileResponse(BaseModel):
@@ -53,10 +53,11 @@ class GenerationRequest(BaseModel):
"""Request model for voice generation."""
profile_id: str
text: str = Field(..., min_length=1, max_length=5000)
language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it)$")
language: str = Field(default="en", pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he)$")
seed: Optional[int] = Field(None, ge=0)
model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B)$")
instruct: Optional[str] = Field(None, max_length=500)
engine: Optional[str] = Field(default="qwen", pattern="^(qwen|luxtts|chatterbox)$")
class GenerationResponse(BaseModel):
@@ -134,6 +135,7 @@ class ModelStatus(BaseModel):
"""Response model for model status."""
model_name: str
display_name: str
hf_repo_id: Optional[str] = None # HuggingFace repository ID
downloaded: bool
downloading: bool = False # True if download is in progress
size_mb: Optional[float] = None
@@ -156,6 +158,10 @@ class ActiveDownloadTask(BaseModel):
status: str
started_at: datetime
error: Optional[str] = None
progress: Optional[float] = None # 0-100 percentage
current: Optional[int] = None # bytes downloaded
total: Optional[int] = None # total bytes
filename: Optional[str] = None # current file being downloaded
class ActiveGenerationTask(BaseModel):
+32 -11
View File
@@ -38,14 +38,22 @@ async def create_profile(
) -> VoiceProfileResponse:
"""
Create a new voice profile.
Args:
data: Profile creation data
db: Database session
Returns:
Created profile
Raises:
ValueError: If a profile with the same name already exists
"""
# Check if profile name already exists
existing_profile = db.query(DBVoiceProfile).filter_by(name=data.name).first()
if existing_profile:
raise ValueError(f"A profile with the name '{data.name}' already exists. Please choose a different name.")
# Create profile in database
db_profile = DBVoiceProfile(
id=str(uuid.uuid4()),
@@ -55,15 +63,15 @@ async def create_profile(
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
db.add(db_profile)
db.commit()
db.refresh(db_profile)
# Create profile directory
profile_dir = _get_profiles_dir() / db_profile.id
profile_dir.mkdir(parents=True, exist_ok=True)
return VoiceProfileResponse.model_validate(db_profile)
@@ -191,28 +199,37 @@ async def update_profile(
) -> Optional[VoiceProfileResponse]:
"""
Update a voice profile.
Args:
profile_id: Profile ID
data: Updated profile data
db: Database session
Returns:
Updated profile or None if not found
Raises:
ValueError: If a profile with the same name already exists (different profile)
"""
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile:
return None
# Check if the new name conflicts with another profile
if profile.name != data.name:
existing_profile = db.query(DBVoiceProfile).filter_by(name=data.name).first()
if existing_profile:
raise ValueError(f"A profile with the name '{data.name}' already exists. Please choose a different name.")
# Update fields
profile.name = data.name
profile.description = data.description
profile.language = data.language
profile.updated_at = datetime.utcnow()
db.commit()
db.refresh(profile)
return VoiceProfileResponse.model_validate(profile)
@@ -327,6 +344,7 @@ async def create_voice_prompt_for_profile(
profile_id: str,
db: Session,
use_cache: bool = True,
engine: str = "qwen",
) -> dict:
"""
Create a combined voice prompt from all samples in a profile.
@@ -335,17 +353,20 @@ async def create_voice_prompt_for_profile(
profile_id: Profile ID
db: Database session
use_cache: Whether to use cached prompts
engine: TTS engine to create prompt for ("qwen" or "luxtts")
Returns:
Voice prompt dictionary
"""
from .backends import get_tts_backend_for_engine
# Get all samples for profile
samples = db.query(DBProfileSample).filter_by(profile_id=profile_id).all()
if not samples:
raise ValueError(f"No samples found for profile {profile_id}")
tts_model = get_tts_model()
tts_model = get_tts_backend_for_engine(engine)
if len(samples) == 1:
# Single sample - use directly
+20 -1
View File
@@ -9,11 +9,30 @@ alembic>=1.13.0
# ML models
torch>=2.1.0
transformers>=4.36.0
transformers>=4.36.0,<=4.57.6
accelerate>=0.26.0
huggingface_hub>=0.20.0
qwen-tts>=0.0.5
# LuxTTS (voice cloning engine)
# piper-phonemize needs custom index (no PyPI wheels)
--find-links https://k2-fsa.github.io/icefall/piper_phonemize.html
# linacodec is a git-only dep of Zipvoice (uv-only source, pip can't resolve it)
linacodec @ git+https://github.com/ysharma3501/LinaCodec.git
Zipvoice @ git+https://github.com/ysharma3501/LuxTTS.git
# Chatterbox TTS sub-dependencies (chatterbox-tts itself is installed
# --no-deps in the setup script because it pins numpy<1.26 / torch==2.6
# which are incompatible with Python 3.12+)
conformer>=0.3.2
diffusers>=0.29.0
omegaconf
pykakasi
resemble-perth>=1.0.1
s3tokenizer
spacy-pkuseg
pyloudnorm
# Audio processing
librosa>=0.10.0
soundfile>=0.12.0
@@ -0,0 +1,217 @@
"""
Tests for profile duplicate name validation.
This test suite verifies that the application correctly handles
duplicate profile names and provides user-friendly error messages.
"""
import pytest
import tempfile
import shutil
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# Add parent directory to path to import backend modules
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from database import Base, VoiceProfile as DBVoiceProfile
from models import VoiceProfileCreate
from profiles import create_profile, update_profile
@pytest.fixture
def test_db():
"""Create a temporary test database."""
# Create temporary directory for test database
temp_dir = tempfile.mkdtemp()
db_path = Path(temp_dir) / "test.db"
# Create engine and session
engine = create_engine(f"sqlite:///{db_path}")
Base.metadata.create_all(bind=engine)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = SessionLocal()
yield db
# Cleanup
db.close()
shutil.rmtree(temp_dir)
@pytest.fixture
def mock_profiles_dir(monkeypatch, tmp_path):
"""Mock the profiles directory to use a temporary path."""
import profiles
monkeypatch.setattr(profiles, '_get_profiles_dir', lambda: tmp_path)
return tmp_path
@pytest.mark.asyncio
async def test_create_profile_duplicate_name_raises_error(test_db, mock_profiles_dir):
"""Test that creating a profile with a duplicate name raises a ValueError."""
# Create first profile
profile_data_1 = VoiceProfileCreate(
name="Test Profile",
description="First profile",
language="en"
)
profile_1 = await create_profile(profile_data_1, test_db)
assert profile_1.name == "Test Profile"
# Try to create second profile with same name
profile_data_2 = VoiceProfileCreate(
name="Test Profile",
description="Second profile",
language="en"
)
with pytest.raises(ValueError) as exc_info:
await create_profile(profile_data_2, test_db)
# Verify error message is user-friendly
assert "already exists" in str(exc_info.value)
assert "Test Profile" in str(exc_info.value)
assert "choose a different name" in str(exc_info.value).lower()
@pytest.mark.asyncio
async def test_create_profile_different_names_succeeds(test_db, mock_profiles_dir):
"""Test that creating profiles with different names succeeds."""
# Create first profile
profile_data_1 = VoiceProfileCreate(
name="Profile One",
description="First profile",
language="en"
)
profile_1 = await create_profile(profile_data_1, test_db)
assert profile_1.name == "Profile One"
# Create second profile with different name
profile_data_2 = VoiceProfileCreate(
name="Profile Two",
description="Second profile",
language="en"
)
profile_2 = await create_profile(profile_data_2, test_db)
assert profile_2.name == "Profile Two"
# Verify both profiles exist
assert profile_1.id != profile_2.id
@pytest.mark.asyncio
async def test_update_profile_to_duplicate_name_raises_error(test_db, mock_profiles_dir):
"""Test that updating a profile to a duplicate name raises a ValueError."""
# Create two profiles with different names
profile_data_1 = VoiceProfileCreate(
name="Profile A",
description="First profile",
language="en"
)
profile_1 = await create_profile(profile_data_1, test_db)
profile_data_2 = VoiceProfileCreate(
name="Profile B",
description="Second profile",
language="en"
)
profile_2 = await create_profile(profile_data_2, test_db)
# Try to update profile_2 to use profile_1's name
update_data = VoiceProfileCreate(
name="Profile A", # Duplicate name
description="Updated description",
language="en"
)
with pytest.raises(ValueError) as exc_info:
await update_profile(profile_2.id, update_data, test_db)
# Verify error message is user-friendly
assert "already exists" in str(exc_info.value)
assert "Profile A" in str(exc_info.value)
@pytest.mark.asyncio
async def test_update_profile_keep_same_name_succeeds(test_db, mock_profiles_dir):
"""Test that updating a profile while keeping the same name succeeds."""
# Create profile
profile_data = VoiceProfileCreate(
name="My Profile",
description="Original description",
language="en"
)
profile = await create_profile(profile_data, test_db)
# Update profile with same name but different description
update_data = VoiceProfileCreate(
name="My Profile", # Same name
description="Updated description",
language="en"
)
updated_profile = await update_profile(profile.id, update_data, test_db)
# Verify update succeeded
assert updated_profile is not None
assert updated_profile.id == profile.id
assert updated_profile.name == "My Profile"
assert updated_profile.description == "Updated description"
@pytest.mark.asyncio
async def test_update_profile_to_new_unique_name_succeeds(test_db, mock_profiles_dir):
"""Test that updating a profile to a new unique name succeeds."""
# Create profile
profile_data = VoiceProfileCreate(
name="Original Name",
description="Profile description",
language="en"
)
profile = await create_profile(profile_data, test_db)
# Update profile with new unique name
update_data = VoiceProfileCreate(
name="New Unique Name",
description="Updated description",
language="en"
)
updated_profile = await update_profile(profile.id, update_data, test_db)
# Verify update succeeded
assert updated_profile is not None
assert updated_profile.id == profile.id
assert updated_profile.name == "New Unique Name"
@pytest.mark.asyncio
async def test_case_sensitive_names_allowed(test_db, mock_profiles_dir):
"""Test that profile names are case-sensitive (e.g., 'Test' and 'test' are different)."""
# Create profile with lowercase name
profile_data_1 = VoiceProfileCreate(
name="test profile",
description="Lowercase",
language="en"
)
profile_1 = await create_profile(profile_data_1, test_db)
# Create profile with different case
profile_data_2 = VoiceProfileCreate(
name="Test Profile",
description="Title case",
language="en"
)
profile_2 = await create_profile(profile_data_2, test_db)
# Both should succeed since SQLite unique constraint is case-sensitive by default
assert profile_1.name == "test profile"
assert profile_2.name == "Test Profile"
assert profile_1.id != profile_2.id
+89
View File
@@ -80,6 +80,95 @@ def save_audio(
sf.write(path, audio, sample_rate)
def trim_tts_output(
audio: np.ndarray,
sample_rate: int = 24000,
frame_ms: int = 20,
silence_threshold_db: float = -40.0,
min_silence_ms: int = 200,
max_internal_silence_ms: int = 1000,
fade_ms: int = 30,
) -> np.ndarray:
"""
Trim trailing silence and post-silence hallucination from TTS output.
Chatterbox sometimes produces ``[speech][silence][hallucinated noise]``.
This detects internal silence gaps longer than *max_internal_silence_ms*
and cuts the audio at that boundary, then trims trailing silence and
applies a short cosine fade-out.
Args:
audio: Input audio array (mono float32)
sample_rate: Sample rate in Hz
frame_ms: Frame size for RMS energy calculation
silence_threshold_db: dB threshold below which a frame is silence
min_silence_ms: Minimum trailing silence to keep
max_internal_silence_ms: Cut after any silence gap longer than this
fade_ms: Cosine fade-out duration in ms
Returns:
Trimmed audio array
"""
frame_len = int(sample_rate * frame_ms / 1000)
if frame_len == 0 or len(audio) < frame_len:
return audio
n_frames = len(audio) // frame_len
threshold_linear = 10 ** (silence_threshold_db / 20)
# Compute per-frame RMS
rms = np.array(
[
np.sqrt(np.mean(audio[i * frame_len : (i + 1) * frame_len] ** 2))
for i in range(n_frames)
]
)
is_speech = rms >= threshold_linear
# Find first speech frame
first_speech = 0
for i, s in enumerate(is_speech):
if s:
first_speech = max(0, i - 1) # keep 1 frame padding
break
# Walk forward from first speech; cut at long internal silence gaps
max_silence_frames = int(max_internal_silence_ms / frame_ms)
consecutive_silence = 0
cut_frame = n_frames
for i in range(first_speech, n_frames):
if is_speech[i]:
consecutive_silence = 0
else:
consecutive_silence += 1
if consecutive_silence >= max_silence_frames:
cut_frame = i - consecutive_silence + 1
break
# Trim trailing silence from the cut point
min_silence_frames = int(min_silence_ms / frame_ms)
end_frame = cut_frame
while end_frame > first_speech and not is_speech[end_frame - 1]:
end_frame -= 1
# Keep a short tail
end_frame = min(end_frame + min_silence_frames, cut_frame)
# Convert frames back to samples
start_sample = first_speech * frame_len
end_sample = min(end_frame * frame_len, len(audio))
trimmed = audio[start_sample:end_sample].copy()
# Cosine fade-out
fade_samples = int(sample_rate * fade_ms / 1000)
if fade_samples > 0 and len(trimmed) > fade_samples:
fade = np.cos(np.linspace(0, np.pi / 2, fade_samples)) ** 2
trimmed[-fade_samples:] *= fade
return trimmed
def validate_reference_audio(
audio_path: str,
min_duration: float = 2.0,
+5 -4
View File
@@ -13,7 +13,7 @@
},
"app": {
"name": "@voicebox/app",
"version": "0.1.11",
"version": "0.1.12",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -68,7 +68,7 @@
},
"landing": {
"name": "@voicebox/landing",
"version": "0.1.11",
"version": "0.1.12",
"dependencies": {
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
@@ -93,7 +93,7 @@
},
"tauri": {
"name": "@voicebox/tauri",
"version": "0.1.11",
"version": "0.1.12",
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
@@ -116,7 +116,7 @@
},
"web": {
"name": "@voicebox/web",
"version": "0.1.11",
"version": "0.1.12",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"react": "^18.3.0",
@@ -125,6 +125,7 @@
"zustand": "^4.5.0",
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
+3 -3
View File
@@ -162,7 +162,7 @@ chmod +x voicebox-*.AppImage
**Solutions:**
1. **Check server is running**
```bash
curl http://localhost:8000/health
curl http://localhost:17493/health
```
2. **Check remote mode**
@@ -170,7 +170,7 @@ chmod +x voicebox-*.AppImage
- Check firewall settings
3. **Check port availability**
- Default port is 8000
- The current local app and dev workflow uses port 17493 by default
- Ensure no other service is using it
### CORS errors in browser
@@ -276,7 +276,7 @@ chmod +x voicebox-*.AppImage
2. **Check OpenAPI endpoint**
```bash
curl http://localhost:8000/openapi.json
curl http://localhost:17493/openapi.json
```
3. **Regenerate client**
+191
View File
@@ -0,0 +1,191 @@
# Voicebox development commands
# Install: brew install just (or cargo install just)
# Usage: just --list
# Directories
backend_dir := "backend"
tauri_dir := "tauri"
app_dir := "app"
web_dir := "web"
venv := backend_dir / "venv"
venv_bin := venv / "bin"
python := venv_bin / "python"
pip := venv_bin / "pip"
# Detect best python for venv creation
system_python := `command -v python3.12 2>/dev/null || command -v python3.13 2>/dev/null || echo python3`
# ─── Setup ────────────────────────────────────────────────────────────
# Full project setup (python venv + JS deps + dev sidecar)
setup: setup-python setup-js
@echo ""
@echo "Setup complete! Run: just dev"
# Create venv and install Python dependencies
setup-python:
#!/usr/bin/env bash
set -euo pipefail
if [ ! -d "{{ venv }}" ]; then
echo "Creating Python virtual environment..."
PY_MINOR=$({{ system_python }} -c "import sys; print(sys.version_info[1])")
if [ "$PY_MINOR" -gt 13 ]; then
echo "Warning: Python 3.$PY_MINOR detected. ML packages may not be compatible."
echo "Recommended: brew install [email protected]"
fi
{{ system_python }} -m venv {{ venv }}
fi
echo "Installing Python dependencies..."
{{ pip }} install --upgrade pip -q
{{ pip }} install -r {{ backend_dir }}/requirements.txt
# Chatterbox pins numpy<1.26 / torch==2.6 which break on Python 3.12+
{{ pip }} install --no-deps chatterbox-tts
# Apple Silicon: install MLX backend
if [ "$(uname -m)" = "arm64" ] && [ "$(uname)" = "Darwin" ]; then
echo "Detected Apple Silicon — installing MLX dependencies..."
{{ pip }} install -r {{ backend_dir }}/requirements-mlx.txt
fi
{{ pip }} install git+https://github.com/QwenLM/Qwen3-TTS.git
echo "Python environment ready."
# Install JavaScript dependencies
setup-js:
bun install
# ─── Development ──────────────────────────────────────────────────────
# Start backend + frontend for development (two processes, one terminal)
dev: _ensure-venv _ensure-sidecar
#!/usr/bin/env bash
set -euo pipefail
trap 'kill 0' EXIT
echo "Starting backend on http://localhost:17493 ..."
{{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 &
sleep 2
echo "Starting Tauri desktop app..."
cd {{ tauri_dir }} && bun run tauri dev &
wait
# Start backend only
dev-backend: _ensure-venv
{{ venv_bin }}/uvicorn backend.main:app --reload --port 17493
# Start Tauri desktop app only (backend must be running separately)
dev-frontend: _ensure-sidecar
cd {{ tauri_dir }} && bun run tauri dev
# Start backend + web app (no Tauri)
dev-web: _ensure-venv
#!/usr/bin/env bash
set -euo pipefail
trap 'kill 0' EXIT
{{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 &
sleep 2
cd {{ web_dir }} && bun run dev &
wait
# Kill all dev processes
kill:
-pkill -f "uvicorn backend.main:app" 2>/dev/null || true
-pkill -f "vite" 2>/dev/null || true
@echo "Dev processes killed."
# ─── Build ────────────────────────────────────────────────────────────
# Build everything (server binary + desktop app)
build: build-server build-tauri
# Build Python server binary
build-server: _ensure-venv
PATH="{{ venv_bin }}:$PATH" ./scripts/build-server.sh
# Build Tauri desktop app
build-tauri:
cd {{ tauri_dir }} && bun run tauri build
# Build web app
build-web:
cd {{ web_dir }} && bun run build
# ─── Code Quality ────────────────────────────────────────────────────
# Run all checks (lint + format + typecheck)
check:
bun run check
# Lint with Biome
lint:
bun run lint
# Format with Biome
format:
bun run format
# Fix lint + format issues
fix:
bun run check:fix
# ─── Database ─────────────────────────────────────────────────────────
# Initialize SQLite database
db-init: _ensure-venv
cd {{ backend_dir }} && {{ python }} -c "from database import init_db; init_db()"
# Reset database (delete + reinit)
db-reset:
rm -f {{ backend_dir }}/data/voicebox.db
just db-init
# ─── Utilities ────────────────────────────────────────────────────────
# Generate TypeScript API client (backend must be running)
generate-api:
./scripts/generate-api.sh
# Open API docs in browser
docs:
open http://localhost:17493/docs 2>/dev/null || xdg-open http://localhost:17493/docs
# Tail backend logs
logs:
tail -f {{ backend_dir }}/logs/*.log 2>/dev/null || echo "No log files found"
# ─── Clean ────────────────────────────────────────────────────────────
# Clean build artifacts
clean:
rm -rf {{ tauri_dir }}/src-tauri/target/release
rm -rf {{ web_dir }}/dist
rm -rf {{ app_dir }}/dist
# Clean Python venv and cache
clean-python:
rm -rf {{ venv }}
find {{ backend_dir }} -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
# Nuclear clean (everything including node_modules)
clean-all: clean clean-python
rm -rf node_modules
rm -rf {{ app_dir }}/node_modules
rm -rf {{ tauri_dir }}/node_modules
rm -rf {{ web_dir }}/node_modules
cd {{ tauri_dir }}/src-tauri && cargo clean
# ─── Internal ─────────────────────────────────────────────────────────
# Ensure venv exists (prompt to run setup if not)
[private]
_ensure-venv:
#!/usr/bin/env bash
if [ ! -d "{{ venv }}" ]; then
echo "Python venv not found. Run: just setup"
exit 1
fi
# Ensure Tauri dev sidecar placeholder exists
[private]
_ensure-sidecar:
bun run setup:dev
+6 -6
View File
@@ -6,7 +6,7 @@ set -e
echo "Generating OpenAPI client..."
# Check if backend is running
if ! curl -s http://localhost:8000/openapi.json > /dev/null 2>&1; then
if ! curl -s http://localhost:17493/openapi.json > /dev/null 2>&1; then
echo "Backend not running. Starting backend..."
cd backend
@@ -26,19 +26,19 @@ if ! curl -s http://localhost:8000/openapi.json > /dev/null 2>&1; then
# Start backend in background
echo "Starting backend server..."
uvicorn main:app --port 8000 &
uvicorn main:app --port 17493 & # Keep the generator on the app's documented local backend port.
BACKEND_PID=$!
# Wait for server to be ready
echo "Waiting for server to start..."
for i in {1..30}; do
if curl -s http://localhost:8000/openapi.json > /dev/null 2>&1; then
for _ in {1..30}; do
if curl -s http://localhost:17493/openapi.json > /dev/null 2>&1; then
break
fi
sleep 1
done
if ! curl -s http://localhost:8000/openapi.json > /dev/null 2>&1; then
if ! curl -s http://localhost:17493/openapi.json > /dev/null 2>&1; then
echo "Error: Backend failed to start"
kill $BACKEND_PID 2>/dev/null || true
exit 1
@@ -52,7 +52,7 @@ fi
# Download OpenAPI schema
echo "Downloading OpenAPI schema..."
curl -s http://localhost:8000/openapi.json > app/openapi.json
curl -s http://localhost:17493/openapi.json > app/openapi.json
# Check if openapi-typescript-codegen is installed
if ! bunx --bun openapi-typescript-codegen --version > /dev/null 2>&1; then
+271 -56
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node
/**
* Creates placeholder sidecar binaries for development mode.
*
@@ -9,10 +10,10 @@
* The actual server should be started separately with `bun run dev:server`.
*/
import { existsSync, mkdirSync, writeFileSync, statSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import { existsSync, mkdirSync, statSync, writeFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -55,7 +56,9 @@ function createPlaceholderBinary(targetTriple) {
try {
const stats = statSync(binaryPath);
if (stats.size > MIN_REAL_BINARY_SIZE) {
console.log(`Real binary already exists: ${binaryName} (${(stats.size / 1024 / 1024).toFixed(1)} MB)`);
console.log(
`Real binary already exists: ${binaryName} (${(stats.size / 1024 / 1024).toFixed(1)} MB)`,
);
return;
}
} catch {
@@ -73,52 +76,275 @@ function createPlaceholderBinary(targetTriple) {
// This is the smallest valid PE that Windows will accept
const minimalPE = Buffer.from([
// DOS Header
0x4D, 0x5A, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00,
0xB8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00,
0x4d,
0x5a,
0x90,
0x00,
0x03,
0x00,
0x00,
0x00,
0x04,
0x00,
0x00,
0x00,
0xff,
0xff,
0x00,
0x00,
0xb8,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x40,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x80,
0x00,
0x00,
0x00,
// DOS Stub
0x0E, 0x1F, 0xBA, 0x0E, 0x00, 0xB4, 0x09, 0xCD, 0x21, 0xB8, 0x01, 0x4C, 0xCD, 0x21, 0x54, 0x68,
0x69, 0x73, 0x20, 0x70, 0x72, 0x6F, 0x67, 0x72, 0x61, 0x6D, 0x20, 0x63, 0x61, 0x6E, 0x6E, 0x6F,
0x74, 0x20, 0x62, 0x65, 0x20, 0x72, 0x75, 0x6E, 0x20, 0x69, 0x6E, 0x20, 0x44, 0x4F, 0x53, 0x20,
0x6D, 0x6F, 0x64, 0x65, 0x2E, 0x0D, 0x0D, 0x0A, 0x24, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x0e,
0x1f,
0xba,
0x0e,
0x00,
0xb4,
0x09,
0xcd,
0x21,
0xb8,
0x01,
0x4c,
0xcd,
0x21,
0x54,
0x68,
0x69,
0x73,
0x20,
0x70,
0x72,
0x6f,
0x67,
0x72,
0x61,
0x6d,
0x20,
0x63,
0x61,
0x6e,
0x6e,
0x6f,
0x74,
0x20,
0x62,
0x65,
0x20,
0x72,
0x75,
0x6e,
0x20,
0x69,
0x6e,
0x20,
0x44,
0x4f,
0x53,
0x20,
0x6d,
0x6f,
0x64,
0x65,
0x2e,
0x0d,
0x0d,
0x0a,
0x24,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
// PE Signature
0x50, 0x45, 0x00, 0x00,
0x50,
0x45,
0x00,
0x00,
// COFF Header (x64)
0x64, 0x86, // Machine: AMD64
0x01, 0x00, // NumberOfSections: 1
0x00, 0x00, 0x00, 0x00, // TimeDateStamp
0x00, 0x00, 0x00, 0x00, // PointerToSymbolTable
0x00, 0x00, 0x00, 0x00, // NumberOfSymbols
0xF0, 0x00, // SizeOfOptionalHeader
0x22, 0x00, // Characteristics: EXECUTABLE_IMAGE | LARGE_ADDRESS_AWARE
0x64,
0x86, // Machine: AMD64
0x01,
0x00, // NumberOfSections: 1
0x00,
0x00,
0x00,
0x00, // TimeDateStamp
0x00,
0x00,
0x00,
0x00, // PointerToSymbolTable
0x00,
0x00,
0x00,
0x00, // NumberOfSymbols
0xf0,
0x00, // SizeOfOptionalHeader
0x22,
0x00, // Characteristics: EXECUTABLE_IMAGE | LARGE_ADDRESS_AWARE
// Optional Header (PE32+)
0x0B, 0x02, // Magic: PE32+
0x00, 0x00, // Linker version
0x00, 0x00, 0x00, 0x00, // SizeOfCode
0x00, 0x00, 0x00, 0x00, // SizeOfInitializedData
0x00, 0x00, 0x00, 0x00, // SizeOfUninitializedData
0x00, 0x10, 0x00, 0x00, // AddressOfEntryPoint
0x00, 0x00, 0x00, 0x00, // BaseOfCode
0x00, 0x00, 0x00, 0x40, 0x01, 0x00, 0x00, 0x00, // ImageBase
0x00, 0x10, 0x00, 0x00, // SectionAlignment
0x00, 0x02, 0x00, 0x00, // FileAlignment
0x06, 0x00, 0x00, 0x00, // OS version
0x00, 0x00, 0x00, 0x00, // Image version
0x06, 0x00, 0x00, 0x00, // Subsystem version
0x00, 0x00, 0x00, 0x00, // Win32VersionValue
0x00, 0x20, 0x00, 0x00, // SizeOfImage
0x00, 0x02, 0x00, 0x00, // SizeOfHeaders
0x00, 0x00, 0x00, 0x00, // CheckSum
0x03, 0x00, // Subsystem: CONSOLE
0x60, 0x01, // DllCharacteristics
0x0b,
0x02, // Magic: PE32+
0x00,
0x00, // Linker version
0x00,
0x00,
0x00,
0x00, // SizeOfCode
0x00,
0x00,
0x00,
0x00, // SizeOfInitializedData
0x00,
0x00,
0x00,
0x00, // SizeOfUninitializedData
0x00,
0x10,
0x00,
0x00, // AddressOfEntryPoint
0x00,
0x00,
0x00,
0x00, // BaseOfCode
0x00,
0x00,
0x00,
0x40,
0x01,
0x00,
0x00,
0x00, // ImageBase
0x00,
0x10,
0x00,
0x00, // SectionAlignment
0x00,
0x02,
0x00,
0x00, // FileAlignment
0x06,
0x00,
0x00,
0x00, // OS version
0x00,
0x00,
0x00,
0x00, // Image version
0x06,
0x00,
0x00,
0x00, // Subsystem version
0x00,
0x00,
0x00,
0x00, // Win32VersionValue
0x00,
0x20,
0x00,
0x00, // SizeOfImage
0x00,
0x02,
0x00,
0x00, // SizeOfHeaders
0x00,
0x00,
0x00,
0x00, // CheckSum
0x03,
0x00, // Subsystem: CONSOLE
0x60,
0x01, // DllCharacteristics
// Stack/Heap sizes (8 bytes each for PE32+)
0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, // LoaderFlags
0x10, 0x00, 0x00, 0x00, // NumberOfRvaAndSizes
0x00,
0x00,
0x10,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x10,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00, // LoaderFlags
0x10,
0x00,
0x00,
0x00, // NumberOfRvaAndSizes
]);
// Pad to 512 bytes minimum for valid PE
@@ -138,19 +364,8 @@ exit 1
}
function main() {
console.log('Setting up development sidecar...');
console.log('');
const targetTriple = getTargetTriple();
console.log(`Platform: ${targetTriple}`);
createPlaceholderBinary(targetTriple);
console.log('');
console.log('Sidecar setup complete.');
console.log('For development, start the Python server in a separate terminal:');
console.log(' bun run dev:server');
console.log('');
}
main();
+382
View File
@@ -0,0 +1,382 @@
#!/usr/bin/env python3
"""
Test script to observe exactly how HuggingFace reports download progress
for each TTS model. Doesn't load models — just downloads and tracks tqdm.
Usage:
backend/venv/bin/python scripts/test_download_progress.py qwen
backend/venv/bin/python scripts/test_download_progress.py luxtts
backend/venv/bin/python scripts/test_download_progress.py chatterbox
Add --delete to clear cache first and force a real download:
backend/venv/bin/python scripts/test_download_progress.py chatterbox --delete
"""
import os
import shutil
import sys
import time
import threading
from pathlib import Path
from contextlib import contextmanager
# ─── Configuration ────────────────────────────────────────────────────────────
MODELS = {
"qwen": {
"repo_id": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
"method": "from_pretrained",
"description": "Qwen TTS 1.7B (uses transformers from_pretrained)",
},
"luxtts": {
"repo_id": "YatharthS/LuxTTS",
"method": "snapshot_download",
"description": "LuxTTS (uses snapshot_download)",
},
"chatterbox": {
"repo_id": "ResembleAI/chatterbox",
"method": "snapshot_download",
"allow_patterns": [
"ve.pt",
"t3_mtl23ls_v2.safetensors",
"s3gen.pt",
"grapheme_mtl_merged_expanded_v1.json",
"conds.pt",
"Cangjie5_TC.json",
],
"description": "Chatterbox Multilingual (uses snapshot_download with allow_patterns)",
},
}
# ─── Progress tracking (mirrors our HFProgressTracker) ────────────────────────
class ProgressSpy:
"""Intercepts tqdm to see exactly what HF reports."""
def __init__(self):
self._lock = threading.Lock()
self.events = [] # List of dicts: {time, type, ...}
self._original_tqdm_class = None
self._original_tqdm_auto = None
self._patched_modules = {}
self._hf_tqdm_original_update = None
self._start_time = None
def _elapsed(self):
return time.time() - self._start_time if self._start_time else 0
def _log(self, event_type, **kwargs):
entry = {"time": f"{self._elapsed():.1f}s", "type": event_type, **kwargs}
self.events.append(entry)
# Live print
parts = [f"[{entry['time']:>7s}] {event_type:>10s}"]
for k, v in kwargs.items():
if k in ("current", "total") and isinstance(v, (int, float)) and v > 1_000_000:
parts.append(f"{k}={v / 1_000_000:.1f}MB")
else:
parts.append(f"{k}={v}")
print(" ".join(parts), flush=True)
def _create_tracked_tqdm_class(self):
spy = self
original_tqdm = self._original_tqdm_class
class SpyTqdm(original_tqdm):
def __init__(self, *args, **kwargs):
desc = kwargs.get("desc", "")
if not desc and args:
first_arg = args[0]
if isinstance(first_arg, str):
desc = first_arg
filename = ""
if desc:
if ":" in desc:
filename = desc.split(":")[0].strip()
else:
filename = desc.strip()
# Filter out non-standard kwargs
tqdm_kwargs = {
'iterable', 'desc', 'total', 'leave', 'file', 'ncols',
'mininterval', 'maxinterval', 'miniters', 'ascii', 'disable',
'unit', 'unit_scale', 'dynamic_ncols', 'smoothing',
'bar_format', 'initial', 'position', 'postfix',
'unit_divisor', 'write_bytes', 'lock_args', 'nrows',
'colour', 'color', 'delay', 'gui', 'disable_default', 'pos',
}
filtered_kwargs = {k: v for k, v in kwargs.items() if k in tqdm_kwargs}
try:
super().__init__(*args, **filtered_kwargs)
except TypeError:
super().__init__(*args, **kwargs)
self._spy_filename = filename or "unknown"
total = getattr(self, "total", None)
spy._log(
"INIT",
filename=self._spy_filename,
total=total or 0,
unit=kwargs.get("unit", "?"),
unit_scale=kwargs.get("unit_scale", False),
disable=kwargs.get("disable", False),
)
def update(self, n=1):
result = super().update(n)
current = getattr(self, "n", 0)
total = getattr(self, "total", 0)
filename = self._spy_filename
spy._log(
"UPDATE",
filename=filename,
n=n,
current=current,
total=total or 0,
pct=f"{100 * current / total:.1f}%" if total else "?",
)
return result
def close(self):
spy._log("CLOSE", filename=self._spy_filename)
return super().close()
return SpyTqdm
@contextmanager
def patch(self):
"""Context manager that patches tqdm globally — same as HFProgressTracker."""
self._start_time = time.time()
try:
import tqdm as tqdm_module
self._original_tqdm_class = tqdm_module.tqdm
except ImportError:
yield
return
tracked_tqdm = self._create_tracked_tqdm_class()
# Patch tqdm.tqdm
tqdm_module.tqdm = tracked_tqdm
# Patch tqdm.auto.tqdm
self._original_tqdm_auto = None
if hasattr(tqdm_module, "auto") and hasattr(tqdm_module.auto, "tqdm"):
self._original_tqdm_auto = tqdm_module.auto.tqdm
tqdm_module.auto.tqdm = tracked_tqdm
# Patch in sys.modules (same as HFProgressTracker)
tqdm_attr_names = ['tqdm', 'base_tqdm', 'old_tqdm']
patched_count = 0
for module_name in list(sys.modules.keys()):
if "huggingface" in module_name or module_name.startswith("tqdm"):
try:
module = sys.modules[module_name]
for attr_name in tqdm_attr_names:
if hasattr(module, attr_name):
attr = getattr(module, attr_name)
is_tqdm_class = (
attr is self._original_tqdm_class
or (self._original_tqdm_auto and attr is self._original_tqdm_auto)
or (
hasattr(attr, "__name__")
and attr.__name__ == "tqdm"
and hasattr(attr, "update")
)
)
if is_tqdm_class:
key = f"{module_name}.{attr_name}"
self._patched_modules[key] = (module, attr_name, attr)
setattr(module, attr_name, tracked_tqdm)
patched_count += 1
except (AttributeError, TypeError):
pass
# Monkey-patch HF's tqdm.update (same as HFProgressTracker)
try:
from huggingface_hub.utils import tqdm as hf_tqdm_module
if hasattr(hf_tqdm_module, 'tqdm'):
hf_tqdm_class = hf_tqdm_module.tqdm
self._hf_tqdm_original_update = hf_tqdm_class.update
spy = self
def patched_update(tqdm_self, n=1):
result = spy._hf_tqdm_original_update(tqdm_self, n)
desc = getattr(tqdm_self, 'desc', '') or ''
current = getattr(tqdm_self, 'n', 0)
total = getattr(tqdm_self, 'total', 0) or 0
spy._log(
"HF_UPDATE",
desc=desc,
current=current,
total=total,
pct=f"{100 * current / total:.1f}%" if total else "?",
)
return result
hf_tqdm_class.update = patched_update
patched_count += 1
except (ImportError, AttributeError):
pass
print(f"\n=== Patched {patched_count} tqdm references ===\n", flush=True)
try:
yield
finally:
# Restore everything
import tqdm as tqdm_module
tqdm_module.tqdm = self._original_tqdm_class
if self._original_tqdm_auto:
tqdm_module.auto.tqdm = self._original_tqdm_auto
for key, (module, attr_name, original) in self._patched_modules.items():
try:
setattr(module, attr_name, original)
except (AttributeError, TypeError):
pass
if self._hf_tqdm_original_update:
try:
from huggingface_hub.utils import tqdm as hf_tqdm_module
if hasattr(hf_tqdm_module, 'tqdm'):
hf_tqdm_module.tqdm.update = self._hf_tqdm_original_update
except (ImportError, AttributeError):
pass
def summary(self):
print("\n" + "=" * 70)
print("SUMMARY")
print("=" * 70)
inits = [e for e in self.events if e["type"] == "INIT"]
updates = [e for e in self.events if e["type"] in ("UPDATE", "HF_UPDATE")]
print(f"\ntqdm bars created: {len(inits)}")
for e in inits:
print(f" - {e.get('filename', '?'):40s} total={e.get('total', '?')}")
print(f"\nTotal update calls: {len(updates)}")
# Group updates by filename
by_file = {}
for e in updates:
fn = e.get("filename") or e.get("desc", "unknown")
if fn not in by_file:
by_file[fn] = []
by_file[fn].append(e)
for fn, evts in by_file.items():
max_current = max(e.get("current", 0) for e in evts)
max_total = max(e.get("total", 0) for e in evts)
print(f"\n {fn}:")
print(f" updates: {len(evts)}")
print(f" max current: {max_current:,}")
print(f" max total: {max_total:,}")
if max_total > 0 and max_current > 0:
print(f" final pct: {100 * max_current / max_total:.1f}%")
else:
print(f" final pct: NO PROGRESS REPORTED")
# ─── Delete cache ─────────────────────────────────────────────────────────────
def delete_cache(repo_id: str):
from huggingface_hub import constants as hf_constants
cache_dir = Path(hf_constants.HF_HUB_CACHE)
repo_cache = cache_dir / ("models--" + repo_id.replace("/", "--"))
if repo_cache.exists():
print(f"Deleting cache: {repo_cache}")
shutil.rmtree(repo_cache)
print("Deleted.")
else:
print(f"No cache found at {repo_cache}")
# ─── Download functions ───────────────────────────────────────────────────────
def download_qwen(spy: ProgressSpy):
"""Mirrors how pytorch_backend.py downloads Qwen."""
from transformers import AutoModel
repo_id = MODELS["qwen"]["repo_id"]
print(f"Downloading {repo_id} via AutoModel.from_pretrained...")
with spy.patch():
# This is what Qwen3TTSModel.from_pretrained does under the hood
from huggingface_hub import snapshot_download
snapshot_download(repo_id)
def download_luxtts(spy: ProgressSpy):
"""Mirrors how luxtts_backend.py downloads LuxTTS."""
from huggingface_hub import snapshot_download
repo_id = MODELS["luxtts"]["repo_id"]
print(f"Downloading {repo_id} via snapshot_download...")
with spy.patch():
snapshot_download(repo_id)
def download_chatterbox(spy: ProgressSpy):
"""Mirrors how chatterbox_backend.py downloads Chatterbox."""
from huggingface_hub import snapshot_download
cfg = MODELS["chatterbox"]
print(f"Downloading {cfg['repo_id']} via snapshot_download with allow_patterns...")
with spy.patch():
snapshot_download(
repo_id=cfg["repo_id"],
repo_type="model",
revision="main",
allow_patterns=cfg["allow_patterns"],
token=os.getenv("HF_TOKEN"),
)
# ─── Main ─────────────────────────────────────────────────────────────────────
def main():
if len(sys.argv) < 2 or sys.argv[1] not in MODELS:
print(f"Usage: {sys.argv[0]} <{'|'.join(MODELS.keys())}> [--delete]")
sys.exit(1)
model_key = sys.argv[1]
should_delete = "--delete" in sys.argv
cfg = MODELS[model_key]
print(f"\n{'=' * 70}")
print(f"Testing download progress for: {cfg['description']}")
print(f"Repo: {cfg['repo_id']}")
print(f"Method: {cfg['method']}")
print(f"{'=' * 70}\n")
if should_delete:
delete_cache(cfg["repo_id"])
print()
spy = ProgressSpy()
dispatch = {
"qwen": download_qwen,
"luxtts": download_luxtts,
"chatterbox": download_chatterbox,
}
try:
dispatch[model_key](spy)
except Exception as e:
print(f"\n!!! Download failed: {e}")
spy.summary()
if __name__ == "__main__":
main()
+2 -1
View File
@@ -5041,7 +5041,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "voicebox"
version = "0.1.12"
version = "0.1.13"
dependencies = [
"base64 0.22.1",
"core-foundation-sys",
@@ -5064,6 +5064,7 @@ dependencies = [
"tauri-plugin-updater",
"tokio",
"wasapi",
"webkit2gtk",
"windows 0.62.2",
]
+3
View File
@@ -37,6 +37,9 @@ core-foundation-sys = "0.8"
wasapi = "0.22"
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_Com"] }
[target.'cfg(target_os = "linux")'.dependencies]
webkit2gtk = "2.0"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2.0"
tauri-plugin-process = "2.0"
Binary file not shown.
Binary file not shown.
+37
View File
@@ -714,6 +714,43 @@ pub fn run() {
}
}
// Enable microphone access on Linux (WebKitGTK denies getUserMedia by default)
#[cfg(target_os = "linux")]
{
use tauri::Manager;
if let Some(window) = app.get_webview_window("main") {
let _ = window.with_webview(|webview| {
use webkit2gtk::{WebViewExt, SettingsExt, PermissionRequestExt};
use webkit2gtk::glib::ObjectExt;
let wk_webview = webview.inner();
// Enable media stream support in WebKitGTK settings
if let Some(settings) = WebViewExt::settings(&wk_webview) {
settings.set_enable_media_stream(true);
}
// Auto-grant UserMediaPermissionRequest (microphone access)
// Only for trusted local origins (Tauri dev server or custom protocol)
wk_webview.connect_permission_request(move |webview, request: &webkit2gtk::PermissionRequest| {
if request.is::<webkit2gtk::UserMediaPermissionRequest>() {
let uri = WebViewExt::uri(webview).unwrap_or_default();
let is_trusted = uri.starts_with("tauri://")
|| uri.starts_with("https://tauri.localhost")
|| uri.starts_with("http://localhost")
|| uri.starts_with("http://127.0.0.1");
if is_trusted {
request.allow();
return true;
}
request.deny();
return true;
}
false
});
});
}
}
Ok(())
})
.invoke_handler(tauri::generate_handler![