+
Models
+
Download and manage AI models for voice generation and transcription
-
-
-
- {isLoading ? (
-
-
-
- ) : modelStatus ? (
-
- {/* TTS Models */}
-
-
- Voice Generation Models
-
-
- {modelStatus.models
- .filter((m) => m.model_name.startsWith('qwen-tts'))
- .map((model) => (
-
+
+
+ {/* Model list */}
+ {isLoading ? (
+
+
+
+ ) : modelStatus ? (
+
+ {sections.map((section) => (
+
+
+ {section.label}
+
+
+ {section.models.map((model) => {
+ const { isDownloading, hasError } = getModelState(model);
+ return (
+
+ );
+ })}
+
+
+ ))}
+
+ {/* Error console */}
+ {errorCount > 0 && (
+
+
+
+
+
+ {consoleOpen && (
+
+ {Array.from(erroredDownloads.entries()).map(([modelName, dl]) => (
+
+
[error]{' '}
+
{modelName}
+ {dl.error ? (
+ <>
+ {': '}
+
+ {dl.error}
+
+ >
+ ) : (
+ <>
+ {': '}
+
+ No error details available. Try downloading again.
+
+ >
+ )}
+
+ started at {new Date(dl.started_at).toLocaleString()}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ ) : null}
+
+ {/* Model Detail Modal */}
+
-
- {/* Whisper Models */}
-
-
- Transcription Models
-
-
- {modelStatus.models
- .filter((m) => m.model_name.startsWith('whisper'))
- .map((model) => (
- handleDownload(model.model_name)}
- onDelete={() => {
- setModelToDelete({
- name: model.model_name,
- displayName: model.display_name,
- sizeMb: model.size_mb,
- });
- setDeleteDialogOpen(true);
- }}
- isDownloading={downloadingModel === model.model_name}
- formatSize={formatSize}
- />
- ))}
-
-
-
-
- ) : null}
-
+ >
+ )}
+
+
{/* Delete Confirmation Dialog */}
@@ -278,9 +795,25 @@ interface ModelItemProps {
function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: ModelItemProps) {
// Use server's downloading state OR local state (for immediate feedback before server updates)
const showDownloading = model.downloading || isDownloading;
-
+
+ const statusText = model.loaded
+ ? 'Loaded'
+ : showDownloading
+ ? 'Downloading'
+ : model.downloaded
+ ? 'Downloaded'
+ : 'Not downloaded';
+ const sizeText =
+ model.downloaded && model.size_mb && !showDownloading ? `, ${formatSize(model.size_mb)}` : '';
+ const rowLabel = `${model.display_name}, ${statusText}${sizeText}. Use Tab to reach Download or Delete.`;
+
return (
-
+
{model.display_name}
@@ -314,17 +847,27 @@ function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: M
variant="outline"
disabled={model.loaded}
title={model.loaded ? 'Unload model before deleting' : 'Delete model'}
+ aria-label={
+ model.loaded
+ ? 'Unload model before deleting'
+ : `Delete ${model.display_name}`
+ }
>
) : showDownloading ? (
-
+ {platform.metadata.isTauri &&
}
{platform.metadata.isTauri &&
}
Created by{' '}
diff --git a/app/src/components/StoriesTab/StoriesTab.tsx b/app/src/components/StoriesTab/StoriesTab.tsx
index f237e8db..7005092d 100644
--- a/app/src/components/StoriesTab/StoriesTab.tsx
+++ b/app/src/components/StoriesTab/StoriesTab.tsx
@@ -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 (
{/* Main content area */}
@@ -18,7 +21,7 @@ export function StoriesTab() {
{/* Floating Generate Box - position is managed via storyStore.trackEditorHeight */}
-
+
);
diff --git a/app/src/components/StoriesTab/StoryList.tsx b/app/src/components/StoriesTab/StoryList.tsx
index ebbd6616..a39a806d 100644
--- a/app/src/components/StoriesTab/StoryList.tsx
+++ b/app/src/components/StoriesTab/StoryList.tsx
@@ -194,17 +194,29 @@ export function StoryList() {
storyList.map((story) => (
setSelectedStoryId(story.id)}
+ onKeyDown={(e) => {
+ if (e.target !== e.currentTarget) return;
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ setSelectedStoryId(story.id);
+ }
+ }}
>
-
setSelectedStoryId(story.id)}
- >
+
{story.name}
{story.description && (
@@ -218,7 +230,7 @@ export function StoryList() {
•
{formatDate(story.updated_at)}
-
+
e.stopPropagation()}
+ aria-label={`Actions for ${story.name}`}
>
diff --git a/app/src/components/StoriesTab/StoryTrackEditor.tsx b/app/src/components/StoriesTab/StoryTrackEditor.tsx
index 74dbde25..71e33cdd 100644
--- a/app/src/components/StoriesTab/StoryTrackEditor.tsx
+++ b/app/src/components/StoriesTab/StoryTrackEditor.tsx
@@ -736,6 +736,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
className="h-7 w-7"
onClick={handlePlayPause}
title="Play/Pause (Space)"
+ aria-label={isCurrentlyPlaying ? 'Pause' : 'Play'}
>
{isCurrentlyPlaying ? : }
@@ -745,6 +746,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
className="h-7 w-7"
onClick={handleStop}
disabled={!isCurrentlyPlaying}
+ aria-label="Stop"
>
@@ -762,6 +764,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
className="h-7 w-7"
onClick={handleSplit}
title="Split at playhead (S)"
+ aria-label="Split at playhead"
>
@@ -771,6 +774,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
className="h-7 w-7"
onClick={handleDuplicate}
title="Duplicate (Cmd/Ctrl+D)"
+ aria-label="Duplicate clip"
>
@@ -780,6 +784,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
className="h-7 w-7"
onClick={handleDelete}
title="Delete (Delete/Backspace)"
+ aria-label="Delete clip"
>
@@ -789,10 +794,22 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
{/* Zoom controls - right side */}
diff --git a/app/src/components/VoiceProfiles/AudioSampleRecording.tsx b/app/src/components/VoiceProfiles/AudioSampleRecording.tsx
index 3807306f..acebbbd4 100644
--- a/app/src/components/VoiceProfiles/AudioSampleRecording.tsx
+++ b/app/src/components/VoiceProfiles/AudioSampleRecording.tsx
@@ -140,7 +140,13 @@ export function AudioSampleRecording({
File: {file.name}
-
+
{isPlaying ? : }
File: {file.name}
-
+
{isPlaying ? : }
{isPlaying ? : }
diff --git a/app/src/components/VoiceProfiles/ProfileCard.tsx b/app/src/components/VoiceProfiles/ProfileCard.tsx
index e879294f..2f13d957 100644
--- a/app/src/components/VoiceProfiles/ProfileCard.tsx
+++ b/app/src/components/VoiceProfiles/ProfileCard.tsx
@@ -61,6 +61,19 @@ export function ProfileCard({ profile }: ProfileCardProps) {
exportProfile.mutate(profile.id);
};
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ const target = e.target as HTMLElement;
+ if (target.closest('button')) return;
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ handleSelect();
+ }
+ };
+
+ const selectLabel = isSelected
+ ? `${profile.name}, ${profile.language}. Selected as voice for generation.`
+ : `${profile.name}, ${profile.language}. Select as voice for generation.`;
+
return (
<>
diff --git a/app/src/components/VoiceProfiles/SampleList.tsx b/app/src/components/VoiceProfiles/SampleList.tsx
index 19aa1ca8..a1dee07b 100644
--- a/app/src/components/VoiceProfiles/SampleList.tsx
+++ b/app/src/components/VoiceProfiles/SampleList.tsx
@@ -102,6 +102,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
className="h-7 w-7 shrink-0"
onClick={handlePlayPause}
disabled={isLoading}
+ aria-label={isPlaying ? 'Pause sample' : 'Play sample'}
>
{isPlaying ? : }
@@ -113,6 +114,8 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
max={100}
step={0.1}
className="flex-1"
+ aria-label="Sample playback position"
+ aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
/>
{formatAudioDuration(currentTime)}
@@ -128,6 +131,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
className="h-7 w-7 shrink-0"
onClick={handleStop}
title="Stop"
+ aria-label="Stop playback"
>
diff --git a/app/src/components/VoicesTab/VoicesTab.tsx b/app/src/components/VoicesTab/VoicesTab.tsx
index c5dbf0a1..52f2f4cd 100644
--- a/app/src/components/VoicesTab/VoicesTab.tsx
+++ b/app/src/components/VoicesTab/VoicesTab.tsx
@@ -179,25 +179,36 @@ function VoiceRow({
onDelete,
}: VoiceRowProps) {
const { data: samples } = useProfileSamples(profile.id);
+ const sampleCount = samples?.length || 0;
+
+ const rowLabel = `${profile.name}, ${profile.language}, ${generationCount} generations, ${sampleCount} samples. Press Enter to edit.`;
return (
-
+
{
+ e.stopPropagation();
+ onEdit();
+ }}
+ >
-
-
{profile.name}
+
+
{profile.name}
{profile.description && (
-
{profile.description}
+
{profile.description}
)}
-
+
e.stopPropagation()}>{profile.language}
e.stopPropagation()}>{generationCount}
-
e.stopPropagation()}>{samples?.length || 0}
+
e.stopPropagation()}>{sampleCount}
e.stopPropagation()}>
({
@@ -213,7 +224,7 @@ function VoiceRow({
e.stopPropagation()}>
-
+
diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts
index c5b079b2..eb78e440 100644
--- a/app/src/lib/api/client.ts
+++ b/app/src/lib/api/client.ts
@@ -1,29 +1,30 @@
-import { useServerStore } from '@/stores/serverStore';
import type { LanguageCode } from '@/lib/constants/languages';
+import { useServerStore } from '@/stores/serverStore';
import type {
- VoiceProfileCreate,
- VoiceProfileResponse,
- ProfileSampleResponse,
+ ActiveTasksResponse,
+ CudaStatus,
GenerationRequest,
GenerationResponse,
- HistoryQuery,
- HistoryListResponse,
- HistoryResponse,
- TranscriptionResponse,
HealthResponse,
- ModelStatusListResponse,
+ HistoryListResponse,
+ HistoryQuery,
+ HistoryResponse,
ModelDownloadRequest,
- ActiveTasksResponse,
+ ModelStatusListResponse,
+ ProfileSampleResponse,
StoryCreate,
- StoryResponse,
StoryDetailResponse,
+ StoryItemBatchUpdate,
StoryItemCreate,
StoryItemDetail,
- StoryItemBatchUpdate,
- StoryItemReorder,
StoryItemMove,
- StoryItemTrim,
+ StoryItemReorder,
StoryItemSplit,
+ StoryItemTrim,
+ StoryResponse,
+ TranscriptionResponse,
+ VoiceProfileCreate,
+ VoiceProfileResponse,
} from './types';
class ApiClient {
@@ -251,7 +252,13 @@ class ApiClient {
return response.blob();
}
- async importGeneration(file: File): Promise<{ id: string; profile_id: string; profile_name: string; text: string; message: string }> {
+ async importGeneration(file: File): Promise<{
+ id: string;
+ profile_id: string;
+ profile_name: string;
+ text: string;
+ message: string;
+ }> {
const url = `${this.getBaseUrl()}/history/import`;
const formData = new FormData();
formData.append('file', file);
@@ -310,7 +317,12 @@ class ApiClient {
}
async triggerModelDownload(modelName: string): Promise<{ message: string }> {
- console.log('[API] triggerModelDownload called for:', modelName, 'at', new Date().toISOString());
+ console.log(
+ '[API] triggerModelDownload called for:',
+ modelName,
+ 'at',
+ new Date().toISOString(),
+ );
const result = await this.request<{ message: string }>('/models/download', {
method: 'POST',
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
@@ -325,11 +337,22 @@ class ApiClient {
});
}
+ async cancelDownload(modelName: string): Promise<{ message: string }> {
+ return this.request<{ message: string }>('/models/download/cancel', {
+ method: 'POST',
+ body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
+ });
+ }
+
// Task Management
async getActiveTasks(): Promise {
return this.request('/tasks/active');
}
+ async clearAllTasks(): Promise<{ message: string }> {
+ return this.request<{ message: string }>('/tasks/clear', { method: 'POST' });
+ }
+
// Audio Channels
async listChannels(): Promise<
Array<{
@@ -343,10 +366,7 @@ class ApiClient {
return this.request('/channels');
}
- async createChannel(data: {
- name: string;
- device_ids: string[];
- }): Promise<{
+ async createChannel(data: { name: string; device_ids: string[] }): Promise<{
id: string;
name: string;
is_default: boolean;
@@ -388,10 +408,7 @@ class ApiClient {
return this.request(`/channels/${channelId}/voices`);
}
- async setChannelVoices(
- channelId: string,
- profileIds: string[],
- ): Promise<{ message: string }> {
+ async setChannelVoices(channelId: string, profileIds: string[]): Promise<{ message: string }> {
return this.request(`/channels/${channelId}/voices`, {
method: 'PUT',
body: JSON.stringify({ profile_ids: profileIds }),
@@ -402,16 +419,30 @@ class ApiClient {
return this.request(`/profiles/${profileId}/channels`);
}
- async setProfileChannels(
- profileId: string,
- channelIds: string[],
- ): Promise<{ message: string }> {
+ async setProfileChannels(profileId: string, channelIds: string[]): Promise<{ message: string }> {
return this.request(`/profiles/${profileId}/channels`, {
method: 'PUT',
body: JSON.stringify({ channel_ids: channelIds }),
});
}
+ // CUDA Backend Management
+ async getCudaStatus(): Promise {
+ return this.request('/backend/cuda-status');
+ }
+
+ async downloadCudaBackend(): Promise<{ message: string; progress_key: string }> {
+ return this.request<{ message: string; progress_key: string }>('/backend/download-cuda', {
+ method: 'POST',
+ });
+ }
+
+ async deleteCudaBackend(): Promise<{ message: string }> {
+ return this.request<{ message: string }>('/backend/cuda', {
+ method: 'DELETE',
+ });
+ }
+
// Stories
async listStories(): Promise {
return this.request('/stories');
@@ -468,21 +499,33 @@ class ApiClient {
});
}
- async moveStoryItem(storyId: string, itemId: string, data: StoryItemMove): Promise {
+ async moveStoryItem(
+ storyId: string,
+ itemId: string,
+ data: StoryItemMove,
+ ): Promise {
return this.request(`/stories/${storyId}/items/${itemId}/move`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
- async trimStoryItem(storyId: string, itemId: string, data: StoryItemTrim): Promise {
+ async trimStoryItem(
+ storyId: string,
+ itemId: string,
+ data: StoryItemTrim,
+ ): Promise {
return this.request(`/stories/${storyId}/items/${itemId}/trim`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
- async splitStoryItem(storyId: string, itemId: string, data: StoryItemSplit): Promise {
+ async splitStoryItem(
+ storyId: string,
+ itemId: string,
+ data: StoryItemSplit,
+ ): Promise {
return this.request(`/stories/${storyId}/items/${itemId}/split`, {
method: 'POST',
body: JSON.stringify(data),
diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts
index 131c1be5..fe5f05a1 100644
--- a/app/src/lib/api/types.ts
+++ b/app/src/lib/api/types.ts
@@ -34,6 +34,8 @@ export interface GenerationRequest {
language: LanguageCode;
seed?: number;
model_size?: '1.7B' | '0.6B';
+ engine?: 'qwen' | 'luxtts' | 'chatterbox' | 'chatterbox_turbo';
+ instruct?: string;
}
export interface GenerationResponse {
@@ -78,7 +80,29 @@ export interface HealthResponse {
model_downloaded?: boolean;
model_size?: string;
gpu_available: boolean;
+ gpu_type?: string;
vram_used_mb?: number;
+ backend_type?: string;
+ backend_variant?: string; // "cpu" or "cuda"
+}
+
+export interface CudaDownloadProgress {
+ model_name: string;
+ current: number;
+ total: number;
+ progress: number;
+ filename?: string;
+ status: 'downloading' | 'extracting' | 'complete' | 'error';
+ timestamp: string;
+ error?: string;
+}
+
+export interface CudaStatus {
+ available: boolean; // CUDA binary exists on disk
+ active: boolean; // Currently running the CUDA binary
+ binary_path?: string;
+ downloading: boolean; // Download in progress
+ download_progress?: CudaDownloadProgress;
}
export interface ModelProgress {
@@ -95,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
+ 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[];
}
@@ -113,6 +154,11 @@ export interface ActiveDownloadTask {
model_name: string;
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 {
diff --git a/app/src/lib/constants/languages.ts b/app/src/lib/constants/languages.ts
index 9ffc396f..19d6bca6 100644
--- a/app/src/lib/constants/languages.ts
+++ b/app/src/lib/constants/languages.ts
@@ -1,26 +1,86 @@
/**
- * Supported languages for Qwen3-TTS
- * Based on: https://github.com/QwenLM/Qwen3-TTS
+ * Supported languages for voice generation, per engine.
+ *
+ * Qwen3-TTS supports 10 languages.
+ * LuxTTS is English-only.
+ * Chatterbox Multilingual supports 23 languages.
+ * Chatterbox Turbo is English-only.
*/
-export const SUPPORTED_LANGUAGES = {
- zh: 'Chinese',
+/** All languages that any engine supports. */
+export const ALL_LANGUAGES = {
+ ar: 'Arabic',
+ da: 'Danish',
+ de: 'German',
+ el: 'Greek',
en: 'English',
+ es: 'Spanish',
+ fi: 'Finnish',
+ fr: 'French',
+ he: 'Hebrew',
+ hi: 'Hindi',
+ it: 'Italian',
ja: 'Japanese',
ko: 'Korean',
- de: 'German',
- fr: 'French',
- ru: 'Russian',
+ ms: 'Malay',
+ nl: 'Dutch',
+ no: 'Norwegian',
+ pl: 'Polish',
pt: 'Portuguese',
- es: 'Spanish',
- it: 'Italian',
+ ru: 'Russian',
+ sv: 'Swedish',
+ sw: 'Swahili',
+ tr: 'Turkish',
+ zh: 'Chinese',
} as const;
-export type LanguageCode = keyof typeof SUPPORTED_LANGUAGES;
+export type LanguageCode = keyof typeof ALL_LANGUAGES;
-export const LANGUAGE_CODES = Object.keys(SUPPORTED_LANGUAGES) as LanguageCode[];
+/** Per-engine supported language codes. */
+export const ENGINE_LANGUAGES: Record = {
+ qwen: ['zh', 'en', 'ja', 'ko', 'de', 'fr', 'ru', 'pt', 'es', 'it'],
+ luxtts: ['en'],
+ chatterbox: [
+ 'ar',
+ 'da',
+ 'de',
+ 'el',
+ 'en',
+ 'es',
+ 'fi',
+ 'fr',
+ 'he',
+ 'hi',
+ 'it',
+ 'ja',
+ 'ko',
+ 'ms',
+ 'nl',
+ 'no',
+ 'pl',
+ 'pt',
+ 'ru',
+ 'sv',
+ 'sw',
+ 'tr',
+ 'zh',
+ ],
+ chatterbox_turbo: ['en'],
+} as const;
+/** Helper: get language options for a given engine. */
+export function getLanguageOptionsForEngine(engine: string) {
+ const codes = ENGINE_LANGUAGES[engine] ?? ENGINE_LANGUAGES.qwen;
+ return codes.map((code) => ({
+ value: code,
+ label: ALL_LANGUAGES[code],
+ }));
+}
+
+// ── Backwards-compatible exports used elsewhere ──────────────────────
+export const SUPPORTED_LANGUAGES = ALL_LANGUAGES;
+export const LANGUAGE_CODES = Object.keys(ALL_LANGUAGES) as LanguageCode[];
export const LANGUAGE_OPTIONS = LANGUAGE_CODES.map((code) => ({
value: code,
- label: SUPPORTED_LANGUAGES[code],
+ label: ALL_LANGUAGES[code],
}));
diff --git a/app/src/lib/hooks/useGenerationForm.ts b/app/src/lib/hooks/useGenerationForm.ts
index c6fdba50..5a83ce41 100644
--- a/app/src/lib/hooks/useGenerationForm.ts
+++ b/app/src/lib/hooks/useGenerationForm.ts
@@ -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', 'chatterbox_turbo']).optional(),
});
export type GenerationFormValues = z.infer;
@@ -47,6 +48,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
seed: undefined,
modelSize: '1.7B',
instruct: '',
+ engine: 'qwen',
...options.defaultValues,
},
});
@@ -67,8 +69,25 @@ 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'
+ : engine === 'chatterbox_turbo'
+ ? 'chatterbox-turbo'
+ : `qwen-tts-${data.modelSize}`;
+ const displayName =
+ engine === 'luxtts'
+ ? 'LuxTTS'
+ : engine === 'chatterbox'
+ ? 'Chatterbox TTS'
+ : engine === 'chatterbox_turbo'
+ ? 'Chatterbox Turbo'
+ : data.modelSize === '1.7B'
+ ? 'Qwen TTS 1.7B'
+ : 'Qwen TTS 0.6B';
try {
const modelStatus = await apiClient.getModelStatus();
@@ -82,13 +101,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 +120,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({
diff --git a/app/src/lib/hooks/useModelDownloadToast.tsx b/app/src/lib/hooks/useModelDownloadToast.tsx
index 2df221e1..d179ed22 100644
--- a/app/src/lib/hooks/useModelDownloadToast.tsx
+++ b/app/src/lib/hooks/useModelDownloadToast.tsx
@@ -10,7 +10,7 @@ interface UseModelDownloadToastOptions {
displayName: string;
enabled?: boolean;
onComplete?: () => void;
- onError?: () => void;
+ onError?: (error: string) => void;
}
/**
@@ -101,7 +101,7 @@ export function useModelDownloadToast({
break;
case 'error':
statusIcon = ;
- statusText = `Error: ${progress.error || 'Unknown error'}`;
+ statusText = 'Download failed. See Problems panel for details.';
break;
case 'downloading':
statusIcon = ;
@@ -131,8 +131,7 @@ export function useModelDownloadToast({
)}
),
- duration: progress.status === 'complete' ? 5000 : Infinity,
- variant: progress.status === 'error' ? 'destructive' : 'default',
+ duration: progress.status === 'complete' || progress.status === 'error' ? 5000 : Infinity,
});
// Close connection and dismiss toast on completion or error
@@ -169,7 +168,7 @@ export function useModelDownloadToast({
onComplete();
} else if (isError && onError) {
console.log('[useModelDownloadToast] Download error, calling onError callback');
- onError();
+ onError(progress.error || 'Unknown error');
}
}
}
diff --git a/app/src/platform/types.ts b/app/src/platform/types.ts
index 5ea4d609..23e99da5 100644
--- a/app/src/platform/types.ts
+++ b/app/src/platform/types.ts
@@ -51,6 +51,7 @@ export interface PlatformAudio {
export interface PlatformLifecycle {
startServer(remote?: boolean): Promise;
stopServer(): Promise;
+ restartServer(): Promise;
setKeepServerRunning(keep: boolean): Promise;
setupWindowCloseHandler(): Promise;
onServerReady?: () => void;
diff --git a/backend/README.md b/backend/README.md
index 57163467..88f872b1 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -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 "file=@sample.wav" \
-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 "file=@audio.wav" \
-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 "file=@sample1.wav" \
-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 "file=@sample2.wav" \
-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
diff --git a/backend/backends/__init__.py b/backend/backends/__init__.py
index f7c47ba9..f120e6ec 100644
--- a/backend/backends/__init__.py
+++ b/backend/backends/__init__.py
@@ -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,73 @@ 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",
+ "chatterbox_turbo": "Chatterbox Turbo",
+}
+
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()
+ elif engine == "chatterbox_turbo":
+ from .chatterbox_turbo_backend import ChatterboxTurboTTSBackend
+ backend = ChatterboxTurboTTSBackend()
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 +206,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
diff --git a/backend/backends/chatterbox_backend.py b/backend/backends/chatterbox_backend.py
new file mode 100644
index 00000000..88f87d4e
--- /dev/null
+++ b/backend/backends/chatterbox_backend.py
@@ -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)
diff --git a/backend/backends/chatterbox_turbo_backend.py b/backend/backends/chatterbox_turbo_backend.py
new file mode 100644
index 00000000..16bb5d70
--- /dev/null
+++ b/backend/backends/chatterbox_turbo_backend.py
@@ -0,0 +1,307 @@
+"""
+Chatterbox Turbo TTS backend implementation.
+
+Wraps ChatterboxTurboTTS from chatterbox-tts for fast, English-only
+voice cloning with paralinguistic tag support ([laugh], [cough], etc.).
+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_TURBO_HF_REPO = "ResembleAI/chatterbox-turbo"
+
+# Files that must be present for the turbo model
+_TURBO_WEIGHT_FILES = [
+ "t3_turbo_v1.safetensors",
+ "s3gen_meanflow.safetensors",
+ "ve.safetensors",
+]
+
+
+class ChatterboxTurboTTSBackend:
+ """Chatterbox Turbo TTS backend — fast, English-only, with paralinguistic tags."""
+
+ # 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_TURBO_HF_REPO
+
+ def _is_model_cached(self, model_size: str = "default") -> bool:
+ """Check if the Chatterbox Turbo model is cached locally."""
+ try:
+ from huggingface_hub import constants as hf_constants
+
+ repo_cache = Path(hf_constants.HF_HUB_CACHE) / (
+ "models--" + CHATTERBOX_TURBO_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 turbo weight files
+ snapshots_dir = repo_cache / "snapshots"
+ if snapshots_dir.exists():
+ for fname in _TURBO_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 Turbo cache: {e}")
+ return False
+
+ async def load_model(self, model_size: str = "default") -> None:
+ """Load the Chatterbox Turbo 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-turbo"
+
+ 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 Turbo TTS on {device}...")
+
+ import torch
+ from huggingface_hub import snapshot_download
+ from chatterbox.tts_turbo import ChatterboxTurboTTS
+
+ # Download model files ourselves so we can pass token=None
+ # (upstream from_pretrained passes token=True which requires
+ # a stored HF token even though the repo is public).
+ try:
+ local_path = snapshot_download(
+ repo_id=CHATTERBOX_TURBO_HF_REPO,
+ token=None,
+ allow_patterns=[
+ "*.safetensors", "*.json", "*.txt", "*.pt", "*.model",
+ ],
+ )
+ finally:
+ tracker_context.__exit__(None, None, None)
+
+ # Monkey-patch torch.load for CPU loading. The model's .pt files
+ # were saved on CUDA; from_local() doesn't pass map_location
+ # so loading on CPU fails without this.
+ 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 ChatterboxTurboTTSBackend._load_lock:
+ torch.load = _patched_load
+ try:
+ self.model = ChatterboxTurboTTS.from_local(
+ local_path, device,
+ )
+ finally:
+ torch.load = _orig_torch_load
+ else:
+ self.model = ChatterboxTurboTTS.from_local(
+ local_path, device,
+ )
+
+ if not is_cached:
+ progress_manager.mark_complete(model_name)
+ task_manager.complete_download(model_name)
+
+ logger.info("Chatterbox Turbo 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 Turbo: {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 Turbo 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 Turbo processes reference audio at generation time, so the
+ prompt just stores the file 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
+
+ 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 Turbo TTS.
+
+ Supports paralinguistic tags in text: [laugh], [cough], [chuckle], etc.
+
+ Args:
+ text: Text to synthesize (may include paralinguistic tags)
+ voice_prompt: Dict with ref_audio path
+ language: Ignored (Turbo is English-only)
+ 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
+
+ def _generate_sync():
+ import torch
+
+ if seed is not None:
+ torch.manual_seed(seed)
+
+ logger.info("[Chatterbox Turbo] Generating (English)")
+
+ wav = self.model.generate(
+ text,
+ audio_prompt_path=ref_audio,
+ temperature=0.8,
+ top_k=1000,
+ top_p=0.95,
+ repetition_penalty=1.2,
+ )
+
+ # 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)
diff --git a/backend/backends/luxtts_backend.py b/backend/backends/luxtts_backend.py
new file mode 100644
index 00000000..549e44ff
--- /dev/null
+++ b/backend/backends/luxtts_backend.py
@@ -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)
diff --git a/backend/backends/mlx_backend.py b/backend/backends/mlx_backend.py
index c4ecc090..49b1b924 100644
--- a/backend/backends/mlx_backend.py
+++ b/backend/backends/mlx_backend.py
@@ -5,8 +5,15 @@ MLX backend implementation for TTS and STT using mlx-audio.
from typing import Optional, List, Tuple
import asyncio
import numpy as np
+import os
from pathlib import Path
+# PATCH: Import and apply offline patch BEFORE any huggingface_hub usage
+# This prevents mlx_audio from making network requests when models are cached
+from ..utils.hf_offline_patch import patch_huggingface_hub_offline, ensure_original_qwen_config_cached
+patch_huggingface_hub_offline()
+ensure_original_qwen_config_cached()
+
from . import TTSBackend, STTBackend
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
from ..utils.audio import normalize_audio, load_audio
@@ -159,15 +166,35 @@ class MLXTTSBackend:
tracker_context = tracker.patch_download()
tracker_context.__enter__()
+ # PATCH: Force offline mode when model is already cached
+ # This prevents crashes when HuggingFace is unreachable
+ original_hf_hub_offline = os.environ.get("HF_HUB_OFFLINE")
+ if is_cached:
+ os.environ["HF_HUB_OFFLINE"] = "1"
+ print(f"[PATCH] Model {model_size} is cached, forcing HF_HUB_OFFLINE=1 to avoid network requests")
+
# Import mlx_audio AFTER patching tqdm
from mlx_audio.tts import load
# Load MLX model (downloads automatically)
try:
self.model = load(model_path)
+ except Exception as load_error:
+ # If offline mode failed, try with network enabled as fallback
+ if is_cached and "offline" in str(load_error).lower():
+ print(f"[PATCH] Offline load failed, trying with network: {load_error}")
+ os.environ.pop("HF_HUB_OFFLINE", None)
+ self.model = load(model_path)
+ else:
+ raise
finally:
# Exit the patch context
tracker_context.__exit__(None, None, None)
+ # Restore original HF_HUB_OFFLINE setting
+ if original_hf_hub_offline is not None:
+ os.environ["HF_HUB_OFFLINE"] = original_hf_hub_offline
+ else:
+ os.environ.pop("HF_HUB_OFFLINE", None)
# Only mark download as complete if we were tracking it
if not is_cached:
@@ -379,9 +406,17 @@ class MLXTTSBackend:
return audio, sample_rate
+WHISPER_HF_REPOS = {
+ "base": "openai/whisper-base",
+ "small": "openai/whisper-small",
+ "medium": "openai/whisper-medium",
+ "large": "openai/whisper-large-v3",
+}
+
+
class MLXSTTBackend:
"""MLX-based STT backend using mlx-audio Whisper."""
-
+
def __init__(self, model_size: str = "base"):
self.model = None
self.model_size = model_size
@@ -402,8 +437,8 @@ class MLXSTTBackend:
"""
try:
from huggingface_hub import constants as hf_constants
- model_name = f"openai/whisper-{model_size}"
- repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_name.replace("/", "--"))
+ hf_repo = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
+ repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + hf_repo.replace("/", "--"))
if not repo_cache.exists():
return False
@@ -474,7 +509,7 @@ class MLXSTTBackend:
from mlx_audio.stt import load
# MLX Whisper uses the standard OpenAI models
- model_name = f"openai/whisper-{model_size}"
+ model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
print(f"Loading MLX Whisper model {model_size}...")
diff --git a/backend/backends/pytorch_backend.py b/backend/backends/pytorch_backend.py
index d0cba11a..729053b4 100644
--- a/backend/backends/pytorch_backend.py
+++ b/backend/backends/pytorch_backend.py
@@ -369,9 +369,18 @@ class PyTorchTTSBackend:
return audio, sample_rate
+WHISPER_HF_REPOS = {
+ "base": "openai/whisper-base",
+ "small": "openai/whisper-small",
+ "medium": "openai/whisper-medium",
+ "large": "openai/whisper-large-v3",
+ "turbo": "openai/whisper-large-v3-turbo",
+}
+
+
class PyTorchSTTBackend:
"""PyTorch-based STT backend using Whisper."""
-
+
def __init__(self, model_size: str = "base"):
self.model = None
self.processor = None
@@ -416,18 +425,18 @@ class PyTorchSTTBackend:
"""
try:
from huggingface_hub import constants as hf_constants
- model_name = f"openai/whisper-{model_size}"
- repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_name.replace("/", "--"))
-
+ hf_repo = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
+ repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + hf_repo.replace("/", "--"))
+
if not repo_cache.exists():
return False
-
+
# Check for .incomplete files - if any exist, download is still in progress
blobs_dir = repo_cache / "blobs"
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
print(f"[_is_model_cached] Found .incomplete files for whisper-{model_size}, treating as not cached")
return False
-
+
# Check that actual model weight files exist in snapshots
snapshots_dir = repo_cache / "snapshots"
if snapshots_dir.exists():
@@ -438,12 +447,12 @@ class PyTorchSTTBackend:
if not has_weights:
print(f"[_is_model_cached] No model weights found for whisper-{model_size}, treating as not cached")
return False
-
+
return True
except Exception as e:
print(f"[_is_model_cached] Error checking cache for whisper-{model_size}: {e}")
return False
-
+
async def load_model_async(self, model_size: Optional[str] = None):
"""
Lazy load the Whisper model.
@@ -494,7 +503,7 @@ class PyTorchSTTBackend:
# Import transformers
from transformers import WhisperProcessor, WhisperForConditionalGeneration
- model_name = f"openai/whisper-{model_size}"
+ model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
print(f"[DEBUG] Model name: {model_name}")
print(f"Loading Whisper model {model_size} on {self.device}...")
@@ -583,21 +592,20 @@ class PyTorchSTTBackend:
)
inputs = inputs.to(self.device)
- # Set language if provided
- forced_decoder_ids = None
+ # Generate transcription
+ # If language is provided, force it; otherwise let Whisper auto-detect
+ generate_kwargs = {}
if language:
- # Support all languages from frontend: en, zh, ja, ko, de, fr, ru, pt, es, it
- # Whisper supports these and many more
forced_decoder_ids = self.processor.get_decoder_prompt_ids(
language=language,
task="transcribe",
)
+ generate_kwargs["forced_decoder_ids"] = forced_decoder_ids
- # Generate transcription
with torch.no_grad():
predicted_ids = self.model.generate(
inputs["input_features"],
- forced_decoder_ids=forced_decoder_ids,
+ **generate_kwargs,
)
# Decode
diff --git a/backend/build_binary.py b/backend/build_binary.py
index 73f21d23..1934db3c 100644
--- a/backend/build_binary.py
+++ b/backend/build_binary.py
@@ -1,8 +1,13 @@
"""
PyInstaller build script for creating standalone Python server binary.
+
+Usage:
+ python build_binary.py # Build default (CPU) server binary
+ python build_binary.py --cuda # Build CUDA-enabled server binary
"""
import PyInstaller.__main__
+import argparse
import os
import platform
from pathlib import Path
@@ -13,15 +18,22 @@ def is_apple_silicon():
return platform.system() == "Darwin" and platform.machine() == "arm64"
-def build_server():
- """Build Python server as standalone binary."""
+def build_server(cuda=False):
+ """Build Python server as standalone binary.
+
+ Args:
+ cuda: If True, build with CUDA support and name the binary
+ voicebox-server-cuda instead of voicebox-server.
+ """
backend_dir = Path(__file__).parent
+ binary_name = 'voicebox-server-cuda' if cuda else 'voicebox-server'
+
# PyInstaller arguments
args = [
'server.py', # Use server.py as entry point instead of main.py
'--onefile',
- '--name', 'voicebox-server',
+ '--name', binary_name,
]
# Add local qwen_tts path if specified (for editable installs)
@@ -49,6 +61,7 @@ def build_server():
'--hidden-import', 'backend.utils.progress',
'--hidden-import', 'backend.utils.hf_progress',
'--hidden-import', 'backend.utils.validation',
+ '--hidden-import', 'backend.cuda_download',
'--hidden-import', 'torch',
'--hidden-import', 'transformers',
'--hidden-import', 'fastapi',
@@ -70,8 +83,16 @@ def build_server():
'--collect-submodules', 'jaraco',
])
- # Add MLX-specific imports if building on Apple Silicon
- if is_apple_silicon():
+ # Add CUDA-specific hidden imports
+ if cuda:
+ print("Building with CUDA support")
+ args.extend([
+ '--hidden-import', 'torch.cuda',
+ '--hidden-import', 'torch.backends.cudnn',
+ ])
+
+ # Add MLX-specific imports if building on Apple Silicon (never for CUDA builds)
+ if is_apple_silicon() and not cuda:
print("Building for Apple Silicon - including MLX dependencies")
args.extend([
'--hidden-import', 'backend.backends.mlx_backend',
@@ -91,7 +112,7 @@ def build_server():
'--collect-all', 'mlx',
'--collect-all', 'mlx_audio',
])
- else:
+ elif not cuda:
print("Building for non-Apple Silicon platform - PyTorch only")
args.extend([
@@ -105,8 +126,15 @@ def build_server():
# Run PyInstaller
PyInstaller.__main__.run(args)
- print(f"Binary built in {backend_dir / 'dist' / 'voicebox-server'}")
+ print(f"Binary built in {backend_dir / 'dist' / binary_name}")
if __name__ == '__main__':
- build_server()
+ parser = argparse.ArgumentParser(description="Build voicebox-server binary")
+ parser.add_argument(
+ '--cuda',
+ action='store_true',
+ help="Build CUDA-enabled binary (voicebox-server-cuda)",
+ )
+ cli_args = parser.parse_args()
+ build_server(cuda=cli_args.cuda)
diff --git a/backend/cuda_download.py b/backend/cuda_download.py
new file mode 100644
index 00000000..51a8302d
--- /dev/null
+++ b/backend/cuda_download.py
@@ -0,0 +1,198 @@
+"""
+CUDA backend binary download, assembly, and verification.
+
+Downloads split parts of the CUDA-enabled voicebox-server binary from
+GitHub Releases, reassembles them, verifies integrity via SHA-256,
+and places the binary in the app's data directory for use on next
+backend restart.
+"""
+
+import hashlib
+import logging
+import os
+import sys
+from pathlib import Path
+from typing import Optional
+
+from .config import get_data_dir
+from .utils.progress import get_progress_manager
+from . import __version__
+
+logger = logging.getLogger(__name__)
+
+GITHUB_RELEASES_URL = "https://github.com/jamiepine/voicebox/releases/download"
+
+PROGRESS_KEY = "cuda-backend"
+
+
+def get_backends_dir() -> Path:
+ """Directory where downloaded backend binaries are stored."""
+ d = get_data_dir() / "backends"
+ d.mkdir(parents=True, exist_ok=True)
+ return d
+
+
+def get_cuda_binary_name() -> str:
+ """Platform-specific CUDA binary filename."""
+ if sys.platform == "win32":
+ return "voicebox-server-cuda.exe"
+ return "voicebox-server-cuda"
+
+
+def get_cuda_binary_path() -> Optional[Path]:
+ """Return path to CUDA binary if it exists."""
+ p = get_backends_dir() / get_cuda_binary_name()
+ if p.exists():
+ return p
+ return None
+
+
+def is_cuda_active() -> bool:
+ """Check if the current process is the CUDA binary.
+
+ The CUDA binary sets this env var on startup (see server.py).
+ """
+ return os.environ.get("VOICEBOX_BACKEND_VARIANT") == "cuda"
+
+
+def get_cuda_status() -> dict:
+ """Get current CUDA backend status for the API."""
+ progress_manager = get_progress_manager()
+ cuda_path = get_cuda_binary_path()
+ progress = progress_manager.get_progress(PROGRESS_KEY)
+
+ return {
+ "available": cuda_path is not None,
+ "active": is_cuda_active(),
+ "binary_path": str(cuda_path) if cuda_path else None,
+ "downloading": progress is not None and progress.get("status") == "downloading",
+ "download_progress": progress,
+ }
+
+
+async def download_cuda_binary(version: Optional[str] = None):
+ """Download the CUDA backend binary from GitHub Releases.
+
+ Downloads split parts listed in a manifest file, concatenates them,
+ and verifies the SHA-256 checksum for integrity. Atomic write
+ (temp file -> rename).
+
+ Args:
+ version: Version tag (e.g. "v0.2.0"). Defaults to current app version.
+ """
+ import httpx
+
+ if version is None:
+ version = f"v{__version__}"
+
+ progress = get_progress_manager()
+ binary_name = get_cuda_binary_name()
+ dest_dir = get_backends_dir()
+ final_path = dest_dir / binary_name
+ temp_path = dest_dir / f"{binary_name}.download"
+
+ # Clean up any leftover partial download
+ if temp_path.exists():
+ temp_path.unlink()
+
+ logger.info(f"Starting CUDA backend download for {version}")
+ progress.update_progress(
+ PROGRESS_KEY, current=0, total=0,
+ filename="Fetching manifest...", status="downloading",
+ )
+
+ base_url = f"{GITHUB_RELEASES_URL}/{version}"
+ stem = Path(binary_name).stem # voicebox-server-cuda
+
+ try:
+ async with httpx.AsyncClient(follow_redirects=True, timeout=30.0) as client:
+ # Fetch the manifest (list of split part filenames)
+ manifest_url = f"{base_url}/{stem}.manifest"
+ manifest_resp = await client.get(manifest_url)
+ manifest_resp.raise_for_status()
+ parts = [p.strip() for p in manifest_resp.text.strip().splitlines() if p.strip()]
+
+ if not parts:
+ raise ValueError("Empty manifest — no split parts found")
+
+ logger.info(f"Found {len(parts)} split parts to download")
+
+ # Fetch expected checksum (optional — for integrity verification)
+ expected_sha = None
+ try:
+ sha_url = f"{base_url}/{stem}.sha256"
+ sha_resp = await client.get(sha_url)
+ if sha_resp.status_code == 200:
+ # Format: "sha256hex filename\n"
+ expected_sha = sha_resp.text.strip().split()[0]
+ logger.info(f"Expected SHA-256: {expected_sha[:16]}...")
+ except Exception as e:
+ logger.warning(f"Could not fetch checksum file — skipping verification: {e}")
+
+ # Download and concatenate parts
+ total_downloaded = 0
+ with open(temp_path, "wb") as f:
+ for i, part_name in enumerate(parts):
+ part_url = f"{base_url}/{part_name}"
+ logger.info(f"Downloading part {i + 1}/{len(parts)}: {part_name}")
+
+ async with client.stream("GET", part_url) as response:
+ response.raise_for_status()
+ async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
+ f.write(chunk)
+ total_downloaded += len(chunk)
+ progress.update_progress(
+ PROGRESS_KEY, current=total_downloaded, total=0,
+ filename=f"Part {i + 1}/{len(parts)}",
+ status="downloading",
+ )
+
+ # Verify integrity if checksum was available
+ if expected_sha:
+ progress.update_progress(
+ PROGRESS_KEY, current=total_downloaded, total=total_downloaded,
+ filename="Verifying integrity...", status="downloading",
+ )
+ sha256 = hashlib.sha256()
+ with open(temp_path, "rb") as f:
+ while True:
+ chunk = f.read(1024 * 1024)
+ if not chunk:
+ break
+ sha256.update(chunk)
+
+ actual = sha256.hexdigest()
+ if actual != expected_sha:
+ raise ValueError(
+ f"Integrity check failed: expected {expected_sha[:16]}..., "
+ f"got {actual[:16]}..."
+ )
+ logger.info(f"Integrity verified: {actual[:16]}...")
+
+ # Atomic move into place (replace handles existing target on all platforms)
+ temp_path.replace(final_path)
+
+ # Make executable on Unix
+ if sys.platform != "win32":
+ final_path.chmod(0o755)
+
+ logger.info(f"CUDA backend downloaded to {final_path}")
+ progress.mark_complete(PROGRESS_KEY)
+
+ except Exception as e:
+ # Clean up on failure
+ if temp_path.exists():
+ temp_path.unlink()
+ logger.error(f"CUDA backend download failed: {e}")
+ progress.mark_error(PROGRESS_KEY, str(e))
+ raise
+
+
+async def delete_cuda_binary() -> bool:
+ """Delete the downloaded CUDA binary. Returns True if deleted."""
+ path = get_cuda_binary_path()
+ if path and path.exists():
+ path.unlink()
+ logger.info(f"Deleted CUDA binary: {path}")
+ return True
+ return False
diff --git a/backend/main.py b/backend/main.py
index e218d237..b76148ae 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -14,7 +14,6 @@ from datetime import datetime
import asyncio
import uvicorn
import argparse
-import torch
import tempfile
import io
from pathlib import Path
@@ -22,6 +21,18 @@ import uuid
import asyncio
import signal
import os
+
+# Set HSA_OVERRIDE_GFX_VERSION for AMD GPUs that aren't officially listed in ROCm
+# (e.g., RX 6600 is gfx1032 which maps to gfx1030 target)
+# This must be set BEFORE any torch.cuda calls
+if not os.environ.get("HSA_OVERRIDE_GFX_VERSION"):
+ os.environ["HSA_OVERRIDE_GFX_VERSION"] = "10.3.0"
+
+# Suppress noisy MIOpen workspace warnings on AMD GPUs
+if not os.environ.get("MIOPEN_LOG_LEVEL"):
+ os.environ["MIOPEN_LOG_LEVEL"] = "4"
+
+import torch
from urllib.parse import quote
@@ -48,6 +59,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",
@@ -206,6 +229,76 @@ async def health():
gpu_type=gpu_type,
vram_used_mb=vram_used,
backend_type=backend_type,
+ backend_variant=os.environ.get("VOICEBOX_BACKEND_VARIANT", "cpu"),
+ )
+
+
+@app.get("/health/filesystem", response_model=models.FilesystemHealthResponse)
+async def filesystem_health():
+ """Check filesystem health: directory existence, write permissions, and disk space."""
+ import shutil
+
+ dirs_to_check = {
+ "generations": config.get_generations_dir(),
+ "profiles": config.get_profiles_dir(),
+ "data": config.get_data_dir(),
+ }
+
+ checks: list[models.DirectoryCheck] = []
+ all_ok = True
+
+ for _label, dir_path in dirs_to_check.items():
+ exists = dir_path.exists()
+ writable = False
+ error = None
+ if exists:
+ # Probe writability with a temp file
+ probe = dir_path / ".voicebox_probe"
+ try:
+ probe.write_text("ok")
+ probe.unlink()
+ writable = True
+ except PermissionError:
+ error = "Permission denied"
+ except OSError as e:
+ error = str(e)
+ finally:
+ try:
+ probe.unlink(missing_ok=True)
+ except Exception:
+ pass
+ else:
+ error = "Directory does not exist"
+
+ if not exists or not writable:
+ all_ok = False
+
+ checks.append(
+ models.DirectoryCheck(
+ path=str(dir_path),
+ exists=exists,
+ writable=writable,
+ error=error,
+ )
+ )
+
+ # Disk space for the data directory
+ disk_free_mb = None
+ disk_total_mb = None
+ try:
+ usage = shutil.disk_usage(str(config.get_data_dir()))
+ disk_free_mb = round(usage.free / (1024 * 1024), 1)
+ disk_total_mb = round(usage.total / (1024 * 1024), 1)
+ if disk_free_mb < 500:
+ all_ok = False
+ except OSError:
+ all_ok = False
+
+ return models.FilesystemHealthResponse(
+ healthy=all_ok,
+ disk_free_mb=disk_free_mb,
+ disk_total_mb=disk_total_mb,
+ directories=checks,
)
@@ -221,7 +314,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))
@@ -277,10 +373,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}")
@@ -601,47 +700,115 @@ 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()
+ elif engine == "chatterbox_turbo":
+ if not tts_model._is_model_cached():
+ model_name = "chatterbox-turbo"
+
+ async def download_chatterbox_turbo_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_turbo_background())
+
+ raise HTTPException(
+ status_code=202,
+ detail={
+ "message": "Chatterbox Turbo 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(
@@ -652,6 +819,11 @@ async def generate_speech(
data.instruct,
)
+ # Trim trailing silence/hallucination for Chatterbox output
+ if engine in ("chatterbox", "chatterbox_turbo"):
+ from .utils.audio import trim_tts_output
+ audio = trim_tts_output(audio, sample_rate)
+
# Calculate duration
duration = len(audio) / sample_rate
@@ -659,7 +831,30 @@ async def generate_speech(
audio_path = config.get_generations_dir() / f"{generation_id}.wav"
from .utils.audio import save_audio
- save_audio(audio, str(audio_path), sample_rate)
+ import errno
+
+ try:
+ save_audio(audio, str(audio_path), sample_rate)
+ except BrokenPipeError:
+ raise HTTPException(
+ status_code=500,
+ detail="Audio save failed: broken pipe (the output stream was closed unexpectedly)",
+ )
+ except OSError as save_err:
+ err_no = getattr(save_err, "errno", None) or (
+ getattr(save_err.__cause__, "errno", None)
+ if save_err.__cause__
+ else None
+ )
+ if err_no == errno.ENOENT:
+ msg = f"Audio save failed: directory not found — {audio_path.parent}"
+ elif err_no == errno.EACCES:
+ msg = f"Audio save failed: permission denied — {audio_path.parent}"
+ elif err_no == errno.ENOSPC:
+ msg = "Audio save failed: no disk space remaining"
+ else:
+ msg = f"Audio save failed: {save_err}"
+ raise HTTPException(status_code=500, detail=msg)
# Create history entry
generation = await history.create_generation(
@@ -698,23 +893,48 @@ 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()
+ elif engine == "chatterbox_turbo":
+ if not tts_model._is_model_cached():
+ raise HTTPException(
+ status_code=400,
+ detail="Chatterbox Turbo 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,
@@ -724,6 +944,11 @@ async def stream_speech(
data.instruct,
)
+ # Trim trailing silence/hallucination for Chatterbox output
+ if engine in ("chatterbox", "chatterbox_turbo"):
+ 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():
@@ -930,9 +1155,14 @@ async def transcribe_audio(
# Transcribe
whisper_model = transcribe.get_whisper_model()
- # Check if Whisper model is downloaded (uses default size "base")
+ # Check if Whisper model is downloaded
model_size = whisper_model.model_size
- model_name = f"openai/whisper-{model_size}"
+ # Map model sizes to HF repo IDs (some need special suffixes)
+ whisper_hf_repos = {
+ "large": "openai/whisper-large-v3",
+ "turbo": "openai/whisper-large-v3-turbo",
+ }
+ model_name = whisper_hf_repos.get(model_size, f"openai/whisper-{model_size}")
# Check if model is cached
from huggingface_hub import constants as hf_constants
@@ -948,7 +1178,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(
@@ -1310,15 +1540,42 @@ async def get_model_status():
whisper_base_id = "openai/whisper-base"
whisper_small_id = "openai/whisper-small"
whisper_medium_id = "openai/whisper-medium"
- whisper_large_id = "openai/whisper-large"
+ whisper_large_id = "openai/whisper-large-v3"
else:
tts_1_7b_id = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
tts_0_6b_id = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
whisper_base_id = "openai/whisper-base"
whisper_small_id = "openai/whisper-small"
whisper_medium_id = "openai/whisper-medium"
- whisper_large_id = "openai/whisper-large"
+ 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
+
+ # Check if Chatterbox Turbo backend is loaded
+ def check_chatterbox_turbo_loaded():
+ try:
+ from .backends import get_tts_backend_for_engine
+ backend = get_tts_backend_for_engine("chatterbox_turbo")
+ return backend.is_loaded()
+ except Exception:
+ return False
+
model_configs = [
{
"model_name": "qwen-tts-1.7B",
@@ -1334,6 +1591,27 @@ 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": "chatterbox-turbo",
+ "display_name": "Chatterbox Turbo (English, Tags)",
+ "hf_repo_id": "ResembleAI/chatterbox-turbo",
+ "model_size": "default",
+ "check_loaded": check_chatterbox_turbo_loaded,
+ },
{
"model_name": "whisper-base",
"display_name": "Whisper Base",
@@ -1362,6 +1640,13 @@ async def get_model_status():
"model_size": "large",
"check_loaded": lambda: check_whisper_loaded("large"),
},
+ {
+ "model_name": "whisper-turbo",
+ "display_name": "Whisper Turbo",
+ "hf_repo_id": "openai/whisper-large-v3-turbo",
+ "model_size": "turbo",
+ "check_loaded": lambda: check_whisper_loaded("turbo"),
+ },
]
# Build a mapping of model_name -> hf_repo_id so we can check if shared repos are downloading
@@ -1485,6 +1770,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,
@@ -1503,6 +1789,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,
@@ -1516,6 +1803,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()
@@ -1529,6 +1817,18 @@ 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(),
+ },
+ "chatterbox-turbo": {
+ "model_size": "default",
+ "load_func": lambda: get_tts_backend_for_engine("chatterbox_turbo").load_model(),
+ },
"whisper-base": {
"model_size": "base",
"load_func": lambda: transcribe.get_whisper_model().load_model("base"),
@@ -1545,6 +1845,10 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
"model_size": "large",
"load_func": lambda: transcribe.get_whisper_model().load_model("large"),
},
+ "whisper-turbo": {
+ "model_size": "turbo",
+ "load_func": lambda: transcribe.get_whisper_model().load_model("turbo"),
+ },
}
if request.model_name not in model_configs:
@@ -1580,12 +1884,48 @@ 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"}
+@app.post("/models/download/cancel")
+async def cancel_model_download(request: models.ModelDownloadRequest):
+ """Cancel or dismiss an errored/stale download task."""
+ task_manager = get_task_manager()
+ progress_manager = get_progress_manager()
+
+ removed = task_manager.cancel_download(request.model_name)
+
+ # Also clear progress state so the model doesn't show as downloading
+ progress_removed = False
+ with progress_manager._lock:
+ if request.model_name in progress_manager._progress:
+ del progress_manager._progress[request.model_name]
+ progress_removed = True
+
+ if removed or progress_removed:
+ return {"message": f"Download task for {request.model_name} cancelled"}
+ return {"message": f"No active task found for {request.model_name}"}
+
+
+@app.post("/tasks/clear")
+async def clear_all_tasks():
+ """Clear all download tasks and progress state. Does not delete downloaded files."""
+ task_manager = get_task_manager()
+ progress_manager = get_progress_manager()
+
+ task_manager.clear_all()
+
+ with progress_manager._lock:
+ progress_manager._progress.clear()
+ progress_manager._last_notify_time.clear()
+ progress_manager._last_notify_progress.clear()
+
+ return {"message": "All task state cleared"}
+
+
@app.delete("/models/{model_name}")
async def delete_model(model_name: str):
"""Delete a downloaded model from the HuggingFace cache."""
@@ -1605,6 +1945,21 @@ 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",
+ },
+ "chatterbox-turbo": {
+ "hf_repo_id": "ResembleAI/chatterbox-turbo",
+ "model_size": "default",
+ "model_type": "chatterbox_turbo",
+ },
"whisper-base": {
"hf_repo_id": "openai/whisper-base",
"model_size": "base",
@@ -1621,12 +1976,17 @@ async def delete_model(model_name: str):
"model_type": "whisper",
},
"whisper-large": {
- "hf_repo_id": "openai/whisper-large",
+ "hf_repo_id": "openai/whisper-large-v3",
"model_size": "large",
"model_type": "whisper",
},
+ "whisper-turbo": {
+ "hf_repo_id": "openai/whisper-large-v3-turbo",
+ "model_size": "turbo",
+ "model_type": "whisper",
+ },
}
-
+
if model_name not in model_configs:
raise HTTPException(status_code=400, detail=f"Unknown model: {model_name}")
@@ -1639,6 +1999,21 @@ 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"] == "chatterbox_turbo":
+ from .backends import get_tts_backend_for_engine
+ turbo = get_tts_backend_for_engine("chatterbox_turbo")
+ if turbo.is_loaded():
+ turbo.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"]:
@@ -1710,10 +2085,29 @@ async def get_active_tasks():
progress = progress_map.get(model_name)
if task:
+ # Prefer task error, fall back to progress manager error
+ error = task.error
+ if not error:
+ with progress_manager._lock:
+ 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
@@ -1730,6 +2124,11 @@ async def get_active_tasks():
model_name=model_name,
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
@@ -1748,6 +2147,75 @@ async def get_active_tasks():
)
+# ============================================
+# CUDA BACKEND MANAGEMENT
+# ============================================
+
+@app.get("/backend/cuda-status")
+async def get_cuda_status():
+ """Get CUDA backend download/availability status."""
+ from . import cuda_download
+ return cuda_download.get_cuda_status()
+
+
+@app.post("/backend/download-cuda")
+async def download_cuda_backend():
+ """Download the CUDA backend binary. Returns immediately; track progress via SSE."""
+ from . import cuda_download
+
+ # Check if already downloaded
+ if cuda_download.get_cuda_binary_path() is not None:
+ raise HTTPException(status_code=409, detail="CUDA backend already downloaded")
+
+ async def _download():
+ try:
+ await cuda_download.download_cuda_binary()
+ except Exception as e:
+ import logging
+ logging.getLogger(__name__).error(f"CUDA download failed: {e}")
+
+ _create_background_task(_download())
+ return {"message": "CUDA backend download started", "progress_key": "cuda-backend"}
+
+
+@app.delete("/backend/cuda")
+async def delete_cuda_backend():
+ """Delete the downloaded CUDA backend binary."""
+ from . import cuda_download
+
+ if cuda_download.is_cuda_active():
+ raise HTTPException(
+ status_code=409,
+ detail="Cannot delete CUDA backend while it is active. Switch to CPU first.",
+ )
+
+ deleted = await cuda_download.delete_cuda_binary()
+ if not deleted:
+ raise HTTPException(status_code=404, detail="No CUDA backend found to delete")
+
+ return {"message": "CUDA backend deleted"}
+
+
+@app.get("/backend/cuda-progress")
+async def get_cuda_download_progress():
+ """Get CUDA backend download progress via Server-Sent Events."""
+ progress_manager = get_progress_manager()
+
+ async def event_generator():
+ async for event in progress_manager.subscribe("cuda-backend"):
+ yield event
+
+ return StreamingResponse(
+ event_generator(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ },
+ )
+
+
# ============================================
# STARTUP & SHUTDOWN
# ============================================
@@ -1756,7 +2224,12 @@ def _get_gpu_status() -> str:
"""Get GPU availability status."""
backend_type = get_backend_type()
if torch.cuda.is_available():
- return f"CUDA ({torch.cuda.get_device_name(0)})"
+ device_name = torch.cuda.get_device_name(0)
+ # Check if this is ROCm (AMD) or CUDA (NVIDIA)
+ is_rocm = hasattr(torch.version, 'hip') and torch.version.hip is not None
+ if is_rocm:
+ return f"ROCm ({device_name})"
+ return f"CUDA ({device_name})"
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
return "MPS (Apple Silicon)"
elif backend_type == "mlx":
diff --git a/backend/models.py b/backend/models.py
index 59e45405..ebfe70e9 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -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|ar|da|el|fi|hi|ms|nl|no|pl|sv|sw|tr)$")
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|chatterbox_turbo)$")
class GenerationResponse(BaseModel):
@@ -127,12 +128,30 @@ class HealthResponse(BaseModel):
gpu_type: Optional[str] = None # GPU type (CUDA, MPS, or None)
vram_used_mb: Optional[float] = None
backend_type: Optional[str] = None # Backend type (mlx or pytorch)
+ backend_variant: Optional[str] = None # Binary variant (cpu or cuda)
+
+
+class DirectoryCheck(BaseModel):
+ """Health status for a single directory."""
+ path: str
+ exists: bool
+ writable: bool
+ error: Optional[str] = None
+
+
+class FilesystemHealthResponse(BaseModel):
+ """Response model for filesystem health check."""
+ healthy: bool
+ disk_free_mb: Optional[float] = None
+ disk_total_mb: Optional[float] = None
+ directories: List[DirectoryCheck]
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
@@ -154,6 +173,11 @@ class ActiveDownloadTask(BaseModel):
model_name: str
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):
diff --git a/backend/profiles.py b/backend/profiles.py
index cda6bee0..86f9bf59 100644
--- a/backend/profiles.py
+++ b/backend/profiles.py
@@ -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
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 4af5cc85..10574711 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -9,17 +9,39 @@ 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
numpy>=1.24.0
numba>=0.60.0,<0.61.0
+# HTTP client (for CUDA backend download)
+httpx>=0.27.0
+
# Utilities
python-multipart>=0.0.6
Pillow>=10.0.0
diff --git a/backend/server.py b/backend/server.py
index b5621cd1..9a26af4a 100644
--- a/backend/server.py
+++ b/backend/server.py
@@ -64,7 +64,29 @@ if __name__ == "__main__":
default=None,
help="Data directory for database, profiles, and generated audio",
)
+ parser.add_argument(
+ "--version",
+ action="store_true",
+ help="Print version and exit",
+ )
args = parser.parse_args()
+
+ if args.version:
+ from backend import __version__
+ print(f"voicebox-server {__version__}")
+ sys.exit(0)
+
+ # Detect backend variant from binary name
+ # voicebox-server-cuda → sets VOICEBOX_BACKEND_VARIANT=cuda
+ import os
+ binary_name = os.path.basename(sys.executable).lower()
+ if "cuda" in binary_name:
+ os.environ["VOICEBOX_BACKEND_VARIANT"] = "cuda"
+ logger.info("Backend variant: CUDA")
+ else:
+ os.environ["VOICEBOX_BACKEND_VARIANT"] = "cpu"
+ logger.info("Backend variant: CPU")
+
logger.info(f"Parsed arguments: host={args.host}, port={args.port}, data_dir={args.data_dir}")
# Set data directory if provided
diff --git a/backend/tests/test_profile_duplicate_names.py b/backend/tests/test_profile_duplicate_names.py
new file mode 100644
index 00000000..81d4f7ef
--- /dev/null
+++ b/backend/tests/test_profile_duplicate_names.py
@@ -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
diff --git a/backend/utils/audio.py b/backend/utils/audio.py
index 302dff25..ab60d0a5 100644
--- a/backend/utils/audio.py
+++ b/backend/utils/audio.py
@@ -70,14 +70,132 @@ def save_audio(
sample_rate: int = 24000,
) -> None:
"""
- Save audio file.
-
+ Save audio file with atomic write and error handling.
+
+ Writes to a temporary file first, then atomically renames to the
+ target path. This prevents corrupted/partial WAV files if the
+ process is interrupted mid-write.
+
Args:
audio: Audio array
path: Output path
sample_rate: Sample rate
+
+ Raises:
+ OSError: If file cannot be written
"""
- sf.write(path, audio, sample_rate)
+ from pathlib import Path
+ import os
+
+ temp_path = f"{path}.tmp"
+ try:
+ # Ensure parent directory exists
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
+
+ # Write to temporary file first
+ sf.write(temp_path, audio, sample_rate)
+
+ # Atomic rename to final path
+ os.replace(temp_path, path)
+
+ except Exception as e:
+ # Clean up temp file on failure
+ try:
+ if Path(temp_path).exists():
+ Path(temp_path).unlink()
+ except Exception:
+ pass # Best effort cleanup
+
+ raise OSError(f"Failed to save audio to {path}: {e}") from e
+
+
+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(
diff --git a/backend/utils/hf_offline_patch.py b/backend/utils/hf_offline_patch.py
new file mode 100644
index 00000000..288ed040
--- /dev/null
+++ b/backend/utils/hf_offline_patch.py
@@ -0,0 +1,100 @@
+"""
+Monkey patch for huggingface_hub to force offline mode with cached models.
+This prevents mlx_audio from making network requests when models are already downloaded.
+"""
+
+import os
+from pathlib import Path
+from typing import Optional, Union
+
+
+def patch_huggingface_hub_offline():
+ """
+ Monkey-patch huggingface_hub to force offline mode.
+ This must be called BEFORE importing mlx_audio.
+ """
+ try:
+ import huggingface_hub
+ from huggingface_hub import constants as hf_constants
+ from huggingface_hub.file_download import _try_to_load_from_cache
+
+ # Store original function
+ original_try_load = _try_to_load_from_cache
+
+ def _patched_try_to_load_from_cache(
+ repo_id: str,
+ filename: str,
+ cache_dir: Union[str, Path, None] = None,
+ revision: Optional[str] = None,
+ repo_type: Optional[str] = None,
+ ):
+ """
+ Patched version that forces offline mode.
+ Returns None if not cached (instead of making network request).
+ """
+ # Always use the original function, but we're already in HF_HUB_OFFLINE mode
+ result = original_try_load(
+ repo_id=repo_id,
+ filename=filename,
+ cache_dir=cache_dir,
+ revision=revision,
+ repo_type=repo_type,
+ )
+
+ if result is None:
+ # File not in cache - log this for debugging
+ cache_path = Path(hf_constants.HF_HUB_CACHE) / f"models--{repo_id.replace('/', '--')}"
+ print(f"[HF_PATCH] File not cached: {repo_id}/{filename}")
+ print(f"[HF_PATCH] Expected at: {cache_path}")
+ else:
+ print(f"[HF_PATCH] Cache hit: {repo_id}/{filename}")
+
+ return result
+
+ # Replace the function
+ import huggingface_hub.file_download as fd
+ fd._try_to_load_from_cache = _patched_try_to_load_from_cache
+
+ print("[HF_PATCH] huggingface_hub patched for offline mode")
+
+ except ImportError:
+ print("[HF_PATCH] huggingface_hub not found, skipping patch")
+ except Exception as e:
+ print(f"[HF_PATCH] Error patching huggingface_hub: {e}")
+
+
+def ensure_original_qwen_config_cached():
+ """
+ The MLX community model is based on the original Qwen model.
+ mlx_audio may try to fetch config from the original repo.
+ We need to ensure that config is available in the cache.
+ """
+ from huggingface_hub import constants as hf_constants
+
+ # Original Qwen model that mlx_audio might reference
+ original_repo = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
+ mlx_repo = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16"
+
+ cache_dir = Path(hf_constants.HF_HUB_CACHE)
+
+ original_path = cache_dir / f"models--{original_repo.replace('/', '--')}"
+ mlx_path = cache_dir / f"models--{mlx_repo.replace('/', '--')}"
+
+ # If original repo cache doesn't exist but MLX does, create a symlink or copy config
+ if not original_path.exists() and mlx_path.exists():
+ print(f"[HF_PATCH] Original repo not cached, but MLX version is")
+ print(f"[HF_PATCH] Creating symlink from {original_repo} -> {mlx_repo}")
+
+ try:
+ # Create a symlink so the cache lookup succeeds
+ original_path.parent.mkdir(parents=True, exist_ok=True)
+ original_path.symlink_to(mlx_path, target_is_directory=True)
+ print(f"[HF_PATCH] Symlink created successfully")
+ except Exception as e:
+ print(f"[HF_PATCH] Could not create symlink: {e}")
+
+
+# Auto-apply patch when module is imported
+if os.environ.get("VOICEBOX_OFFLINE_PATCH", "1") != "0":
+ patch_huggingface_hub_offline()
+ ensure_original_qwen_config_cached()
diff --git a/backend/utils/tasks.py b/backend/utils/tasks.py
index 05b8e019..8baf71c3 100644
--- a/backend/utils/tasks.py
+++ b/backend/utils/tasks.py
@@ -72,6 +72,15 @@ class TaskManager:
"""Get all active generations."""
return list(self._active_generations.values())
+ def cancel_download(self, model_name: str) -> bool:
+ """Cancel/dismiss a download task (removes it from active list)."""
+ return self._active_downloads.pop(model_name, None) is not None
+
+ def clear_all(self) -> None:
+ """Clear all download and generation tasks."""
+ self._active_downloads.clear()
+ self._active_generations.clear()
+
def is_download_active(self, model_name: str) -> bool:
"""Check if a download is active."""
return model_name in self._active_downloads
diff --git a/docs/PR-ACCESSIBILITY.md b/docs/PR-ACCESSIBILITY.md
new file mode 100644
index 00000000..d9e71ac9
--- /dev/null
+++ b/docs/PR-ACCESSIBILITY.md
@@ -0,0 +1,70 @@
+# Accessibility: screen reader and keyboard improvements
+
+## Summary
+
+Improvements to support screen reader and keyboard users across the main app surfaces: audio player, generation UI, voice selection, history, voices tab, model management, server tab, and stories.
+
+**Tested with NVDA and Narrator on Windows.**
+
+---
+
+## What changed
+
+### Audio player (after generating audio)
+
+- **Play/Pause, Loop, Mute, Close** – `aria-label` added so each control is announced (e.g. "Play", "Pause", "Loop", "Mute", "Close player").
+- **Playback position slider** – `aria-label="Playback position"` and `aria-valuetext` with current/total time (e.g. "0:30 of 2:15").
+- **Volume** – Wrapped in a labelled group; volume slider has an associated screen-reader-only label and `aria-valuetext` for the level (e.g. "Volume level, 75%").
+
+### Generation UI (text box and voice choice)
+
+- **Generate speech** (submit) and **Fine-tune instructions** (sliders) – Icon buttons now have `aria-label` (and state for fine-tune, e.g. "Fine-tune instructions, on").
+
+### Voice selection (cards on Generate screen)
+
+- Each **voice card** is focusable (`tabIndex={0}`), has `role="button"`, and an `aria-label` (e.g. "Prashant, en. Select as voice for generation.") with `aria-pressed` when selected.
+- **Enter/Space** on the card selects that voice; tab order is card → Export/Edit/Delete.
+
+### History list (generated samples)
+
+- Each **sample row** is focusable with `role="button"` and an `aria-label` (e.g. "Sample from [profile], [duration], [date]. Press Enter to play."); **Enter/Space** plays or restarts.
+- **Transcript textarea** has `aria-label` (e.g. "Transcript for sample from [profile], [duration]") so when you focus on the text area, the sample is announced in context.
+
+### Voices tab (table)
+
+- Each **voice row** is focusable with `role="button"` and an `aria-label` (e.g. "[Name], [language], [N] generations, [N] samples. Press Enter to edit."); **Enter/Space** opens edit (except when focus is in a control).
+- **Actions** dropdown trigger has `aria-label="Actions for [profile name]"`.
+
+### Model management
+
+- Each **model row** is a focusable region (`tabIndex={0}`, `role="group"`) with an `aria-label` (e.g. "[Model name], [status], [size]. Use Tab to reach Download or Delete.").
+- **Download** and **Delete** (and Downloading) buttons have `aria-label` (e.g. "Download [name]", "Delete [name]").
+
+### Server tab (panels)
+
+- **Server Connection**, **Server Status**, and **App Updates** cards are landmarks: `role="region"`, `aria-label`, and `tabIndex={0}` so each panel is focusable and announced (e.g. "Server Connection", "Server Status", "App Updates").
+
+### Stories list
+
+- Each **story row** is a focusable control (`role="button"`, `tabIndex={0}`) with `aria-label` (e.g. "Story [name], [N] items, [date]. Press Enter to select."); **Enter/Space** selects the story. Actions button has `aria-label="Actions for [story name]"`.
+
+### Other controls
+
+- **Story list** – Actions (⋮) button: `aria-label="Actions for [story name]"`.
+- **Story track editor** – Play/Pause, Stop, Split, Duplicate, Delete, Zoom in/out: `aria-label` on all icon buttons.
+- **Voice profile samples** (SampleList, AudioSampleUpload, AudioSampleRecording, AudioSampleSystem) – Play/Pause and Stop: `aria-label` (e.g. "Play sample", "Pause", "Stop playback").
+- **SampleList** mini sample player – Seek slider has `aria-label="Sample playback position"` and `aria-valuetext` for time.
+
+---
+
+## Testing
+
+- **Screen readers:** Tested with **NVDA** and **Narrator** on Windows.
+- **Keyboard:** Tab order and Enter/Space activation verified for focusable rows and buttons.
+
+---
+
+## Tech note
+
+- React + TypeScript; Radix UI primitives; labels added via `aria-label`, `aria-labelledby`, `aria-valuetext`, and `role`/`tabIndex` where needed.
+- No new dependencies.
diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md
index 6f0317a0..78749f96 100644
--- a/docs/TROUBLESHOOTING.md
+++ b/docs/TROUBLESHOOTING.md
@@ -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**
diff --git a/docs/plans/CUDA_BACKEND_SWAP.md b/docs/plans/CUDA_BACKEND_SWAP.md
new file mode 100644
index 00000000..b270e962
--- /dev/null
+++ b/docs/plans/CUDA_BACKEND_SWAP.md
@@ -0,0 +1,581 @@
+# CUDA Backend Swap via Binary Replacement
+
+> Status: Plan | Target: v0.2.0 | Created: 2026-03-12
+
+## Problem
+
+The CUDA PyTorch backend binary is ~2.4 GB. GitHub Releases has a 2 GB asset limit. The current release ships CPU-only PyTorch on Windows and Intel Mac — NVIDIA GPU users get no acceleration from official releases. This is the #1 reported issue category (19 open issues).
+
+Users who want GPU today must clone the repo and run from source. That's not acceptable for a desktop app targeting non-technical users.
+
+## Solution
+
+Ship two backend binaries: a default CPU build (~150 MB) bundled with the app, and a downloadable CUDA build (~2.4 GB) hosted externally. When the user downloads the CUDA build, the app kills the current backend process, swaps in the CUDA binary, and relaunches — a backend-only restart. The frontend stays running, all UI state is preserved.
+
+No subprocesses. No HTTP protocol between processes. No port allocation. No provider manager. The backend is still one monolithic process — just a different binary.
+
+## Architecture
+
+### What Exists Today
+
+```
+Tauri App
+ ├── React Frontend (in-process webview)
+ └── voicebox-server (sidecar subprocess on :17493)
+ └── One PyInstaller binary: CPU PyTorch or MLX
+```
+
+**Sidecar lifecycle** (`tauri/src-tauri/src/main.rs`):
+- `start_server` command spawns `voicebox-server` sidecar (line 181)
+- Binary located at `tauri/src-tauri/binaries/voicebox-server-{platform-triple}`
+- Tauri resolves the sidecar name via `externalBin` in `tauri.conf.json` (line 16)
+- Waits up to 120s for "Uvicorn running" in stdout/stderr (line 286)
+- `stop_server` kills the process tree (line 466)
+
+**Frontend reconnection** (`app/src/lib/hooks/useServer.ts`):
+- Health check polls `GET /health` every 30 seconds
+- React Query cache retains data for 10 minutes after disconnect
+- All UI state (Zustand stores, form data, open tabs) survives disconnection
+- No active reconnect logic — just keeps polling until server responds
+
+This means a backend restart is mostly invisible to the frontend: it sees a few seconds of failed health checks, then the server comes back. The only risk is in-flight operations (generation, transcription) failing mid-request.
+
+### What Changes
+
+```
+Tauri App
+ ├── React Frontend (in-process webview)
+ └── voicebox-server (sidecar subprocess on :17493)
+ └── One of:
+ ├── voicebox-server-cpu (bundled, ~150 MB)
+ └── voicebox-server-cuda (downloaded, ~2.4 GB)
+```
+
+The CUDA binary is functionally identical to the CPU binary. Same FastAPI app, same endpoints, same code. The only difference is PyTorch is compiled with CUDA 12.1 support and the binary includes CUDA runtime libraries.
+
+The user downloads it once. On every subsequent app launch, Tauri checks which binary variant exists and spawns the appropriate one.
+
+## Implementation Plan
+
+### Phase 1: Build Infrastructure
+
+Build the CUDA binary in CI separately from the main release.
+
+#### 1a. CUDA PyInstaller Build
+
+Add a `build_binary_cuda.py` or parameterize the existing `build_binary.py`:
+
+```python
+# backend/build_binary.py — add flag
+def build_server(cuda=False):
+ args = [
+ 'server.py',
+ '--onefile',
+ '--name', f'voicebox-server-{"cuda" if cuda else "cpu"}',
+ ]
+
+ if cuda:
+ args.extend([
+ '--hidden-import', 'torch.cuda',
+ '--hidden-import', 'torch.backends.cudnn',
+ ])
+ # ... rest of existing build
+```
+
+The `--onefile` flag is already used, which produces a single executable. This is important — `--onedir` would complicate the swap (replacing a directory vs a file).
+
+#### 1b. CI Workflow for CUDA Binary
+
+New workflow: `.github/workflows/build-cuda.yml`
+
+```yaml
+name: Build CUDA Provider
+on:
+ workflow_dispatch:
+ push:
+ tags: ["v*"]
+
+jobs:
+ build-cuda:
+ runs-on: windows-latest # CUDA is Windows/Linux only
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with: { python-version: "3.12" }
+ - name: Install dependencies
+ run: |
+ pip install pyinstaller
+ pip install -r backend/requirements.txt
+ pip install torch --index-url https://download.pytorch.org/whl/cu121 --force-reinstall
+ - name: Build CUDA binary
+ run: python backend/build_binary.py --cuda
+ - name: Split binary for GitHub Releases
+ run: |
+ python scripts/split_binary.py backend/dist/voicebox-server-cuda.exe \
+ --chunk-size 1900MB \
+ --output release-assets/
+ - name: Upload to R2
+ # Full binary to R2 (no size limit)
+ run: |
+ aws s3 cp backend/dist/voicebox-server-cuda.exe \
+ s3://voicebox-downloads/cuda/v${{ github.ref_name }}/voicebox-server-cuda.exe \
+ --endpoint-url ${{ secrets.R2_ENDPOINT }}
+ - name: Upload split parts to GitHub Release
+ # Split parts as GitHub Release assets (each <2 GB)
+ uses: softprops/action-gh-release@v1
+ with:
+ files: release-assets/*
+```
+
+Two distribution paths for redundancy:
+- **Cloudflare R2**: Full binary, direct download, no size limit.
+- **GitHub Releases**: Split into <2 GB chunks as fallback.
+
+#### 1c. Binary Splitting Script
+
+```python
+# scripts/split_binary.py
+"""Split a large binary into chunks for GitHub Releases."""
+import hashlib
+import argparse
+from pathlib import Path
+
+def split(input_path: Path, chunk_size: int, output_dir: Path):
+ output_dir.mkdir(parents=True, exist_ok=True)
+ data = input_path.read_bytes()
+
+ # Write SHA-256 of the complete file
+ sha256 = hashlib.sha256(data).hexdigest()
+ (output_dir / f"{input_path.stem}.sha256").write_text(
+ f"{sha256} {input_path.name}\n"
+ )
+
+ # Split into chunks
+ parts = []
+ for i in range(0, len(data), chunk_size):
+ part_name = f"{input_path.stem}.part{len(parts):02d}{input_path.suffix}"
+ part_path = output_dir / part_name
+ part_path.write_bytes(data[i:i + chunk_size])
+ parts.append(part_name)
+
+ # Write manifest
+ (output_dir / f"{input_path.stem}.manifest").write_text(
+ "\n".join(parts) + "\n"
+ )
+
+ print(f"Split into {len(parts)} parts, SHA-256: {sha256}")
+```
+
+### Phase 2: Download & Assemble in App
+
+#### 2a. Backend Download Endpoint
+
+Add to `backend/main.py`:
+
+```python
+@app.post("/backend/download-cuda")
+async def download_cuda_backend():
+ """Download the CUDA backend binary."""
+ # Returns immediately, runs download in background
+ task = asyncio.create_task(_download_cuda_binary())
+ task.add_done_callback(lambda t: logger.error(f"CUDA download failed: {t.exception()}") if t.exception() else None)
+ return {"status": "downloading"}
+
+@app.get("/backend/cuda-status")
+async def cuda_status():
+ """Check if CUDA binary is available."""
+ cuda_path = _get_cuda_binary_path()
+ return {
+ "available": cuda_path is not None and cuda_path.exists(),
+ "active": _is_cuda_active(),
+ "download_progress": progress_manager.get_progress("cuda-backend"),
+ }
+```
+
+#### 2b. Download + Assemble + Verify Logic
+
+New file: `backend/cuda_download.py`
+
+Core logic:
+
+```python
+import hashlib
+from pathlib import Path
+from backend.config import get_data_dir
+from backend.utils.progress import get_progress_manager
+
+CUDA_DOWNLOAD_URL = "https://downloads.voicebox.sh/cuda/{version}/voicebox-server-cuda{ext}"
+CUDA_CHECKSUMS = {
+ # Populated per release
+ "0.2.0-windows": "sha256:abc123...",
+ "0.2.0-linux": "sha256:def456...",
+}
+
+def get_cuda_binary_dir() -> Path:
+ """Where CUDA binaries live. Inside the app's data directory."""
+ return get_data_dir() / "backends"
+
+def get_cuda_binary_path() -> Path | None:
+ """Return path to CUDA binary if it exists and is verified."""
+ d = get_cuda_binary_dir()
+ for name in ["voicebox-server-cuda.exe", "voicebox-server-cuda"]:
+ p = d / name
+ if p.exists():
+ return p
+ return None
+
+async def download_cuda_binary(version: str):
+ """Download, assemble (if split), and verify the CUDA binary."""
+ progress = get_progress_manager()
+ dest_dir = get_cuda_binary_dir()
+ dest_dir.mkdir(parents=True, exist_ok=True)
+
+ ext = ".exe" if sys.platform == "win32" else ""
+ url = CUDA_DOWNLOAD_URL.format(version=version, ext=ext)
+
+ # Download with progress tracking
+ temp_path = dest_dir / f"voicebox-server-cuda{ext}.download"
+ async with httpx.AsyncClient(follow_redirects=True) as client:
+ async with client.stream("GET", url) as response:
+ total = int(response.headers.get("content-length", 0))
+ downloaded = 0
+ with open(temp_path, "wb") as f:
+ async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
+ f.write(chunk)
+ downloaded += len(chunk)
+ progress.update("cuda-backend", downloaded, total)
+
+ # Verify checksum
+ sha256 = hashlib.sha256(temp_path.read_bytes()).hexdigest()
+ expected = CUDA_CHECKSUMS.get(f"{version}-{sys.platform}")
+ if expected and not expected.endswith(sha256):
+ temp_path.unlink()
+ raise ValueError(f"Checksum mismatch: expected {expected}, got sha256:{sha256}")
+
+ # Atomic move into place
+ final_path = dest_dir / f"voicebox-server-cuda{ext}"
+ temp_path.rename(final_path)
+
+ # Make executable on Unix
+ if sys.platform != "win32":
+ final_path.chmod(0o755)
+
+ progress.complete("cuda-backend")
+```
+
+Key points:
+- Downloads to a `.download` temp file, verifies checksum, then atomically renames. No partial binaries left on crash.
+- Progress tracked via the existing `ProgressManager` so the frontend SSE system works unchanged.
+- CUDA binary lives in the **app data directory** (`data/backends/`), not alongside the app bundle. This avoids code-signing issues on macOS (though CUDA isn't relevant on macOS) and survives app updates.
+
+#### 2c. Reassembly from Split Parts (GitHub Releases Fallback)
+
+If the R2 download fails, fall back to downloading split parts from GitHub Releases:
+
+```python
+async def download_cuda_from_github(version: str):
+ """Fallback: download split parts from GitHub Releases, reassemble."""
+ base_url = f"https://github.com/jamiepine/voicebox/releases/download/v{version}"
+
+ # Get manifest
+ manifest_url = f"{base_url}/voicebox-server-cuda.manifest"
+ async with httpx.AsyncClient(follow_redirects=True) as client:
+ manifest = (await client.get(manifest_url)).text
+ parts = [p.strip() for p in manifest.strip().splitlines()]
+
+ # Download checksum
+ sha256_url = f"{base_url}/voicebox-server-cuda.sha256"
+ expected_sha = (await client.get(sha256_url)).text.split()[0]
+
+ # Download parts
+ dest_dir = get_cuda_binary_dir()
+ dest_dir.mkdir(parents=True, exist_ok=True)
+ temp_path = dest_dir / "voicebox-server-cuda.exe.download"
+
+ total_downloaded = 0
+ with open(temp_path, "wb") as f:
+ for i, part_name in enumerate(parts):
+ part_url = f"{base_url}/{part_name}"
+ async with client.stream("GET", part_url) as response:
+ async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
+ f.write(chunk)
+ total_downloaded += len(chunk)
+ get_progress_manager().update(
+ "cuda-backend", total_downloaded, None,
+ message=f"Downloading part {i+1}/{len(parts)}"
+ )
+
+ # Verify reassembled file
+ sha256 = hashlib.sha256(temp_path.read_bytes()).hexdigest()
+ if sha256 != expected_sha:
+ temp_path.unlink()
+ raise ValueError(f"Checksum mismatch after reassembly")
+
+ final_path = dest_dir / "voicebox-server-cuda.exe"
+ temp_path.rename(final_path)
+ get_progress_manager().complete("cuda-backend")
+```
+
+### Phase 3: Backend Restart (The Swap)
+
+This is the core of the feature: kill the CPU backend, launch the CUDA backend, frontend reconnects automatically.
+
+#### 3a. New Tauri Command: `restart_server`
+
+Add to `tauri/src-tauri/src/main.rs`:
+
+```rust
+#[command]
+async fn restart_server(
+ app: tauri::AppHandle,
+ state: State<'_, ServerState>,
+ use_cuda: Option,
+) -> Result {
+ println!("restart_server: use_cuda={:?}", use_cuda);
+
+ // 1. Stop the current server
+ stop_server(state.clone()).await?;
+
+ // 2. Brief wait for port release
+ tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
+
+ // 3. Start with the appropriate binary
+ // The start_server logic needs to check for CUDA binary
+ start_server(app, state, None).await
+}
+```
+
+#### 3b. Modify `start_server` to Prefer CUDA Binary
+
+The existing `start_server` uses `app.shell().sidecar("voicebox-server")` which resolves via Tauri's `externalBin` config. For the CUDA binary (which lives in the data directory, not the app bundle), we need an alternative launch path.
+
+Modify `start_server` in `main.rs`:
+
+```rust
+// After the existing sidecar logic, before spawning:
+
+// Check for CUDA binary in data directory
+let cuda_binary = data_dir.join("backends")
+ .join(if cfg!(windows) { "voicebox-server-cuda.exe" } else { "voicebox-server-cuda" });
+
+let (mut rx, child) = if cuda_binary.exists() {
+ println!("Found CUDA backend binary at {:?}", cuda_binary);
+
+ // Launch CUDA binary directly (not as Tauri sidecar)
+ let mut cmd = app.shell().command(cuda_binary.to_str().unwrap());
+ cmd = cmd.args([
+ "--data-dir",
+ data_dir.to_str().ok_or("Invalid data dir path")?,
+ "--port",
+ &SERVER_PORT.to_string(),
+ ]);
+ if remote.unwrap_or(false) {
+ cmd = cmd.args(["--host", "0.0.0.0"]);
+ }
+ cmd.spawn().map_err(|e| format!("Failed to spawn CUDA backend: {}", e))?
+} else {
+ // Existing sidecar launch (CPU binary bundled with app)
+ sidecar.spawn().map_err(|e| format!("Failed to spawn: {}", e))?
+};
+```
+
+Key decisions:
+- CUDA binary is launched via `app.shell().command()` (arbitrary path), not `app.shell().sidecar()` (bundled path). Tauri's sidecar system only resolves binaries within the app bundle.
+- The CUDA binary gets the same args (`--data-dir`, `--port`) as the CPU binary. It's the same `server.py` entry point.
+- Preference: if CUDA binary exists, use it. Otherwise fall back to bundled CPU. No user configuration needed.
+
+#### 3c. Frontend: Trigger Restart After Download
+
+Add to the platform lifecycle interface (`app/src/platform/types.ts`):
+
+```typescript
+interface PlatformLifecycle {
+ startServer(remote?: boolean): Promise;
+ stopServer(): Promise;
+ restartServer(useCuda?: boolean): Promise; // new
+ // ...
+}
+```
+
+Implement in `tauri/src/platform/lifecycle.ts`:
+
+```typescript
+async restartServer(useCuda?: boolean): Promise {
+ const result = await invoke('restart_server', { useCuda });
+ this.onServerReady?.();
+ return result;
+}
+```
+
+#### 3d. Frontend: GPU Settings UI
+
+Add a section to the Server Settings page (or Model Management). Minimal UI:
+
+```
+┌─────────────────────────────────────────────┐
+│ GPU Acceleration │
+│ │
+│ Status: CPU only (no CUDA backend) │
+│ │
+│ [Download CUDA Backend (2.4 GB)] │
+│ │
+│ Requires an NVIDIA GPU with 4+ GB VRAM. │
+│ The app will restart its backend process │
+│ after download. Your work is preserved. │
+└─────────────────────────────────────────────┘
+```
+
+After download:
+
+```
+┌─────────────────────────────────────────────┐
+│ GPU Acceleration │
+│ │
+│ Status: ✓ CUDA backend active (RTX 4090) │
+│ │
+│ [Switch to CPU] [Delete CUDA Backend] │
+└─────────────────────────────────────────────┘
+```
+
+#### 3e. Frontend: Reconnection During Restart
+
+The current health poll interval is 30 seconds — too slow for a restart UX. During a restart, temporarily increase polling:
+
+```typescript
+// In the component that triggers restart:
+const restart = async () => {
+ setRestarting(true);
+ try {
+ await platform.lifecycle.restartServer(true);
+ } catch (e) {
+ // Frontend will show "reconnecting" state
+ }
+ // Aggressively poll until health check succeeds
+ const interval = setInterval(async () => {
+ try {
+ await apiClient.getHealth();
+ clearInterval(interval);
+ setRestarting(false);
+ queryClient.invalidateQueries(); // Refresh all data
+ } catch {}
+ }, 1000); // Poll every 1s during restart
+ // Safety timeout
+ setTimeout(() => clearInterval(interval), 30000);
+};
+```
+
+### Phase 4: Auto-Detection on Startup
+
+No user action needed on subsequent launches. The preference logic in `start_server` (Phase 3b) handles this:
+
+1. App launches → `start_server` called
+2. Check `data/backends/voicebox-server-cuda{.exe}`
+3. If exists → launch CUDA binary
+4. If not → launch bundled CPU binary
+
+The user downloads CUDA once, and every future app launch (including after updates) uses it automatically. The CUDA binary lives in the data directory, not the app bundle, so app updates don't overwrite it.
+
+### Phase 5: Handling Version Mismatches
+
+When the app updates but the CUDA binary is from an older version, the API might be incompatible. Handle this by:
+
+1. Add `--version` flag to `server.py`:
+
+```python
+parser.add_argument("--version", action="store_true")
+# If invoked with --version, print version and exit
+if args.version:
+ from backend import __version__
+ print(f"voicebox-server {__version__}")
+ sys.exit(0)
+```
+
+2. In `start_server` (Rust), before launching the CUDA binary:
+
+```rust
+// Quick version check
+let version_output = std::process::Command::new(cuda_binary.to_str().unwrap())
+ .arg("--version")
+ .output();
+
+match version_output {
+ Ok(output) => {
+ let version = String::from_utf8_lossy(&output.stdout);
+ let app_version = env!("CARGO_PKG_VERSION");
+ if !version.contains(app_version) {
+ println!("CUDA binary version mismatch (app: {}, cuda: {}), falling back to CPU",
+ app_version, version.trim());
+ // Fall through to CPU sidecar launch
+ }
+ }
+ Err(_) => {
+ println!("Failed to check CUDA binary version, falling back to CPU");
+ }
+}
+```
+
+3. Frontend shows a notification: "Your GPU backend needs an update. [Download latest] or [Use CPU for now]"
+
+## Files Changed
+
+### New Files
+
+| File | Purpose |
+|------|---------|
+| `backend/cuda_download.py` | Download, reassemble, verify CUDA binary |
+| `scripts/split_binary.py` | Split binary into <2 GB chunks for GitHub Releases |
+| `.github/workflows/build-cuda.yml` | CI: build + upload CUDA binary |
+
+### Modified Files
+
+| File | Change |
+|------|--------|
+| `tauri/src-tauri/src/main.rs` | Add `restart_server` command, modify `start_server` to check for CUDA binary in data dir |
+| `backend/server.py` | Add `--version` flag |
+| `backend/main.py` | Add `/backend/download-cuda`, `/backend/cuda-status`, `/backend/progress/cuda-backend` endpoints |
+| `backend/build_binary.py` | Accept `--cuda` flag to build CUDA variant |
+| `app/src/platform/types.ts` | Add `restartServer` to lifecycle interface |
+| `tauri/src/platform/lifecycle.ts` | Implement `restartServer` |
+| `app/src/components/ServerSettings/` | New GPU acceleration section |
+| `.github/workflows/release.yml` | Trigger CUDA build workflow on tag |
+
+### NOT Changed
+
+| File | Why |
+|------|-----|
+| `backend/backends/__init__.py` | No changes to the TTSBackend singleton or factory. CUDA binary runs the same code. |
+| `backend/backends/pytorch_backend.py` | Already detects CUDA at runtime (line 28-49). No changes needed. |
+| `app/src/lib/api/client.ts` | API is identical between CPU and CUDA backends. |
+| `app/src/lib/hooks/useGenerationForm.ts` | Generation flow is unchanged. |
+
+## What This Doesn't Solve
+
+- **Multi-model support** — This is purely about GPU acceleration. LuxTTS, Chatterbox, etc. need the in-process model registry, which is an independent workstream.
+- **AMD GPU support** — DirectML/ROCm needs a different PyTorch build. Same pattern applies (another binary variant) but deferred.
+- **Linux CUDA** — Same approach works, just another CI matrix entry. Can be added in the same release or shortly after.
+- **Remote server mode** — Users who want to run TTS on a different machine still need the external provider architecture. Separate concern.
+
+## What This DOES Solve
+
+- **19 "GPU not detected" issues** — Users download the CUDA backend, restart, GPU works.
+- **2 GB GitHub Release limit** — Binary splitting + R2 hosting.
+- **Update burden** — App updates don't re-download the 2.4 GB CUDA binary. It persists in the data directory.
+- **First-run experience** — App works immediately on CPU. GPU is an optional enhancement, not a setup blocker.
+
+## Rollout Plan
+
+1. Build and test CUDA binary locally on Windows with an NVIDIA GPU.
+2. Set up R2 bucket at `downloads.voicebox.sh/cuda/`.
+3. Ship the backend restart + download UI in v0.2.0.
+4. Announce: "GPU acceleration is here — one click in Settings."
+
+## Risks
+
+| Risk | Mitigation |
+|------|-----------|
+| CUDA binary doesn't work on some GPU/driver combos | `/health` endpoint reports GPU info. Fallback to CPU if CUDA init fails. Clear error message. |
+| Antivirus flags downloaded binary (Windows) | Code-sign the CUDA binary in CI. Document AV exceptions. |
+| Data dir CUDA binary survives app uninstall | Document in uninstall notes. Not a real problem — it's just a file. |
+| Version mismatch after app update | Version check on startup (Phase 5). Auto-fallback to CPU. Prompt to re-download. |
+| R2 downtime | GitHub Releases split-binary fallback. |
+| Download interrupted | Temp file with `.download` extension. Atomic rename on completion. Resume not implemented in v1 — restart download from scratch. |
diff --git a/docs/plans/CUDA_BACKEND_SWAP_FINAL.md b/docs/plans/CUDA_BACKEND_SWAP_FINAL.md
new file mode 100644
index 00000000..ebd22534
--- /dev/null
+++ b/docs/plans/CUDA_BACKEND_SWAP_FINAL.md
@@ -0,0 +1,133 @@
+# CUDA Backend Swap — Implementation Summary
+
+> Status: **Complete** | Branch: `feat/cuda-backend-swap` | Created: 2026-03-12
+
+## What This Is
+
+A standalone feature that lets users download a CUDA-enabled backend binary (~2.4 GB) and swap it in via a backend-only restart. The frontend stays running, all UI state is preserved. This solves the #1 user pain point: 19 open issues about "GPU not detected" caused by GitHub's 2 GB release asset limit preventing CUDA binaries from shipping in official releases.
+
+## How It Works
+
+```
+User clicks "Download CUDA Backend" in Settings
+ → Backend fetches manifest from GitHub Releases
+ → Downloads split parts (<2 GB each), concatenates them
+ → SHA-256 integrity check on reassembled binary
+ → Binary placed in {app_data_dir}/backends/voicebox-server-cuda
+ → User clicks "Switch to CUDA Backend"
+ → Tauri kills CPU process, launches CUDA binary, frontend reconnects
+ → On all future app launches, CUDA binary is auto-detected and used
+```
+
+The CUDA binary is functionally identical to the CPU binary — same FastAPI app, same endpoints, same code. The only difference is PyTorch compiled with CUDA 12.1 and bundled CUDA runtime libraries.
+
+## Architecture Decisions
+
+**Backend-only restart, not full app restart.** The Tauri shell kills the current `voicebox-server` process, waits 1 second for port release, and spawns the new binary. The React frontend stays running. Health polling detects the new backend within seconds.
+
+**No provider/subprocess architecture.** This is explicitly not the PR #33 approach (10K+ lines, 136 files, 22 bugs). One process at a time. The CUDA binary replaces the CPU binary — it doesn't run alongside it.
+
+**Data directory, not app bundle.** The CUDA binary lives in `{app_data_dir}/backends/`, which persists across app updates and avoids code-signing issues. The bundled CPU binary in the app bundle is untouched.
+
+**Version mismatch protection.** On startup, Rust runs `voicebox-server-cuda --version` and compares to the app version from `tauri.conf.json`. If they don't match (e.g., after an app update), it falls back to the bundled CPU binary silently.
+
+**GitHub Releases distribution.** The CUDA binary is split into <2 GB chunks (GitHub's asset limit) via `scripts/split_binary.py`. The app downloads a manifest, fetches each part, concatenates them, and runs a SHA-256 integrity check to verify reassembly. No external hosting needed.
+
+## Files Changed
+
+### New Files
+
+| File | Lines | Purpose |
+|------|-------|---------|
+| `backend/cuda_download.py` | ~190 | Download split parts from GitHub Releases, reassemble, verify integrity |
+| `scripts/split_binary.py` | ~80 | Split large binary into <2 GB chunks with SHA-256 manifest |
+| `.github/workflows/build-cuda.yml` | ~70 | CI workflow: build CUDA binary, split, upload to GitHub Releases |
+| `app/src/components/ServerSettings/GpuAcceleration.tsx` | 371 | GPU Acceleration UI card (status, download, restart, delete) |
+| `docs/plans/CUDA_BACKEND_SWAP.md` | 581 | Original implementation plan (5 phases with code sketches) |
+| `docs/plans/CUDA_BACKEND_SWAP_FINAL.md` | this file | Final implementation summary |
+| `docs/plans/PROJECT_STATUS.md` | 462 | Full project triage (all PRs, issues, architecture) |
+| `docs/plans/PR33_CUDA_PROVIDER_REVIEW.md` | ~350 | Detailed code review of PR #33 (22 bugs documented) |
+
+### Modified Files
+
+| File | What Changed |
+|------|-------------|
+| `backend/build_binary.py` | Added `--cuda` flag, parameterized output binary name |
+| `backend/server.py` | Added `--version` flag, auto-detect backend variant from binary name (`VOICEBOX_BACKEND_VARIANT` env var) |
+| `backend/main.py` | 4 new endpoints (`/backend/cuda-status`, `/backend/download-cuda`, `/backend/cuda`, `/backend/cuda-progress`), health endpoint returns `backend_variant` |
+| `backend/models.py` | `HealthResponse` model: added `backend_variant` field |
+| `backend/requirements.txt` | Added `httpx>=0.27.0` for async HTTP downloads |
+| `tauri/src-tauri/src/main.rs` | `restart_server` command (stop → wait → start), `start_server` checks for CUDA binary in data dir and launches via `shell().command()`, version mismatch check |
+| `app/src/platform/types.ts` | `PlatformLifecycle.restartServer()` added |
+| `tauri/src/platform/lifecycle.ts` | `restartServer()` implementation via `invoke('restart_server')` |
+| `web/src/platform/lifecycle.ts` | `restartServer()` noop for web platform |
+| `app/src/lib/api/types.ts` | `CudaStatus`, `CudaDownloadProgress` interfaces; `HealthResponse` updated with `gpu_type`, `backend_type`, `backend_variant` |
+| `app/src/lib/api/client.ts` | `getCudaStatus()`, `downloadCudaBackend()`, `deleteCudaBackend()` methods |
+| `app/src/components/ServerTab/ServerTab.tsx` | Wired in `` component (Tauri-only) |
+
+## Backend API Endpoints
+
+| Method | Path | Purpose |
+|--------|------|---------|
+| `GET` | `/backend/cuda-status` | Returns `{ available, active, binary_path, downloading, download_progress }` |
+| `POST` | `/backend/download-cuda` | Starts background download; returns immediately. Track via SSE. |
+| `DELETE` | `/backend/cuda` | Deletes CUDA binary (blocked if CUDA is currently active) |
+| `GET` | `/backend/cuda-progress` | SSE stream of download progress (reuses existing `ProgressManager`) |
+
+The existing `GET /health` endpoint now returns two new fields:
+- `backend_type`: `"pytorch"` or `"mlx"` (existing detection)
+- `backend_variant`: `"cpu"` or `"cuda"` (set from `VOICEBOX_BACKEND_VARIANT` env var)
+
+## Frontend UI States
+
+The `GpuAcceleration` card in Server Settings handles these states:
+
+1. **Native GPU detected** (MPS, MLX, XPU, DirectML) — Shows info message, no download needed
+2. **No CUDA binary** — Download button with size estimate, description of requirements
+3. **Downloading** — SSE-driven progress bar with bytes/total and percentage
+4. **Downloaded, not active** — "Switch to CUDA Backend" button + "Remove" option
+5. **CUDA active** — Shows CUDA badge, "Switch to CPU Backend" button
+6. **Restarting** — Spinner with phase text, 1s health polling as safety net
+7. **Error** — Red error message with details
+
+### Key UX detail: switching to CPU
+
+Since `start_server` always prefers the CUDA binary if it exists on disk, "Switch to CPU" must delete the CUDA binary first, then restart. The user can re-download later. This avoids a persistent configuration mechanism (no new state to manage, no new config file, no DB column).
+
+## Rust: Server Lifecycle
+
+```
+start_server
+ ├── Check for CUDA binary at {data_dir}/backends/voicebox-server-cuda
+ ├── If found: run --version, compare to app version
+ │ ├── Match: launch via shell().command() with --data-dir, --port
+ │ └── Mismatch: log warning, fall through to CPU
+ └── Else: launch bundled sidecar via shell().sidecar()
+
+restart_server
+ ├── stop_server (kill process tree)
+ ├── wait 1 second for port release
+ └── start_server (auto-detects CUDA)
+```
+
+## What This Doesn't Cover
+
+- **AMD GPU / ROCm / DirectML binary** — Same pattern, different PyTorch build. Future PR.
+- **Linux CUDA** — Same approach, just another CI matrix entry. Can ship same release.
+- **Multi-model support** — LuxTTS, Chatterbox, etc. are a separate architectural concern (in-process model registry). Independent of binary variant.
+- **Download resume** — If download is interrupted, it restarts from scratch. Acceptable for v1.
+- **Remote server CUDA** — Users running voicebox-server on a remote machine manage their own binaries. This feature is for the desktop app.
+
+## Testing Checklist
+
+- [ ] Build CUDA binary locally with `python backend/build_binary.py --cuda`
+- [ ] `voicebox-server-cuda --version` prints correct version
+- [ ] Place CUDA binary in `{data_dir}/backends/`, launch app → auto-detects and uses it
+- [ ] Version mismatch: rename binary to have wrong version → falls back to CPU
+- [ ] Frontend: GpuAcceleration card shows correct state for CPU, CUDA available, CUDA active
+- [ ] Download flow: POST triggers download, SSE progress works, completion updates status
+- [ ] Switch to CUDA: restart works, health endpoint shows `backend_variant: "cuda"`
+- [ ] Switch to CPU: deletes binary, restarts, health shows `backend_variant: "cpu"`
+- [ ] Delete CUDA while active: returns 409 error
+- [ ] Split binary script: `python scripts/split_binary.py` creates manifest + parts + sha256
+- [ ] Native GPU (macOS MPS): shows info message, no download section
diff --git a/docs/plans/PR33_CUDA_PROVIDER_REVIEW.md b/docs/plans/PR33_CUDA_PROVIDER_REVIEW.md
new file mode 100644
index 00000000..66554dd6
--- /dev/null
+++ b/docs/plans/PR33_CUDA_PROVIDER_REVIEW.md
@@ -0,0 +1,500 @@
+# PR #33 — CUDA Provider System Review
+
+> Branch: `external-provider-binaries` | Created: 2026-02-01 | 34 commits, 136 files, +10,266 lines
+> Reviewed: 2026-03-12
+
+---
+
+## The Problem
+
+The CUDA PyTorch binary is ~2.4 GB. GitHub Releases has a 2 GB artifact limit. This means:
+
+- Windows/Linux users with NVIDIA GPUs cannot get GPU acceleration from official releases
+- 19 open issues about "GPU not detected" — the single most reported problem category
+- Users who want GPU must clone the repo and run from source
+- Every app update forces re-download of the entire binary
+
+This is the #1 user pain point by volume.
+
+---
+
+## What PR #33 Does
+
+Splits the monolithic Voicebox binary into two layers:
+
+```
+┌──────────────────────────────────────┐
+│ Main App (~150MB Win/Lin, ~300 Mac) │
+│ Tauri + React + FastAPI + Whisper │
+│ No PyTorch. MLX bundled on macOS. │
+├──────────────────────────────────────┤
+│ HTTP (localhost) │
+├──────────────────────────────────────┤
+│ Provider Binary (downloaded later) │
+│ PyTorch CPU (~300MB) │
+│ PyTorch CUDA (~2.4GB) │
+│ Hosted on Cloudflare R2 │
+└──────────────────────────────────────┘
+```
+
+### New Backend Code
+
+| File | Purpose |
+|------|---------|
+| `backend/providers/__init__.py` (327 lines) | `ProviderManager` — lifecycle management, subprocess spawning, port allocation |
+| `backend/providers/base.py` (97 lines) | `TTSProvider` Protocol definition |
+| `backend/providers/bundled.py` (144 lines) | `BundledProvider` — wraps existing MLX/PyTorch backends for the new interface |
+| `backend/providers/local.py` (191 lines) | `LocalProvider` — HTTP client that talks to external provider processes |
+| `backend/providers/installer.py` (262 lines) | Download, extract, delete provider binaries |
+| `backend/providers/types.py` (34 lines) | `ProviderType` enum, `ProviderInfo` dataclass |
+| `backend/providers/checksums.py` (11 lines) | Checksum dict (currently empty) |
+
+### Provider Servers (Standalone Executables)
+
+| File | Purpose |
+|------|---------|
+| `providers/pytorch-cpu/main.py` (238 lines) | FastAPI server wrapping PyTorch CPU inference |
+| `providers/pytorch-cuda/main.py` (238 lines) | FastAPI server wrapping PyTorch CUDA inference |
+| `providers/pytorch-*/build.py` | PyInstaller build scripts |
+| `providers/pytorch-*/requirements.txt` | Isolated dependencies |
+
+### Frontend
+
+| File | Purpose |
+|------|---------|
+| `app/src/components/ServerSettings/ProviderSettings.tsx` (400 lines) | Provider download/start/stop/delete UI |
+
+### Also Included (Scope Creep)
+
+The PR bundles several unrelated changes that inflate the diff:
+
+- `docs2/` — Entire documentation site rewrite (Fumadocs migration, ~3000 lines)
+- `Dockerfile`, `Dockerfile.cuda`, `docker-compose.yml` — Docker support
+- `landing/` — Banner removal
+- UI refactors in Stories, History, Voice Profiles, Audio tab
+- Linux audio capture module
+- Various dependency bumps
+
+---
+
+## Bug Report
+
+### Critical — Will Crash at Runtime
+
+#### C1. Provider `generate` endpoint can't parse requests
+
+**`providers/pytorch-cpu/main.py:91-97`** (same in pytorch-cuda)
+
+```python
+@app.post("/tts/generate")
+async def generate(
+ text: str,
+ voice_prompt: dict,
+ language: str = "auto",
+ seed: int = None,
+ model_size: str = "1.7B"
+):
+```
+
+Parameters declared as function arguments. FastAPI interprets these as **query parameters**, not JSON body. But `LocalProvider.generate()` sends a JSON body via `httpx`:
+
+```python
+# backend/providers/local.py:33-40
+response = await self.client.post("/tts/generate", json={
+ "text": text,
+ "voice_prompt": voice_prompt,
+ ...
+})
+```
+
+**Result:** Every generation call to an external provider returns HTTP 422 (Validation Error). The generation path is completely broken for external providers.
+
+**Fix:** Use a Pydantic request body model:
+```python
+class GenerateRequest(BaseModel):
+ text: str
+ voice_prompt: dict
+ language: str = "auto"
+ seed: Optional[int] = None
+ model_size: str = "1.7B"
+
+@app.post("/tts/generate")
+async def generate(data: GenerateRequest):
+```
+
+#### C2. Timeout error handler references undefined variables
+
+**`backend/providers/__init__.py:82-90`**
+
+```python
+stdout_content = ""
+stderr_content = ""
+# ... threads write to stdout_queue / stderr_queue ...
+except TimeoutError:
+ while not stdout_queue.empty():
+ stdout_lines.append(stdout_queue.get_nowait()) # NameError
+ while not stderr_queue.empty():
+ stderr_lines.append(stderr_queue.get_nowait()) # NameError
+```
+
+`stdout_lines` and `stderr_lines` are never defined. Every provider startup timeout will throw `NameError`, masking the real failure cause. Then `stdout_content` and `stderr_content` are logged but they're still empty strings — the queue data is never assigned back.
+
+#### C3. Sync `get_tts_model()` ignores external provider in async context
+
+**`backend/tts.py:15-29`**
+
+```python
+def get_tts_model():
+ manager = get_provider_manager()
+ loop = asyncio.get_event_loop()
+ if loop.is_running():
+ # We're in an async context, but can't await here
+ return manager._get_default_provider()
+```
+
+FastAPI routes are async. This function is called from several code paths during generation. In async context it **always returns the bundled provider**, ignoring whatever external provider the user selected. The user downloads and starts a CUDA provider, but generation still runs on CPU.
+
+### Critical — Security
+
+#### C4. Path traversal via `tarfile.extractall()` (CVE-2007-4559)
+
+**`backend/providers/installer.py:115-118`**
+
+```python
+with tarfile.open(archive_path, 'r:gz') as tar_ref:
+ tar_ref.extractall(providers_dir)
+```
+
+No member path filtering. A crafted `.tar.gz` from a compromised CDN can write files anywhere on disk via `../` entries. Python 3.12+ emits a deprecation warning for exactly this pattern.
+
+**Fix:**
+```python
+tar_ref.extractall(providers_dir, filter='data') # Python 3.12+
+```
+
+Or manually validate each member:
+```python
+for member in tar_ref.getmembers():
+ member_path = os.path.join(providers_dir, member.name)
+ if not os.path.commonpath([providers_dir, member_path]).startswith(str(providers_dir)):
+ raise ValueError(f"Path traversal attempt: {member.name}")
+tar_ref.extractall(providers_dir)
+```
+
+#### C5. No checksum verification on downloaded binaries
+
+**`backend/providers/checksums.py`**
+
+```python
+PROVIDER_CHECKSUMS = {}
+```
+
+Empty dict. `download_provider()` in `installer.py` never calls any verification function. Downloaded binaries are `chmod 0o755`'d and executed without integrity checks. A MitM or CDN compromise delivers arbitrary code.
+
+**Fix:** Populate checksums per release. Verify SHA-256 after download before extraction:
+```python
+import hashlib
+sha256 = hashlib.sha256(archive_path.read_bytes()).hexdigest()
+if sha256 != expected:
+ archive_path.unlink()
+ raise ValueError(f"Checksum mismatch for {provider_type}")
+```
+
+#### C6. Provider servers have no authentication
+
+**`providers/pytorch-cpu/main.py:18-23`**
+
+```python
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ ...
+)
+```
+
+Zero auth. Any local process — including browser JavaScript via localhost — can send requests to the provider on its ephemeral port. Port is discoverable by scanning.
+
+**Fix:** Generate a random token in the parent process, pass via environment variable to the child, validate in middleware:
+```python
+# Parent (ProviderManager)
+token = secrets.token_urlsafe(32)
+env = {**os.environ, "VOICEBOX_PROVIDER_TOKEN": token}
+process = subprocess.Popen([...], env=env, ...)
+
+# Child (provider server)
+EXPECTED_TOKEN = os.environ.get("VOICEBOX_PROVIDER_TOKEN")
+
+@app.middleware("http")
+async def verify_token(request, call_next):
+ if request.headers.get("X-Provider-Token") != EXPECTED_TOKEN:
+ return JSONResponse(status_code=403, content={"error": "unauthorized"})
+ return await call_next(request)
+```
+
+### Major — Will Cause Problems in Production
+
+#### M1. Leaked file handles on subprocess stdout/stderr
+
+**`backend/providers/__init__.py:68-73`**
+
+```python
+process = subprocess.Popen(
+ [...],
+ stdout=open(stdout_log, 'w'), # leaked handle
+ stderr=open(stderr_log, 'w'), # leaked handle
+)
+```
+
+File handles passed directly from `open()` without storing references. They close on GC, not deterministically. On Windows the log files stay locked and unreadable until the process exits.
+
+**Fix:**
+```python
+stdout_fh = open(stdout_log, 'w')
+stderr_fh = open(stderr_log, 'w')
+try:
+ process = subprocess.Popen([...], stdout=stdout_fh, stderr=stderr_fh)
+finally:
+ stdout_fh.close()
+ stderr_fh.close()
+```
+
+#### M2. No subprocess crash detection or recovery
+
+**`backend/providers/__init__.py:56-110`**
+
+Once `start_provider()` succeeds, the `Popen` object is stored but never polled. If the provider process crashes mid-session:
+- `LocalProvider` HTTP calls fail with `httpx.ConnectError`
+- No auto-restart
+- No health-check loop
+- User sees cryptic "connection refused" errors
+- Must manually restart provider from UI
+
+**Fix:** Background asyncio task that polls `process.poll()` every few seconds. On crash, update provider status and optionally auto-restart:
+```python
+async def _watch_provider_process(self):
+ while self._provider_process and self._provider_process.poll() is None:
+ await asyncio.sleep(5)
+ if self._provider_process and self._provider_process.returncode != 0:
+ logger.error(f"Provider crashed with code {self._provider_process.returncode}")
+ self.active_provider = self._default_provider
+ # Notify frontend via next health check
+```
+
+#### M3. Port allocation race condition (TOCTOU)
+
+**`backend/providers/__init__.py:145-149`**
+
+```python
+def _get_free_port(self) -> int:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(('', 0))
+ return s.getsockname()[1]
+ # Socket closed here — port is free but unprotected
+```
+
+Between this function returning and the provider process binding, another process can claim the port. On busy systems this causes "address already in use" failures.
+
+**Fix options:**
+- Pass the socket fd to the child process (complex, platform-specific)
+- Retry with a new port on bind failure (simplest)
+- Use a fixed port range and try sequentially
+
+#### M4. `delete_provider()` leaves hundreds of MB behind
+
+**`backend/providers/installer.py:155-168`**
+
+```python
+provider_path.unlink() # Deletes just the executable
+```
+
+PyInstaller `--onedir` produces a directory with the executable plus all shared libraries. `unlink()` only removes the binary file, leaving behind hundreds of MB of `.so`/`.dll`/`.dylib` files.
+
+**Fix:**
+```python
+provider_dir = provider_path.parent
+shutil.rmtree(provider_dir)
+```
+
+#### M5. `LocalProvider.combine_voice_prompts()` bypasses the provider
+
+**`backend/providers/local.py:68-88`**
+
+This method imports from `..utils.audio` and processes locally instead of sending to the provider server. If the user chose an external provider because they lack local dependencies (e.g., no PyTorch on the machine), this will crash with `ImportError`.
+
+#### M6. Download errors silently swallowed
+
+**`backend/main.py:1640`**
+
+```python
+asyncio.create_task(download_provider(provider_type))
+```
+
+Fire-and-forget. If the download fails, the exception is logged as "Task exception was never retrieved." The frontend SSE progress stream may hang forever showing "downloading" without the error.
+
+**Fix:** Store the task, add an error callback:
+```python
+task = asyncio.create_task(download_provider(provider_type))
+task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)
+```
+And propagate errors through the progress manager so the SSE stream surfaces them.
+
+#### M7. `LocalProvider.is_loaded()` always returns `True`
+
+**`backend/providers/local.py:105-108`**
+
+```python
+def is_loaded(self) -> bool:
+ return True # Return True optimistically
+```
+
+Health/status checks always report the model as loaded for external providers, even when the provider hasn't loaded anything yet. This breaks the "download model if not cached" logic in the generation flow.
+
+#### M8. `instruct` parameter silently dropped
+
+**`backend/providers/local.py:33-40`**
+
+The `generate()` method accepts `instruct` but never includes it in the JSON payload. The provider server also hardcodes `instruct=None`. Delivery instructions silently do nothing for external providers.
+
+### Minor
+
+| # | Issue | Location |
+|---|-------|----------|
+| m1 | `pytorch-cpu/main.py` and `pytorch-cuda/main.py` are 95% identical | Both files |
+| m2 | `build.py` scripts also nearly identical | Both build files |
+| m3 | `navigator.platform` is deprecated | `ProviderSettings.tsx:20-23` |
+| m4 | `console.log('currentProvider', ...)` left in | `ProviderSettings.tsx:151` |
+| m5 | `ProviderType` enum defined but never used for validation | `types.py:10-15` |
+| m6 | `list_installed()` reimplements platform detection | `__init__.py:129-143` |
+| m7 | New `httpx.AsyncClient` created per health poll iteration | `__init__.py:151-165` |
+| m8 | `load_model_async()` only stores size, doesn't actually preload | `local.py:95-99` |
+
+---
+
+## Scope Creep
+
+The PR should be split. These are independent changes bundled in:
+
+| Change | Lines | Should Be Separate PR |
+|--------|-------|-----------------------|
+| `docs2/` site rewrite | ~3000 | Yes |
+| Docker support (Dockerfile, compose, docs) | ~600 | Yes — overlaps with PR #161 |
+| Landing page banner removal | ~30 | Yes |
+| UI refactors (Stories, History, Voices, Audio) | ~400 | Yes |
+| Linux audio capture module | ~10 | Yes |
+| Dependency bumps | ~100 | Yes |
+
+**Core provider system** (the actual feature) is ~2500 lines across backend + frontend + provider servers. That's the reviewable scope.
+
+---
+
+## What's Well-Designed
+
+These parts should survive any rewrite:
+
+1. **`TTSProvider` Protocol** (`base.py`) — Structural typing via `@runtime_checkable Protocol`. Right pattern. Comprehensive interface.
+
+2. **`BundledProvider` / `LocalProvider` split** — Clean separation between in-process and HTTP-based inference. The wrapper pattern in `BundledProvider` correctly delegates to existing `TTSBackend`.
+
+3. **R2 distribution strategy** — Provider binaries on Cloudflare R2, main app on GitHub Releases. Correct solution to the 2 GB limit.
+
+4. **Progress tracking** — SSE-based download progress integrated with the existing `ProgressManager`. Good UX.
+
+5. **Subprocess log files** — Writing provider stdout/stderr to log files in the data directory is pragmatic and debuggable.
+
+6. **Frontend `ProviderSettings.tsx`** — Clean component structure. Proper loading/disabled states, confirmation dialogs, platform-aware visibility.
+
+7. **CI split** — Separate `build-providers` and `release` jobs. Providers built and uploaded to R2 independently.
+
+---
+
+## Options for Moving Forward
+
+### Option A — Fix and Slim PR #33
+
+Strip the PR down to just the provider system (~2500 lines). Fix the 5 critical and 8 major bugs. Rebase onto current `main`.
+
+**Effort:** ~2-3 days focused work
+**Pros:** Full auto-managed provider lifecycle. Foundation for multi-model.
+**Cons:** Still complex. Process management is inherently fragile cross-platform.
+
+### Option B — Manual External Server Mode
+
+Skip subprocess management entirely. Ship a "Connect to External Server" feature:
+
+1. User downloads CUDA provider zip from `downloads.voicebox.sh`
+2. User runs it manually (`./tts-provider-pytorch-cuda --port 8100`)
+3. In Voicebox UI: paste `http://localhost:8100` as the TTS server URL
+4. Voicebox routes generation to that URL via `LocalProvider`
+
+This reuses `LocalProvider` from PR #33 but removes:
+- `ProviderManager` subprocess spawning (the buggiest part)
+- `installer.py` download/extract logic (the security risks)
+- Port allocation (user picks the port)
+- Process lifecycle management (user's responsibility)
+
+**Effort:** ~1 day. `LocalProvider` + a URL input field + health check.
+**Pros:** Simple, reliable, no process management bugs, no security surface.
+**Cons:** Manual setup. Not seamless. But CUDA users are already technical (they run from source today).
+
+### Option C — Hybrid (Recommended)
+
+Ship Option B first as v0.2.0. Then iterate toward auto-management:
+
+**Phase 1 (v0.2.0):** Manual external server mode
+- `LocalProvider` HTTP client (from PR #33, with the 422 bug fixed)
+- Server URL input in Settings
+- Health indicator
+- CUDA provider published as standalone zip on R2
+- One page of docs: "download, unzip, run, paste URL"
+
+**Phase 2 (v0.2.x):** Auto-download + auto-start
+- `installer.py` with checksum verification and safe extraction
+- `ProviderManager` subprocess spawning with crash detection
+- Provider settings UI with download/start/stop buttons
+
+**Phase 3 (v0.3.0):** Multi-model providers
+- Provider per model family (not just per hardware)
+- LuxTTS provider, Chatterbox provider, etc.
+- Provider marketplace / registry
+
+This gets CUDA into users' hands immediately (Phase 1 is ~1 day) while building toward the full vision incrementally. Each phase is independently shippable and testable.
+
+### Option D — GitHub Workaround
+
+Avoid the provider architecture entirely. Host CUDA binaries on R2 and add a download link in the app that opens the user's browser. User downloads the full monolithic CUDA build, replaces their existing install.
+
+**Effort:** Minimal — just hosting + a link.
+**Pros:** Zero architecture changes.
+**Cons:** Doesn't solve: multi-model, independent app updates, or the re-download-everything-on-update problem. Kicks the can.
+
+---
+
+## Recommendation
+
+**Option C (Hybrid)** is the strongest path. Specifically:
+
+1. **Now:** Close PR #33 as-is. It's too large, too buggy, and too stale to salvage as a single merge.
+
+2. **Extract:** Cherry-pick the good parts into small focused PRs:
+ - PR: `TTSProvider` Protocol + `BundledProvider` + `LocalProvider` (the abstractions)
+ - PR: Provider settings UI (the frontend)
+ - PR: `installer.py` + checksums (the download system)
+ - PR: CI changes for R2 upload (the distribution)
+
+3. **Ship Phase 1:** Manual external server mode. One small PR. Unblocks every CUDA user immediately.
+
+4. **Iterate:** Layer in auto-management once the manual mode is proven stable.
+
+The critical bugs in PR #33 (C1-C6) are all fixable, but the PR's size makes review unreliable. Splitting it ensures each piece gets proper attention and nothing ships broken.
+
+---
+
+## Bug Summary
+
+| Severity | Count | Blocks Ship? |
+|----------|-------|-------------|
+| Critical (runtime crash) | 3 | Yes — C1, C2, C3 |
+| Critical (security) | 3 | Yes — C4, C5, C6 |
+| Major | 8 | Some — M1, M2, M3 are high risk |
+| Minor | 8 | No |
+| **Total** | **22** | |
diff --git a/docs/plans/PROJECT_STATUS.md b/docs/plans/PROJECT_STATUS.md
new file mode 100644
index 00000000..d47dfebf
--- /dev/null
+++ b/docs/plans/PROJECT_STATUS.md
@@ -0,0 +1,472 @@
+# Voicebox Project Status & Roadmap
+
+> Last updated: 2026-03-13 | Current version: **v0.1.13** | 13.1k stars | ~176 open issues | 25 open PRs
+
+---
+
+## Table of Contents
+
+1. [Architecture Overview](#architecture-overview)
+2. [Current State](#current-state)
+3. [Open PRs — Triage & Analysis](#open-prs--triage--analysis)
+4. [Open Issues — Categorized](#open-issues--categorized)
+5. [Existing Plan Documents — Status](#existing-plan-documents--status)
+6. [New Model Integration — Landscape](#new-model-integration--landscape)
+7. [Architectural Bottlenecks](#architectural-bottlenecks)
+8. [Recommended Priorities](#recommended-priorities)
+
+---
+
+## Architecture Overview
+
+```
+┌─────────────────────────────────────────────────────┐
+│ Tauri Shell (Rust) │
+│ ┌───────────────────────────────────────────────┐ │
+│ │ React Frontend (app/) │ │
+│ │ Zustand stores · API client · Generation UI │ │
+│ │ Stories Editor · Voice Profiles · Model Mgmt │ │
+│ └──────────────────────┬────────────────────────┘ │
+│ │ HTTP :17493 │
+│ ┌──────────────────────▼────────────────────────┐ │
+│ │ FastAPI Backend (backend/) │ │
+│ │ ┌─────────────────────────────────────────┐ │ │
+│ │ │ TTSBackend Protocol │ │ │
+│ │ │ ┌──────────┐ ┌───────┐ ┌───────────┐ │ │ │
+│ │ │ │ Qwen3-TTS│ │LuxTTS │ │Chatterbox │ │ │ │
+│ │ │ │(Py/MLX) │ │ │ │(MTL+Turbo)│ │ │ │
+│ │ │ └──────────┘ └───────┘ └───────────┘ │ │ │
+│ │ └─────────────────────────────────────────┘ │ │
+│ │ ┌───────────┐ ┌─────────┐ │ │
+│ │ │ STTBackend│ │ Profiles│ │ │
+│ │ │ (Whisper) │ │ History │ │ │
+│ │ └───────────┘ │ Stories │ │ │
+│ │ └─────────┘ │ │
+│ └───────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────┘
+```
+
+### Key Files
+
+| Layer | File | Purpose |
+|-------|------|---------|
+| Backend entry | `backend/main.py` | FastAPI app, all API routes (~2100 lines) |
+| TTS protocol | `backend/backends/__init__.py:14-81` | `TTSBackend` Protocol definition |
+| TTS factory | `backend/backends/__init__.py:138-178` | Thread-safe engine registry (double-checked locking) |
+| PyTorch TTS | `backend/backends/pytorch_backend.py` | Qwen3-TTS via `qwen_tts` package |
+| MLX TTS | `backend/backends/mlx_backend.py` | Qwen3-TTS via `mlx_audio.tts` |
+| LuxTTS | `backend/backends/luxtts_backend.py` | LuxTTS — fast, CPU-friendly |
+| Chatterbox MTL | `backend/backends/chatterbox_backend.py` | Chatterbox Multilingual — 23 languages |
+| Chatterbox Turbo | `backend/backends/chatterbox_turbo_backend.py` | Chatterbox Turbo — English, paralinguistic tags |
+| Platform detect | `backend/platform_detect.py` | Apple Silicon → MLX, else → PyTorch |
+| API types | `backend/models.py` | Pydantic request/response models |
+| HF progress | `backend/utils/hf_progress.py` | HFProgressTracker (tqdm patching for download progress) |
+| Audio utils | `backend/utils/audio.py` | `trim_tts_output()`, normalize, load/save audio |
+| Frontend API | `app/src/lib/api/client.ts` | Hand-written fetch wrapper |
+| Frontend types | `app/src/lib/api/types.ts` | TypeScript API types |
+| Generation form | `app/src/components/Generation/GenerationForm.tsx` | TTS generation UI |
+| Floating gen box | `app/src/components/Generation/FloatingGenerateBox.tsx` | Compact generation UI |
+| Model manager | `app/src/components/ServerSettings/ModelManagement.tsx` | Model download/status/progress UI |
+| GPU acceleration | `app/src/components/ServerSettings/GpuAcceleration.tsx` | CUDA backend swap UI |
+| Gen form hook | `app/src/lib/hooks/useGenerationForm.ts` | Form validation + submission |
+| Language constants | `app/src/lib/constants/languages.ts` | Per-engine language maps |
+
+### How TTS Generation Works (Current Flow)
+
+```
+POST /generate
+ 1. Look up voice profile from DB
+ 2. Resolve engine from request (qwen | luxtts | chatterbox | chatterbox_turbo)
+ 3. Get backend: get_tts_backend_for_engine(engine) # thread-safe singleton per engine
+ 4. Check model cache → if missing, trigger background download, return HTTP 202
+ 5. Load model (lazy): tts_backend.load_model(model_size)
+ 6. Create voice prompt: profiles.create_voice_prompt_for_profile(engine=engine)
+ → tts_backend.create_voice_prompt(audio_path, reference_text)
+ 7. Generate: tts_backend.generate(text, voice_prompt, language, seed, instruct)
+ 8. Post-process: trim_tts_output() for Chatterbox engines
+ 9. Save WAV → data/generations/{id}.wav
+ 10. Insert history record in SQLite
+ 11. Return GenerationResponse
+```
+
+---
+
+## Current State
+
+### What's Shipped (v0.1.13 + recent merges)
+
+**Core TTS:**
+- Qwen3-TTS voice cloning (1.7B and 0.6B models)
+- MLX backend for Apple Silicon, PyTorch for everything else
+- Multi-engine TTS architecture with thread-safe backend registry (PR #254)
+- LuxTTS integration — fast, CPU-friendly English TTS (PR #254)
+- Chatterbox Multilingual TTS — 23 languages including Hebrew (PR #257)
+- Delivery instructions (instruct parameter, Qwen only)
+- Single flat model dropdown (Qwen 1.7B, Qwen 0.6B, LuxTTS, Chatterbox, Chatterbox Turbo)
+
+**Infrastructure:**
+- CUDA backend swap via binary download and restart (PR #252)
+- GPU acceleration settings UI
+- Voice profiles with multi-sample support
+- Stories editor (multi-track DAW timeline)
+- Whisper transcription (base, small, medium, large variants)
+- Model management UI with inline download progress bars (HFProgressTracker)
+- Download cancel/clear UI with error panel (PR #238)
+- Generation history with caching
+- Streaming generation endpoint (MLX only)
+- Duplicate profile name validation (PR #175)
+- Linux NVIDIA GBM buffer + WebKitGTK microphone fix (PR #210)
+
+### What's In-Flight
+
+| Feature | Branch/PR | Status |
+|---------|-----------|--------|
+| Chatterbox Turbo + per-engine language lists | `feat/chatterbox-turbo` / PR #258 | Open, ready for review |
+
+### TTS Engine Comparison
+
+| Engine | Model Name | Languages | Size | Key Features |
+|--------|-----------|-----------|------|-------------|
+| Qwen3-TTS 1.7B | `qwen-tts-1.7B` | 10 (zh, en, ja, ko, de, fr, ru, pt, es, it) | ~3.5 GB | Instruct mode, highest quality |
+| Qwen3-TTS 0.6B | `qwen-tts-0.6B` | 10 | ~1.2 GB | Lighter, faster |
+| LuxTTS | `luxtts` | English | ~300 MB | CPU-friendly, 48 kHz, fast |
+| Chatterbox | `chatterbox-tts` | 23 (incl. Hebrew, Arabic, Hindi, etc.) | ~3.2 GB | Zero-shot cloning, multilingual |
+| Chatterbox Turbo | `chatterbox-turbo` | English | ~1.5 GB | Paralinguistic tags ([laugh], [cough]), 350M params, low latency |
+
+### Multi-Engine Architecture (Shipped)
+
+The singleton TTS backend blocker described in the previous version of this doc has been **resolved**. The architecture now supports:
+
+- **Thread-safe backend registry** (`_tts_backends` dict + `_tts_backends_lock`) with double-checked locking
+- **Per-engine backend instances** — each engine gets its own singleton, loaded lazily
+- **Engine field on GenerationRequest** — frontend sends `engine: 'qwen' | 'luxtts' | 'chatterbox' | 'chatterbox_turbo'`
+- **Per-engine language filtering** — `ENGINE_LANGUAGES` map in frontend, backend regex accepts all languages
+- **Per-engine voice prompts** — `create_voice_prompt_for_profile()` dispatches to the correct backend
+- **Trim post-processing** — `trim_tts_output()` for Chatterbox engines (cuts trailing silence/hallucination)
+
+### Known Limitations
+
+- **HF XET progress**: Large files downloaded via `hf-xet` (HuggingFace's new transfer backend) report `n=0` in tqdm updates. Progress bars may appear stuck for large `.safetensors` files even though the download is proceeding. This is a known upstream limitation.
+- **Chatterbox Turbo upstream token bug**: `from_pretrained()` passes `token=os.getenv("HF_TOKEN") or True` which fails without a stored HF token. Our backend works around this by calling `snapshot_download(token=None)` + `from_local()`.
+- **chatterbox-tts must install with `--no-deps`**: It pins `numpy<1.26`, `torch==2.6.0`, `transformers==4.46.3` — all incompatible with our stack (Python 3.12, torch 2.10, transformers 4.57.3). Sub-deps listed explicitly in `requirements.txt`.
+- **Streaming generation** only works for Qwen on MLX. Other engines use the non-streaming `/generate` endpoint.
+- **dicta-onnx** (Hebrew diacritization) not included — upstream Chatterbox bug requires `model_path` arg but calls `Dicta()` with none. Hebrew works fine without it.
+
+---
+
+## Open PRs — Triage & Analysis
+
+### Recently Merged (Since Last Update)
+
+| PR | Title | Merged |
+|----|-------|--------|
+| **#257** | feat: Chatterbox TTS engine with multilingual voice cloning | 2026-03-13 |
+| **#254** | feat: LuxTTS integration — multi-engine TTS support | 2026-03-13 |
+| **#252** | feat: CUDA backend swap via binary download and restart | 2026-03-13 |
+| **#238** | Download cancel/clear UI, fixed model downloading | 2026-03-13 |
+| **#250** | docs: align local API port examples | 2026-03-13 |
+| **#210** | fix: Linux NVIDIA GBM buffer crash | 2026-03-13 |
+| **#175** | Fix #134: duplicate profile name validation | 2026-03-13 |
+
+### In-Flight (Our Work)
+
+| PR | Title | Status | Notes |
+|----|-------|--------|-------|
+| **#258** | feat: Chatterbox Turbo engine + per-engine language lists | Open | Ready for review. Adds Turbo engine + dynamic language dropdown. |
+
+### Merge-Ready / Near-Ready (Bug Fixes & Small Features)
+
+| PR | Title | Risk | Notes |
+|----|-------|------|-------|
+| **#230** | docs: fix README grammar | None | Docs-only |
+| **#243** | a11y: screen reader and keyboard improvements | Low | Accessibility, no backend changes |
+| **#178** | Fix #168 #140: generation error handling | Low | Error handling improvements |
+| **#152** | Fix: prevent crashes when HuggingFace unreachable | Medium | Monkey-patches HF hub; solves real offline bug (#150, #151) |
+| **#218** | fix: unify qwen tts cache dir on Windows | Low | Windows-specific path fix |
+| **#214** | fix: panic on launch from tokio::spawn | Low | Rust-side Tauri fix |
+| **#88** | security: restrict CORS to known local origins | Low | Security hardening |
+| **#133** | feat: network access toggle | Low | Wires up existing plumbing |
+
+### Significant Feature PRs
+
+| PR | Title | Complexity | Notes |
+|----|-------|-----------|-------|
+| **#253** | Enhance speech tokenizer with 48kHz version | Medium | Qwen tokenizer upgrade |
+| **#97** | fix: pass language parameter to TTS models | Medium | May be partially obsoleted by multi-engine work — needs review |
+| **#99** | feat: chunked TTS with quality selector | Medium | Solves 500-char limit. Addresses #191, #203, #69, #111. |
+| **#154** | feat: Audiobook tab | Medium | Full audiobook workflow. Depends on #99 concepts. |
+| **#91** | fix: CoreAudio device enumeration | Medium | macOS audio device handling |
+
+### Architectural PRs (Need Careful Review)
+
+| PR | Title | Complexity | Notes |
+|----|-------|-----------|-------|
+| **#225** | feat: custom HuggingFace model support | High | Arbitrary HF repo loading. May need rework given multi-engine arch is now shipped. |
+| **#194** | feat: Hebrew + Chatterbox TTS | High | **Superseded** by PR #257 which shipped Chatterbox multilingual (23 langs incl. Hebrew). May be closeable. |
+| **#195** | feat: per-profile LoRA fine-tuning | Very High | Training pipeline, adapter management, 15 new endpoints. Depends on #194 (now superseded). |
+| **#161** | feat: Docker + web deployment | High | 3-stage Dockerfile, SPA serving. Independent of TTS engine work. |
+| **#124** / **#123** | Docker (simpler attempts) | Low-Medium | Overlap with #161 |
+| **#227** | fix: harden input validation & file safety | Medium | Coupled to #225 (custom models) |
+
+### PRs That Need Author Action / Are Stale
+
+| PR | Title | Notes |
+|----|-------|-------|
+| **#237** | fix: bundle qwen_tts source files in PyInstaller | Build system, needs review |
+| **#215** | Update prerequisites with Tauri deps | Branch is `main` — will have conflicts |
+| **#89** | Linux Support | Branch is `main` — will have conflicts. Broad scope. |
+| **#83** | Update download links for v0.1.12 | Outdated (we're on v0.1.13) |
+
+### PRs Likely Superseded
+
+| PR | Superseded By | Notes |
+|----|--------------|-------|
+| **#194** (Hebrew + Chatterbox) | PR #257 (merged) | #257 ships Chatterbox multilingual with 23 languages including Hebrew. #194 took a different approach (route by language). Can likely be closed. |
+| **#33** (External provider binaries) | PR #252 (merged) | #252 shipped CUDA backend swap. #33's broader provider architecture may still have value but needs reassessment. |
+
+---
+
+## Open Issues — Categorized
+
+### GPU / Hardware Detection (19 issues)
+
+The single most reported category. Users on Windows with NVIDIA GPUs frequently report "GPU not detected."
+
+**Root causes (likely):**
+- PyInstaller binary doesn't bundle CUDA correctly → falls back to CPU
+- DirectML/Vulkan path not implemented (AMD on Windows)
+- Binary size limit means CUDA can't ship in the main release
+
+**Key issues:** #239, #222, #220, #217, #208, #198, #192, #167, #164, #141, #130, #127
+
+**Fix path:** PR #252 (CUDA backend swap) is now merged. Users can download the CUDA binary separately from the GPU acceleration settings. Many of these issues may now be resolvable — needs triage to confirm.
+
+### Model Downloads (20 issues)
+
+Second most reported. Users get stuck downloads, can't resume, no offline fallback.
+
+**Key issues:** #249, #240, #221, #216, #212, #181, #180, #159, #150, #149, #145, #143, #135, #134
+
+**Fix path:** PR #238 (cancel/clear UI) is now merged. PR #152 (offline crash fix) still open. Inline progress bars now show for all engines. Resume support not yet addressed.
+
+### Language Requests (18 issues)
+
+Strong demand for: Hindi (#245), Indonesian (#247), Dutch (#236), Hebrew (#199), Greek (#188), Portuguese (#183), Persian (#162), and many more.
+
+**Key issues:** #247, #245, #236, #211, #205, #199, #189, #188, #187, #183, #179, #162
+
+**Fix path:** Chatterbox Multilingual (merged via #257) now supports 23 languages including many of the requested ones: Arabic, Danish, German, Greek, Finnish, Hebrew, Hindi, Dutch, Norwegian, Polish, Swedish, Swahili, Turkish. Per-engine language filtering (PR #258) ensures the UI shows correct options. Several of these issues may be closeable.
+
+### New Model Requests (5 explicit issues)
+
+| Issue | Model Requested |
+|-------|----------------|
+| #226 | GGUF support |
+| #172 | VibeVoice |
+| #138 | Export to ONNX/Piper format |
+| #132 | LavaSR (transcription) |
+| #76 | (General model expansion) |
+
+Community also requests: XTTS-v2, Fish Speech, CosyVoice, Kokoro. The multi-engine architecture is now in place, making new model integration significantly easier.
+
+### Long-Form / Chunking (5 issues)
+
+Users hitting the ~500 character practical limit.
+
+**Key issues:** #234 (queue system), #203 (500 char limit), #191 (auto-split), #111, #69
+
+**Fix path:** PR #99 (chunked TTS + quality selector) directly addresses this. PR #154 (Audiobook tab) builds on it.
+
+### Feature Requests (23 issues)
+
+Notable requests:
+- **#234** — Queue system for batch generation
+- **#182** — Concurrent/multi-thread generation
+- **#173** — Vocal intonation/inflection control
+- **#165** — Audiobook mode
+- **#144** — Copy text to clipboard
+- **#184** — Cancel button for progress bar
+- **#242** — Seed value pinning for consistency
+- **#228** — Always use 0.6B option
+- **#233** — Transcribe audio API improvements
+- **#235** — Finetuned Qwen3-TTS tokenizer
+
+### Bugs (19 issues)
+
+| Category | Issues |
+|----------|--------|
+| Generation failures | #248 (broken pipe), #219 (unsupported scalarType), #202 (clipping error), #170 (load failed) |
+| UI bugs | #231 (history not updating), #190 (mobile landing), #169 (blank interface) |
+| File operations | #207 (transcribe file error), #168 (no such file), #142 (download audio fail) |
+| Server lifecycle | #166 (server processes remain), #164 (no auto-update) |
+| Database | #174 (sqlite3 IntegrityError) |
+| Dependency | #131 (numpy ABI mismatch), #209 (import error) |
+
+---
+
+## Existing Plan Documents — Status
+
+| Document | Target Version | Status | Relevance |
+|----------|---------------|--------|-----------|
+| `TTS_PROVIDER_ARCHITECTURE.md` | v0.1.13 | **Partially superseded** by multi-engine arch + CUDA swap | Core concepts implemented differently than planned |
+| `CUDA_BACKEND_SWAP.md` | — | **Shipped** (PR #252) | CUDA binary download + backend restart |
+| `CUDA_BACKEND_SWAP_FINAL.md` | — | **Shipped** (PR #252) | Final implementation plan |
+| `EXTERNAL_PROVIDERS.md` | v0.2.0 | **Not started** | Remote server support |
+| `MLX_AUDIO.md` | — | **Shipped** | MLX backend is live |
+| `DOCKER_DEPLOYMENT.md` | v0.2.0 | **PR exists** (#161) | Waiting on review |
+| `OPENAI_SUPPORT.md` | v0.2.0 | **Not started** | OpenAI-compatible API layer |
+| `PR33_CUDA_PROVIDER_REVIEW.md` | — | **Reference** | Analysis of the original provider approach |
+
+---
+
+## New Model Integration — Landscape
+
+### Models Worth Supporting (2026 SOTA)
+
+| Model | Cloning | Speed | Sample Rate | Languages | VRAM | Integration Ease | Status |
+|-------|---------|-------|-------------|-----------|------|-----------------|--------|
+| **Qwen3-TTS** | 10s zero-shot | Medium | 24 kHz | 10 | Medium | **Shipped** | v0.1.13 |
+| **LuxTTS** | 3s zero-shot | 150x RT, CPU ok | 48 kHz | English | <1 GB | **Shipped** | PR #254 |
+| **Chatterbox MTL** | 5s zero-shot | Medium | 24 kHz | 23 | Medium | **Shipped** | PR #257 |
+| **Chatterbox Turbo** | 5s zero-shot | Fast | 24 kHz | English | Low | **PR #258** | In review |
+| **XTTS-v2** | 6s zero-shot | Mid-GPU | 24 kHz | 17+ | Medium | Ready | Multi-engine arch in place |
+| **Fish Speech** | 10-30s few-shot | Real-time | 24-44 kHz | 50+ | Medium | Ready | Multi-engine arch in place |
+| **CosyVoice2-0.5B** | 3-10s zero-shot | Very fast | 24 kHz | Multilingual | Low | Ready | Multi-engine arch in place |
+| **Kokoro-82M** | 3s instant | CPU realtime | 24 kHz | English | Tiny | Ready | Multi-engine arch in place |
+
+### Adding a New Engine (Now Straightforward)
+
+With the multi-engine architecture shipped, adding a new TTS engine requires:
+
+1. **Create `backend/backends/_backend.py`** — implement `TTSBackend` protocol (~200-300 lines)
+2. **Register in `backend/backends/__init__.py`** — add to `TTS_ENGINES` dict + factory function
+3. **Update `backend/models.py`** — add engine name to regex
+4. **Update `backend/main.py`** — add engine cases in generate, stream, model-status, download, delete (5 dispatch points)
+5. **Update frontend** — add to engine union type, form schema, model dropdown, language map (5-6 files)
+
+Total effort: **~1 day** for a well-documented model with a PyPI package.
+
+---
+
+## Architectural Bottlenecks
+
+### ~~1. Single Backend Singleton~~ — RESOLVED
+
+The singleton TTS backend was replaced with a thread-safe per-engine registry in PR #254. Multiple engines can now be loaded simultaneously.
+
+### 2. `main.py` is 2100+ Lines
+
+All API routes, all model configs, all business logic in one file. Five separate dispatch points for each engine. Any new engine touches this file in 5 places. A model config registry pattern would reduce duplication.
+
+### 3. Model Config is Scattered (Improved)
+
+Model identifiers are still duplicated across `main.py` (3 dicts), backend files, frontend components, and the languages constant. However, the pattern is now consistent and well-understood. A centralized model registry would help but isn't blocking.
+
+### 4. Voice Prompt Cache Assumes PyTorch Tensors
+
+`backend/utils/cache.py` uses `torch.save()` / `torch.load()`. LuxTTS and Chatterbox backends work around this by storing reference audio paths instead of tensors in their voice prompt dicts. Not ideal but functional.
+
+### 5. ~~Frontend Assumes Qwen Model Sizes~~ — RESOLVED
+
+The generation form now uses a flat model dropdown with engine-based routing. Per-engine language filtering is in place. Model size is only sent for Qwen.
+
+---
+
+## Recommended Priorities
+
+### Tier 1 — Ship Now (Low Risk)
+
+| Priority | PR/Item | Impact | Effort |
+|----------|---------|--------|--------|
+| 1 | **#258** — Chatterbox Turbo + per-engine languages | Paralinguistic tags, proper language filtering | Review only |
+| 2 | **#152** — Offline mode crash fix | Fixes #150, #151 | Low |
+| 3 | **#99** — Chunked TTS + quality selector | Removes 500-char limit, addresses 5 issues | Medium |
+| 4 | **#218** — Windows HF cache dir fix | Windows-specific pain | Low |
+| 5 | **#178** — Generation error handling | Error UX | Low |
+| 6 | **#230** — Docs fixes | Zero risk | None |
+| 7 | **#133** — Network access toggle | Wires up existing code | Low |
+| 8 | **#88** — CORS restriction | Security improvement | Low |
+| 9 | **#214** — Tauri window close panic fix | Stability | Low |
+| 10 | Triage GPU issues | Many may be resolved by CUDA swap (#252) | Low |
+| 11 | Close superseded PRs | #194 (superseded by #257), #83 (outdated) | None |
+
+### Tier 2 — Next Release (v0.2.0)
+
+| Priority | Item | Impact | Effort |
+|----------|------|--------|--------|
+| 1 | **#253** — 48kHz speech tokenizer | Quality improvement | Medium |
+| 2 | **#161** — Docker deployment | Server/headless users | Medium |
+| 3 | **#154** — Audiobook tab | Long-form users | Medium |
+| 4 | **Model config registry** | Reduce 5-dispatch-point duplication in main.py | Medium |
+| 5 | **#225** — Custom HuggingFace models | User-supplied models | High (needs rework for multi-engine) |
+
+### Tier 3 — Future (v0.3.0+)
+
+| Item | Notes |
+|------|-------|
+| XTTS-v2 / Fish Speech / CosyVoice | Multi-engine arch is ready; just needs backend implementation |
+| OpenAI-compatible API (plan doc exists) | Low effort once API is stable |
+| LoRA fine-tuning (PR #195) | Complex, needs rework for multi-engine |
+| External/remote providers | Depends on use case demand |
+| GGUF support (#226) | Depends on model ecosystem maturity |
+| Queue system (#234) | Batch generation |
+| Streaming for non-MLX engines | Currently MLX-only |
+| Kokoro-82M | Tiny model, great for CPU-only machines |
+
+---
+
+## Branch Inventory
+
+| Branch | PR | Status | Notes |
+|--------|-----|--------|-------|
+| `feat/chatterbox-turbo` | #258 | Open | Chatterbox Turbo + per-engine languages |
+| `feat/chatterbox` | #257 | **Merged** | Chatterbox Multilingual |
+| `feat/luxtts` | #254 | **Merged** | LuxTTS + multi-engine arch |
+| `external-provider-binaries` | #33 | Superseded by #252 | Original CUDA provider approach |
+| `feat/dual-server-binaries` | — | No PR | Related to provider split |
+| `fix-multi-sample` | — | No PR | Voice profile multi-sample fix |
+| `fix-dl-notification-...` | — | No PR | Model download UX |
+
+---
+
+## Quick Reference: API Endpoints
+
+
+All current endpoints
+
+| Endpoint | Method | Purpose |
+|----------|--------|---------|
+| `/health` | GET | Health check, model/GPU status |
+| `/profiles` | POST, GET | Create/list voice profiles |
+| `/profiles/{id}` | GET, PUT, DELETE | Profile CRUD |
+| `/profiles/{id}/samples` | POST, GET | Add/list voice samples |
+| `/profiles/{id}/avatar` | POST, GET, DELETE | Avatar management |
+| `/profiles/{id}/export` | GET | Export profile as ZIP |
+| `/profiles/import` | POST | Import profile from ZIP |
+| `/generate` | POST | Generate speech (engine param selects TTS backend) |
+| `/generate/stream` | POST | Stream speech (MLX only) |
+| `/history` | GET | List generation history |
+| `/history/{id}` | GET, DELETE | Get/delete generation |
+| `/history/{id}/export` | GET | Export generation ZIP |
+| `/history/{id}/export-audio` | GET | Export audio only |
+| `/transcribe` | POST | Transcribe audio (Whisper) |
+| `/models/status` | GET | All model statuses (Qwen, LuxTTS, Chatterbox, Chatterbox Turbo, Whisper) |
+| `/models/download` | POST | Trigger model download |
+| `/models/download/cancel` | POST | Cancel/dismiss download |
+| `/models/{name}` | DELETE | Delete downloaded model |
+| `/models/load` | POST | Load model into memory |
+| `/models/unload` | POST | Unload model |
+| `/models/progress/{name}` | GET | SSE download progress |
+| `/tasks/active` | GET | Active downloads/generations (with inline progress) |
+| `/stories` | POST, GET | Create/list stories |
+| `/stories/{id}` | GET, PUT, DELETE | Story CRUD |
+| `/stories/{id}/items` | POST, GET | Story items CRUD |
+| `/stories/{id}/export` | GET | Export story audio |
+| `/channels` | POST, GET | Audio channel CRUD |
+| `/channels/{id}` | PUT, DELETE | Channel update/delete |
+| `/cache/clear` | POST | Clear voice prompt cache |
+| `/server/cuda/status` | GET | CUDA binary availability |
+| `/server/cuda/download` | POST | Download CUDA binary |
+| `/server/cuda/switch` | POST | Switch to CUDA backend |
+
+
diff --git a/justfile b/justfile
new file mode 100644
index 00000000..ad172fa8
--- /dev/null
+++ b/justfile
@@ -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 python@3.12"
+ 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
diff --git a/scripts/generate-api.sh b/scripts/generate-api.sh
index f2f057e5..c2de5293 100755
--- a/scripts/generate-api.sh
+++ b/scripts/generate-api.sh
@@ -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
diff --git a/scripts/setup-dev-sidecar.js b/scripts/setup-dev-sidecar.js
index 6d5d5524..0fb9e327 100644
--- a/scripts/setup-dev-sidecar.js
+++ b/scripts/setup-dev-sidecar.js
@@ -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();
diff --git a/scripts/split_binary.py b/scripts/split_binary.py
new file mode 100644
index 00000000..0310fbd8
--- /dev/null
+++ b/scripts/split_binary.py
@@ -0,0 +1,82 @@
+"""
+Split a large binary into chunks for GitHub Releases (<2 GB each).
+
+Usage:
+ python scripts/split_binary.py backend/dist/voicebox-server-cuda.exe
+ python scripts/split_binary.py backend/dist/voicebox-server-cuda.exe --chunk-size 1900000000
+ python scripts/split_binary.py backend/dist/voicebox-server-cuda.exe --output release-assets/
+
+The script produces:
+ - voicebox-server-cuda.part00.exe, .part01.exe, ... (binary chunks)
+ - voicebox-server-cuda.sha256 (SHA-256 checksum of the complete file)
+ - voicebox-server-cuda.manifest (ordered list of part filenames)
+"""
+
+import argparse
+import hashlib
+import sys
+from pathlib import Path
+
+
+def split(input_path: Path, chunk_size: int, output_dir: Path):
+ output_dir.mkdir(parents=True, exist_ok=True)
+ data = input_path.read_bytes()
+ total_size = len(data)
+
+ # Write SHA-256 of the complete file
+ sha256 = hashlib.sha256(data).hexdigest()
+ checksum_file = output_dir / f"{input_path.stem}.sha256"
+ checksum_file.write_text(f"{sha256} {input_path.name}\n")
+
+ # Split into chunks
+ parts = []
+ for i in range(0, total_size, chunk_size):
+ part_index = len(parts)
+ part_name = f"{input_path.stem}.part{part_index:02d}{input_path.suffix}"
+ part_path = output_dir / part_name
+ part_path.write_bytes(data[i:i + chunk_size])
+ parts.append(part_name)
+
+ # Write manifest (ordered list of part filenames)
+ manifest_file = output_dir / f"{input_path.stem}.manifest"
+ manifest_file.write_text("\n".join(parts) + "\n")
+
+ print(f"Input: {input_path} ({total_size / (1024**3):.2f} GB)")
+ print(f"Output: {output_dir}/")
+ print(f"Parts: {len(parts)} (chunk size: {chunk_size / (1024**3):.2f} GB)")
+ print(f"SHA-256: {sha256}")
+ print(f"Manifest: {manifest_file.name}")
+ for p in parts:
+ size = (output_dir / p).stat().st_size
+ print(f" {p} ({size / (1024**3):.2f} GB)")
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Split a large binary into chunks for GitHub Releases"
+ )
+ parser.add_argument("input", type=Path, help="Path to the binary file to split")
+ parser.add_argument(
+ "--chunk-size",
+ type=int,
+ default=1_900_000_000, # 1.9 GB — safely under 2 GB GitHub limit
+ help="Maximum chunk size in bytes (default: 1.9 GB)",
+ )
+ parser.add_argument(
+ "--output",
+ type=Path,
+ default=None,
+ help="Output directory (default: same directory as input)",
+ )
+ args = parser.parse_args()
+
+ if not args.input.exists():
+ print(f"Error: {args.input} does not exist", file=sys.stderr)
+ sys.exit(1)
+
+ output_dir = args.output or args.input.parent
+ split(args.input, args.chunk_size, output_dir)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/test_download_progress.py b/scripts/test_download_progress.py
new file mode 100644
index 00000000..a0fbe551
--- /dev/null
+++ b/scripts/test_download_progress.py
@@ -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()
diff --git a/tauri/src-tauri/Cargo.lock b/tauri/src-tauri/Cargo.lock
index 35b15188..1ee065f2 100644
--- a/tauri/src-tauri/Cargo.lock
+++ b/tauri/src-tauri/Cargo.lock
@@ -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",
]
diff --git a/tauri/src-tauri/Cargo.toml b/tauri/src-tauri/Cargo.toml
index aa3b1a9c..1f3654d0 100644
--- a/tauri/src-tauri/Cargo.toml
+++ b/tauri/src-tauri/Cargo.toml
@@ -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"
diff --git a/tauri/src-tauri/gen/Assets.car b/tauri/src-tauri/gen/Assets.car
index de0e9a08..a5f68f95 100644
Binary files a/tauri/src-tauri/gen/Assets.car and b/tauri/src-tauri/gen/Assets.car differ
diff --git a/tauri/src-tauri/gen/voicebox.icns b/tauri/src-tauri/gen/voicebox.icns
index 59661d99..e4492f52 100644
Binary files a/tauri/src-tauri/gen/voicebox.icns and b/tauri/src-tauri/gen/voicebox.icns differ
diff --git a/tauri/src-tauri/src/audio_capture/linux.rs b/tauri/src-tauri/src/audio_capture/linux.rs
index 8af26e97..3cae59e9 100644
--- a/tauri/src-tauri/src/audio_capture/linux.rs
+++ b/tauri/src-tauri/src/audio_capture/linux.rs
@@ -1,16 +1,312 @@
use crate::audio_capture::AudioCaptureState;
+use base64::{engine::general_purpose, Engine as _};
+use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
+use cpal::{SampleFormat, StreamConfig};
+use hound::{WavSpec, WavWriter};
+use std::io::Cursor;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+use std::thread;
+/// Start capturing system audio on Linux using PulseAudio monitor sources.
+///
+/// PulseAudio exposes "monitor" devices that mirror the output of each sink,
+/// allowing us to capture whatever audio is currently playing on the system.
+/// We use `cpal` with the default host (which will be PulseAudio or PipeWire
+/// on modern Linux) and look for monitor input devices.
pub async fn start_capture(
state: &AudioCaptureState,
max_duration_secs: u32,
) -> Result<(), String> {
- todo!("implement Linux audio capture")
+ // Reset previous samples
+ state.reset();
+
+ let samples = state.samples.clone();
+ let sample_rate_arc = state.sample_rate.clone();
+ let channels_arc = state.channels.clone();
+ let stop_tx = state.stop_tx.clone();
+ let error_arc = state.error.clone();
+
+ // Use AtomicBool for stop signal (works across threads)
+ let stop_flag = Arc::new(AtomicBool::new(false));
+ let stop_flag_clone = stop_flag.clone();
+
+ // Create tokio channel and spawn a task to bridge it to the AtomicBool
+ let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
+ *stop_tx.lock().unwrap() = Some(tx);
+
+ tokio::spawn(async move {
+ rx.recv().await;
+ stop_flag_clone.store(true, Ordering::Relaxed);
+ });
+
+ // Spawn capture on a dedicated thread
+ thread::spawn(move || {
+ let host = cpal::default_host();
+
+ // Try to find a monitor device for system audio capture.
+ // On PulseAudio/PipeWire, monitor sources have "monitor" in their name.
+ let device = {
+ let mut monitor_device = None;
+
+ if let Ok(devices) = host.input_devices() {
+ for d in devices {
+ if let Ok(name) = d.name() {
+ let name_lower = name.to_lowercase();
+ if name_lower.contains("monitor") {
+ eprintln!("Linux audio capture: Found monitor device: {}", name);
+ monitor_device = Some(d);
+ break;
+ }
+ }
+ }
+ }
+
+ match monitor_device {
+ Some(d) => d,
+ None => {
+ // Fallback to default input device (microphone)
+ eprintln!("Linux audio capture: No monitor device found, falling back to default input");
+ match host.default_input_device() {
+ Some(d) => d,
+ None => {
+ let error_msg = "No audio input device available".to_string();
+ eprintln!("{}", error_msg);
+ *error_arc.lock().unwrap() = Some(error_msg);
+ return;
+ }
+ }
+ }
+ }
+ };
+
+ let device_name = device.name().unwrap_or_else(|_| "unknown".to_string());
+ eprintln!("Linux audio capture: Using device: {}", device_name);
+
+ // Get supported config
+ let config = match device.default_input_config() {
+ Ok(c) => c,
+ Err(e) => {
+ let error_msg = format!("Failed to get default input config: {}", e);
+ eprintln!("{}", error_msg);
+ *error_arc.lock().unwrap() = Some(error_msg);
+ return;
+ }
+ };
+
+ let sample_rate = config.sample_rate().0;
+ let channels = config.channels();
+ let sample_format = config.sample_format();
+
+ eprintln!(
+ "Linux audio capture: Config - {}Hz, {} channels, format: {:?}",
+ sample_rate, channels, sample_format
+ );
+
+ *sample_rate_arc.lock().unwrap() = sample_rate;
+ *channels_arc.lock().unwrap() = channels;
+
+ let stream_config = StreamConfig {
+ channels,
+ sample_rate: cpal::SampleRate(sample_rate),
+ buffer_size: cpal::BufferSize::Default,
+ };
+
+ let samples_clone = samples.clone();
+ let error_arc_clone = error_arc.clone();
+ let stop_flag_for_stream = stop_flag.clone();
+
+ let err_fn = {
+ let error_arc = error_arc.clone();
+ move |err: cpal::StreamError| {
+ let error_msg = format!("Stream error: {}", err);
+ eprintln!("{}", error_msg);
+ *error_arc.lock().unwrap() = Some(error_msg);
+ }
+ };
+
+ let stream = match sample_format {
+ SampleFormat::F32 => {
+ let samples = samples_clone.clone();
+ let stop = stop_flag_for_stream.clone();
+ device.build_input_stream(
+ &stream_config,
+ move |data: &[f32], _: &cpal::InputCallbackInfo| {
+ if stop.load(Ordering::Relaxed) {
+ return;
+ }
+ let mut guard = samples.lock().unwrap();
+ guard.extend_from_slice(data);
+ },
+ err_fn,
+ None,
+ )
+ }
+ SampleFormat::I16 => {
+ let samples = samples_clone.clone();
+ let stop = stop_flag_for_stream.clone();
+ device.build_input_stream(
+ &stream_config,
+ move |data: &[i16], _: &cpal::InputCallbackInfo| {
+ if stop.load(Ordering::Relaxed) {
+ return;
+ }
+ let mut guard = samples.lock().unwrap();
+ for &s in data {
+ guard.push(s as f32 / 32768.0);
+ }
+ },
+ err_fn,
+ None,
+ )
+ }
+ SampleFormat::U16 => {
+ let samples = samples_clone.clone();
+ let stop = stop_flag_for_stream.clone();
+ device.build_input_stream(
+ &stream_config,
+ move |data: &[u16], _: &cpal::InputCallbackInfo| {
+ if stop.load(Ordering::Relaxed) {
+ return;
+ }
+ let mut guard = samples.lock().unwrap();
+ for &s in data {
+ guard.push((s as f32 / 32768.0) - 1.0);
+ }
+ },
+ err_fn,
+ None,
+ )
+ }
+ _ => {
+ let error_msg = format!("Unsupported sample format: {:?}", sample_format);
+ eprintln!("{}", error_msg);
+ *error_arc_clone.lock().unwrap() = Some(error_msg);
+ return;
+ }
+ };
+
+ let stream = match stream {
+ Ok(s) => s,
+ Err(e) => {
+ let error_msg = format!("Failed to build input stream: {}", e);
+ eprintln!("{}", error_msg);
+ *error_arc_clone.lock().unwrap() = Some(error_msg);
+ return;
+ }
+ };
+
+ if let Err(e) = stream.play() {
+ let error_msg = format!("Failed to start stream: {}", e);
+ eprintln!("{}", error_msg);
+ *error_arc_clone.lock().unwrap() = Some(error_msg);
+ return;
+ }
+
+ eprintln!("Linux audio capture: Stream started successfully");
+
+ // Keep thread alive until stop signal
+ loop {
+ if stop_flag.load(Ordering::Relaxed) {
+ break;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(100));
+ }
+
+ // Stream will be dropped here, stopping capture
+ eprintln!("Linux audio capture: Stream stopped");
+ });
+
+ // Spawn timeout task
+ let stop_tx_clone = state.stop_tx.clone();
+ tokio::spawn(async move {
+ tokio::time::sleep(tokio::time::Duration::from_secs(max_duration_secs as u64)).await;
+ let tx = stop_tx_clone.lock().unwrap().take();
+ if let Some(tx) = tx {
+ let _ = tx.send(()).await;
+ }
+ });
+
+ Ok(())
}
pub async fn stop_capture(state: &AudioCaptureState) -> Result {
- todo!("implement Linux audio capture stop")
+ // Signal stop
+ if let Some(tx) = state.stop_tx.lock().unwrap().take() {
+ let _ = tx.send(());
+ }
+
+ // Wait a bit for capture to stop
+ tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
+
+ // Check if there was an error during capture
+ if let Some(error) = state.error.lock().unwrap().as_ref() {
+ return Err(error.clone());
+ }
+
+ // Get samples
+ let samples = state.samples.lock().unwrap().clone();
+ let sample_rate = *state.sample_rate.lock().unwrap();
+ let channels = *state.channels.lock().unwrap();
+
+ if samples.is_empty() {
+ return Err(
+ "No audio samples captured. Make sure audio is playing on your system during recording."
+ .to_string(),
+ );
+ }
+
+ // Convert to WAV
+ let wav_data = samples_to_wav(&samples, sample_rate, channels)?;
+
+ // Encode to base64
+ let base64_data = general_purpose::STANDARD.encode(&wav_data);
+
+ Ok(base64_data)
}
pub fn is_supported() -> bool {
- false
+ // Check if we can find a monitor device for system audio capture
+ let host = cpal::default_host();
+ if let Ok(devices) = host.input_devices() {
+ for d in devices {
+ if let Ok(name) = d.name() {
+ if name.to_lowercase().contains("monitor") {
+ return true;
+ }
+ }
+ }
+ }
+ // Even without a monitor, basic input capture is available
+ host.default_input_device().is_some()
+}
+
+fn samples_to_wav(samples: &[f32], sample_rate: u32, channels: u16) -> Result, String> {
+ let mut buffer = Vec::new();
+ let cursor = Cursor::new(&mut buffer);
+
+ let spec = WavSpec {
+ channels,
+ sample_rate,
+ bits_per_sample: 16,
+ sample_format: hound::SampleFormat::Int,
+ };
+
+ let mut writer =
+ WavWriter::new(cursor, spec).map_err(|e| format!("Failed to create WAV writer: {}", e))?;
+
+ // Convert f32 samples to i16
+ for sample in samples {
+ let clamped = sample.clamp(-1.0, 1.0);
+ let i16_sample = (clamped * 32767.0) as i16;
+ writer
+ .write_sample(i16_sample)
+ .map_err(|e| format!("Failed to write sample: {}", e))?;
+ }
+
+ writer
+ .finalize()
+ .map_err(|e| format!("Failed to finalize WAV: {}", e))?;
+
+ Ok(buffer)
}
diff --git a/tauri/src-tauri/src/main.rs b/tauri/src-tauri/src/main.rs
index 255655aa..157fee9e 100644
--- a/tauri/src-tauri/src/main.rs
+++ b/tauri/src-tauri/src/main.rs
@@ -178,6 +178,56 @@ async fn start_server(
println!("Data directory: {:?}", data_dir);
println!("Remote mode: {}", remote.unwrap_or(false));
+ // Check for CUDA backend binary in data directory
+ let cuda_binary = {
+ let backends_dir = data_dir.join("backends");
+ let cuda_name = if cfg!(windows) {
+ "voicebox-server-cuda.exe"
+ } else {
+ "voicebox-server-cuda"
+ };
+ let path = backends_dir.join(cuda_name);
+ if path.exists() {
+ println!("Found CUDA backend binary at {:?}", path);
+
+ // Version check: run --version and compare to app version
+ let app_version = app.config().version.clone().unwrap_or_default();
+ let version_ok = match std::process::Command::new(&path)
+ .arg("--version")
+ .output()
+ {
+ Ok(output) => {
+ // Output format: "voicebox-server X.Y.Z\n"
+ let version_str = String::from_utf8_lossy(&output.stdout);
+ let binary_version = version_str.trim().split_whitespace().last().unwrap_or("");
+ if binary_version == app_version {
+ println!("CUDA binary version {} matches app version", binary_version);
+ true
+ } else {
+ println!(
+ "CUDA binary version mismatch: binary={}, app={}. Falling back to CPU.",
+ binary_version, app_version
+ );
+ false
+ }
+ }
+ Err(e) => {
+ println!("Failed to check CUDA binary version: {}. Falling back to CPU.", e);
+ false
+ }
+ };
+
+ if version_ok {
+ Some(path)
+ } else {
+ None
+ }
+ } else {
+ println!("No CUDA backend found, using bundled CPU binary");
+ None
+ }
+ };
+
let sidecar_result = app.shell().sidecar("voicebox-server");
let mut sidecar = match sidecar_result {
@@ -216,22 +266,32 @@ async fn start_server(
println!("Sidecar command created successfully");
- // Pass data directory and port to Python server
- sidecar = sidecar.args([
- "--data-dir",
- data_dir
- .to_str()
- .ok_or_else(|| "Invalid data dir path".to_string())?,
- "--port",
- &SERVER_PORT.to_string(),
- ]);
+ // Build common args
+ let data_dir_str = data_dir
+ .to_str()
+ .ok_or_else(|| "Invalid data dir path".to_string())?
+ .to_string();
+ let port_str = SERVER_PORT.to_string();
+ let is_remote = remote.unwrap_or(false);
- if remote.unwrap_or(false) {
- sidecar = sidecar.args(["--host", "0.0.0.0"]);
- }
-
- println!("Spawning server process...");
- let spawn_result = sidecar.spawn();
+ // If CUDA binary exists, launch it directly instead of the bundled sidecar
+ let spawn_result = if let Some(ref cuda_path) = cuda_binary {
+ println!("Launching CUDA backend: {:?}", cuda_path);
+ let mut cmd = app.shell().command(cuda_path.to_str().unwrap());
+ cmd = cmd.args(["--data-dir", &data_dir_str, "--port", &port_str]);
+ if is_remote {
+ cmd = cmd.args(["--host", "0.0.0.0"]);
+ }
+ cmd.spawn()
+ } else {
+ // Use the bundled CPU sidecar
+ sidecar = sidecar.args(["--data-dir", &data_dir_str, "--port", &port_str]);
+ if is_remote {
+ sidecar = sidecar.args(["--host", "0.0.0.0"]);
+ }
+ println!("Spawning server process...");
+ sidecar.spawn()
+ };
let (mut rx, child) = match spawn_result {
Ok(result) => result,
@@ -549,6 +609,25 @@ async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
Ok(())
}
+#[command]
+async fn restart_server(
+ app: tauri::AppHandle,
+ state: State<'_, ServerState>,
+) -> Result {
+ println!("restart_server: stopping current server...");
+
+ // Stop the current server
+ stop_server(state.clone()).await?;
+
+ // Wait for port to be released
+ println!("restart_server: waiting for port release...");
+ tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
+
+ // Start server again (will auto-detect CUDA binary)
+ println!("restart_server: starting server...");
+ start_server(app, state, None).await
+}
+
#[command]
fn set_keep_server_running(state: State<'_, ServerState>, keep_running: bool) {
*state.keep_running_on_close.lock().unwrap() = keep_running;
@@ -635,11 +714,49 @@ 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::() {
+ 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![
start_server,
stop_server,
+ restart_server,
set_keep_server_running,
start_system_audio_capture,
stop_system_audio_capture,
@@ -675,7 +792,9 @@ pub fn run() {
});
// Wait for frontend response or timeout
- tokio::spawn(async move {
+ // Use tauri::async_runtime::spawn instead of tokio::spawn to avoid
+ // panics when the Tokio runtime is being dropped during app shutdown
+ tauri::async_runtime::spawn(async move {
tokio::select! {
_ = rx.recv() => {
// Frontend responded, close window
diff --git a/tauri/src/platform/lifecycle.ts b/tauri/src/platform/lifecycle.ts
index 562c75aa..60063f3e 100644
--- a/tauri/src/platform/lifecycle.ts
+++ b/tauri/src/platform/lifecycle.ts
@@ -1,5 +1,5 @@
import { invoke } from '@tauri-apps/api/core';
-import { listen, emit } from '@tauri-apps/api/event';
+import { emit, listen } from '@tauri-apps/api/event';
import type { PlatformLifecycle } from '@/platform/types';
class TauriLifecycle implements PlatformLifecycle {
@@ -27,6 +27,18 @@ class TauriLifecycle implements PlatformLifecycle {
}
}
+ async restartServer(): Promise {
+ try {
+ const result = await invoke('restart_server');
+ console.log('Server restarted:', result);
+ this.onServerReady?.();
+ return result;
+ } catch (error) {
+ console.error('Failed to restart server:', error);
+ throw error;
+ }
+ }
+
async setKeepServerRunning(keepRunning: boolean): Promise {
try {
await invoke('set_keep_server_running', { keepRunning });
diff --git a/web/src/platform/lifecycle.ts b/web/src/platform/lifecycle.ts
index c5e9ea6e..f40f1a90 100644
--- a/web/src/platform/lifecycle.ts
+++ b/web/src/platform/lifecycle.ts
@@ -15,6 +15,11 @@ class WebLifecycle implements PlatformLifecycle {
// No-op for web - server is managed externally
}
+ async restartServer(): Promise {
+ // No-op for web - server is managed externally
+ return import.meta.env.VITE_SERVER_URL || 'http://localhost:17493';
+ }
+
async setKeepServerRunning(_keep: boolean): Promise {
// No-op for web
}