+
Models
+
Download and manage AI models for voice generation and transcription
-
-
-
- {isLoading ? (
-
-
+
+
+
+ {/* Model storage location */}
+ {platform.metadata.isTauri && cacheDir && (
+
+
+
+
Storage location
+
+ {cacheDir.path}
+
+
+
+ {
+ try {
+ await platform.filesystem.openPath(cacheDir.path);
+ } catch {
+ toast({ title: 'Failed to open model folder', variant: 'destructive' });
+ }
+ }}
+ >
+
+ Open
+
+ {
+ try {
+ const newDir = await platform.filesystem.pickDirectory(
+ 'Choose model storage folder',
+ );
+ if (!newDir) return;
+ setPendingMigrateDir(newDir);
+ } catch {
+ toast({ title: 'Failed to open folder picker', variant: 'destructive' });
+ }
+ }}
+ disabled={migrating}
+ >
+ {migrating ? (
+
+ ) : (
+
+ )}
+ {migrating ? 'Migrating...' : 'Change'}
+
+ {customModelsDir && (
+ {
+ setCustomModelsDir(null);
+ toast({ title: 'Reset to default location. Restarting server...' });
+ await platform.lifecycle.restartServer('');
+ queryClient.invalidateQueries();
+ }}
+ >
+
+ Reset
+
+ )}
+
- ) : 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 (
+
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}
- />
- ))}
+ type="button"
+ onClick={() => openModelDetail(model)}
+ className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-muted/50 transition-colors group"
+ >
+ {/* Status indicator */}
+
+ {hasError ? (
+
+ ) : isDownloading ? (
+
+ ) : model.loaded ? (
+
+ ) : model.downloaded ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Name + inline progress */}
+
+
{model.display_name}
+ {isDownloading &&
+ (() => {
+ const dl = downloadProgressMap.get(model.model_name);
+ const pct = dl?.progress ?? 0;
+ const hasProgress = dl && dl.total && dl.total > 0;
+ return (
+
+
+
+ {hasProgress
+ ? `${formatBytes(dl.current ?? 0)} / ${formatBytes(dl.total!)} (${pct.toFixed(0)}%)`
+ : dl?.filename || 'Connecting...'}
+
+
+ );
+ })()}
+
+
+ {/* Right side info */}
+
+ {hasError && (
+
+ Error
+
+ )}
+ {model.loaded && (
+
+ Loaded
+
+ )}
+ {model.downloaded && !isDownloading && !hasError && (
+
+ {formatSize(model.size_mb)}
+
+ )}
+
+
+
+
+ );
+ })}
+ ))}
- {/* 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}
- />
- ))}
+ {/* Error console */}
+ {errorCount > 0 && (
+
+
+ setConsoleOpen((v) => !v)}
+ className="flex items-center gap-2 hover:text-foreground transition-colors"
+ >
+ {consoleOpen ? (
+
+ ) : (
+
+ )}
+ Problems
+
+ {errorCount}
+
+
+ clearAllMutation.mutate()}
+ disabled={clearAllMutation.isPending}
+ >
+
+ Clear All
+
+ {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}
-
- ) : null}
-
+ {/* Model Detail Modal */}
+
+
+ {freshSelectedModel && (
+ <>
+
+ {freshSelectedModel.display_name}
+
+ {freshSelectedModel.hf_repo_id ? (
+
+ {freshSelectedModel.hf_repo_id}
+
+
+ ) : (
+ freshSelectedModel.model_name
+ )}
+
+
+
+
+ {/* Status badges */}
+
+ {freshSelectedModel.loaded && (
+
+
+ Loaded
+
+ )}
+ {selectedState?.hasError && (
+
+
+ Error
+
+ )}
+
+
+ {/* HuggingFace model card info */}
+ {hfLoading && freshSelectedModel.hf_repo_id && (
+
+
+ Loading model info...
+
+ )}
+
+ {/* Description */}
+ {MODEL_DESCRIPTIONS[freshSelectedModel.model_name] && (
+
+ {MODEL_DESCRIPTIONS[freshSelectedModel.model_name]}
+
+ )}
+
+ {hfModelInfo && (
+
+ {/* Pipeline tag + author */}
+
+ {hfModelInfo.pipeline_tag && (
+
+ {formatPipelineTag(hfModelInfo.pipeline_tag)}
+
+ )}
+ {hfModelInfo.library_name && (
+
+ {hfModelInfo.library_name}
+
+ )}
+ {hfModelInfo.author && (
+
+ by {hfModelInfo.author}
+
+ )}
+
+
+ {/* Stats row */}
+
+
+
+ {formatDownloads(hfModelInfo.downloads)}
+
+
+
+ {formatDownloads(hfModelInfo.likes)}
+
+ {license && (
+
+
+ {formatLicense(license)}
+
+ )}
+
+
+ {/* Languages */}
+ {hfModelInfo.cardData?.language && hfModelInfo.cardData.language.length > 0 && (
+
+
+ {hfModelInfo.cardData.language.length > 10
+ ? `${hfModelInfo.cardData.language.length} languages supported`
+ : `Languages: ${hfModelInfo.cardData.language.join(', ')}`}
+
+
+ )}
+
+ )}
+
+ {/* Disk size */}
+ {freshSelectedModel.downloaded && freshSelectedModel.size_mb && (
+
+
+ {formatSize(freshSelectedModel.size_mb)} on disk
+
+ )}
+
+ {/* Error detail */}
+ {selectedError?.error && (
+
+ {selectedError.error}
+
+ )}
+
+ {/* Actions */}
+
+ {selectedState?.hasError ? (
+ <>
+
handleDownload(freshSelectedModel.model_name)}
+ variant="outline"
+ className="flex-1"
+ >
+
+ Retry Download
+
+
handleCancel(freshSelectedModel.model_name)}
+ variant="ghost"
+ disabled={
+ cancelMutation.isPending &&
+ cancelMutation.variables === freshSelectedModel.model_name
+ }
+ >
+
+
+ >
+ ) : selectedState?.isDownloading ? (
+ <>
+
+ {(() => {
+ const dl = freshSelectedModel
+ ? downloadProgressMap.get(freshSelectedModel.model_name)
+ : undefined;
+ const pct = dl?.progress ?? 0;
+ const hasProgress = dl && dl.total && dl.total > 0;
+ return (
+ <>
+
+
+ {hasProgress
+ ? `${formatBytes(dl.current ?? 0)} / ${formatBytes(dl.total!)} (${pct.toFixed(1)}%)`
+ : dl?.filename || 'Connecting to HuggingFace...'}
+
+ >
+ );
+ })()}
+
+
handleCancel(freshSelectedModel.model_name)}
+ variant="ghost"
+ disabled={
+ cancelMutation.isPending &&
+ cancelMutation.variables === freshSelectedModel.model_name
+ }
+ >
+
+
+ >
+ ) : freshSelectedModel.downloaded ? (
+
+ {freshSelectedModel.loaded && (
+ unloadMutation.mutate(freshSelectedModel.model_name)}
+ variant="outline"
+ disabled={unloadMutation.isPending}
+ className="flex-1"
+ >
+ {unloadMutation.isPending ? (
+
+ ) : (
+
+ )}
+ {unloadMutation.isPending ? 'Unloading...' : 'Unload'}
+
+ )}
+ {
+ setModelToDelete({
+ name: freshSelectedModel.model_name,
+ displayName: freshSelectedModel.display_name,
+ sizeMb: freshSelectedModel.size_mb,
+ });
+ setDeleteDialogOpen(true);
+ }}
+ variant="outline"
+ disabled={freshSelectedModel.loaded}
+ title={
+ freshSelectedModel.loaded
+ ? 'Unload model before deleting'
+ : 'Delete model'
+ }
+ className="flex-1"
+ >
+
+ Delete Model
+
+
+ ) : (
+
handleDownload(freshSelectedModel.model_name)}
+ className="flex-1"
+ >
+
+ Download
+
+ )}
+
+
+ >
+ )}
+
+
{/* Delete Confirmation Dialog */}
@@ -256,7 +927,127 @@ export function ModelManagement() {
-
+
+ {/* Migration confirmation dialog */}
+
!open && setPendingMigrateDir(null)}
+ >
+
+
+ Move models to new location?
+
+ The server will shut down while models are being moved to the new folder. It will
+ restart automatically once the migration is complete.
+
+
+
+ {pendingMigrateDir}
+
+
+ Cancel
+ {
+ if (!pendingMigrateDir) return;
+ const newDir = pendingMigrateDir;
+ setPendingMigrateDir(null);
+ setMigrating(true);
+ setMigrationProgress({
+ current: 0,
+ total: 0,
+ progress: 0,
+ status: 'downloading',
+ filename: 'Preparing...',
+ });
+ try {
+ // Start the migration (background task)
+ await apiClient.migrateModels(newDir);
+
+ // Connect to SSE for progress
+ await new Promise((resolve, reject) => {
+ const es = new EventSource(apiClient.getMigrationProgressUrl());
+ es.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ setMigrationProgress(data);
+ if (data.status === 'complete') {
+ es.close();
+ resolve();
+ } else if (data.status === 'error') {
+ es.close();
+ reject(new Error(data.error || 'Migration failed'));
+ }
+ } catch {
+ /* ignore parse errors */
+ }
+ };
+ es.onerror = () => {
+ es.close();
+ reject(new Error('Lost connection during migration'));
+ };
+ });
+
+ setCustomModelsDir(newDir);
+ setMigrationProgress({
+ current: 1,
+ total: 1,
+ progress: 100,
+ status: 'complete',
+ filename: 'Restarting server...',
+ });
+ await platform.lifecycle.restartServer(newDir);
+ queryClient.invalidateQueries();
+ toast({ title: 'Models moved successfully' });
+ } catch (e) {
+ toast({
+ title: 'Migration failed',
+ description: e instanceof Error ? e.message : 'Failed to migrate models',
+ variant: 'destructive',
+ });
+ } finally {
+ setMigrating(false);
+ setMigrationProgress(null);
+ }
+ }}
+ >
+ Move Models
+
+
+
+
+
+ {/* Migration progress overlay */}
+ {migrating && migrationProgress && (
+
+
+
+
+
Moving models
+
+ {migrationProgress.status === 'complete'
+ ? 'Restarting server...'
+ : 'The server is offline while models are being moved.'}
+
+
+ {migrationProgress.total > 0 && (
+
+
+
+ {migrationProgress.filename}
+
+ {formatBytes(migrationProgress.current)} /{' '}
+ {formatBytes(migrationProgress.total)}
+
+
+
+ )}
+
+
+ )}
+
);
}
@@ -265,22 +1056,38 @@ interface ModelItemProps {
model_name: string;
display_name: string;
downloaded: boolean;
- downloading?: boolean; // From server - true if download in progress
+ downloading?: boolean; // From server - true if download in progress
size_mb?: number;
loaded: boolean;
};
onDownload: () => void;
onDelete: () => void;
- isDownloading: boolean; // Local state - true if user just clicked download
+ isDownloading: boolean; // Local state - true if user just clicked download
formatSize: (sizeMb?: number) => string;
}
function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: ModelItemProps) {
// Use server's downloading state OR local state (for immediate feedback before server updates)
const showDownloading = model.downloading || isDownloading;
-
+
+ const statusText = model.loaded
+ ? 'Loaded'
+ : showDownloading
+ ? 'Downloading'
+ : model.downloaded
+ ? 'Downloaded'
+ : 'Not downloaded';
+ const sizeText =
+ model.downloaded && model.size_mb && !showDownloading ? `, ${formatSize(model.size_mb)}` : '';
+ const rowLabel = `${model.display_name}, ${statusText}${sizeText}. Use Tab to reach Download or Delete.`;
+
return (
-
+
{model.display_name}
@@ -314,17 +1121,30 @@ 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 ? (
-
+
Downloading...
) : (
-
+
Download
diff --git a/app/src/components/ServerSettings/ServerStatus.tsx b/app/src/components/ServerSettings/ServerStatus.tsx
index 02a94ec2..093d3664 100644
--- a/app/src/components/ServerSettings/ServerStatus.tsx
+++ b/app/src/components/ServerSettings/ServerStatus.tsx
@@ -3,14 +3,13 @@ import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useServerHealth } from '@/lib/hooks/useServer';
import { useServerStore } from '@/stores/serverStore';
-import { ModelProgress } from './ModelProgress';
export function ServerStatus() {
const { data: health, isLoading, error } = useServerHealth();
const serverUrl = useServerStore((state) => state.serverUrl);
return (
-
+
Server Status
@@ -20,16 +19,6 @@ export function ServerStatus() {
{serverUrl}
- {/* Model download progress */}
-
-
-
-
-
-
-
-
-
{isLoading ? (
diff --git a/app/src/components/ServerSettings/UpdateStatus.tsx b/app/src/components/ServerSettings/UpdateStatus.tsx
index a3d832aa..4af44fdd 100644
--- a/app/src/components/ServerSettings/UpdateStatus.tsx
+++ b/app/src/components/ServerSettings/UpdateStatus.tsx
@@ -11,6 +11,7 @@ export function UpdateStatus() {
const platform = usePlatform();
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
const [currentVersion, setCurrentVersion] = useState
('');
+ const isDev = !import.meta.env?.PROD;
useEffect(() => {
platform.metadata
@@ -20,7 +21,7 @@ export function UpdateStatus() {
}, [platform]);
return (
-
+
App Updates
@@ -28,97 +29,110 @@ export function UpdateStatus() {
Current Version
-
v{currentVersion}
+
+ v{currentVersion}
+ {isDev ? ' (dev)' : ''}
+
-
-
- Check for Updates
-
+ {!isDev && (
+
+
+ Check for Updates
+
+ )}
- {status.checking && (
-
-
- Checking for updates...
+ {isDev ? (
+
+ Auto-updates are disabled in development mode.
- )}
-
- {status.error && (
-
- )}
-
- {status.available && !status.downloading && !status.readyToInstall && (
-
-
-
-
Update Available
-
Version {status.version}
+ ) : (
+ <>
+ {status.checking && (
+
+
+ Checking for updates...
-
New
-
-
-
- Download Update
-
-
- )}
+ )}
- {status.downloading && (
-
-
-
-
- Downloading update...
+ {status.error && (
+
- {status.downloadProgress !== undefined && (
-
{status.downloadProgress}%
- )}
-
-
- {status.downloadedBytes !== undefined &&
- status.totalBytes !== undefined &&
- status.totalBytes > 0 && (
-
- {(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB /{' '}
- {(status.totalBytes / 1024 / 1024).toFixed(1)} MB
+ )}
+
+ {status.available && !status.downloading && !status.readyToInstall && (
+
+
+
+
Update Available
+
Version {status.version}
+
+
New
- )}
-
- )}
+
+
+ Download Update
+
+
+ )}
- {status.readyToInstall && (
-
-
-
-
Update Ready to Install
+ {status.downloading && (
+
+
+
+
+ Downloading update...
+
+ {status.downloadProgress !== undefined && (
+
{status.downloadProgress}%
+ )}
+
+
+ {status.downloadedBytes !== undefined &&
+ status.totalBytes !== undefined &&
+ status.totalBytes > 0 && (
+
+ {(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB /{' '}
+ {(status.totalBytes / 1024 / 1024).toFixed(1)} MB
+
+ )}
+
+ )}
+
+ {status.readyToInstall && (
+
+
+
+
Update Ready to Install
+
+ Version {status.version} has been downloaded
+
+
+
- Version {status.version} has been downloaded
+ The app needs to restart to complete the installation. You can do this now or
+ later at your convenience.
+
+
+ Restart Now
+
-
-
- The app needs to restart to complete the installation. You can do this now or later at
- your convenience.
-
-
-
- Restart Now
-
-
- )}
+ )}
- {!status.available && !status.checking && !status.error && status.checking === false && (
-
- You're up to date
-
+ {!status.available && !status.checking && !status.error && (
+
+ You're up to date
+
+ )}
+ >
)}
diff --git a/app/src/components/ServerTab/ServerTab.tsx b/app/src/components/ServerTab/ServerTab.tsx
index abf91ac2..d9954c90 100644
--- a/app/src/components/ServerTab/ServerTab.tsx
+++ b/app/src/components/ServerTab/ServerTab.tsx
@@ -1,17 +1,25 @@
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
-import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
+import { GenerationSettings } from '@/components/ServerSettings/GenerationSettings';
+import { GpuAcceleration } from '@/components/ServerSettings/GpuAcceleration';
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
+import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
+import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext';
+import { usePlayerStore } from '@/stores/playerStore';
export function ServerTab() {
const platform = usePlatform();
+ const isPlayerVisible = !!usePlayerStore((state) => state.audioUrl);
return (
-
+
-
+
+ {platform.metadata.isTauri && }
+ {platform.metadata.isTauri && }
- {platform.metadata.isTauri &&
}
Created by{' '}
state.isGenerating);
- const audioUrl = usePlayerStore((state) => state.audioUrl);
- const isPlayerVisible = !!audioUrl;
const matchRoute = useMatchRoute();
+ const isPlayerOpen = !!usePlayerStore((s) => s.audioUrl);
+ const platform = usePlatform();
+
+ const [updateStatus, setUpdateStatus] = useState(platform.updater.getStatus());
+ useEffect(() => platform.updater.subscribe(setUpdateStatus), [platform.updater]);
return (
{/* Logo */}
-
+
{/* Navigation Buttons */}
- {tabs.map((tab) => {
+ {tabs.map((tab, index) => {
const Icon = tab.icon;
// For index route, use exact match; for others, use default matching
const isActive =
- tab.path === '/'
- ? matchRoute({ to: '/', exact: true })
- : matchRoute({ to: tab.path });
+ tab.path === '/' ? matchRoute({ to: '/', exact: true }) : matchRoute({ to: tab.path });
+
+ // Accent fades as buttons get further from the logo
+ const accentOpacity = Math.max(0.08, 0.5 - index * 0.07);
return (
-
+ {isActive && (
+
+ )}
+
);
})}
- {/* Spacer to push loader to bottom */}
-
-
- {/* Generation Loader */}
- {isGenerating && (
-
-
-
- )}
+ {/* Version */}
+
+ v{version}
+ {updateStatus.available && (
+
+ Update
+
+ )}
+
);
}
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/StoryContent.tsx b/app/src/components/StoriesTab/StoryContent.tsx
index 483e6657..0f53c2c9 100644
--- a/app/src/components/StoriesTab/StoryContent.tsx
+++ b/app/src/components/StoriesTab/StoryContent.tsx
@@ -13,8 +13,11 @@ import {
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
+import { Link } from '@tanstack/react-router';
+import { AnimatePresence, motion } from 'framer-motion';
import { Download, Plus } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
+import Loader from 'react-loaders';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
@@ -28,6 +31,7 @@ import {
useStory,
} from '@/lib/hooks/useStories';
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
+import { useGenerationStore } from '@/stores/generationStore';
import { useStoryStore } from '@/stores/storyStore';
import { SortableStoryChatItem } from './StoryChatItem';
@@ -40,6 +44,7 @@ export function StoryContent() {
const addStoryItem = useAddStoryItem();
const { toast } = useToast();
const scrollRef = useRef
(null);
+ const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
// Add generation popover state
const [searchQuery, setSearchQuery] = useState('');
@@ -53,9 +58,9 @@ export function StoryContent() {
const query = searchQuery.toLowerCase();
return historyData.items.filter(
(gen) =>
+ gen.status === 'completed' &&
!storyGenerationIds.has(gen.id) &&
- (gen.text.toLowerCase().includes(query) ||
- gen.profile_name.toLowerCase().includes(query)),
+ (gen.text.toLowerCase().includes(query) || gen.profile_name.toLowerCase().includes(query)),
);
}, [historyData, story, searchQuery]);
@@ -267,7 +272,31 @@ export function StoryContent() {
{story.description}
)}
-
+
+
+ {pendingCount > 0 && (
+
+
+
+
+ Generating {pendingCount} {pendingCount === 1 ? 'audio' : 'audios'}
+
+
+
+ )}
+
@@ -287,9 +316,7 @@ export function StoryContent() {
{availableGenerations.length === 0 ? (
- {searchQuery
- ? 'No matching generations found'
- : 'No available generations'}
+ {searchQuery ? 'No matching generations found' : 'No available generations'}
) : (
availableGenerations.map((gen) => (
diff --git a/app/src/components/StoriesTab/StoryList.tsx b/app/src/components/StoriesTab/StoryList.tsx
index ebbd6616..4222a800 100644
--- a/app/src/components/StoriesTab/StoryList.tsx
+++ b/app/src/components/StoriesTab/StoryList.tsx
@@ -1,5 +1,5 @@
-import { Plus, BookOpen, MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
-import { useState } from 'react';
+import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
+import { useEffect, useState } from 'react';
import {
AlertDialog,
AlertDialogAction,
@@ -29,7 +29,13 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
-import { useStories, useCreateStory, useUpdateStory, useDeleteStory } from '@/lib/hooks/useStories';
+import {
+ useCreateStory,
+ useDeleteStory,
+ useStories,
+ useStory,
+ useUpdateStory,
+} from '@/lib/hooks/useStories';
import { cn } from '@/lib/utils/cn';
import { formatDate } from '@/lib/utils/format';
import { useStoryStore } from '@/stores/storyStore';
@@ -38,6 +44,8 @@ export function StoryList() {
const { data: stories, isLoading } = useStories();
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
+ const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
+ const { data: selectedStory } = useStory(selectedStoryId);
const createStory = useCreateStory();
const updateStory = useUpdateStory();
const deleteStory = useDeleteStory();
@@ -54,6 +62,13 @@ export function StoryList() {
const [newStoryDescription, setNewStoryDescription] = useState('');
const { toast } = useToast();
+ // Auto-select the first story when the list loads with no selection
+ useEffect(() => {
+ if (!selectedStoryId && stories && stories.length > 0) {
+ setSelectedStoryId(stories[0].id);
+ }
+ }, [selectedStoryId, stories, setSelectedStoryId]);
+
const handleCreateStory = () => {
if (!newStoryName.trim()) {
toast({
@@ -170,20 +185,29 @@ export function StoryList() {
}
const storyList = stories || [];
+ const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0;
return (
-
- {/* Header */}
-
-
Stories
-
setCreateDialogOpen(true)} size="sm">
-
- New Story
-
+
+ {/* Scroll Mask */}
+
+
+ {/* Fixed Header */}
+
+
+
Stories
+
setCreateDialogOpen(true)} size="sm">
+
+ New Story
+
+
- {/* Story List */}
-
+ {/* Scrollable Story List */}
+
{storyList.length === 0 ? (
@@ -191,62 +215,68 @@ export function StoryList() {
Create your first story to get started
) : (
- storyList.map((story) => (
-
-
-
setSelectedStoryId(story.id)}
- >
- {story.name}
- {story.description && (
-
- {story.description}
-
- )}
-
-
- {story.item_count} {story.item_count === 1 ? 'item' : 'items'}
-
-
•
-
{formatDate(story.updated_at)}
+
+ {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);
+ }
+ }}
+ >
+
+
+
{story.name}
+
+
+ {story.item_count} {story.item_count === 1 ? 'item' : 'items'}
+
+ ·
+ {formatDate(story.updated_at)}
+
-
-
-
- e.stopPropagation()}
- >
-
-
-
-
- handleEditClick(story)}>
-
- Edit
-
- handleDeleteClick(story.id)}
- className="text-destructive focus:text-destructive"
- >
-
- Delete
-
-
-
+
+
+ e.stopPropagation()}
+ aria-label={`Actions for ${story.name}`}
+ >
+
+
+
+
+ handleEditClick(story)}>
+
+ Edit
+
+ handleDeleteClick(story.id)}
+ className="text-destructive focus:text-destructive"
+ >
+
+ Delete
+
+
+
+
-
- ))
+ ))}
+
)}
diff --git a/app/src/components/StoriesTab/StoryTrackEditor.tsx b/app/src/components/StoriesTab/StoryTrackEditor.tsx
index 74dbde25..ef20bf95 100644
--- a/app/src/components/StoriesTab/StoryTrackEditor.tsx
+++ b/app/src/components/StoriesTab/StoryTrackEditor.tsx
@@ -1,5 +1,7 @@
import {
+ Check,
Copy,
+ GalleryVerticalEnd,
GripHorizontal,
Minus,
Pause,
@@ -12,6 +14,12 @@ import {
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type { StoryItemDetail } from '@/lib/api/types';
@@ -19,6 +27,7 @@ import {
useDuplicateStoryItem,
useMoveStoryItem,
useRemoveStoryItem,
+ useSetStoryItemVersion,
useSplitStoryItem,
useTrimStoryItem,
} from '@/lib/hooks/useStories';
@@ -28,12 +37,14 @@ import { useStoryStore } from '@/stores/storyStore';
// Clip waveform component with trim support
function ClipWaveform({
generationId,
+ versionId,
width,
trimStartMs,
trimEndMs,
duration,
}: {
generationId: string;
+ versionId?: string;
width: number;
trimStartMs: number;
trimEndMs: number;
@@ -79,7 +90,9 @@ function ClipWaveform({
wavesurferRef.current = wavesurfer;
- const audioUrl = apiClient.getAudioUrl(generationId);
+ const audioUrl = versionId
+ ? apiClient.getVersionAudioUrl(versionId)
+ : apiClient.getAudioUrl(generationId);
wavesurfer.load(audioUrl).catch(() => {
// Ignore load errors
});
@@ -88,7 +101,7 @@ function ClipWaveform({
wavesurfer.destroy();
wavesurferRef.current = null;
};
- }, [generationId, fullWaveformWidth]);
+ }, [generationId, versionId, fullWaveformWidth]);
return (
@@ -135,12 +148,57 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const splitItem = useSplitStoryItem();
const duplicateItem = useDuplicateStoryItem();
const removeItem = useRemoveStoryItem();
+ const setItemVersion = useSetStoryItemVersion();
const { toast } = useToast();
// Selection state
const selectedClipId = useStoryStore((state) => state.selectedClipId);
const setSelectedClipId = useStoryStore((state) => state.setSelectedClipId);
+ // Selected clip item (for version picker)
+ const selectedItem = useMemo(
+ () => (selectedClipId ? items.find((i) => i.id === selectedClipId) : undefined),
+ [selectedClipId, items],
+ );
+ const selectedItemVersions = selectedItem?.versions;
+ const hasMultipleVersions = selectedItemVersions && selectedItemVersions.length > 1;
+
+ // Determine which version label is active for the selected clip
+ const activeVersionLabel = useMemo(() => {
+ if (!selectedItem || !selectedItemVersions) return null;
+ // If the item has a pinned version_id, find its label
+ if (selectedItem.version_id) {
+ const pinned = selectedItemVersions.find((v) => v.id === selectedItem.version_id);
+ return pinned?.label ?? null;
+ }
+ // Otherwise use the generation's default version
+ const defaultVersion = selectedItemVersions.find((v) => v.is_default);
+ return defaultVersion?.label ?? null;
+ }, [selectedItem, selectedItemVersions]);
+
+ const handleSetVersion = useCallback(
+ (versionId: string | null) => {
+ if (!selectedClipId) return;
+ setItemVersion.mutate(
+ {
+ storyId,
+ itemId: selectedClipId,
+ data: { version_id: versionId },
+ },
+ {
+ onError: (error) => {
+ toast({
+ title: 'Failed to set version',
+ description: error instanceof Error ? error.message : String(error),
+ variant: 'destructive',
+ });
+ },
+ },
+ );
+ },
+ [selectedClipId, storyId, setItemVersion, toast],
+ );
+
// Trim state
const [trimmingItem, setTrimmingItem] = useState
(null);
const [trimSide, setTrimSide] = useState<'start' | 'end' | null>(null);
@@ -736,6 +794,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 +804,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
className="h-7 w-7"
onClick={handleStop}
disabled={!isCurrentlyPlaying}
+ aria-label="Stop"
>
@@ -762,6 +822,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 +832,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
className="h-7 w-7"
onClick={handleDuplicate}
title="Duplicate (Cmd/Ctrl+D)"
+ aria-label="Duplicate clip"
>
@@ -780,19 +842,75 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
className="h-7 w-7"
onClick={handleDelete}
title="Delete (Delete/Backspace)"
+ aria-label="Delete clip"
>
+ {hasMultipleVersions && (
+ <>
+
+
+
+
+
+
+ {activeVersionLabel ?? 'default'}
+
+
+
+
+ {selectedItemVersions.map((version) => {
+ const isActive = selectedItem?.version_id
+ ? version.id === selectedItem.version_id
+ : version.is_default;
+ return (
+ handleSetVersion(version.id)}
+ className="gap-2 text-xs"
+ >
+
+ {version.label}
+ {version.effects_chain && version.effects_chain.length > 0 && (
+
+ {version.effects_chain.length} fx
+
+ )}
+
+ );
+ })}
+
+
+ >
+ )}
)}
{/* Zoom controls - right side */}
@@ -941,6 +1059,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
- );
+ if (isWindows) return null;
+
+ return
;
}
diff --git a/app/src/components/VoiceProfiles/AudioSampleRecording.tsx b/app/src/components/VoiceProfiles/AudioSampleRecording.tsx
index 4f2db4e3..acebbbd4 100644
--- a/app/src/components/VoiceProfiles/AudioSampleRecording.tsx
+++ b/app/src/components/VoiceProfiles/AudioSampleRecording.tsx
@@ -58,6 +58,7 @@ export function AudioSampleRecording({
// Request microphone access when component mounts
useEffect(() => {
if (!showWaveform) return;
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return;
let stream: MediaStream | null = null;
@@ -139,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..3675b765 100644
--- a/app/src/components/VoiceProfiles/ProfileCard.tsx
+++ b/app/src/components/VoiceProfiles/ProfileCard.tsx
@@ -1,4 +1,4 @@
-import { Download, Edit, Mic, Trash2 } from 'lucide-react';
+import { Download, Edit, Sparkles, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -15,7 +15,6 @@ import {
import type { VoiceProfileResponse } from '@/lib/api/types';
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn';
-import { useServerStore } from '@/stores/serverStore';
import { useUIStore } from '@/stores/uiStore';
interface ProfileCardProps {
@@ -24,19 +23,16 @@ interface ProfileCardProps {
export function ProfileCard({ profile }: ProfileCardProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
- const [avatarError, setAvatarError] = useState(false);
+
const deleteProfile = useDeleteProfile();
const exportProfile = useExportProfile();
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
- const serverUrl = useServerStore((state) => state.serverUrl);
const isSelected = selectedProfileId === profile.id;
- const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null;
-
const handleSelect = () => {
setSelectedProfileId(isSelected ? null : profile.id);
};
@@ -61,32 +57,35 @@ 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 (
<>
-
-
- {avatarUrl && !avatarError ? (
-
setAvatarError(true)}
- />
- ) : (
-
- )}
-
+
{profile.name}
@@ -94,10 +93,13 @@ export function ProfileCard({ profile }: ProfileCardProps) {
{profile.description || 'No description'}
-
+
{profile.language}
+ {profile.effects_chain && profile.effects_chain.length > 0 && (
+
+ )}
state.serverUrl);
+ const [profileEffectsChain, setProfileEffectsChain] = useState([]);
+ const [effectsDirty, setEffectsDirty] = useState(false);
const form = useForm({
resolver: zodResolver(profileSchema),
@@ -280,6 +285,8 @@ export function ProfileForm() {
referenceText: undefined,
avatarFile: undefined,
});
+ setProfileEffectsChain(editingProfile.effects_chain ?? []);
+ setEffectsDirty(false);
} else if (profileFormDraft && open) {
// Restore from draft when opening in create mode
form.reset({
@@ -435,6 +442,24 @@ export function ProfileForm() {
}
}
+ // Save effects chain if changed
+ if (effectsDirty) {
+ try {
+ await apiClient.updateProfileEffects(
+ editingProfileId,
+ profileEffectsChain.length > 0 ? profileEffectsChain : null,
+ );
+ } catch (fxError) {
+ toast({
+ title: 'Effects update failed',
+ description:
+ fxError instanceof Error ? fxError.message : 'Failed to save effects chain',
+ variant: 'destructive',
+ });
+ return;
+ }
+ }
+
toast({
title: 'Voice updated',
description: `"${data.name}" has been updated successfully.`,
@@ -505,10 +530,23 @@ export function ProfileForm() {
language: data.language,
});
+ // Convert non-WAV uploads to WAV so the backend can always use soundfile.
+ // Recorded audio is already WAV (from useAudioRecording's convertToWav call).
+ let fileToUpload: File = sampleFile;
+ if (!sampleFile.type.includes('wav') && !sampleFile.name.toLowerCase().endsWith('.wav')) {
+ try {
+ const wavBlob = await convertToWav(sampleFile);
+ const wavName = sampleFile.name.replace(/\.[^.]+$/, '.wav');
+ fileToUpload = new File([wavBlob], wavName, { type: 'audio/wav' });
+ } catch {
+ // If browser can't decode the format, send the original and let the backend try.
+ }
+ }
+
try {
await addSample.mutateAsync({
profileId: profile.id,
- file: sampleFile,
+ file: fileToUpload,
referenceText: referenceText,
});
@@ -885,6 +923,23 @@ export function ProfileForm() {
)}
/>
+
+ {editingProfileId && (
+
+
Default Effects
+
+ Effects applied automatically to all new generations with this voice.
+
+
{
+ setProfileEffectsChain(chain);
+ setEffectsDirty(true);
+ }}
+ compact
+ />
+
+ )}
diff --git a/app/src/components/VoiceProfiles/ProfileList.tsx b/app/src/components/VoiceProfiles/ProfileList.tsx
index 8dcb06a4..89252433 100644
--- a/app/src/components/VoiceProfiles/ProfileList.tsx
+++ b/app/src/components/VoiceProfiles/ProfileList.tsx
@@ -41,9 +41,11 @@ export function ProfileList() {
) : (
-
+
{allProfiles.map((profile) => (
-
+
))}
)}
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/VoiceInspector.tsx b/app/src/components/VoicesTab/VoiceInspector.tsx
new file mode 100644
index 00000000..062726be
--- /dev/null
+++ b/app/src/components/VoicesTab/VoiceInspector.tsx
@@ -0,0 +1,340 @@
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Edit2, Mic, X } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import * as z from 'zod';
+import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
+import { Button } from '@/components/ui/button';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Textarea } from '@/components/ui/textarea';
+import { useToast } from '@/components/ui/use-toast';
+import { SampleList } from '@/components/VoiceProfiles/SampleList';
+import { apiClient } from '@/lib/api/client';
+import type { EffectConfig } from '@/lib/api/types';
+import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
+import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
+import {
+ useDeleteAvatar,
+ useProfile,
+ useUpdateProfile,
+ useUploadAvatar,
+} from '@/lib/hooks/useProfiles';
+import { cn } from '@/lib/utils/cn';
+import { usePlayerStore } from '@/stores/playerStore';
+import { useServerStore } from '@/stores/serverStore';
+
+const profileSchema = z.object({
+ name: z.string().min(1, 'Name is required').max(100),
+ description: z.string().max(500).optional(),
+ language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
+});
+
+type ProfileFormValues = z.infer
;
+
+interface VoiceInspectorProps {
+ profileId: string;
+}
+
+export function VoiceInspector({ profileId }: VoiceInspectorProps) {
+ const { data: profile } = useProfile(profileId);
+ const audioUrl = usePlayerStore((state) => state.audioUrl);
+ const isPlayerVisible = !!audioUrl;
+ const updateProfile = useUpdateProfile();
+ const uploadAvatar = useUploadAvatar();
+ const deleteAvatar = useDeleteAvatar();
+ const serverUrl = useServerStore((state) => state.serverUrl);
+ const { toast } = useToast();
+
+ const [avatarPreview, setAvatarPreview] = useState(null);
+ const [avatarError, setAvatarError] = useState(false);
+ const avatarInputRef = useRef(null);
+
+ const [effectsChain, setEffectsChain] = useState([]);
+ const [effectsDirty, setEffectsDirty] = useState(false);
+
+ const form = useForm({
+ resolver: zodResolver(profileSchema),
+ defaultValues: {
+ name: '',
+ description: '',
+ language: 'en',
+ },
+ });
+
+ // Populate form when profile loads
+ useEffect(() => {
+ if (profile) {
+ form.reset({
+ name: profile.name,
+ description: profile.description || '',
+ language: profile.language as LanguageCode,
+ });
+ setEffectsChain(profile.effects_chain ?? []);
+ setEffectsDirty(false);
+ }
+ }, [profile, form]);
+
+ // Avatar preview
+ useEffect(() => {
+ if (profile?.avatar_path) {
+ setAvatarPreview(`${serverUrl}/profiles/${profile.id}/avatar`);
+ } else {
+ setAvatarPreview(null);
+ }
+ setAvatarError(false);
+ }, [profile, serverUrl]);
+
+ function handleAvatarFileChange(e: React.ChangeEvent) {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ if (!file.type.startsWith('image/')) {
+ toast({
+ title: 'Invalid file type',
+ description: 'Please select PNG, JPG, or WebP',
+ variant: 'destructive',
+ });
+ return;
+ }
+ if (file.size > 5 * 1024 * 1024) {
+ toast({
+ title: 'File too large',
+ description: 'Image must be less than 5MB',
+ variant: 'destructive',
+ });
+ return;
+ }
+ // Upload immediately
+ uploadAvatar.mutate(
+ { profileId, file },
+ {
+ onSuccess: () => {
+ setAvatarPreview(URL.createObjectURL(file));
+ toast({ title: 'Avatar updated' });
+ },
+ onError: (err) => {
+ toast({
+ title: 'Avatar upload failed',
+ description: err instanceof Error ? err.message : 'Unknown error',
+ variant: 'destructive',
+ });
+ },
+ },
+ );
+ }
+
+ async function handleRemoveAvatar() {
+ if (profile?.avatar_path) {
+ try {
+ await deleteAvatar.mutateAsync(profileId);
+ toast({ title: 'Avatar removed' });
+ } catch (err) {
+ toast({
+ title: 'Failed to remove avatar',
+ description: err instanceof Error ? err.message : 'Unknown error',
+ variant: 'destructive',
+ });
+ }
+ }
+ setAvatarPreview(null);
+ if (avatarInputRef.current) avatarInputRef.current.value = '';
+ }
+
+ async function onSubmit(data: ProfileFormValues) {
+ try {
+ await updateProfile.mutateAsync({
+ profileId,
+ data: {
+ name: data.name,
+ description: data.description,
+ language: data.language,
+ },
+ });
+
+ if (effectsDirty) {
+ try {
+ await apiClient.updateProfileEffects(
+ profileId,
+ effectsChain.length > 0 ? effectsChain : null,
+ );
+ setEffectsDirty(false);
+ } catch (fxError) {
+ toast({
+ title: 'Effects update failed',
+ description: fxError instanceof Error ? fxError.message : 'Failed to save effects',
+ variant: 'destructive',
+ });
+ return;
+ }
+ }
+
+ toast({ title: 'Voice updated', description: `"${data.name}" saved.` });
+ } catch (error) {
+ toast({
+ title: 'Error',
+ description: error instanceof Error ? error.message : 'Failed to save profile',
+ variant: 'destructive',
+ });
+ }
+ }
+
+ if (!profile) {
+ return (
+
+ Loading...
+
+ );
+ }
+
+ const isDirty = form.formState.isDirty || effectsDirty;
+
+ return (
+
+ );
+}
diff --git a/app/src/components/VoicesTab/VoicesTab.tsx b/app/src/components/VoicesTab/VoicesTab.tsx
index 12fedef5..20921f63 100644
--- a/app/src/components/VoicesTab/VoicesTab.tsx
+++ b/app/src/components/VoicesTab/VoicesTab.tsx
@@ -1,13 +1,9 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
-import { Edit, MoreHorizontal, Plus, Trash2, Mic } from 'lucide-react';
-import { useMemo, useRef } from 'react';
+import { Mic, Plus, Search, Sparkles } from 'lucide-react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu';
+import { Input } from '@/components/ui/input';
+
import { MultiSelect } from '@/components/ui/multi-select';
import {
Table,
@@ -21,33 +17,46 @@ import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm';
import { apiClient } from '@/lib/api/client';
import type { VoiceProfileResponse } from '@/lib/api/types';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
-import { useHistory } from '@/lib/hooks/useHistory';
-import { useDeleteProfile, useProfileSamples, useProfiles } from '@/lib/hooks/useProfiles';
+import { useProfiles } from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore';
+import { useServerStore } from '@/stores/serverStore';
import { useUIStore } from '@/stores/uiStore';
+import { VoiceInspector } from './VoiceInspector';
export function VoicesTab() {
const { data: profiles, isLoading } = useProfiles();
- const { data: historyData } = useHistory({ limit: 1000 });
const queryClient = useQueryClient();
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
- const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
- const deleteProfile = useDeleteProfile();
+ const selectedVoiceId = useUIStore((state) => state.selectedVoiceId);
+ const setSelectedVoiceId = useUIStore((state) => state.setSelectedVoiceId);
const scrollRef = useRef(null);
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
+ const [search, setSearch] = useState('');
- // Get generation counts per profile
- const generationCounts = useMemo(() => {
- const counts: Record = {};
- if (historyData?.items) {
- historyData.items.forEach((item) => {
- counts[item.profile_id] = (counts[item.profile_id] || 0) + 1;
- });
+ const filteredProfiles = useMemo(() => {
+ if (!profiles) return [];
+ if (!search.trim()) return profiles;
+ const q = search.toLowerCase();
+ return profiles.filter(
+ (p) =>
+ p.name.toLowerCase().includes(q) ||
+ p.description?.toLowerCase().includes(q) ||
+ p.language.toLowerCase().includes(q),
+ );
+ }, [profiles, search]);
+
+ // Auto-select first profile if none selected
+ useEffect(() => {
+ if (!selectedVoiceId && profiles && profiles.length > 0) {
+ setSelectedVoiceId(profiles[0].id);
}
- return counts;
- }, [historyData]);
+ // Clear selection if selected profile was deleted
+ if (selectedVoiceId && profiles && !profiles.find((p) => p.id === selectedVoiceId)) {
+ setSelectedVoiceId(profiles.length > 0 ? profiles[0].id : null);
+ }
+ }, [profiles, selectedVoiceId, setSelectedVoiceId]);
// Get channel assignments for each profile
const { data: channelAssignments } = useQuery({
@@ -74,17 +83,6 @@ export function VoicesTab() {
queryFn: () => apiClient.listChannels(),
});
- const handleEdit = (profileId: string) => {
- setEditingProfileId(profileId);
- setDialogOpen(true);
- };
-
- const handleDelete = (profileId: string) => {
- if (confirm('Are you sure you want to delete this profile?')) {
- deleteProfile.mutate(profileId);
- }
- };
-
const handleChannelChange = async (profileId: string, channelIds: string[]) => {
try {
await apiClient.setProfileChannels(profileId, channelIds);
@@ -103,56 +101,76 @@ export function VoicesTab() {
}
return (
-
- {/* Scroll Mask - Always visible, behind content */}
-
+
+ {/* Left: Table */}
+
+ {/* Scroll Mask */}
+
- {/* Fixed Header */}
-
-
-
Voices
-
setDialogOpen(true)}>
-
- New Voice
-
+ {/* Fixed Header */}
+
+
+
Voices
+
+
+
+ setSearch(e.target.value)}
+ className="h-10 pl-8 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
+ />
+
+
setDialogOpen(true)}>
+
+ New Voice
+
+
+
+
+ {/* Scrollable Content */}
+
+
+
+
+ Name
+ Language
+ Generations
+ Samples
+ Effects
+ Channels
+
+
+
+
+ {filteredProfiles.map((profile) => (
+ setSelectedVoiceId(profile.id)}
+ channelIds={channelAssignments?.[profile.id] || []}
+ channels={channels || []}
+ onChannelChange={(channelIds) => handleChannelChange(profile.id, channelIds)}
+ />
+ ))}
+
+
- {/* Scrollable Content */}
-
-
-
-
- Name
- Language
- Generations
- Samples
- Channels
-
-
-
-
- {profiles?.map((profile) => (
- handleChannelChange(profile.id, channelIds)}
- onEdit={() => handleEdit(profile.id)}
- onDelete={() => handleDelete(profile.id)}
- />
- ))}
-
-
-
+ {/* Right: Inspector */}
+ {selectedVoiceId && (
+
+
+
+ )}
@@ -161,43 +179,71 @@ export function VoicesTab() {
interface VoiceRowProps {
profile: VoiceProfileResponse;
- generationCount: number;
+ isSelected: boolean;
+ onSelect: () => void;
channelIds: string[];
channels: Array<{ id: string; name: string; is_default: boolean }>;
onChannelChange: (channelIds: string[]) => void;
- onEdit: () => void;
- onDelete: () => void;
}
function VoiceRow({
profile,
- generationCount,
+ isSelected,
+ onSelect,
channelIds,
channels,
onChannelChange,
- onEdit,
- onDelete,
}: VoiceRowProps) {
- const { data: samples } = useProfileSamples(profile.id);
+ const serverUrl = useServerStore((state) => state.serverUrl);
+ const [avatarError, setAvatarError] = useState(false);
+ const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null;
+
+ const enabledEffects = profile.effects_chain?.filter((e) => e.enabled) ?? [];
+ const effectsSummary = enabledEffects.map((e) => e.type).join(' → ');
return (
-
+
-
-
-
+
+
+ {avatarUrl && !avatarError ? (
+
setAvatarError(true)}
+ />
+ ) : (
+
+ )}
-
-
{profile.name}
+
+
{profile.name}
{profile.description && (
-
{profile.description}
+
{profile.description}
)}
-
e.stopPropagation()}>{profile.language}
-
e.stopPropagation()}>{generationCount}
-
e.stopPropagation()}>{samples?.length || 0}
+
{profile.language}
+
{profile.generation_count}
+
{profile.sample_count}
+
+ {enabledEffects.length > 0 ? (
+
+
+ {enabledEffects.length}
+
+ ) : (
+ —
+ )}
+
e.stopPropagation()}>
({
@@ -207,28 +253,10 @@ function VoiceRow({
value={channelIds}
onChange={onChannelChange}
placeholder="Select channels..."
- className="min-w-[200px]"
+ className="w-full"
/>
-
e.stopPropagation()}>
-
-
-
-
-
-
-
-
-
- Edit
-
-
-
- Delete
-
-
-
-
+
);
}
diff --git a/app/src/components/ui/checkbox.tsx b/app/src/components/ui/checkbox.tsx
index f423fef0..be2cfd29 100644
--- a/app/src/components/ui/checkbox.tsx
+++ b/app/src/components/ui/checkbox.tsx
@@ -1,5 +1,5 @@
-import * as React from 'react';
import { Check } from 'lucide-react';
+import * as React from 'react';
import { cn } from '@/lib/utils/cn';
export interface CheckboxProps {
diff --git a/app/src/components/ui/dropdown-menu.tsx b/app/src/components/ui/dropdown-menu.tsx
index d59c291c..0a870dfb 100644
--- a/app/src/components/ui/dropdown-menu.tsx
+++ b/app/src/components/ui/dropdown-menu.tsx
@@ -1,6 +1,6 @@
-import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { MoreHorizontal } from 'lucide-react';
+import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const DropdownMenu = DropdownMenuPrimitive.Root;
@@ -73,7 +73,7 @@ const DropdownMenuItem = React.forwardRef<
) => {
- return ;
+ return (
+
+ );
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
diff --git a/app/src/components/ui/select.tsx b/app/src/components/ui/select.tsx
index 4a1949fc..6c07e8dd 100644
--- a/app/src/components/ui/select.tsx
+++ b/app/src/components/ui/select.tsx
@@ -16,7 +16,7 @@ const SelectTrigger = React.forwardRef<
span]:line-clamp-1',
+ 'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
@@ -108,7 +108,7 @@ const SelectItem = React.forwardRef<
>(({ className, ...props }, ref) => (
-
+
));
TableHeader.displayName = 'TableHeader';
@@ -42,10 +46,7 @@ const TableRow = React.forwardRef (
),
diff --git a/app/src/index.css b/app/src/index.css
index 06381711..65c11d84 100644
--- a/app/src/index.css
+++ b/app/src/index.css
@@ -1,4 +1,5 @@
@import "tailwindcss" source(".");
+@import "loaders.css/loaders.min.css";
@theme {
--radius-sm: calc(var(--radius) - 4px);
@@ -155,3 +156,18 @@
animation: fadeIn 0.5s ease-out 0.15s forwards;
opacity: 0;
}
+
+/* react-loaders */
+.line-scale-pulse-out-rapid > div,
+.line-scale > div {
+ background-color: hsl(var(--accent)) !important;
+}
+
+.loader-hidden {
+ display: block;
+}
+
+.loader-hidden > div > div {
+ animation-play-state: paused !important;
+ background-color: hsl(var(--muted-foreground)) !important;
+}
diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts
index c5b079b2..c6691ab5 100644
--- a/app/src/lib/api/client.ts
+++ b/app/src/lib/api/client.ts
@@ -1,29 +1,37 @@
-import { useServerStore } from '@/stores/serverStore';
import type { LanguageCode } from '@/lib/constants/languages';
+import { useServerStore } from '@/stores/serverStore';
import type {
- VoiceProfileCreate,
- VoiceProfileResponse,
- ProfileSampleResponse,
+ ActiveTasksResponse,
+ ApplyEffectsRequest,
+ AvailableEffectsResponse,
+ CudaStatus,
+ EffectConfig,
+ EffectPresetCreate,
+ EffectPresetResponse,
GenerationRequest,
GenerationResponse,
- HistoryQuery,
- HistoryListResponse,
- HistoryResponse,
- TranscriptionResponse,
+ GenerationVersionResponse,
HealthResponse,
- ModelStatusListResponse,
+ HistoryListResponse,
+ HistoryQuery,
+ HistoryResponse,
ModelDownloadRequest,
- ActiveTasksResponse,
+ ModelStatusListResponse,
+ ProfileSampleResponse,
StoryCreate,
- StoryResponse,
StoryDetailResponse,
+ StoryItemBatchUpdate,
StoryItemCreate,
StoryItemDetail,
- StoryItemBatchUpdate,
- StoryItemReorder,
StoryItemMove,
- StoryItemTrim,
+ StoryItemReorder,
StoryItemSplit,
+ StoryItemTrim,
+ StoryItemVersionUpdate,
+ StoryResponse,
+ TranscriptionResponse,
+ VoiceProfileCreate,
+ VoiceProfileResponse,
} from './types';
class ApiClient {
@@ -199,6 +207,24 @@ class ApiClient {
});
}
+ async retryGeneration(generationId: string): Promise {
+ return this.request(`/generate/${generationId}/retry`, {
+ method: 'POST',
+ });
+ }
+
+ async regenerateGeneration(generationId: string): Promise {
+ return this.request(`/generate/${generationId}/regenerate`, {
+ method: 'POST',
+ });
+ }
+
+ async toggleFavorite(generationId: string): Promise<{ is_favorited: boolean }> {
+ return this.request<{ is_favorited: boolean }>(`/history/${generationId}/favorite`, {
+ method: 'POST',
+ });
+ }
+
// History
async listHistory(query?: HistoryQuery): Promise {
const params = new URLSearchParams();
@@ -251,7 +277,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);
@@ -271,6 +303,11 @@ class ApiClient {
return response.json();
}
+ // Generation status SSE
+ getGenerationStatusUrl(generationId: string): string {
+ return `${this.getBaseUrl()}/generate/${generationId}/status`;
+ }
+
// Audio
getAudioUrl(audioId: string): string {
return `${this.getBaseUrl()}/audio/${audioId}`;
@@ -309,8 +346,28 @@ class ApiClient {
return this.request('/models/status');
}
+ async getModelsCacheDir(): Promise<{ path: string }> {
+ return this.request<{ path: string }>('/models/cache-dir');
+ }
+
+ async migrateModels(destination: string): Promise<{ source: string; destination: string }> {
+ return this.request('/models/migrate', {
+ method: 'POST',
+ body: JSON.stringify({ destination }),
+ });
+ }
+
+ getMigrationProgressUrl(): string {
+ return `${this.getBaseUrl()}/models/migrate/progress`;
+ }
+
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 +382,28 @@ class ApiClient {
});
}
+ async unloadModel(modelName: string): Promise<{ message: string }> {
+ return this.request<{ message: string }>(`/models/${modelName}/unload`, {
+ method: 'POST',
+ });
+ }
+
+ 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 +417,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 +459,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 +470,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 +550,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),
@@ -495,6 +589,17 @@ class ApiClient {
});
}
+ async setStoryItemVersion(
+ storyId: string,
+ itemId: string,
+ data: StoryItemVersionUpdate,
+ ): Promise {
+ return this.request(`/stories/${storyId}/items/${itemId}/version`, {
+ method: 'PUT',
+ body: JSON.stringify(data),
+ });
+ }
+
async exportStoryAudio(storyId: string): Promise {
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
const response = await fetch(url);
@@ -508,6 +613,103 @@ class ApiClient {
return response.blob();
}
+
+ // Effects & Versions
+ async getAvailableEffects(): Promise {
+ return this.request('/effects/available');
+ }
+
+ async listEffectPresets(): Promise {
+ return this.request('/effects/presets');
+ }
+
+ async createEffectPreset(data: EffectPresetCreate): Promise {
+ return this.request('/effects/presets', {
+ method: 'POST',
+ body: JSON.stringify(data),
+ });
+ }
+
+ async updateEffectPreset(
+ presetId: string,
+ data: { name?: string; description?: string; effects_chain?: EffectConfig[] },
+ ): Promise {
+ return this.request(`/effects/presets/${presetId}`, {
+ method: 'PUT',
+ body: JSON.stringify(data),
+ });
+ }
+
+ async deleteEffectPreset(presetId: string): Promise {
+ await this.request(`/effects/presets/${presetId}`, {
+ method: 'DELETE',
+ });
+ }
+
+ async listGenerationVersions(generationId: string): Promise {
+ return this.request(`/generations/${generationId}/versions`);
+ }
+
+ async applyEffectsToGeneration(
+ generationId: string,
+ data: ApplyEffectsRequest,
+ ): Promise {
+ return this.request(
+ `/generations/${generationId}/versions/apply-effects`,
+ {
+ method: 'POST',
+ body: JSON.stringify(data),
+ },
+ );
+ }
+
+ async setDefaultVersion(
+ generationId: string,
+ versionId: string,
+ ): Promise {
+ return this.request(
+ `/generations/${generationId}/versions/${versionId}/set-default`,
+ { method: 'PUT' },
+ );
+ }
+
+ async deleteGenerationVersion(generationId: string, versionId: string): Promise {
+ await this.request(`/generations/${generationId}/versions/${versionId}`, {
+ method: 'DELETE',
+ });
+ }
+
+ getVersionAudioUrl(versionId: string): string {
+ return `${this.getBaseUrl()}/audio/version/${versionId}`;
+ }
+
+ async updateProfileEffects(
+ profileId: string,
+ effectsChain: EffectConfig[] | null,
+ ): Promise {
+ return this.request(`/profiles/${profileId}/effects`, {
+ method: 'PUT',
+ body: JSON.stringify({ effects_chain: effectsChain }),
+ });
+ }
+
+ async previewEffects(generationId: string, effectsChain: EffectConfig[]): Promise {
+ const url = `${this.getBaseUrl()}/effects/preview/${generationId}`;
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ effects_chain: effectsChain }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({
+ detail: response.statusText,
+ }));
+ throw new Error(error.detail || `HTTP error! status: ${response.status}`);
+ }
+
+ return response.blob();
+ }
}
export const apiClient = new ApiClient();
diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts
index 131c1be5..49e90918 100644
--- a/app/src/lib/api/types.ts
+++ b/app/src/lib/api/types.ts
@@ -13,6 +13,9 @@ export interface VoiceProfileResponse {
description?: string;
language: string;
avatar_path?: string;
+ effects_chain?: EffectConfig[];
+ generation_count: number;
+ sample_count: number;
created_at: string;
updated_at: string;
}
@@ -28,12 +31,35 @@ export interface ProfileSampleResponse {
reference_text: string;
}
+export interface EffectConfig {
+ type: string;
+ enabled: boolean;
+ params: Record;
+}
+
export interface GenerationRequest {
profile_id: string;
text: string;
language: LanguageCode;
seed?: number;
model_size?: '1.7B' | '0.6B';
+ engine?: 'qwen' | 'luxtts' | 'chatterbox' | 'chatterbox_turbo';
+ instruct?: string;
+ max_chunk_chars?: number;
+ crossfade_ms?: number;
+ normalize?: boolean;
+ effects_chain?: EffectConfig[];
+}
+
+export interface GenerationVersionResponse {
+ id: string;
+ generation_id: string;
+ label: string;
+ audio_path: string;
+ effects_chain?: EffectConfig[];
+ source_version_id?: string;
+ is_default: boolean;
+ created_at: string;
}
export interface GenerationResponse {
@@ -41,10 +67,18 @@ export interface GenerationResponse {
profile_id: string;
text: string;
language: string;
- audio_path: string;
- duration: number;
+ audio_path?: string;
+ duration?: number;
seed?: number;
+ instruct?: string;
+ engine?: string;
+ model_size?: string;
+ status: 'loading_model' | 'generating' | 'completed' | 'failed';
+ error?: string;
+ is_favorited?: boolean;
created_at: string;
+ versions?: GenerationVersionResponse[];
+ active_version_id?: string;
}
export interface HistoryQuery {
@@ -56,6 +90,8 @@ export interface HistoryQuery {
export interface HistoryResponse extends GenerationResponse {
profile_name: string;
+ versions?: GenerationVersionResponse[];
+ active_version_id?: string;
}
export interface HistoryListResponse {
@@ -78,7 +114,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 +153,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 +188,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 {
@@ -145,6 +225,7 @@ export interface StoryItemDetail {
id: string;
story_id: string;
generation_id: string;
+ version_id?: string;
start_time_ms: number;
track: number;
trim_start_ms: number;
@@ -159,6 +240,12 @@ export interface StoryItemDetail {
seed?: number;
instruct?: string;
generation_created_at: string;
+ versions?: GenerationVersionResponse[];
+ active_version_id?: string;
+}
+
+export interface StoryItemVersionUpdate {
+ version_id: string | null;
}
export interface StoryDetailResponse {
@@ -202,3 +289,52 @@ export interface StoryItemTrim {
export interface StoryItemSplit {
split_time_ms: number;
}
+
+// Effects
+
+export interface EffectPresetResponse {
+ id: string;
+ name: string;
+ description?: string;
+ effects_chain: EffectConfig[];
+ is_builtin: boolean;
+ created_at: string;
+}
+
+export interface EffectPresetCreate {
+ name: string;
+ description?: string;
+ effects_chain: EffectConfig[];
+}
+
+export interface EffectPresetUpdate {
+ name?: string;
+ description?: string;
+ effects_chain?: EffectConfig[];
+}
+
+export interface AvailableEffectParam {
+ default: number;
+ min: number;
+ max: number;
+ step: number;
+ description: string;
+}
+
+export interface AvailableEffect {
+ type: string;
+ label: string;
+ description: string;
+ params: Record;
+}
+
+export interface AvailableEffectsResponse {
+ effects: AvailableEffect[];
+}
+
+export interface ApplyEffectsRequest {
+ effects_chain: EffectConfig[];
+ source_version_id?: string;
+ label?: string;
+ set_as_default?: boolean;
+}
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/constants/ui.ts b/app/src/lib/constants/ui.ts
index 41a46483..af495e84 100644
--- a/app/src/lib/constants/ui.ts
+++ b/app/src/lib/constants/ui.ts
@@ -2,11 +2,14 @@
* UI layout constants for safe area padding
*/
+const isWindows = typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows');
+
/**
* Top safe area padding - height of the drag region bar
- * Corresponds to Tailwind's pt-12 (3rem / 48px)
+ * On macOS this accounts for the overlay titlebar (48px).
+ * On Windows the native title bar is outside the webview, so no padding is needed.
*/
-export const TOP_SAFE_AREA_PADDING = 'pt-12';
+export const TOP_SAFE_AREA_PADDING = isWindows ? 'pt-8' : 'pt-12';
/**
* Bottom safe area padding - height of the audio player
diff --git a/app/src/lib/hooks/useAudioRecording.ts b/app/src/lib/hooks/useAudioRecording.ts
index 2916937c..152f90c1 100644
--- a/app/src/lib/hooks/useAudioRecording.ts
+++ b/app/src/lib/hooks/useAudioRecording.ts
@@ -20,11 +20,13 @@ export function useAudioRecording({
const streamRef = useRef(null);
const timerRef = useRef(null);
const startTimeRef = useRef(null);
+ const cancelledRef = useRef(false);
const startRecording = useCallback(async () => {
try {
setError(null);
chunksRef.current = [];
+ cancelledRef.current = false;
setDuration(0);
// Check if getUserMedia is available
@@ -87,31 +89,34 @@ export function useAudioRecording({
};
mediaRecorder.onstop = async () => {
+ // Snapshot the cancellation flag and recorded duration immediately —
+ // cancelRecording() clears chunks and sets cancelledRef synchronously
+ // before this async handler runs, so we must check it first.
+ const wasCancelled = cancelledRef.current;
+ const recordedDuration = startTimeRef.current
+ ? (Date.now() - startTimeRef.current) / 1000
+ : undefined;
+
const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
- // Convert to WAV format to avoid needing ffmpeg on backend
- try {
- const wavBlob = await convertToWav(webmBlob);
-
- // Pass the actual recorded duration
- const recordedDuration = startTimeRef.current
- ? (Date.now() - startTimeRef.current) / 1000
- : undefined;
- onRecordingComplete?.(wavBlob, recordedDuration);
- } catch (err) {
- console.error('Error converting audio to WAV:', err);
- // Fallback to original blob if conversion fails
- const recordedDuration = startTimeRef.current
- ? (Date.now() - startTimeRef.current) / 1000
- : undefined;
- onRecordingComplete?.(webmBlob, recordedDuration);
- }
-
- // Stop all tracks
+ // Stop all tracks now that we have the data
streamRef.current?.getTracks().forEach((track) => {
track.stop();
});
streamRef.current = null;
+
+ // Don't fire completion callback if the recording was cancelled
+ if (wasCancelled) return;
+
+ // Convert to WAV format to avoid needing ffmpeg on backend
+ try {
+ const wavBlob = await convertToWav(webmBlob);
+ onRecordingComplete?.(wavBlob, recordedDuration);
+ } catch (err) {
+ console.error('Error converting audio to WAV:', err);
+ // Fallback to original blob if conversion fails
+ onRecordingComplete?.(webmBlob, recordedDuration);
+ }
};
mediaRecorder.onerror = (event) => {
@@ -167,9 +172,10 @@ export function useAudioRecording({
const cancelRecording = useCallback(() => {
if (mediaRecorderRef.current) {
+ cancelledRef.current = true; // Must be set before stop() triggers onstop
+ chunksRef.current = [];
mediaRecorderRef.current.stop();
setIsRecording(false);
- chunksRef.current = [];
setDuration(0);
}
diff --git a/app/src/lib/hooks/useGenerationForm.ts b/app/src/lib/hooks/useGenerationForm.ts
index c6fdba50..74f9a94c 100644
--- a/app/src/lib/hooks/useGenerationForm.ts
+++ b/app/src/lib/hooks/useGenerationForm.ts
@@ -4,18 +4,20 @@ import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
+import type { EffectConfig } from '@/lib/api/types';
import { LANGUAGE_CODES, type LanguageCode } from '@/lib/constants/languages';
import { useGeneration } from '@/lib/hooks/useGeneration';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { useGenerationStore } from '@/stores/generationStore';
-import { usePlayerStore } from '@/stores/playerStore';
+import { useServerStore } from '@/stores/serverStore';
const generationSchema = z.object({
- text: z.string().min(1, 'Text is required').max(5000),
+ text: z.string().min(1, '').max(50000),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
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;
@@ -23,13 +25,16 @@ export type GenerationFormValues = z.infer;
interface UseGenerationFormOptions {
onSuccess?: (generationId: string) => void;
defaultValues?: Partial;
+ getEffectsChain?: () => EffectConfig[] | undefined;
}
export function useGenerationForm(options: UseGenerationFormOptions = {}) {
const { toast } = useToast();
const generation = useGeneration();
- const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
- const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
+ const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
+ const maxChunkChars = useServerStore((state) => state.maxChunkChars);
+ const crossfadeMs = useServerStore((state) => state.crossfadeMs);
+ const normalizeAudio = useServerStore((state) => state.normalizeAudio);
const [downloadingModelName, setDownloadingModelName] = useState(null);
const [downloadingDisplayName, setDownloadingDisplayName] = useState(null);
@@ -47,6 +52,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
seed: undefined,
modelSize: '1.7B',
instruct: '',
+ engine: 'qwen',
...options.defaultValues,
},
});
@@ -65,11 +71,27 @@ 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';
+ // Check if model needs downloading
try {
const modelStatus = await apiClient.getModelStatus();
const model = modelStatus.models.find((m) => m.model_name === modelName);
@@ -82,24 +104,35 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
console.error('Failed to check model status:', error);
}
+ const isQwen = engine === 'qwen';
+ const effectsChain = options.getEffectsChain?.();
+ // This now returns immediately with status="generating"
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,
+ max_chunk_chars: maxChunkChars,
+ crossfade_ms: crossfadeMs,
+ normalize: normalizeAudio,
+ effects_chain: effectsChain?.length ? effectsChain : undefined,
});
- toast({
- title: 'Generation complete!',
- description: `Audio generated (${result.duration.toFixed(2)}s)`,
+ // Track this generation for SSE status updates
+ addPendingGeneration(result.id);
+
+ // Reset form immediately — user can start typing again
+ form.reset({
+ text: '',
+ language: data.language,
+ seed: undefined,
+ modelSize: data.modelSize,
+ instruct: '',
+ engine: data.engine,
});
-
- const audioUrl = apiClient.getAudioUrl(result.id);
- setAudioWithAutoPlay(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
-
- form.reset();
options.onSuccess?.(result.id);
} catch (error) {
toast({
@@ -108,7 +141,6 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
variant: 'destructive',
});
} finally {
- setIsGenerating(false);
setDownloadingModelName(null);
setDownloadingDisplayName(null);
}
diff --git a/app/src/lib/hooks/useGenerationProgress.ts b/app/src/lib/hooks/useGenerationProgress.ts
new file mode 100644
index 00000000..17d9e0cd
--- /dev/null
+++ b/app/src/lib/hooks/useGenerationProgress.ts
@@ -0,0 +1,154 @@
+import { useQueryClient } from '@tanstack/react-query';
+import { useEffect, useRef } from 'react';
+import { useToast } from '@/components/ui/use-toast';
+import { apiClient } from '@/lib/api/client';
+import { useGenerationStore } from '@/stores/generationStore';
+import { usePlayerStore } from '@/stores/playerStore';
+import { useServerStore } from '@/stores/serverStore';
+
+interface GenerationStatusEvent {
+ id: string;
+ status: 'loading_model' | 'generating' | 'completed' | 'failed' | 'not_found';
+ duration?: number;
+ error?: string;
+}
+
+/**
+ * Subscribes to SSE for all pending generations. When a generation completes,
+ * invalidates the history query, removes it from pending, and auto-plays
+ * if the player is idle.
+ */
+export function useGenerationProgress() {
+ const queryClient = useQueryClient();
+ const { toast } = useToast();
+ const pendingIds = useGenerationStore((s) => s.pendingGenerationIds);
+ const removePendingGeneration = useGenerationStore((s) => s.removePendingGeneration);
+ const removePendingStoryAdd = useGenerationStore((s) => s.removePendingStoryAdd);
+ const isPlaying = usePlayerStore((s) => s.isPlaying);
+ const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
+ const autoplayOnGenerate = useServerStore((s) => s.autoplayOnGenerate);
+
+ // Keep refs to avoid stale closures in EventSource handlers
+ const isPlayingRef = useRef(isPlaying);
+ const autoplayRef = useRef(autoplayOnGenerate);
+ isPlayingRef.current = isPlaying;
+ autoplayRef.current = autoplayOnGenerate;
+
+ // Track active EventSource instances
+ const eventSourcesRef = useRef>(new Map());
+
+ // Unmount-only cleanup — close all SSE connections when the hook is torn down
+ useEffect(() => {
+ const sources = eventSourcesRef.current;
+ return () => {
+ for (const source of sources.values()) {
+ source.close();
+ }
+ sources.clear();
+ };
+ }, []);
+
+ useEffect(() => {
+ const currentSources = eventSourcesRef.current;
+
+ // Close SSE connections for IDs no longer pending
+ for (const [id, source] of currentSources.entries()) {
+ if (!pendingIds.has(id)) {
+ source.close();
+ currentSources.delete(id);
+ }
+ }
+
+ // Open SSE connections for new pending IDs
+ for (const id of pendingIds) {
+ if (currentSources.has(id)) continue;
+
+ const url = apiClient.getGenerationStatusUrl(id);
+ const source = new EventSource(url);
+
+ source.onmessage = (event) => {
+ try {
+ const data: GenerationStatusEvent = JSON.parse(event.data);
+
+ if (data.status === 'completed') {
+ source.close();
+ currentSources.delete(id);
+ removePendingGeneration(id);
+
+ // Refresh history to pick up the completed generation
+ queryClient.invalidateQueries({ queryKey: ['history'] });
+
+ // If this generation was queued for a story, add it now
+ const storyId = removePendingStoryAdd(id);
+ if (storyId) {
+ apiClient
+ .addStoryItem(storyId, { generation_id: id })
+ .then(() => {
+ queryClient.invalidateQueries({ queryKey: ['stories'] });
+ queryClient.invalidateQueries({ queryKey: ['stories', storyId] });
+ toast({
+ title: 'Added to story',
+ description: data.duration
+ ? `Audio generated (${data.duration.toFixed(2)}s) and added to story`
+ : 'Audio generated and added to story',
+ });
+ })
+ .catch(() => {
+ toast({
+ title: 'Generation complete',
+ description: 'Audio generated but failed to add to story',
+ variant: 'destructive',
+ });
+ });
+ } else {
+ // toast({
+ // title: 'Generation complete!',
+ // description: data.duration
+ // ? `Audio generated (${data.duration.toFixed(2)}s)`
+ // : 'Audio generated',
+ // });
+ }
+
+ // Auto-play if enabled and nothing is currently playing
+ if (autoplayRef.current && !isPlayingRef.current) {
+ const genAudioUrl = apiClient.getAudioUrl(id);
+ setAudioWithAutoPlay(genAudioUrl, id, '', '');
+ }
+ } else if (data.status === 'failed' || data.status === 'not_found') {
+ source.close();
+ currentSources.delete(id);
+ removePendingGeneration(id);
+ removePendingStoryAdd(id);
+
+ queryClient.invalidateQueries({ queryKey: ['history'] });
+
+ toast({
+ title: data.status === 'not_found' ? 'Generation not found' : 'Generation failed',
+ description: data.error || 'An error occurred during generation',
+ variant: 'destructive',
+ });
+ }
+ } catch {
+ // Ignore parse errors from heartbeats etc
+ }
+ };
+
+ source.onerror = () => {
+ // EventSource auto-reconnects, but if we get repeated errors
+ // just clean up
+ source.close();
+ currentSources.delete(id);
+ removePendingGeneration(id);
+ };
+
+ currentSources.set(id, source);
+ }
+ }, [
+ pendingIds,
+ removePendingGeneration,
+ removePendingStoryAdd,
+ queryClient,
+ toast,
+ setAudioWithAutoPlay,
+ ]);
+}
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/lib/hooks/useRestoreActiveTasks.tsx b/app/src/lib/hooks/useRestoreActiveTasks.tsx
index 063e6bcb..809cba03 100644
--- a/app/src/lib/hooks/useRestoreActiveTasks.tsx
+++ b/app/src/lib/hooks/useRestoreActiveTasks.tsx
@@ -1,23 +1,23 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { apiClient } from '@/lib/api/client';
-import { useGenerationStore } from '@/stores/generationStore';
import type { ActiveDownloadTask } from '@/lib/api/types';
+import { useGenerationStore } from '@/stores/generationStore';
// Polling interval in milliseconds
-const POLL_INTERVAL = 2000;
+const POLL_INTERVAL = 30000;
/**
* Hook to monitor active tasks (downloads and generations).
* Polls the server periodically to catch downloads triggered from anywhere
* (transcription, generation, explicit download, etc.).
- *
+ *
* Returns the active downloads so components can render download toasts.
*/
export function useRestoreActiveTasks() {
const [activeDownloads, setActiveDownloads] = useState
([]);
- const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
const setActiveGenerationId = useGenerationStore((state) => state.setActiveGenerationId);
-
+ const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
+
// Track which downloads we've seen to detect new ones
const seenDownloadsRef = useRef>(new Set());
@@ -25,15 +25,15 @@ export function useRestoreActiveTasks() {
try {
const tasks = await apiClient.getActiveTasks();
- // Update generation state
+ // Restore pending generations (e.g., after page refresh)
if (tasks.generations.length > 0) {
- setIsGenerating(true);
setActiveGenerationId(tasks.generations[0].task_id);
+ for (const gen of tasks.generations) {
+ addPendingGeneration(gen.task_id);
+ }
} else {
- // Only clear if we were tracking a generation
const currentId = useGenerationStore.getState().activeGenerationId;
if (currentId) {
- setIsGenerating(false);
setActiveGenerationId(null);
}
}
@@ -41,14 +41,14 @@ export function useRestoreActiveTasks() {
// Update active downloads
// Keep track of all active downloads (including new ones)
const currentDownloadNames = new Set(tasks.downloads.map((d) => d.model_name));
-
+
// Remove completed downloads from our seen set
for (const name of seenDownloadsRef.current) {
if (!currentDownloadNames.has(name)) {
seenDownloadsRef.current.delete(name);
}
}
-
+
// Add new downloads to seen set
for (const download of tasks.downloads) {
seenDownloadsRef.current.add(download.model_name);
@@ -59,7 +59,7 @@ export function useRestoreActiveTasks() {
// Silently fail - server might be temporarily unavailable
console.debug('Failed to fetch active tasks:', error);
}
- }, [setIsGenerating, setActiveGenerationId]);
+ }, [setActiveGenerationId, addPendingGeneration]);
useEffect(() => {
// Fetch immediately on mount
diff --git a/app/src/lib/hooks/useStories.ts b/app/src/lib/hooks/useStories.ts
index 2b35f381..ffc5aee3 100644
--- a/app/src/lib/hooks/useStories.ts
+++ b/app/src/lib/hooks/useStories.ts
@@ -1,6 +1,15 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
-import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder, StoryItemMove, StoryItemTrim, StoryItemSplit } from '@/lib/api/types';
+import type {
+ StoryCreate,
+ StoryItemBatchUpdate,
+ StoryItemCreate,
+ StoryItemMove,
+ StoryItemReorder,
+ StoryItemSplit,
+ StoryItemTrim,
+ StoryItemVersionUpdate,
+} from '@/lib/api/types';
import { usePlatform } from '@/platform/PlatformContext';
export function useStories() {
@@ -109,8 +118,15 @@ export function useMoveStoryItem() {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemMove }) =>
- apiClient.moveStoryItem(storyId, itemId, data),
+ mutationFn: ({
+ storyId,
+ itemId,
+ data,
+ }: {
+ storyId: string;
+ itemId: string;
+ data: StoryItemMove;
+ }) => apiClient.moveStoryItem(storyId, itemId, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
@@ -122,8 +138,15 @@ export function useTrimStoryItem() {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemTrim }) =>
- apiClient.trimStoryItem(storyId, itemId, data),
+ mutationFn: ({
+ storyId,
+ itemId,
+ data,
+ }: {
+ storyId: string;
+ itemId: string;
+ data: StoryItemTrim;
+ }) => apiClient.trimStoryItem(storyId, itemId, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
@@ -135,8 +158,15 @@ export function useSplitStoryItem() {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: ({ storyId, itemId, data }: { storyId: string; itemId: string; data: StoryItemSplit }) =>
- apiClient.splitStoryItem(storyId, itemId, data),
+ mutationFn: ({
+ storyId,
+ itemId,
+ data,
+ }: {
+ storyId: string;
+ itemId: string;
+ data: StoryItemSplit;
+ }) => apiClient.splitStoryItem(storyId, itemId, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['stories'] });
queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
@@ -157,6 +187,26 @@ export function useDuplicateStoryItem() {
});
}
+export function useSetStoryItemVersion() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({
+ storyId,
+ itemId,
+ data,
+ }: {
+ storyId: string;
+ itemId: string;
+ data: StoryItemVersionUpdate;
+ }) => apiClient.setStoryItemVersion(storyId, itemId, data),
+ onSuccess: (_, variables) => {
+ queryClient.invalidateQueries({ queryKey: ['stories'] });
+ queryClient.invalidateQueries({ queryKey: ['stories', variables.storyId] });
+ },
+ });
+}
+
export function useExportStoryAudio() {
const platform = usePlatform();
@@ -165,7 +215,10 @@ export function useExportStoryAudio() {
const blob = await apiClient.exportStoryAudio(storyId);
// Create safe filename
- const safeName = storyName.substring(0, 50).replace(/[^a-z0-9]/gi, '-').toLowerCase();
+ const safeName = storyName
+ .substring(0, 50)
+ .replace(/[^a-z0-9]/gi, '-')
+ .toLowerCase();
const filename = `${safeName || 'story'}.wav`;
await platform.filesystem.saveFile(filename, blob, [
diff --git a/app/src/lib/hooks/useStoryPlayback.ts b/app/src/lib/hooks/useStoryPlayback.ts
index f9678cdc..12cff59f 100644
--- a/app/src/lib/hooks/useStoryPlayback.ts
+++ b/app/src/lib/hooks/useStoryPlayback.ts
@@ -70,6 +70,16 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
}
}, []);
+ // Resolve the audio buffer key and URL for an item.
+ // When a version_id is pinned, use that version's audio; otherwise use the generation default.
+ const getAudioKey = (item: StoryItemDetail) =>
+ item.version_id ? `v:${item.version_id}` : item.generation_id;
+
+ const getAudioUrlForItem = (item: StoryItemDetail) =>
+ item.version_id
+ ? apiClient.getVersionAudioUrl(item.version_id)
+ : apiClient.getAudioUrl(item.generation_id);
+
// Preload audio files as AudioBuffers
useEffect(() => {
if (!items || items.length === 0) {
@@ -78,12 +88,12 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
return;
}
- const currentIds = new Set(items.map((item) => item.generation_id));
+ const currentKeys = new Set(items.map(getAudioKey));
const audioContext = getAudioContext();
// Remove buffers for items that no longer exist
for (const [id] of audioBuffersRef.current) {
- if (!currentIds.has(id)) {
+ if (!currentKeys.has(id)) {
audioBuffersRef.current.delete(id);
}
}
@@ -91,24 +101,25 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
// Preload audio for new items
const preloadPromises: Promise[] = [];
for (const item of items) {
- if (!audioBuffersRef.current.has(item.generation_id)) {
- const audioUrl = apiClient.getAudioUrl(item.generation_id);
- console.log('[StoryPlayback] Preloading audio buffer:', item.generation_id);
+ const key = getAudioKey(item);
+ if (!audioBuffersRef.current.has(key)) {
+ const audioUrl = getAudioUrlForItem(item);
+ console.log('[StoryPlayback] Preloading audio buffer:', key);
const preloadPromise = fetch(audioUrl)
.then((response) => response.arrayBuffer())
.then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer))
.then((audioBuffer) => {
- audioBuffersRef.current.set(item.generation_id, audioBuffer);
+ audioBuffersRef.current.set(key, audioBuffer);
console.log(
'[StoryPlayback] Preloaded buffer:',
- item.generation_id,
+ key,
'duration:',
audioBuffer.duration,
);
})
.catch((err) => {
- console.error('[StoryPlayback] Failed to preload audio:', item.generation_id, err);
+ console.error('[StoryPlayback] Failed to preload audio:', key, err);
});
preloadPromises.push(preloadPromise);
@@ -216,15 +227,16 @@ export function useStoryPlayback(items: StoryItemDetail[] | undefined) {
// Schedule new sources for items that should be playing
for (const item of shouldBePlaying) {
if (!activeSourcesRef.current.has(item.id)) {
- const buffer = audioBuffersRef.current.get(item.generation_id);
+ const bufferKey = getAudioKey(item);
+ const buffer = audioBuffersRef.current.get(bufferKey);
if (!buffer) {
- console.warn('[StoryPlayback] Buffer not loaded for:', item.generation_id);
+ console.warn('[StoryPlayback] Buffer not loaded for:', bufferKey);
continue;
}
// Calculate when this item should start in AudioContext time
const itemStartContextTime = storyTimeToContextTime(item.start_time_ms);
-
+
// Calculate effective duration and trim offsets
const trimStartSec = (item.trim_start_ms || 0) / 1000;
const trimEndSec = (item.trim_end_ms || 0) / 1000;
diff --git a/app/src/lib/utils/audio.ts b/app/src/lib/utils/audio.ts
index 159af57d..a8ccc722 100644
--- a/app/src/lib/utils/audio.ts
+++ b/app/src/lib/utils/audio.ts
@@ -22,6 +22,11 @@ export function formatAudioDuration(seconds: number): string {
* If the file has a recordedDuration property (from recording hooks),
* use that instead of trying to read metadata. This fixes issues on Windows
* where WebM files from MediaRecorder don't have proper duration metadata.
+ *
+ * For uploaded files we use AudioContext.decodeAudioData which fully decodes
+ * the audio and returns the exact duration. This is more reliable than
+ * HTMLMediaElement.duration which can return incorrect large values for VBR
+ * MP3 files that lack a proper XING/VBRI header.
*/
export async function getAudioDuration(
file: File & { recordedDuration?: number },
@@ -30,26 +35,39 @@ export async function getAudioDuration(
return file.recordedDuration;
}
- return new Promise((resolve, reject) => {
- const audio = new Audio();
- const url = URL.createObjectURL(file);
+ // Use Web Audio API for accurate duration — avoids VBR MP3 metadata issues.
+ try {
+ const audioContext = new AudioContext();
+ try {
+ const arrayBuffer = await file.arrayBuffer();
+ const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
+ return audioBuffer.duration;
+ } finally {
+ await audioContext.close();
+ }
+ } catch {
+ // Fallback: read duration from the media element (less accurate but works for WAV).
+ return new Promise((resolve, reject) => {
+ const audio = new Audio();
+ const url = URL.createObjectURL(file);
- audio.addEventListener('loadedmetadata', () => {
- URL.revokeObjectURL(url);
- if (Number.isFinite(audio.duration) && audio.duration > 0) {
- resolve(audio.duration);
- } else {
- reject(new Error('Audio file has invalid duration metadata'));
- }
+ audio.addEventListener('loadedmetadata', () => {
+ URL.revokeObjectURL(url);
+ if (Number.isFinite(audio.duration) && audio.duration > 0) {
+ resolve(audio.duration);
+ } else {
+ reject(new Error('Audio file has invalid duration metadata'));
+ }
+ });
+
+ audio.addEventListener('error', () => {
+ URL.revokeObjectURL(url);
+ reject(new Error('Failed to load audio file'));
+ });
+
+ audio.src = url;
});
-
- audio.addEventListener('error', () => {
- URL.revokeObjectURL(url);
- reject(new Error('Failed to load audio file'));
- });
-
- audio.src = url;
- });
+ }
}
/**
diff --git a/app/src/lib/utils/format.ts b/app/src/lib/utils/format.ts
index fbd7a884..e1cec0e6 100644
--- a/app/src/lib/utils/format.ts
+++ b/app/src/lib/utils/format.ts
@@ -21,10 +21,25 @@ export function formatDate(date: string | Date): string {
} else {
dateObj = date;
}
-
+
return formatDistance(dateObj, new Date(), { addSuffix: true }).replace(/^about /i, '');
}
+const ENGINE_DISPLAY_NAMES: Record = {
+ qwen: 'Qwen',
+ luxtts: 'LuxTTS',
+ chatterbox: 'Chatterbox',
+ chatterbox_turbo: 'Chatterbox Turbo',
+};
+
+export function formatEngineName(engine?: string, modelSize?: string): string {
+ const name = ENGINE_DISPLAY_NAMES[engine ?? 'qwen'] ?? engine ?? 'Qwen';
+ if (engine === 'qwen' && modelSize) {
+ return `${name} ${modelSize}`;
+ }
+ return name;
+}
+
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
diff --git a/app/src/platform/types.ts b/app/src/platform/types.ts
index 5ea4d609..ef936575 100644
--- a/app/src/platform/types.ts
+++ b/app/src/platform/types.ts
@@ -10,6 +10,8 @@ export interface FileFilter {
export interface PlatformFilesystem {
saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise;
+ openPath(path: string): Promise;
+ pickDirectory(title: string): Promise;
}
export interface UpdateStatus {
@@ -49,8 +51,9 @@ export interface PlatformAudio {
}
export interface PlatformLifecycle {
- startServer(remote?: boolean): Promise;
+ startServer(remote?: boolean, modelsDir?: string | null): Promise;
stopServer(): Promise;
+ restartServer(modelsDir?: string | null): Promise;
setKeepServerRunning(keep: boolean): Promise;
setupWindowCloseHandler(): Promise;
onServerReady?: () => void;
diff --git a/app/src/router.tsx b/app/src/router.tsx
index dbf94038..7e6e14ef 100644
--- a/app/src/router.tsx
+++ b/app/src/router.tsx
@@ -1,6 +1,7 @@
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router';
import { AppFrame } from '@/components/AppFrame/AppFrame';
import { AudioTab } from '@/components/AudioTab/AudioTab';
+import { EffectsTab } from '@/components/EffectsTab/EffectsTab';
import { MainEditor } from '@/components/MainEditor/MainEditor';
import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
import { ServerTab } from '@/components/ServerTab/ServerTab';
@@ -8,8 +9,10 @@ import { Sidebar } from '@/components/Sidebar';
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
import { Toaster } from '@/components/ui/toaster';
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
+import { useGenerationProgress } from '@/lib/hooks/useGenerationProgress';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
+
// Simple platform check that works in both web and Tauri
const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
@@ -18,6 +21,9 @@ function RootLayout() {
// Monitor active downloads/generations and show toasts for them
const activeDownloads = useRestoreActiveTasks();
+ // Subscribe to SSE for pending generations — handles completion, auto-play, and history refresh
+ useGenerationProgress();
+
return (
@@ -100,6 +106,13 @@ const audioRoute = createRoute({
component: AudioTab,
});
+// Effects route
+const effectsRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/effects',
+ component: EffectsTab,
+});
+
// Models route
const modelsRoute = createRoute({
getParentRoute: () => rootRoute,
@@ -120,6 +133,7 @@ const routeTree = rootRoute.addChildren([
storiesRoute,
voicesRoute,
audioRoute,
+ effectsRoute,
modelsRoute,
serverRoute,
]);
diff --git a/app/src/stores/effectsStore.ts b/app/src/stores/effectsStore.ts
new file mode 100644
index 00000000..086dd312
--- /dev/null
+++ b/app/src/stores/effectsStore.ts
@@ -0,0 +1,26 @@
+import { create } from 'zustand';
+import type { EffectConfig } from '@/lib/api/types';
+
+interface EffectsStore {
+ selectedPresetId: string | null;
+ setSelectedPresetId: (id: string | null) => void;
+
+ // Working chain for the detail panel (editing a preset or building a new one)
+ workingChain: EffectConfig[];
+ setWorkingChain: (chain: EffectConfig[]) => void;
+
+ // Track if editing an existing preset vs creating new
+ isCreatingNew: boolean;
+ setIsCreatingNew: (v: boolean) => void;
+}
+
+export const useEffectsStore = create
((set) => ({
+ selectedPresetId: null,
+ setSelectedPresetId: (id) => set({ selectedPresetId: id, isCreatingNew: false }),
+
+ workingChain: [],
+ setWorkingChain: (chain) => set({ workingChain: chain }),
+
+ isCreatingNew: false,
+ setIsCreatingNew: (v) => set({ isCreatingNew: v, ...(v && { selectedPresetId: null }) }),
+}));
diff --git a/app/src/stores/generationStore.ts b/app/src/stores/generationStore.ts
index c0d63383..edf715d1 100644
--- a/app/src/stores/generationStore.ts
+++ b/app/src/stores/generationStore.ts
@@ -1,15 +1,58 @@
import { create } from 'zustand';
interface GenerationState {
+ /** IDs of generations currently in progress */
+ pendingGenerationIds: Set;
+ /** Whether any generation is in progress (derived from pendingGenerationIds) */
isGenerating: boolean;
- activeGenerationId: string | null;
- setIsGenerating: (generating: boolean) => void;
+ /** Map of generationId → storyId for deferred story additions */
+ pendingStoryAdds: Map;
+ addPendingGeneration: (id: string) => void;
+ removePendingGeneration: (id: string) => void;
+ addPendingStoryAdd: (generationId: string, storyId: string) => void;
+ removePendingStoryAdd: (generationId: string) => string | undefined;
setActiveGenerationId: (id: string | null) => void;
+ activeGenerationId: string | null;
}
-export const useGenerationStore = create((set) => ({
+export const useGenerationStore = create((set, get) => ({
+ pendingGenerationIds: new Set(),
isGenerating: false,
activeGenerationId: null,
- setIsGenerating: (generating) => set({ isGenerating: generating }),
+ pendingStoryAdds: new Map(),
+
+ addPendingGeneration: (id) =>
+ set((state) => {
+ const next = new Set(state.pendingGenerationIds);
+ next.add(id);
+ return { pendingGenerationIds: next, isGenerating: true };
+ }),
+
+ removePendingGeneration: (id) =>
+ set((state) => {
+ const next = new Set(state.pendingGenerationIds);
+ next.delete(id);
+ return { pendingGenerationIds: next, isGenerating: next.size > 0 };
+ }),
+
+ addPendingStoryAdd: (generationId, storyId) =>
+ set((state) => {
+ const next = new Map(state.pendingStoryAdds);
+ next.set(generationId, storyId);
+ return { pendingStoryAdds: next };
+ }),
+
+ removePendingStoryAdd: (generationId) => {
+ const storyId = get().pendingStoryAdds.get(generationId);
+ if (storyId) {
+ set((state) => {
+ const next = new Map(state.pendingStoryAdds);
+ next.delete(generationId);
+ return { pendingStoryAdds: next };
+ });
+ }
+ return storyId;
+ },
+
setActiveGenerationId: (id) => set({ activeGenerationId: id }),
}));
diff --git a/app/src/stores/serverStore.ts b/app/src/stores/serverStore.ts
index 36d9f0af..8f983049 100644
--- a/app/src/stores/serverStore.ts
+++ b/app/src/stores/serverStore.ts
@@ -13,6 +13,21 @@ interface ServerStore {
keepServerRunningOnClose: boolean;
setKeepServerRunningOnClose: (keepRunning: boolean) => void;
+
+ maxChunkChars: number;
+ setMaxChunkChars: (value: number) => void;
+
+ crossfadeMs: number;
+ setCrossfadeMs: (value: number) => void;
+
+ normalizeAudio: boolean;
+ setNormalizeAudio: (value: boolean) => void;
+
+ autoplayOnGenerate: boolean;
+ setAutoplayOnGenerate: (value: boolean) => void;
+
+ customModelsDir: string | null;
+ setCustomModelsDir: (dir: string | null) => void;
}
export const useServerStore = create()(
@@ -29,6 +44,21 @@ export const useServerStore = create()(
keepServerRunningOnClose: false,
setKeepServerRunningOnClose: (keepRunning) => set({ keepServerRunningOnClose: keepRunning }),
+
+ maxChunkChars: 800,
+ setMaxChunkChars: (value) => set({ maxChunkChars: value }),
+
+ crossfadeMs: 50,
+ setCrossfadeMs: (value) => set({ crossfadeMs: value }),
+
+ normalizeAudio: true,
+ setNormalizeAudio: (value) => set({ normalizeAudio: value }),
+
+ autoplayOnGenerate: true,
+ setAutoplayOnGenerate: (value) => set({ autoplayOnGenerate: value }),
+
+ customModelsDir: null,
+ setCustomModelsDir: (dir) => set({ customModelsDir: dir }),
}),
{
name: 'voicebox-server',
diff --git a/app/src/stores/uiStore.ts b/app/src/stores/uiStore.ts
index 3822d5f9..f2db88a2 100644
--- a/app/src/stores/uiStore.ts
+++ b/app/src/stores/uiStore.ts
@@ -31,6 +31,10 @@ interface UIStore {
selectedProfileId: string | null;
setSelectedProfileId: (id: string | null) => void;
+ // Selected voice in Voices tab inspector
+ selectedVoiceId: string | null;
+ setSelectedVoiceId: (id: string | null) => void;
+
// Profile form draft (for persisting create voice modal state)
profileFormDraft: ProfileFormDraft | null;
setProfileFormDraft: (draft: ProfileFormDraft | null) => void;
@@ -55,6 +59,9 @@ export const useUIStore = create((set) => ({
selectedProfileId: null,
setSelectedProfileId: (id) => set({ selectedProfileId: id }),
+ selectedVoiceId: null,
+ setSelectedVoiceId: (id) => set({ selectedVoiceId: id }),
+
profileFormDraft: null,
setProfileFormDraft: (draft) => set({ profileFormDraft: draft }),
diff --git a/backend/README.md b/backend/README.md
index 57163467..170cab1c 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -1,459 +1,135 @@
-# voicebox Backend
+# Voicebox Backend
-Production-quality FastAPI backend for Qwen3-TTS voice cloning.
+FastAPI server powering voice cloning, speech generation, and audio processing. Runs locally as a Tauri sidecar or standalone via `python -m backend.main`.
-## Features
+## Running
-- ✅ **Voice Profile Management** - Create, update, delete voice profiles with multi-sample support
-- ✅ **Voice Cloning** - Generate speech using voice profiles with caching
-- ✅ **Generation History** - Full history tracking with search and filtering
-- ✅ **Transcription** - Whisper-based audio transcription
-- ✅ **Multi-Sample Profiles** - Combine multiple reference samples for better quality
-- ✅ **Voice Prompt Caching** - Dual memory + disk caching for fast generation
-- ✅ **Audio Validation** - Automatic validation of reference audio quality
-- ✅ **Model Management** - Lazy loading and VRAM management
+```bash
+# Via justfile (recommended)
+just dev:server
+
+# Standalone
+python -m backend.main --host 127.0.0.1 --port 17493
+
+# With custom data directory
+python -m backend.main --data-dir /path/to/data
+```
+
+The server auto-initializes the SQLite database on first startup. Models are downloaded from HuggingFace on first use.
## Architecture
```
backend/
-├── main.py # FastAPI app with all routes
-├── models.py # Pydantic request/response models
-├── platform_detect.py # Platform detection for backend selection
-├── tts.py # TTS backend abstraction (delegates to MLX or PyTorch)
-├── transcribe.py # STT backend abstraction (delegates to MLX or PyTorch)
-├── backends/ # Backend implementations
-│ ├── __init__.py # Backend factory and protocols
-│ ├── mlx_backend.py # MLX backend (Apple Silicon)
-│ └── pytorch_backend.py # PyTorch backend (Windows/Linux/Intel)
-├── profiles.py # Voice profile CRUD
-├── history.py # Generation history
-├── studio.py # Audio editing (TODO)
-├── database.py # SQLite ORM
-└── utils/
- ├── audio.py # Audio processing utilities
- ├── cache.py # Voice prompt caching
- └── validation.py # Input validation
+ app.py # FastAPI app factory, CORS, lifecycle events
+ main.py # Entry point (imports app, runs uvicorn)
+ config.py # Data directory paths and configuration
+ models.py # Pydantic request/response schemas
+ server.py # Tauri sidecar launcher, parent-pid watchdog
+
+ routes/ # Thin HTTP handlers — validation, delegation, response formatting
+ services/ # Business logic, CRUD, orchestration
+ backends/ # TTS/STT engine implementations (MLX, PyTorch, etc.)
+ database/ # ORM models, session management, migrations, seed data
+ utils/ # Shared utilities (audio, effects, caching, progress tracking)
```
-### Backend Selection
-
-Voicebox automatically selects the best backend based on platform:
-
-- **Apple Silicon (M1/M2/M3)**: Uses MLX backend with native Metal acceleration (4-5x faster)
-- **Windows/Linux/Intel Mac**: Uses PyTorch backend (CUDA GPU if available, CPU fallback)
-
-The backend is detected at runtime via `platform_detect.py`. Both backends implement the same interface, so the API remains consistent across platforms.
-
-## API Endpoints
-
-### Health & Info
-
-#### `GET /`
-Root endpoint with version info.
-
-#### `GET /health`
-Health check with model status.
-
-**Response:**
-```json
-{
- "status": "healthy",
- "model_loaded": true,
- "gpu_available": true,
- "gpu_type": "Metal (Apple Silicon via MLX)",
- "backend_type": "mlx",
- "vram_used_mb": null
-}
-```
-
-**Backend Types:**
-- `"mlx"` - MLX backend (Apple Silicon with Metal acceleration)
-- `"pytorch"` - PyTorch backend (Windows/Linux/Intel Mac)
-
-### Voice Profiles
-
-**Note:** The database is automatically initialized when the server starts. No manual setup required.
-
-#### `POST /profiles`
-Create a new voice profile.
-
-**Request:**
-```json
-{
- "name": "My Voice",
- "description": "Optional description",
- "language": "en"
-}
-```
-
-**Response:**
-```json
-{
- "id": "uuid",
- "name": "My Voice",
- "description": "Optional description",
- "language": "en",
- "created_at": "2024-01-01T00:00:00Z",
- "updated_at": "2024-01-01T00:00:00Z"
-}
-```
-
-#### `GET /profiles`
-List all voice profiles.
-
-#### `GET /profiles/{profile_id}`
-Get a specific profile.
-
-#### `PUT /profiles/{profile_id}`
-Update a profile.
-
-#### `DELETE /profiles/{profile_id}`
-Delete a profile and all associated samples.
-
-#### `POST /profiles/{profile_id}/samples`
-Add a sample to a profile.
-
-**Form Data:**
-- `file`: Audio file (WAV, MP3, etc.)
-- `reference_text`: Transcript of the audio
-
-**Response:**
-```json
-{
- "id": "sample-uuid",
- "profile_id": "profile-uuid",
- "audio_path": "/path/to/sample.wav",
- "reference_text": "This is my voice"
-}
-```
-
-#### `GET /profiles/{profile_id}/samples`
-List all samples for a profile.
-
-#### `DELETE /profiles/samples/{sample_id}`
-Delete a specific sample.
-
-### Generation
-
-#### `POST /generate`
-Generate speech from text using a voice profile.
-
-**Request:**
-```json
-{
- "profile_id": "uuid",
- "text": "Hello, this is a test.",
- "language": "en",
- "seed": 42
-}
-```
-
-**Response:**
-```json
-{
- "id": "generation-uuid",
- "profile_id": "profile-uuid",
- "text": "Hello, this is a test.",
- "language": "en",
- "audio_path": "/path/to/audio.wav",
- "duration": 2.5,
- "seed": 42,
- "created_at": "2024-01-01T00:00:00Z"
-}
-```
-
-### History
-
-#### `GET /history`
-List generation history with optional filters.
-
-**Query Parameters:**
-- `profile_id` (optional): Filter by profile
-- `search` (optional): Search in text content
-- `limit` (default: 50): Results per page
-- `offset` (default: 0): Pagination offset
-
-#### `GET /history/{generation_id}`
-Get a specific generation.
-
-#### `DELETE /history/{generation_id}`
-Delete a generation.
-
-#### `GET /history/stats`
-Get generation statistics.
-
-**Response:**
-```json
-{
- "total_generations": 100,
- "total_duration_seconds": 250.5,
- "generations_by_profile": {
- "profile-uuid-1": 50,
- "profile-uuid-2": 50
- }
-}
-```
-
-### Audio Files
-
-#### `GET /audio/{generation_id}`
-Download generated audio file.
-
-Returns WAV file with appropriate headers.
-
-### Transcription
-
-#### `POST /transcribe`
-Transcribe audio file to text.
-
-**Form Data:**
-- `file`: Audio file
-- `language` (optional): Language hint (en or zh)
-
-**Response:**
-```json
-{
- "text": "Transcribed text here",
- "duration": 5.5
-}
-```
-
-### Model Management
-
-#### `POST /models/load`
-Manually load TTS model.
-
-**Query Parameters:**
-- `model_size`: Model size (1.7B or 0.6B)
-
-#### `POST /models/unload`
-Unload TTS model to free memory.
-
-## Database Schema
-
-### profiles
-- `id`: UUID primary key
-- `name`: Profile name (unique)
-- `description`: Optional description
-- `language`: Language code (en/zh)
-- `created_at`: Creation timestamp
-- `updated_at`: Last update timestamp
-
-### profile_samples
-- `id`: UUID primary key
-- `profile_id`: Foreign key to profiles
-- `audio_path`: Path to audio file
-- `reference_text`: Transcript
-
-### generations
-- `id`: UUID primary key
-- `profile_id`: Foreign key to profiles
-- `text`: Generated text
-- `language`: Language code
-- `audio_path`: Path to audio file
-- `duration`: Duration in seconds
-- `seed`: Random seed (optional)
-- `created_at`: Creation timestamp
-
-### projects
-- `id`: UUID primary key
-- `name`: Project name
-- `data`: JSON data
-- `created_at`: Creation timestamp
-- `updated_at`: Last update timestamp
-
-## File Structure
+### Request flow
```
-data/
-├── profiles/
-│ └── {profile_id}/
-│ ├── {sample_id}.wav
-│ └── ...
-├── generations/
-│ └── {generation_id}.wav
-├── cache/
-│ └── {hash}.prompt
-├── projects/
-│ └── {project_id}.json
-└── voicebox.db
+HTTP request
+ -> routes/ (validate input, parse params)
+ -> services/ (business logic, database queries, orchestration)
+ -> backends/ (TTS/STT inference)
+ -> utils/ (audio processing, effects, caching)
```
-## Setup
+Route handlers are intentionally thin. They validate input, delegate to a service function, and format the response. All business logic lives in `services/`.
-### 1. Install Dependencies
+### Key modules
+
+**services/generation.py** -- Single `run_generation()` function that handles all three generation modes (generate, retry, regenerate). Manages model loading, voice prompt creation, chunked inference, normalization, effects, and version persistence.
+
+**services/task_queue.py** -- Serial generation queue. Ensures only one GPU inference runs at a time. Background tasks are tracked to prevent garbage collection.
+
+**backends/__init__.py** -- Protocol definitions (`TTSBackend`, `STTBackend`), model config registry, and factory functions. Adding a new engine means implementing the protocol and registering a config entry.
+
+**backends/base.py** -- Shared utilities used across all engine implementations: HuggingFace cache checks, device detection, voice prompt combination, progress tracking.
+
+**database/** -- SQLAlchemy ORM models with a re-exporting `__init__.py` for backward compatibility. Migrations run automatically on startup.
+
+### Backend selection
+
+The server detects the best inference backend at startup:
+
+| Platform | Backend | Acceleration |
+|----------|---------|-------------|
+| macOS (Apple Silicon) | MLX | Metal / Neural Engine |
+| Windows / Linux (NVIDIA) | PyTorch | CUDA |
+| Linux (AMD) | PyTorch | ROCm |
+| Intel Arc | PyTorch | IPEX / XPU |
+| Windows (any GPU) | PyTorch | DirectML |
+| Any | PyTorch | CPU fallback |
+
+Detection is handled by `utils/platform_detect.py`. Both backends implement the same `TTSBackend` protocol, so the API layer is engine-agnostic.
+
+## API
+
+90 endpoints organized by domain. Full interactive documentation available at `http://localhost:17493/docs` when the server is running.
+
+| Domain | Prefix | Description |
+|--------|--------|-------------|
+| Health | `/`, `/health` | Server status, GPU info, filesystem checks |
+| Profiles | `/profiles` | Voice profile CRUD, samples, avatars, import/export |
+| Channels | `/channels` | Audio channel management and voice assignment |
+| Generation | `/generate` | TTS generation, retry, regenerate, status SSE |
+| History | `/history` | Generation history, search, favorites, export |
+| Transcription | `/transcribe` | Whisper-based audio-to-text |
+| Stories | `/stories` | Multi-track timeline editor, audio export |
+| Effects | `/effects` | Effect presets, preview, version management |
+| Audio | `/audio`, `/samples` | Audio file serving |
+| Models | `/models` | Load, unload, download, migrate, status |
+| Tasks | `/tasks`, `/cache` | Active task tracking, cache management |
+| CUDA | `/backend/cuda-*` | CUDA binary download and management |
+
+### Quick examples
```bash
-pip install -r requirements.txt
-```
-
-**Note:** On Apple Silicon, also install MLX dependencies for faster inference:
-```bash
-pip install -r requirements-mlx.txt
-```
-
-### 2. Download Models (Automatic)
-
-The Qwen3-TTS models are automatically downloaded from HuggingFace Hub on first use, similar to how Whisper models work.
-
-**No manual download required!** The models will be cached locally after the first download.
-
-Available models:
-- **1.7B** (recommended): `Qwen/Qwen3-TTS-12Hz-1.7B-Base` (~4GB)
-- **0.6B** (faster): `Qwen/Qwen3-TTS-12Hz-0.6B-Base` (~2GB)
-
-**Note:** The first generation will take longer as the model downloads. Subsequent generations will use the cached model.
-
-#### Manual Download (Optional)
-
-If you prefer to download models manually or have limited internet during runtime:
-
-```bash
-# Install huggingface-cli
-pip install huggingface_hub
-
-# Download 1.7B model
-huggingface-cli download Qwen/Qwen3-TTS-12Hz-1.7B-Base
-
-# Or use Python
-python -c "from huggingface_hub import snapshot_download; snapshot_download('Qwen/Qwen3-TTS-12Hz-1.7B-Base')"
-```
-
-Models are cached in `~/.cache/huggingface/hub/` by default.
-
-### 4. Run Server
-
-```bash
-# Development (local only)
-python -m backend.main
-
-# Production (allow remote access)
-python -m backend.main --host 0.0.0.0 --port 8000
-```
-
-## Usage Examples
-
-### Creating a Voice Profile
-
-```bash
-# 1. Create profile
-curl -X POST http://localhost:8000/profiles \
+# Generate speech
+curl -X POST http://localhost:17493/generate \
-H "Content-Type: application/json" \
- -d '{"name": "My Voice", "language": "en"}'
+ -d '{"text": "Hello world", "profile_id": "...", "language": "en"}'
-# Response: {"id": "abc-123", ...}
+# List profiles
+curl http://localhost:17493/profiles
-# 2. Add sample
-curl -X POST http://localhost:8000/profiles/abc-123/samples \
- -F "file=@sample.wav" \
- -F "reference_text=This is my voice sample"
+# Stream generation status (SSE)
+curl http://localhost:17493/generate/{id}/status
```
-### Generating Speech
+## Data directory
+
+```
+{data_dir}/
+ voicebox.db # SQLite database
+ profiles/{id}/ # Voice samples per profile
+ generations/ # Generated audio files
+ cache/ # Voice prompt cache (memory + disk)
+ backends/ # Downloaded CUDA binary (if applicable)
+```
+
+Default location is the OS-specific app data directory. Override with `--data-dir` or the `VOICEBOX_DATA_DIR` environment variable.
+
+## Code quality
+
+Linting and formatting are enforced by [ruff](https://docs.astral.sh/ruff/), configured in `pyproject.toml`. See `STYLE_GUIDE.md` for conventions.
```bash
-curl -X POST http://localhost:8000/generate \
- -H "Content-Type: application/json" \
- -d '{
- "profile_id": "abc-123",
- "text": "Hello, this is a test.",
- "language": "en",
- "seed": 42
- }'
-
-# Response: {"id": "gen-456", "audio_path": "/path/to/audio.wav", ...}
-
-# Download audio
-curl http://localhost:8000/audio/gen-456 -o output.wav
+just check-python # lint + format check
+just fix-python # auto-fix lint issues + reformat
+just test # run pytest
```
-### Transcribing Audio
+## Dependencies
-```bash
-curl -X POST http://localhost:8000/transcribe \
- -F "file=@audio.wav" \
- -F "language=en"
-
-# Response: {"text": "Transcribed text", "duration": 5.5}
-```
-
-## Advanced Features
-
-### Multi-Sample Profiles
-
-Add multiple samples to a profile for better quality:
-
-```bash
-# Add first sample
-curl -X POST http://localhost:8000/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 \
- -F "file=@sample2.wav" \
- -F "reference_text=Second sample"
-
-# Generation will automatically combine all samples
-```
-
-### Voice Prompt Caching
-
-Voice prompts are automatically cached for faster generation:
-- First generation: ~5-10 seconds (creates prompt)
-- Subsequent generations: ~1-2 seconds (uses cached prompt)
-
-Cache is stored in `data/cache/` and persists across server restarts.
-
-### VRAM Management
-
-Models are lazy-loaded and can be manually unloaded:
-
-```bash
-# Unload TTS model
-curl -X POST http://localhost:8000/models/unload
-
-# Load specific model size
-curl -X POST "http://localhost:8000/models/load?model_size=0.6B"
-```
-
-## Error Handling
-
-All endpoints return proper HTTP status codes:
-
-- `200 OK`: Success
-- `400 Bad Request`: Invalid input
-- `404 Not Found`: Resource not found
-- `500 Internal Server Error`: Server error
-
-Error responses include details:
-
-```json
-{
- "detail": "Profile not found"
-}
-```
-
-## Performance Tips
-
-1. **Use multi-sample profiles** - Better quality than single sample
-2. **Let caching work** - Voice prompts are cached automatically
-3. **Use 0.6B model on CPU** - Faster than 1.7B with acceptable quality
-4. **Use 1.7B model on GPU** - Best quality, still fast
-5. **Unload Whisper after transcription** - Frees VRAM for TTS
-
-## TODO
-
-- [ ] WebSocket support for generation progress
-- [ ] Batch generation endpoint
-- [ ] Audio effects (M3GAN, etc.)
-- [ ] Voice design (text-to-voice)
-- [ ] Audio studio timeline features
-- [ ] Project management
-- [ ] Authentication & rate limiting
-- [ ] Export/import profiles
-
-## License
-
-See main project LICENSE.
+Runtime dependencies are in `requirements.txt`. macOS-only MLX dependencies are in `requirements-mlx.txt`. Dev tools (ruff, pytest) are installed automatically by `just setup-python`.
diff --git a/backend/STYLE_GUIDE.md b/backend/STYLE_GUIDE.md
new file mode 100644
index 00000000..693b5308
--- /dev/null
+++ b/backend/STYLE_GUIDE.md
@@ -0,0 +1,404 @@
+# Python Style Guide
+
+Target: **Python 3.12+** | Formatter/Linter: **Ruff** | Config: `backend/pyproject.toml`
+
+This guide codifies the conventions used across the backend, and prescribes the target style for code written during the refactor (Phases 3-6). Existing code should be migrated incrementally -- don't reformat entire files in unrelated PRs.
+
+---
+
+## Formatting
+
+Enforced by `ruff format` (Black-compatible).
+
+- **Line length**: 120 characters.
+- **Indent**: 4 spaces. No tabs.
+- **Trailing commas**: Required on multi-line function signatures, arguments, collections.
+- **Quotes**: Double quotes (`"`) for strings. Single quotes are acceptable in f-string expressions and dict keys inside f-strings where avoiding escapes improves readability.
+
+Run: `ruff format backend/`
+
+---
+
+## Imports
+
+Enforced by ruff's `isort` rules (rule set `I`).
+
+**Grouping** -- three blocks separated by a blank line:
+
+```python
+import asyncio # 1. stdlib
+from pathlib import Path
+
+import numpy as np # 2. third-party
+from fastapi import APIRouter, HTTPException
+from sqlalchemy.orm import Session
+
+from backend.config import get_data_dir # 3. local (absolute)
+from .database import get_db # or relative
+```
+
+**Rules:**
+- Within the `backend` package, use **relative imports** for sibling/child modules: `from .database import get_db`, `from ..utils.audio import load_audio`.
+- Absolute imports are fine for top-level references from entry points (`main.py`, `server.py`).
+- Never use wildcard imports (`from module import *`).
+- One import per line for `from X import Y` when there are 4+ names; below that, comma-separated is fine.
+- **Lazy imports** are acceptable for heavy dependencies (torch, transformers, mlx) inside functions to reduce startup time. Add a comment: `# lazy: heavy import`.
+
+---
+
+## Type Annotations
+
+Python 3.12 means we use **built-in generics and union syntax natively**. No `from __future__ import annotations`, no `typing.List`/`typing.Dict`.
+
+```python
+# Yes
+def process(items: list[str], config: dict[str, int] | None = None) -> tuple[int, str]: ...
+
+# No
+from typing import List, Dict, Optional, Tuple
+def process(items: List[str], config: Optional[Dict[str, int]] = None) -> Tuple[int, str]: ...
+```
+
+**What to annotate:**
+- All public function signatures (parameters + return type).
+- Private functions: parameters at minimum; return type encouraged.
+- Module-level variables: only when the type isn't obvious from the assignment.
+- Route handlers: parameters are annotated via FastAPI's dependency injection. Add explicit `-> SomeResponse` return types when the route doesn't use `response_model`.
+
+**Imports from `typing` that are still needed** (no built-in equivalent):
+`Literal`, `TypeAlias`, `Protocol`, `runtime_checkable`, `Callable`, `Any`, `ClassVar`, `TypeVar`, `overload`, `TYPE_CHECKING`.
+
+Use `collections.abc` for abstract types: `Sequence`, `Mapping`, `Iterable`, `Iterator`, `Generator`.
+
+---
+
+## Naming
+
+| Thing | Convention | Example |
+|-------|-----------|---------|
+| Module | `snake_case` | `task_queue.py` |
+| Class | `PascalCase` | `ProgressManager` |
+| Function / method | `snake_case` | `create_profile` |
+| Variable | `snake_case` | `sample_rate` |
+| Constant | `UPPER_SNAKE_CASE` | `DEFAULT_SAMPLE_RATE` |
+| Private | `_leading_underscore` | `_generation_queue` |
+| Type alias | `PascalCase` | `EffectChain = list[dict[str, Any]]` |
+
+**Specific conventions:**
+- Database ORM models imported with `DB` prefix alias: `from .database import VoiceProfile as DBVoiceProfile`.
+- Pydantic models use descriptive suffixes: `VoiceProfileCreate`, `VoiceProfileResponse`, `GenerationRequest`.
+- Backend classes use engine-name prefix: `MLXTTSBackend`, `PyTorchSTTBackend`.
+
+---
+
+## Docstrings
+
+**Google style**. Required on all public functions, classes, and modules.
+
+```python
+def combine_voice_prompts(
+ profile_dir: Path,
+ *,
+ target_sr: int = 24000,
+) -> tuple[np.ndarray, int]:
+ """Load and concatenate all voice prompt files for a profile.
+
+ Reads .wav/.mp3/.flac files from the profile directory, resamples to
+ the target sample rate, normalizes, and concatenates into a single array.
+
+ Args:
+ profile_dir: Path to the voice profile directory containing audio files.
+ target_sr: Target sample rate for the output. Defaults to 24000.
+
+ Returns:
+ Tuple of (concatenated audio array, sample rate).
+
+ Raises:
+ FileNotFoundError: If profile_dir does not exist.
+ ValueError: If no valid audio files are found.
+ """
+```
+
+**Short form** is fine for simple functions:
+
+```python
+def get_db_path() -> Path:
+ """Get the path to the SQLite database file."""
+```
+
+**When to skip**: Private helpers under ~5 lines where the name and signature make intent obvious.
+
+**Module docstrings**: A single sentence at the top of every file describing its purpose.
+
+```python
+"""Voice profile CRUD operations."""
+```
+
+---
+
+## Comments
+
+Comments explain **why**, not **what**. If the code needs a comment to explain what it does, the code should be rewritten to be clearer. The exceptions are non-obvious performance choices, external constraints, and concurrency/race-condition reasoning -- those always deserve a comment.
+
+### No section dividers
+
+Do not use ASCII dividers to create visual sections in files:
+
+```python
+# No -- any of these:
+# ============================================
+# GENERATION ENDPOINTS
+# ============================================
+
+# ---------------------------------------------------------------------------
+# Device detection
+# ---------------------------------------------------------------------------
+
+# --- Load model --------------------------------------------------
+```
+
+If a file needs section dividers to be navigable, the file is too long. Split it into modules. Within a function, if you need labeled sections to follow the logic, extract those sections into named functions.
+
+### Inline comments
+
+Inline comments (end-of-line) are fine when they add information the code can't express:
+
+```python
+# Yes -- explains a non-obvious constraint or gives context:
+audio, sr = load_audio(path, sr=24000) # Qwen expects 24kHz mono
+_generation_queue: asyncio.Queue = None # type: ignore # initialized at startup
+"tauri://localhost", # Tauri webview (macOS)
+
+# No -- restates the code:
+# Check if profile name already exists
+existing = db.query(DBVoiceProfile).filter_by(name=data.name).first()
+
+# Delete from database
+db.delete(sample)
+
+# Update fields
+profile.name = data.name
+```
+
+Delete comments that narrate what the next line of code obviously does. If the function name, variable name, or method call already communicates intent, the comment is noise.
+
+### Block comments
+
+Use block comments for **why** explanations -- constraints, workarounds, non-obvious decisions:
+
+```python
+# PyInstaller + multiprocessing: child processes re-execute the frozen binary
+# with internal arguments. freeze_support() handles this and exits early.
+multiprocessing.freeze_support()
+
+# Mark any stale "generating" records as failed -- these are leftovers
+# from a previous process that was killed mid-generation.
+db.query(Generation).filter_by(status="generating").update({"status": "failed"})
+```
+
+Keep block comments tight. Two to three lines is normal. If you need a paragraph, it probably belongs in the docstring or a design doc.
+
+### Linter/type-checker suppression
+
+Always add a reason after `noqa` and `type: ignore`:
+
+```python
+import intel_extension_for_pytorch # noqa: F401 -- side-effect import enables XPU
+_queue: asyncio.Queue = None # type: ignore[assignment] # initialized at startup
+```
+
+Bare `# noqa` or `# type: ignore` with no explanation are not allowed.
+
+### TODO / FIXME
+
+Use sparingly. Every `TODO` must include a brief description of what needs doing. Don't use them as a substitute for tracking work properly:
+
+```python
+# TODO: replace with async SQLAlchemy once CRUD modules are migrated (Phase 5)
+result = await asyncio.to_thread(profiles.get_profile, profile_id, db)
+```
+
+Never commit `HACK`, `XXX`, or `FIXME` -- fix the problem or file an issue.
+
+### Commented-out code
+
+Delete it. That's what git is for. If you need to document that something was intentionally removed, a short tombstone comment is acceptable:
+
+```python
+# Removed config.json-only check -- too lenient, doesn't confirm weights exist.
+```
+
+---
+
+## Error Handling
+
+The refactor is standardizing on a **two-layer pattern**:
+
+### 1. Domain layer -- raise plain exceptions
+
+CRUD modules and services raise `ValueError`, `FileNotFoundError`, or (post-refactor) custom exceptions defined in `backend/errors.py`:
+
+```python
+# backend/errors.py (to be created in Phase 4)
+class NotFoundError(Exception):
+ """Raised when a requested resource does not exist."""
+
+class ConflictError(Exception):
+ """Raised on uniqueness constraint violations."""
+```
+
+```python
+# In a service or CRUD module:
+raise NotFoundError(f"Profile {profile_id} not found")
+```
+
+### 2. Route layer -- translate to HTTPException
+
+Route handlers catch domain exceptions and convert:
+
+```python
+@router.post("/profiles")
+async def create_profile(data: VoiceProfileCreate, db: Session = Depends(get_db)):
+ try:
+ return await profiles.create_profile(data, db)
+ except ConflictError as e:
+ raise HTTPException(status_code=409, detail=str(e))
+```
+
+**Background tasks** catch `Exception` broadly, log with `logger.exception()`, and update the task status to `"failed"`.
+
+**Never**: silently swallow exceptions, use bare `except:`, or catch `BaseException`.
+
+---
+
+## Async
+
+### Rules for the refactor
+
+1. **Don't declare `async def` unless the function awaits something.** Several service modules still declare `async def` without awaiting -- these should be migrated to sync functions with `asyncio.to_thread()` at the route layer, or to real async SQLAlchemy.
+2. **CPU-bound work** (audio processing, numpy operations) goes through `asyncio.to_thread()`:
+ ```python
+ audio, sr = await asyncio.to_thread(load_audio, source_path)
+ ```
+3. **GPU-bound TTS inference** is serialized through the generation queue (`services/task_queue.py`). Never call a backend's `generate()` directly from a route handler.
+4. **Fire-and-forget tasks**: use `asyncio.create_task()` and track the task reference to prevent garbage collection:
+ ```python
+ task = asyncio.create_task(some_coro())
+ _background_tasks.add(task)
+ task.add_done_callback(_background_tasks.discard)
+ ```
+
+---
+
+## Logging
+
+Use the `logging` module. Not `print()`.
+
+```python
+import logging
+
+logger = logging.getLogger(__name__)
+
+logger.info("Loading model %s on %s", model_name, device)
+logger.warning("Cache miss for %s, downloading", repo_id)
+logger.exception("Generation %s failed") # logs traceback automatically
+```
+
+**Rules:**
+- Use `%s`-style placeholders in log calls (not f-strings). This avoids formatting the string if the log level is filtered out.
+- Use `logger.exception()` inside `except` blocks -- it captures the traceback.
+- Logger name should be `__name__` (yields `backend.utils.audio`, etc.).
+- Existing `print()` calls should be migrated to logging as files are touched during the refactor.
+
+---
+
+## Constants
+
+- Define at **module level** in the file where they're primarily used.
+- Use `UPPER_SNAKE_CASE`.
+- Shared/cross-cutting constants (sample rates, file size limits, CORS origins) go in `backend/config.py` after Phase 6 consolidation.
+- Magic numbers in function bodies should be extracted to named constants:
+ ```python
+ # No
+ if len(audio) > 24000 * 60 * 10:
+
+ # Yes
+ MAX_AUDIO_DURATION_SAMPLES = SAMPLE_RATE * 60 * 10
+ if len(audio) > MAX_AUDIO_DURATION_SAMPLES:
+ ```
+
+---
+
+## Function Signatures
+
+- **Keyword-only arguments** (after `*`) for functions with 3+ parameters, especially when several share the same type:
+ ```python
+ def is_model_cached(
+ hf_repo: str,
+ *,
+ weight_extensions: tuple[str, ...] = (".safetensors", ".bin"),
+ required_files: list[str] | None = None,
+ ) -> bool:
+ ```
+- Parameters on **separate lines** when the signature exceeds ~100 characters or has 3+ params.
+- **Trailing comma** after the last parameter in multi-line signatures.
+- Default values inline with the parameter.
+
+---
+
+## String Formatting
+
+- **f-strings** for runtime string construction.
+- **`%s`-style** for `logging` calls (lazy evaluation).
+- **`.format()`**: avoid; f-strings are preferred.
+
+---
+
+## Testing
+
+Framework: **pytest** with `pytest-asyncio`.
+
+- Test files: `test_.py` in `backend/tests/`.
+- Use `conftest.py` for shared fixtures (db sessions, test client, mock backends).
+- Group related tests in classes: `class TestProfileCRUD:`.
+- Use `@pytest.mark.asyncio` for async tests.
+- Use `@pytest.mark.parametrize` to reduce repetition.
+- Manual integration scripts stay in `tests/` but are clearly marked (filename prefix `manual_` or documented in `tests/README.md`).
+
+---
+
+## Project Layout
+
+```
+backend/
+ app.py # FastAPI app factory, CORS, lifecycle events
+ main.py # Entry point (imports app, runs uvicorn)
+ config.py # Data directory paths
+ models.py # Pydantic request/response schemas
+ server.py # Tauri sidecar launcher, parent-pid watchdog
+ routes/ # Thin HTTP handlers (validation, delegation, response formatting)
+ services/ # Business logic, CRUD, orchestration
+ backends/ # TTS/STT engine implementations
+ database/ # ORM models, session management, migrations, seeds
+ utils/ # Shared utilities (audio, effects, caching, progress)
+ tests/ # pytest suite
+```
+
+---
+
+## Ruff Adoption
+
+`pyproject.toml` configures ruff for linting and formatting. Run:
+
+```bash
+# Lint (check)
+ruff check backend/
+
+# Lint (auto-fix)
+ruff check backend/ --fix
+
+# Format
+ruff format backend/
+```
+
+Introduce ruff fixes file-by-file as you touch them. Don't run `--fix` across the entire codebase in one shot -- that creates unreviewable diffs.
diff --git a/backend/__init__.py b/backend/__init__.py
index e75772bd..5fde0541 100644
--- a/backend/__init__.py
+++ b/backend/__init__.py
@@ -1,3 +1,3 @@
# Backend package
-__version__ = "0.1.12"
+__version__ = "0.2.3"
diff --git a/backend/app.py b/backend/app.py
new file mode 100644
index 00000000..fb6dc89c
--- /dev/null
+++ b/backend/app.py
@@ -0,0 +1,215 @@
+"""FastAPI application factory, middleware, and lifecycle events."""
+
+import asyncio
+import logging
+import os
+import sys
+from pathlib import Path
+
+
+class ColoredFormatter(logging.Formatter):
+ """Custom formatter to add colors matching uvicorn's style."""
+
+ COLORS = {
+ "DEBUG": "\033[36m", # Cyan
+ "INFO": "\033[32m", # Green
+ "WARNING": "\033[33m", # Yellow
+ "ERROR": "\033[31m", # Red
+ "CRITICAL": "\033[35m", # Magenta
+ }
+ RESET = "\033[0m"
+
+ def format(self, record):
+ log_color = self.COLORS.get(record.levelname, self.RESET)
+ record.levelname = f"{log_color}{record.levelname}{self.RESET}"
+ return super().format(record)
+
+
+# Configure logging to match uvicorn's format with colors
+handler = logging.StreamHandler(sys.stderr)
+handler.setFormatter(ColoredFormatter("%(levelname)s: %(message)s"))
+logging.basicConfig(
+ level=logging.INFO,
+ handlers=[handler],
+)
+
+logger = logging.getLogger(__name__)
+
+# AMD GPU environment variables must be set before torch import
+if not os.environ.get("HSA_OVERRIDE_GFX_VERSION"):
+ os.environ["HSA_OVERRIDE_GFX_VERSION"] = "10.3.0"
+if not os.environ.get("MIOPEN_LOG_LEVEL"):
+ os.environ["MIOPEN_LOG_LEVEL"] = "4"
+
+import torch
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from urllib.parse import quote
+
+from . import __version__, config, database
+from .services import tts, transcribe
+from .database import get_db
+from .utils.platform_detect import get_backend_type
+from .utils.progress import get_progress_manager
+from .services.task_queue import create_background_task, init_queue
+from .routes import register_routers
+
+
+def safe_content_disposition(disposition_type: str, filename: str) -> str:
+ """Build a Content-Disposition header safe for non-ASCII filenames.
+
+ Uses RFC 5987 ``filename*`` parameter so browsers can decode UTF-8
+ filenames while the ``filename`` fallback stays ASCII-only.
+ """
+ ascii_name = "".join(c for c in filename if c.isascii() and (c.isalnum() or c in " -_.")).strip() or "download"
+ utf8_name = quote(filename, safe="")
+ return f"{disposition_type}; filename=\"{ascii_name}\"; filename*=UTF-8''{utf8_name}"
+
+
+def create_app() -> FastAPI:
+ """Create and configure the FastAPI application."""
+ application = FastAPI(
+ title="voicebox API",
+ description="Production-quality Qwen3-TTS voice cloning API",
+ version=__version__,
+ )
+
+ _configure_cors(application)
+ register_routers(application)
+ _register_lifecycle(application)
+
+ return application
+
+
+def _configure_cors(application: FastAPI) -> None:
+ """Set up CORS middleware with local-first defaults."""
+ default_origins = [
+ "http://localhost:5173", # Vite dev server
+ "http://127.0.0.1:5173",
+ "http://localhost:17493",
+ "http://127.0.0.1:17493",
+ "tauri://localhost", # Tauri webview (macOS)
+ "https://tauri.localhost", # Tauri webview (Windows/Linux)
+ "http://tauri.localhost", # Tauri webview (Windows, some builds)
+ ]
+ env_origins = os.environ.get("VOICEBOX_CORS_ORIGINS", "")
+ all_origins = default_origins + [o.strip() for o in env_origins.split(",") if o.strip()]
+
+ application.add_middleware(
+ CORSMiddleware,
+ allow_origins=all_origins,
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+
+def _get_gpu_status() -> str:
+ """Return a human-readable string describing GPU availability."""
+ backend_type = get_backend_type()
+ if torch.cuda.is_available():
+ device_name = torch.cuda.get_device_name(0)
+ 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":
+ return "Metal (Apple Silicon via MLX)"
+ return "None (CPU only)"
+
+
+def _register_lifecycle(application: FastAPI) -> None:
+ """Attach startup and shutdown event handlers."""
+
+ @application.on_event("startup")
+ async def startup_event():
+ import platform
+ import sys
+
+ logger.info("Voicebox v%s starting up", __version__)
+ logger.info(
+ "Python %s on %s %s (%s)",
+ sys.version.split()[0],
+ platform.system(),
+ platform.release(),
+ platform.machine(),
+ )
+
+ database.init_db()
+
+ from .database.session import _db_path
+
+ logger.info("Database: %s", _db_path)
+ logger.info("Data directory: %s", config.get_data_dir())
+
+ init_queue()
+
+ # Mark stale "generating" records as failed -- leftovers from a killed process
+ from sqlalchemy import text as sa_text
+
+ db = next(get_db())
+ try:
+ result = db.execute(
+ sa_text(
+ "UPDATE generations SET status = 'failed', "
+ "error = 'Server was shut down during generation' "
+ "WHERE status IN ('generating', 'loading_model')"
+ )
+ )
+ if result.rowcount > 0:
+ logger.info("Marked %d stale generation(s) as failed", result.rowcount)
+
+ from .database import VoiceProfile as DBVoiceProfile, Generation as DBGeneration
+
+ profile_count = db.query(DBVoiceProfile).count()
+ generation_count = db.query(DBGeneration).count()
+ logger.info("Profiles: %d, Generations: %d", profile_count, generation_count)
+
+ db.commit()
+ except Exception as e:
+ db.rollback()
+ logger.warning("Could not clean up stale generations: %s", e)
+ finally:
+ db.close()
+
+ backend_type = get_backend_type()
+ logger.info("Backend: %s", backend_type.upper())
+ logger.info("GPU: %s", _get_gpu_status())
+
+ from .services.cuda import check_and_update_cuda_binary
+
+ create_background_task(check_and_update_cuda_binary())
+
+ try:
+ progress_manager = get_progress_manager()
+ progress_manager._set_main_loop(asyncio.get_running_loop())
+ except Exception as e:
+ logger.warning("Could not initialize progress manager event loop: %s", e)
+
+ try:
+ from huggingface_hub import constants as hf_constants
+
+ cache_dir = Path(hf_constants.HF_HUB_CACHE)
+ cache_dir.mkdir(parents=True, exist_ok=True)
+ logger.info("Model cache: %s", cache_dir)
+ except Exception as e:
+ logger.warning("Could not create HuggingFace cache directory: %s", e)
+
+ logger.info("Ready")
+
+ @application.on_event("shutdown")
+ async def shutdown_event():
+ logger.info("Voicebox server shutting down...")
+ try:
+ tts.unload_tts_model()
+ except Exception:
+ logger.exception("Failed to unload TTS model")
+ try:
+ transcribe.unload_whisper_model()
+ except Exception:
+ logger.exception("Failed to unload Whisper model")
+
+
+app = create_app()
diff --git a/backend/backends/__init__.py b/backend/backends/__init__.py
index f7c47ba9..cc35eabe 100644
--- a/backend/backends/__init__.py
+++ b/backend/backends/__init__.py
@@ -1,24 +1,66 @@
"""
Backend abstraction layer for TTS and STT.
-Provides a unified interface for MLX and PyTorch backends.
+Provides a unified interface for MLX and PyTorch backends,
+and a model config registry that eliminates per-engine dispatch maps.
"""
+import threading
+from dataclasses import dataclass, field
from typing import Protocol, Optional, Tuple, List
from typing_extensions import runtime_checkable
import numpy as np
-from ..platform_detect import get_backend_type
+from ..utils.platform_detect import get_backend_type
+
+LANGUAGE_CODE_TO_NAME = {
+ "zh": "chinese",
+ "en": "english",
+ "ja": "japanese",
+ "ko": "korean",
+ "de": "german",
+ "fr": "french",
+ "ru": "russian",
+ "pt": "portuguese",
+ "es": "spanish",
+ "it": "italian",
+}
+
+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",
+}
+
+
+@dataclass
+class ModelConfig:
+ """Declarative config for a downloadable model variant."""
+
+ model_name: str # e.g. "luxtts", "chatterbox-tts"
+ display_name: str # e.g. "LuxTTS (Fast, CPU-friendly)"
+ engine: str # e.g. "luxtts", "chatterbox"
+ hf_repo_id: str # e.g. "YatharthS/LuxTTS"
+ model_size: str = "default"
+ size_mb: int = 0
+ needs_trim: bool = False
+ supports_instruct: bool = False
+ languages: list[str] = field(default_factory=lambda: ["en"])
@runtime_checkable
class TTSBackend(Protocol):
"""Protocol for TTS backend implementations."""
-
+
+ # Each backend class should define MODEL_CONFIGS as a class variable:
+ # MODEL_CONFIGS: list[ModelConfig]
+
async def load_model(self, model_size: str) -> None:
"""Load TTS model."""
...
-
+
async def create_voice_prompt(
self,
audio_path: str,
@@ -27,12 +69,12 @@ class TTSBackend(Protocol):
) -> Tuple[dict, bool]:
"""
Create voice prompt from reference audio.
-
+
Returns:
Tuple of (voice_prompt_dict, was_cached)
"""
...
-
+
async def combine_voice_prompts(
self,
audio_paths: List[str],
@@ -40,12 +82,12 @@ class TTSBackend(Protocol):
) -> Tuple[np.ndarray, str]:
"""
Combine multiple voice prompts.
-
+
Returns:
Tuple of (combined_audio_array, combined_text)
"""
...
-
+
async def generate(
self,
text: str,
@@ -56,24 +98,24 @@ class TTSBackend(Protocol):
) -> Tuple[np.ndarray, int]:
"""
Generate audio from text.
-
+
Returns:
Tuple of (audio_array, sample_rate)
"""
...
-
+
def unload_model(self) -> None:
"""Unload model to free memory."""
...
-
+
def is_loaded(self) -> bool:
"""Check if model is loaded."""
...
-
+
def _get_model_path(self, model_size: str) -> str:
"""
Get model path for a given size.
-
+
Returns:
Model path or HuggingFace Hub ID
"""
@@ -83,11 +125,11 @@ class TTSBackend(Protocol):
@runtime_checkable
class STTBackend(Protocol):
"""Protocol for STT (Speech-to-Text) backend implementations."""
-
+
async def load_model(self, model_size: str) -> None:
"""Load STT model."""
...
-
+
async def transcribe(
self,
audio_path: str,
@@ -95,16 +137,16 @@ class STTBackend(Protocol):
) -> str:
"""
Transcribe audio to text.
-
+
Returns:
Transcribed text
"""
...
-
+
def unload_model(self) -> None:
"""Unload model to free memory."""
...
-
+
def is_loaded(self) -> bool:
"""Check if model is loaded."""
...
@@ -112,55 +154,375 @@ 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 — keyed by engine name, value is the backend class import path.
+# The factory function uses this for the if/elif chain; the model configs live on the backend classes.
+TTS_ENGINES = {
+ "qwen": "Qwen TTS",
+ "luxtts": "LuxTTS",
+ "chatterbox": "Chatterbox TTS",
+ "chatterbox_turbo": "Chatterbox Turbo",
+}
+
+
+def _get_qwen_model_configs() -> list[ModelConfig]:
+ """Return Qwen model configs with backend-aware HF repo IDs."""
+ backend_type = get_backend_type()
+ if backend_type == "mlx":
+ repo_1_7b = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16"
+ repo_0_6b = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16" # 0.6B not available in MLX, falls back
+ else:
+ repo_1_7b = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
+ repo_0_6b = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
+
+ return [
+ ModelConfig(
+ model_name="qwen-tts-1.7B",
+ display_name="Qwen TTS 1.7B",
+ engine="qwen",
+ hf_repo_id=repo_1_7b,
+ model_size="1.7B",
+ size_mb=3500,
+ supports_instruct=False, # Base model drops instruct silently
+ languages=["zh", "en", "ja", "ko", "de", "fr", "ru", "pt", "es", "it"],
+ ),
+ ModelConfig(
+ model_name="qwen-tts-0.6B",
+ display_name="Qwen TTS 0.6B",
+ engine="qwen",
+ hf_repo_id=repo_0_6b,
+ model_size="0.6B",
+ size_mb=1200,
+ supports_instruct=False,
+ languages=["zh", "en", "ja", "ko", "de", "fr", "ru", "pt", "es", "it"],
+ ),
+ ]
+
+
+def _get_non_qwen_tts_configs() -> list[ModelConfig]:
+ """Return model configs for non-Qwen TTS engines.
+
+ These are static — no backend-type branching needed.
+ """
+ return [
+ ModelConfig(
+ model_name="luxtts",
+ display_name="LuxTTS (Fast, CPU-friendly)",
+ engine="luxtts",
+ hf_repo_id="YatharthS/LuxTTS",
+ size_mb=300,
+ languages=["en"],
+ ),
+ ModelConfig(
+ model_name="chatterbox-tts",
+ display_name="Chatterbox TTS (Multilingual)",
+ engine="chatterbox",
+ hf_repo_id="ResembleAI/chatterbox",
+ size_mb=3200,
+ needs_trim=True,
+ languages=[
+ "zh",
+ "en",
+ "ja",
+ "ko",
+ "de",
+ "fr",
+ "ru",
+ "pt",
+ "es",
+ "it",
+ "he",
+ "ar",
+ "da",
+ "el",
+ "fi",
+ "hi",
+ "ms",
+ "nl",
+ "no",
+ "pl",
+ "sv",
+ "sw",
+ "tr",
+ ],
+ ),
+ ModelConfig(
+ model_name="chatterbox-turbo",
+ display_name="Chatterbox Turbo (English, Tags)",
+ engine="chatterbox_turbo",
+ hf_repo_id="ResembleAI/chatterbox-turbo",
+ size_mb=1500,
+ needs_trim=True,
+ languages=["en"],
+ ),
+ ]
+
+
+def _get_whisper_configs() -> list[ModelConfig]:
+ """Return Whisper STT model configs."""
+ return [
+ ModelConfig(
+ model_name="whisper-base",
+ display_name="Whisper Base",
+ engine="whisper",
+ hf_repo_id="openai/whisper-base",
+ model_size="base",
+ ),
+ ModelConfig(
+ model_name="whisper-small",
+ display_name="Whisper Small",
+ engine="whisper",
+ hf_repo_id="openai/whisper-small",
+ model_size="small",
+ ),
+ ModelConfig(
+ model_name="whisper-medium",
+ display_name="Whisper Medium",
+ engine="whisper",
+ hf_repo_id="openai/whisper-medium",
+ model_size="medium",
+ ),
+ ModelConfig(
+ model_name="whisper-large",
+ display_name="Whisper Large",
+ engine="whisper",
+ hf_repo_id="openai/whisper-large-v3",
+ model_size="large",
+ ),
+ ModelConfig(
+ model_name="whisper-turbo",
+ display_name="Whisper Turbo",
+ engine="whisper",
+ hf_repo_id="openai/whisper-large-v3-turbo",
+ model_size="turbo",
+ ),
+ ]
+
+
+def get_all_model_configs() -> list[ModelConfig]:
+ """Return the full list of model configs (TTS + STT)."""
+ return _get_qwen_model_configs() + _get_non_qwen_tts_configs() + _get_whisper_configs()
+
+
+def get_tts_model_configs() -> list[ModelConfig]:
+ """Return only TTS model configs."""
+ return _get_qwen_model_configs() + _get_non_qwen_tts_configs()
+
+
+# Lookup helpers — these replace the if/elif chains in main.py
+
+
+def get_model_config(model_name: str) -> Optional[ModelConfig]:
+ """Look up a model config by model_name."""
+ for cfg in get_all_model_configs():
+ if cfg.model_name == model_name:
+ return cfg
+ return None
+
+
+def engine_needs_trim(engine: str) -> bool:
+ """Whether this engine's output should be run through trim_tts_output."""
+ for cfg in get_tts_model_configs():
+ if cfg.engine == engine:
+ return cfg.needs_trim
+ return False
+
+
+def engine_has_model_sizes(engine: str) -> bool:
+ """Whether this engine supports multiple model sizes (only Qwen currently)."""
+ configs = [c for c in get_tts_model_configs() if c.engine == engine]
+ return len(configs) > 1
+
+
+async def load_engine_model(engine: str, model_size: str = "default") -> None:
+ """Load a model for the given engine, handling the Qwen model_size special case."""
+ backend = get_tts_backend_for_engine(engine)
+ if engine == "qwen":
+ await backend.load_model_async(model_size)
+ else:
+ await backend.load_model()
+
+
+async def ensure_model_cached_or_raise(engine: str, model_size: str = "default") -> None:
+ """Check if a model is cached, raise HTTPException if not. Used by streaming endpoint."""
+ from fastapi import HTTPException
+
+ backend = get_tts_backend_for_engine(engine)
+ cfg = None
+ for c in get_tts_model_configs():
+ if c.engine == engine and c.model_size == model_size:
+ cfg = c
+ break
+
+ if engine == "qwen":
+ if not backend._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.",
+ )
+ else:
+ if not backend._is_model_cached():
+ display = cfg.display_name if cfg else engine
+ raise HTTPException(
+ status_code=400,
+ detail=f"{display} model is not downloaded yet. Use /generate to trigger a download.",
+ )
+
+
+def unload_model_by_config(config: ModelConfig) -> bool:
+ """Unload a model given its config. Returns True if it was loaded, False otherwise."""
+ from . import get_tts_backend_for_engine
+ from ..services import tts, transcribe
+
+ if config.engine == "whisper":
+ whisper_model = transcribe.get_whisper_model()
+ if whisper_model.is_loaded() and whisper_model.model_size == config.model_size:
+ transcribe.unload_whisper_model()
+ return True
+ return False
+
+ if config.engine == "qwen":
+ tts_model = tts.get_tts_model()
+ loaded_size = getattr(tts_model, "_current_model_size", None) or getattr(tts_model, "model_size", None)
+ if tts_model.is_loaded() and loaded_size == config.model_size:
+ tts.unload_tts_model()
+ return True
+ return False
+
+ # All other TTS engines
+ backend = get_tts_backend_for_engine(config.engine)
+ if backend.is_loaded():
+ backend.unload_model()
+ return True
+ return False
+
+
+def check_model_loaded(config: ModelConfig) -> bool:
+ """Check if a model is currently loaded."""
+ from . import get_tts_backend_for_engine
+ from ..services import tts, transcribe
+
+ try:
+ if config.engine == "whisper":
+ whisper_model = transcribe.get_whisper_model()
+ return whisper_model.is_loaded() and getattr(whisper_model, "model_size", None) == config.model_size
+
+ if config.engine == "qwen":
+ tts_model = tts.get_tts_model()
+ loaded_size = getattr(tts_model, "_current_model_size", None) or getattr(tts_model, "model_size", None)
+ return tts_model.is_loaded() and loaded_size == config.model_size
+
+ backend = get_tts_backend_for_engine(config.engine)
+ return backend.is_loaded()
+ except Exception:
+ return False
+
+
+def get_model_load_func(config: ModelConfig):
+ """Return a callable that loads/downloads the model."""
+ from . import get_tts_backend_for_engine
+ from ..services import tts, transcribe
+
+ if config.engine == "whisper":
+ return lambda: transcribe.get_whisper_model().load_model(config.model_size)
+
+ if config.engine == "qwen":
+ return lambda: tts.get_tts_model().load_model(config.model_size)
+
+ return lambda: get_tts_backend_for_engine(config.engine).load_model()
+
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
-
- if _tts_backend is None:
- backend_type = get_backend_type()
-
- if backend_type == "mlx":
- from .mlx_backend import MLXTTSBackend
- _tts_backend = MLXTTSBackend()
+ 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.
+
+ Args:
+ engine: Engine name (e.g. "qwen", "luxtts", "chatterbox", "chatterbox_turbo")
+
+ 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 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:
"""
Get or create STT backend instance based on platform.
-
+
Returns:
STT backend instance (MLX or PyTorch)
"""
global _stt_backend
-
+
if _stt_backend is None:
backend_type = get_backend_type()
-
+
if backend_type == "mlx":
from .mlx_backend import MLXSTTBackend
+
_stt_backend = MLXSTTBackend()
else:
from .pytorch_backend import PyTorchSTTBackend
+
_stt_backend = PyTorchSTTBackend()
-
+
return _stt_backend
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/base.py b/backend/backends/base.py
new file mode 100644
index 00000000..9a3049a0
--- /dev/null
+++ b/backend/backends/base.py
@@ -0,0 +1,258 @@
+"""
+Shared utilities for TTS/STT backend implementations.
+
+Eliminates duplication of cache checking, device detection,
+voice prompt combination, and model loading progress tracking.
+"""
+
+import logging
+import platform
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Callable, List, Optional, Tuple
+
+import numpy as np
+
+from ..utils.audio import normalize_audio, load_audio
+from ..utils.progress import get_progress_manager
+from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
+from ..utils.tasks import get_task_manager
+
+logger = logging.getLogger(__name__)
+
+
+def is_model_cached(
+ hf_repo: str,
+ *,
+ weight_extensions: tuple[str, ...] = (".safetensors", ".bin"),
+ required_files: Optional[list[str]] = None,
+) -> bool:
+ """
+ Check if a HuggingFace model is fully cached locally.
+
+ Args:
+ hf_repo: HuggingFace repo ID (e.g. "Qwen/Qwen3-TTS-12Hz-1.7B-Base")
+ weight_extensions: File extensions that count as model weights.
+ required_files: If set, check that these specific filenames exist
+ in snapshots instead of checking by extension.
+
+ Returns:
+ True if model is fully cached, False if missing or incomplete.
+ """
+ try:
+ from huggingface_hub import constants as hf_constants
+
+ repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + hf_repo.replace("/", "--"))
+
+ if not repo_cache.exists():
+ return False
+
+ # Incomplete blobs mean a download is still in progress
+ blobs_dir = repo_cache / "blobs"
+ if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
+ logger.debug(f"Found .incomplete files for {hf_repo}")
+ return False
+
+ snapshots_dir = repo_cache / "snapshots"
+ if not snapshots_dir.exists():
+ return False
+
+ if required_files:
+ # Check that every required filename exists somewhere in snapshots
+ for fname in required_files:
+ if not any(snapshots_dir.rglob(fname)):
+ return False
+ return True
+
+ # Check that at least one weight file exists
+ for ext in weight_extensions:
+ if any(snapshots_dir.rglob(f"*{ext}")):
+ return True
+
+ logger.debug(f"No model weights found for {hf_repo}")
+ return False
+
+ except Exception as e:
+ logger.warning(f"Error checking cache for {hf_repo}: {e}")
+ return False
+
+
+def get_torch_device(
+ *,
+ allow_xpu: bool = False,
+ allow_directml: bool = False,
+ allow_mps: bool = False,
+ force_cpu_on_mac: bool = False,
+) -> str:
+ """
+ Detect the best available torch device.
+
+ Args:
+ allow_xpu: Check for Intel XPU (IPEX) support.
+ allow_directml: Check for DirectML (Windows) support.
+ allow_mps: Allow MPS (Apple Silicon). If False, MPS falls back to CPU.
+ force_cpu_on_mac: Force CPU on macOS regardless of GPU availability.
+ """
+ if force_cpu_on_mac and platform.system() == "Darwin":
+ return "cpu"
+
+ import torch
+
+ if torch.cuda.is_available():
+ return "cuda"
+
+ if allow_xpu:
+ try:
+ import intel_extension_for_pytorch # noqa: F401
+
+ if hasattr(torch, "xpu") and torch.xpu.is_available():
+ return "xpu"
+ except ImportError:
+ pass
+
+ if allow_directml:
+ try:
+ import torch_directml
+
+ if torch_directml.device_count() > 0:
+ return torch_directml.device(0)
+ except ImportError:
+ pass
+
+ if allow_mps:
+ if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
+ return "mps"
+
+ return "cpu"
+
+
+async def combine_voice_prompts(
+ audio_paths: List[str],
+ reference_texts: List[str],
+ *,
+ sample_rate: Optional[int] = None,
+) -> Tuple[np.ndarray, str]:
+ """
+ Combine multiple reference audio samples into one.
+
+ Loads each audio file, normalizes, concatenates, and joins texts.
+
+ Args:
+ audio_paths: Paths to reference audio files.
+ reference_texts: Corresponding transcripts.
+ sample_rate: If set, resample audio to this rate during loading.
+ """
+ combined_audio = []
+
+ for path in audio_paths:
+ kwargs = {"sample_rate": sample_rate} if sample_rate else {}
+ audio, _sr = load_audio(path, **kwargs)
+ 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
+
+
+@contextmanager
+def model_load_progress(
+ model_name: str,
+ is_cached: bool,
+ filter_non_downloads: Optional[bool] = None,
+):
+ """
+ Context manager for model loading with HF download progress tracking.
+
+ Handles the tqdm patching, progress_manager/task_manager lifecycle,
+ and error reporting that every backend duplicates.
+
+ Args:
+ model_name: Progress tracking key (e.g. "qwen-tts-1.7B", "whisper-base").
+ is_cached: Whether the model is already downloaded.
+ filter_non_downloads: Whether to filter non-download tqdm bars.
+ Defaults to `is_cached`.
+
+ Yields:
+ The tracker context (already entered). The caller loads the model
+ inside the `with` block. The tqdm patch is torn down on exit.
+
+ Usage:
+ with model_load_progress("qwen-tts-1.7B", is_cached) as ctx:
+ self.model = SomeModel.from_pretrained(...)
+ """
+ if filter_non_downloads is None:
+ filter_non_downloads = is_cached
+
+ progress_manager = get_progress_manager()
+ task_manager = get_task_manager()
+
+ progress_callback = create_hf_progress_callback(model_name, progress_manager)
+ tracker = HFProgressTracker(progress_callback, filter_non_downloads=filter_non_downloads)
+
+ 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:
+ yield tracker_context
+ except Exception as e:
+ # Report error to both managers
+ progress_manager.mark_error(model_name, str(e))
+ task_manager.error_download(model_name, str(e))
+ raise
+ else:
+ # Only mark complete if we were tracking a download
+ if not is_cached:
+ progress_manager.mark_complete(model_name)
+ task_manager.complete_download(model_name)
+ finally:
+ tracker_context.__exit__(None, None, None)
+
+
+def patch_chatterbox_f32(model) -> None:
+ """
+ Patch float64 -> float32 dtype mismatches in upstream chatterbox.
+
+ librosa.load returns float64 numpy arrays. Multiple upstream code paths
+ convert these to torch tensors via torch.from_numpy() without casting,
+ then matmul against float32 model weights. This patches the two known
+ entry points:
+
+ 1. S3Tokenizer.log_mel_spectrogram — audio tensor hits _mel_filters (f32)
+ 2. VoiceEncoder.forward — float64 mel spectrograms hit LSTM weights (f32)
+ """
+ import types
+
+ # Patch S3Tokenizer
+ _tokzr = model.s3gen.tokenizer
+ _orig_log_mel = _tokzr.log_mel_spectrogram.__func__
+
+ def _f32_log_mel(self_tokzr, audio, padding=0):
+ import torch as _torch
+
+ if _torch.is_tensor(audio):
+ audio = audio.float()
+ return _orig_log_mel(self_tokzr, audio, padding)
+
+ _tokzr.log_mel_spectrogram = types.MethodType(_f32_log_mel, _tokzr)
+
+ # Patch VoiceEncoder
+ _ve = model.ve
+ _orig_ve_forward = _ve.forward.__func__
+
+ def _f32_ve_forward(self_ve, mels):
+ return _orig_ve_forward(self_ve, mels.float())
+
+ _ve.forward = types.MethodType(_f32_ve_forward, _ve)
diff --git a/backend/backends/chatterbox_backend.py b/backend/backends/chatterbox_backend.py
new file mode 100644
index 00000000..7efe371a
--- /dev/null
+++ b/backend/backends/chatterbox_backend.py
@@ -0,0 +1,230 @@
+"""
+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 threading
+from pathlib import Path
+from typing import ClassVar, List, Optional, Tuple
+
+import numpy as np
+
+from . import TTSBackend
+from .base import (
+ is_model_cached,
+ get_torch_device,
+ combine_voice_prompts as _combine_voice_prompts,
+ model_load_progress,
+ patch_chatterbox_f32,
+)
+
+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:
+ return get_torch_device(force_cpu_on_mac=True)
+
+ 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:
+ return is_model_cached(CHATTERBOX_HF_REPO, required_files=_MTL_WEIGHT_FILES)
+
+ 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."""
+ model_name = "chatterbox-tts"
+ is_cached = self._is_model_cached()
+
+ with model_load_progress(model_name, is_cached):
+ device = self._get_device()
+ self._device = device
+ logger.info(f"Loading Chatterbox Multilingual TTS on {device}...")
+
+ import torch
+ from chatterbox.mtl_tts import ChatterboxMultilingualTTS
+
+ 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:
+ model = ChatterboxMultilingualTTS.from_pretrained(device=device)
+ finally:
+ torch.load = _orig_torch_load
+ else:
+ model = ChatterboxMultilingualTTS.from_pretrained(device=device)
+
+ # Fix sdpa attention for output_attentions support
+ t3_tfmr = 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"
+
+ patch_chatterbox_f32(model)
+ self.model = model
+
+ logger.info("Chatterbox Multilingual TTS loaded successfully")
+
+ 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]:
+ return await _combine_voice_prompts(audio_paths, reference_texts)
+
+ # 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..a8bfe503
--- /dev/null
+++ b/backend/backends/chatterbox_turbo_backend.py
@@ -0,0 +1,210 @@
+"""
+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 threading
+from pathlib import Path
+from typing import ClassVar, List, Optional, Tuple
+
+import numpy as np
+
+from . import TTSBackend
+from .base import (
+ is_model_cached,
+ get_torch_device,
+ combine_voice_prompts as _combine_voice_prompts,
+ model_load_progress,
+ patch_chatterbox_f32,
+)
+
+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:
+ return get_torch_device(force_cpu_on_mac=True)
+
+ 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:
+ return is_model_cached(CHATTERBOX_TURBO_HF_REPO, required_files=_TURBO_WEIGHT_FILES)
+
+ 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."""
+ model_name = "chatterbox-turbo"
+ is_cached = self._is_model_cached()
+
+ with model_load_progress(model_name, is_cached):
+ 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
+
+ local_path = snapshot_download(
+ repo_id=CHATTERBOX_TURBO_HF_REPO,
+ token=None,
+ allow_patterns=["*.safetensors", "*.json", "*.txt", "*.pt", "*.model"],
+ )
+
+ 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:
+ model = ChatterboxTurboTTS.from_local(local_path, device)
+ finally:
+ torch.load = _orig_torch_load
+ else:
+ model = ChatterboxTurboTTS.from_local(local_path, device)
+
+ patch_chatterbox_f32(model)
+ self.model = model
+
+ logger.info("Chatterbox Turbo TTS loaded successfully")
+
+ 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]:
+ return await _combine_voice_prompts(audio_paths, reference_texts)
+
+ 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..ba00359e
--- /dev/null
+++ b/backend/backends/luxtts_backend.py
@@ -0,0 +1,178 @@
+"""
+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 typing import Optional, Tuple
+
+import numpy as np
+
+from . import TTSBackend
+from .base import is_model_cached, get_torch_device, combine_voice_prompts as _combine_voice_prompts, model_load_progress
+from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
+
+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:
+ return get_torch_device(allow_mps=True)
+
+ 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:
+ return is_model_cached(
+ LUXTTS_HF_REPO,
+ weight_extensions=(".pt", ".safetensors", ".onnx", ".bin"),
+ )
+
+ 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):
+ model_name = "luxtts"
+ is_cached = self._is_model_cached()
+
+ with model_load_progress(model_name, is_cached):
+ from zipvoice.luxvoice import LuxTTS
+
+ device = self.device
+ logger.info(f"Loading LuxTTS on {device}...")
+
+ 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)
+
+ logger.info("LuxTTS loaded successfully")
+
+ 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, reference_texts):
+ return await _combine_voice_prompts(audio_paths, reference_texts, sample_rate=24000)
+
+ 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..e4a1ea97 100644
--- a/backend/backends/mlx_backend.py
+++ b/backend/backends/mlx_backend.py
@@ -4,36 +4,44 @@ MLX backend implementation for TTS and STT using mlx-audio.
from typing import Optional, List, Tuple
import asyncio
+import logging
import numpy as np
+import os
from pathlib import Path
-from . import TTSBackend, STTBackend
+logger = logging.getLogger(__name__)
+
+# 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, LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS
+from .base import is_model_cached, combine_voice_prompts as _combine_voice_prompts, model_load_progress
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
-from ..utils.audio import normalize_audio, load_audio
-from ..utils.progress import get_progress_manager
-from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
-from ..utils.tasks import get_task_manager
class MLXTTSBackend:
"""MLX-based TTS backend using mlx-audio."""
-
+
def __init__(self, model_size: str = "1.7B"):
self.model = None
self.model_size = model_size
self._current_model_size = None
-
+
def is_loaded(self) -> bool:
"""Check if model is loaded."""
return self.model is not None
-
+
def _get_model_path(self, model_size: str) -> str:
"""
Get the MLX model path.
-
+
Args:
model_size: Model size (1.7B or 0.6B)
-
+
Returns:
HuggingFace Hub model ID for MLX
"""
@@ -43,167 +51,90 @@ class MLXTTSBackend:
# 0.6B not yet converted to MLX format
"0.6B": "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16", # Fallback to 1.7B
}
-
+
if model_size not in mlx_model_map:
raise ValueError(f"Unknown model size: {model_size}")
-
+
hf_model_id = mlx_model_map[model_size]
- print(f"Will download MLX model from HuggingFace Hub: {hf_model_id}")
-
+ logger.info("Will download MLX model from HuggingFace Hub: %s", hf_model_id)
+
return hf_model_id
-
+
def _is_model_cached(self, model_size: str) -> bool:
- """
- Check if the model is already cached locally AND fully downloaded.
-
- Args:
- model_size: Model size to check
-
- Returns:
- True if model is fully cached, False if missing or incomplete
- """
- try:
- from huggingface_hub import constants as hf_constants
- model_path = self._get_model_path(model_size)
- repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_path.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 {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():
- has_weights = (
- any(snapshots_dir.rglob("*.safetensors")) or
- any(snapshots_dir.rglob("*.bin")) or
- any(snapshots_dir.rglob("*.npz"))
- )
- if not has_weights:
- print(f"[_is_model_cached] No model weights found for {model_size}, treating as not cached")
- return False
-
- return True
- except Exception as e:
- print(f"[_is_model_cached] Error checking cache for {model_size}: {e}")
- return False
-
+ return is_model_cached(
+ self._get_model_path(model_size),
+ weight_extensions=(".safetensors", ".bin", ".npz"),
+ )
+
async def load_model_async(self, model_size: Optional[str] = None):
"""
Lazy load the MLX TTS model.
-
+
Args:
model_size: Model size to load (1.7B or 0.6B)
"""
if model_size is None:
model_size = self.model_size
-
+
# If already loaded with correct size, return
if self.model is not None and self._current_model_size == model_size:
return
-
+
# Unload existing model if different size requested
if self.model is not None and self._current_model_size != model_size:
self.unload_model()
-
+
# Run blocking load in thread pool
await asyncio.to_thread(self._load_model_sync, model_size)
-
+
# Alias for compatibility
load_model = load_model_async
-
+
def _load_model_sync(self, model_size: str):
"""Synchronous model loading."""
+ model_path = self._get_model_path(model_size)
+ model_name = f"qwen-tts-{model_size}"
+ is_cached = self._is_model_cached(model_size)
+
+ # Force offline mode when cached to avoid network requests
+ original_hf_hub_offline = os.environ.get("HF_HUB_OFFLINE")
+ if is_cached:
+ os.environ["HF_HUB_OFFLINE"] = "1"
+ logger.info("[PATCH] Model %s is cached, forcing HF_HUB_OFFLINE=1 to avoid network requests", model_size)
+
try:
- # Get model path BEFORE importing mlx_audio
- model_path = self._get_model_path(model_size)
-
- # Set up progress tracking
- progress_manager = get_progress_manager()
- task_manager = get_task_manager()
- model_name = f"qwen-tts-{model_size}"
-
- # Check if model is already cached
- is_cached = self._is_model_cached(model_size)
-
- # Set up progress callback
- # If cached: filter out non-download progress
- # If not cached: report all progress (we're actually downloading)
- progress_callback = create_hf_progress_callback(model_name, progress_manager)
- tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
-
- print(f"Loading MLX TTS model {model_size}...")
-
- # Only track download progress if model is NOT cached
- if not is_cached:
- # Start tracking download task
- task_manager.start_download(model_name)
-
- # Initialize progress state so SSE endpoint has initial data to send
- # This provides immediate feedback while HuggingFace fetches metadata
- progress_manager.update_progress(
- model_name=model_name,
- current=0,
- total=0, # Will be updated once actual total is known
- filename="Connecting to HuggingFace...",
- status="downloading",
- )
-
- # IMPORTANT: Patch tqdm BEFORE importing mlx_audio
- # Otherwise mlx_audio caches reference to original tqdm
- tracker_context = tracker.patch_download()
- tracker_context.__enter__()
-
- # Import mlx_audio AFTER patching tqdm
- from mlx_audio.tts import load
-
- # Load MLX model (downloads automatically)
- try:
- self.model = load(model_path)
- finally:
- # Exit the patch context
- tracker_context.__exit__(None, None, None)
-
- # Only mark download as complete if we were tracking it
- if not is_cached:
- progress_manager.mark_complete(model_name)
- task_manager.complete_download(model_name)
-
- self._current_model_size = model_size
- self.model_size = model_size
-
- print(f"MLX TTS model {model_size} loaded successfully")
-
- except ImportError as e:
- print(f"Error: mlx_audio package not found. Install with: pip install mlx-audio")
- progress_manager = get_progress_manager()
- task_manager = get_task_manager()
- model_name = f"qwen-tts-{model_size}"
- progress_manager.mark_error(model_name, str(e))
- task_manager.error_download(model_name, str(e))
- raise
- except Exception as e:
- print(f"Error loading MLX TTS model: {e}")
- progress_manager = get_progress_manager()
- task_manager = get_task_manager()
- model_name = f"qwen-tts-{model_size}"
- progress_manager.mark_error(model_name, str(e))
- task_manager.error_download(model_name, str(e))
- raise
-
+ with model_load_progress(model_name, is_cached):
+ from mlx_audio.tts import load
+
+ logger.info("Loading MLX TTS model %s...", model_size)
+
+ try:
+ self.model = load(model_path)
+ except Exception as load_error:
+ if is_cached and "offline" in str(load_error).lower():
+ logger.warning("[PATCH] Offline load failed, trying with network: %s", load_error)
+ os.environ.pop("HF_HUB_OFFLINE", None)
+ self.model = load(model_path)
+ else:
+ raise
+ finally:
+ 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)
+
+ self._current_model_size = model_size
+ self.model_size = model_size
+ logger.info("MLX TTS model %s loaded successfully", model_size)
+
def unload_model(self):
"""Unload the model to free memory."""
if self.model is not None:
del self.model
self.model = None
self._current_model_size = None
- print("MLX TTS model unloaded")
-
+ logger.info("MLX TTS model unloaded")
+
async def create_voice_prompt(
self,
audio_path: str,
@@ -212,20 +143,20 @@ class MLXTTSBackend:
) -> Tuple[dict, bool]:
"""
Create voice prompt from reference audio.
-
+
MLX backend stores voice prompt as a dict with audio path and text.
The actual voice prompt processing happens during generation.
-
+
Args:
audio_path: Path to reference audio file
reference_text: Transcript of reference audio
use_cache: Whether to use cached prompt if available
-
+
Returns:
Tuple of (voice_prompt_dict, was_cached)
"""
await self.load_model_async(None)
-
+
# Check cache if enabled
if use_cache:
cache_key = get_cache_key(audio_path, reference_text)
@@ -239,53 +170,25 @@ class MLXTTSBackend:
return cached_prompt, True
else:
# Cached file no longer exists, invalidate cache
- print(f"Cached audio file not found: {cached_audio_path}, regenerating prompt")
-
+ logger.warning("Cached audio file not found: %s, regenerating prompt", cached_audio_path)
+
# MLX voice prompt format - store audio path and text
# The model will process this during generation
voice_prompt_items = {
"ref_audio": str(audio_path),
"ref_text": reference_text,
}
-
+
# Cache if enabled
if use_cache:
cache_key = get_cache_key(audio_path, reference_text)
cache_voice_prompt(cache_key, voice_prompt_items)
-
+
return voice_prompt_items, False
-
- async def combine_voice_prompts(
- self,
- audio_paths: List[str],
- reference_texts: List[str],
- ) -> Tuple[np.ndarray, str]:
- """
- Combine multiple reference samples for better quality.
-
- Args:
- audio_paths: List of audio file paths
- reference_texts: List of reference texts
-
- Returns:
- Tuple of (combined_audio, combined_text)
- """
- combined_audio = []
-
- for audio_path in audio_paths:
- audio, sr = load_audio(audio_path)
- audio = normalize_audio(audio)
- combined_audio.append(audio)
-
- # Concatenate audio
- mixed = np.concatenate(combined_audio)
- mixed = normalize_audio(mixed)
-
- # Combine texts
- combined_text = " ".join(reference_texts)
-
- return mixed, combined_text
-
+
+ async def combine_voice_prompts(self, audio_paths, reference_texts):
+ return await _combine_voice_prompts(audio_paths, reference_texts)
+
async def generate(
self,
text: str,
@@ -309,31 +212,33 @@ class MLXTTSBackend:
"""
await self.load_model_async(None)
- print(f"Generating audio for text: {text}")
+ logger.info("Generating audio for text: %s", text)
def _generate_sync():
"""Run synchronous generation in thread pool."""
# MLX generate() returns a generator yielding GenerationResult objects
audio_chunks = []
sample_rate = 24000
-
+ lang = LANGUAGE_CODE_TO_NAME.get(language, "auto")
+
# Set seed if provided (MLX uses numpy random)
if seed is not None:
import mlx.core as mx
+
np.random.seed(seed)
mx.random.seed(seed)
-
+
# Extract voice prompt info
ref_audio = voice_prompt.get("ref_audio") or voice_prompt.get("ref_audio_path")
ref_text = voice_prompt.get("ref_text", "")
-
+
# Validate that the audio file exists
if ref_audio and not Path(ref_audio).exists():
- print(f"Warning: Audio file not found: {ref_audio}")
- print("This may be due to a cached voice prompt referencing a deleted temp file.")
- print("Regenerating without voice prompt.")
+ logger.warning("Audio file not found: %s", ref_audio)
+ logger.warning("This may be due to a cached voice prompt referencing a deleted temp file.")
+ logger.warning("Regenerating without voice prompt.")
ref_audio = None
-
+
# Check if model supports voice cloning via generate method
# MLX API may support ref_audio parameter directly
try:
@@ -341,36 +246,37 @@ class MLXTTSBackend:
if ref_audio:
# Check if generate accepts ref_audio parameter
import inspect
+
sig = inspect.signature(self.model.generate)
if "ref_audio" in sig.parameters:
# Generate with voice cloning
- for result in self.model.generate(text, ref_audio=ref_audio, ref_text=ref_text):
+ for result in self.model.generate(text, ref_audio=ref_audio, ref_text=ref_text, lang_code=lang):
audio_chunks.append(np.array(result.audio))
sample_rate = result.sample_rate
else:
# Fallback: generate without voice cloning
- for result in self.model.generate(text):
+ for result in self.model.generate(text, lang_code=lang):
audio_chunks.append(np.array(result.audio))
sample_rate = result.sample_rate
else:
# No voice prompt, generate normally
- for result in self.model.generate(text):
+ for result in self.model.generate(text, lang_code=lang):
audio_chunks.append(np.array(result.audio))
sample_rate = result.sample_rate
except Exception as e:
# If voice cloning fails, try without it
- print(f"Warning: Voice cloning failed, generating without voice prompt: {e}")
- for result in self.model.generate(text):
+ logger.warning("Voice cloning failed, generating without voice prompt: %s", e)
+ for result in self.model.generate(text, lang_code=lang):
audio_chunks.append(np.array(result.audio))
sample_rate = result.sample_rate
-
+
# Concatenate all chunks
if audio_chunks:
audio = np.concatenate([np.asarray(chunk, dtype=np.float32) for chunk in audio_chunks])
else:
# Fallback: empty audio
audio = np.array([], dtype=np.float32)
-
+
return audio, sample_rate
# Run blocking inference in thread pool
@@ -381,157 +287,60 @@ class MLXTTSBackend:
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
-
+
def is_loaded(self) -> bool:
"""Check if model is loaded."""
return self.model is not None
-
+
def _is_model_cached(self, model_size: str) -> bool:
- """
- Check if the Whisper model is already cached locally AND fully downloaded.
-
- Args:
- model_size: Model size to check
-
- Returns:
- True if model is fully cached, False if missing or incomplete
- """
- 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("/", "--"))
-
- 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():
- has_weights = (
- any(snapshots_dir.rglob("*.safetensors")) or
- any(snapshots_dir.rglob("*.bin")) or
- any(snapshots_dir.rglob("*.npz"))
- )
- 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
-
+ hf_repo = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
+ return is_model_cached(hf_repo, weight_extensions=(".safetensors", ".bin", ".npz"))
+
async def load_model_async(self, model_size: Optional[str] = None):
"""
Lazy load the MLX Whisper model.
-
+
Args:
model_size: Model size (tiny, base, small, medium, large)
"""
if model_size is None:
model_size = self.model_size
-
+
if self.model is not None and self.model_size == model_size:
return
-
+
# Run blocking load in thread pool
await asyncio.to_thread(self._load_model_sync, model_size)
-
+
# Alias for compatibility
load_model = load_model_async
-
+
def _load_model_sync(self, model_size: str):
"""Synchronous model loading."""
- try:
- progress_manager = get_progress_manager()
- task_manager = get_task_manager()
- progress_model_name = f"whisper-{model_size}"
+ progress_model_name = f"whisper-{model_size}"
+ is_cached = self._is_model_cached(model_size)
- # Check if model is already cached
- is_cached = self._is_model_cached(model_size)
-
- # Set up progress callback and tracker
- # If cached: filter out non-download progress
- # If not cached: report all progress (we're actually downloading)
- progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
- tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
-
- # Patch tqdm BEFORE importing mlx_audio
- tracker_context = tracker.patch_download()
- tracker_context.__enter__()
-
- # Import mlx_audio
+ with model_load_progress(progress_model_name, is_cached):
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}")
+ logger.info("Loading MLX Whisper model %s...", model_size)
+ self.model = load(model_name)
- print(f"Loading MLX Whisper model {model_size}...")
+ self.model_size = model_size
+ logger.info("MLX Whisper model %s loaded successfully", model_size)
- # Only track download progress if model is NOT cached
- if not is_cached:
- # Start tracking download task
- task_manager.start_download(progress_model_name)
-
- # Initialize progress state so SSE endpoint has initial data to send
- progress_manager.update_progress(
- model_name=progress_model_name,
- current=0,
- total=0,
- filename="Connecting to HuggingFace...",
- status="downloading",
- )
-
- # Load the model (tqdm is patched, but filters out non-download progress)
- try:
- self.model = load(model_name)
- finally:
- # Exit the patch context
- tracker_context.__exit__(None, None, None)
-
- # Only mark download as complete if we were tracking it
- if not is_cached:
- progress_manager.mark_complete(progress_model_name)
- task_manager.complete_download(progress_model_name)
-
- self.model_size = model_size
-
- print(f"MLX Whisper model {model_size} loaded successfully")
-
- except ImportError as e:
- print(f"Error: mlx_audio package not found. Install with: pip install mlx-audio")
- progress_manager = get_progress_manager()
- task_manager = get_task_manager()
- progress_model_name = f"whisper-{model_size}"
- progress_manager.mark_error(progress_model_name, str(e))
- task_manager.error_download(progress_model_name, str(e))
- raise
- except Exception as e:
- print(f"Error loading MLX Whisper model: {e}")
- progress_manager = get_progress_manager()
- task_manager = get_task_manager()
- progress_model_name = f"whisper-{model_size}"
- progress_manager.mark_error(progress_model_name, str(e))
- task_manager.error_download(progress_model_name, str(e))
- raise
-
def unload_model(self):
"""Unload the model to free memory."""
if self.model is not None:
del self.model
self.model = None
- print("MLX Whisper model unloaded")
-
+ logger.info("MLX Whisper model unloaded")
+
async def transcribe(
self,
audio_path: str,
diff --git a/backend/backends/pytorch_backend.py b/backend/backends/pytorch_backend.py
index 26f38726..8f4a7a58 100644
--- a/backend/backends/pytorch_backend.py
+++ b/backend/backends/pytorch_backend.py
@@ -4,47 +4,47 @@ PyTorch backend implementation for TTS and STT.
from typing import Optional, List, Tuple
import asyncio
+import logging
import torch
import numpy as np
-from pathlib import Path
-from . import TTSBackend, STTBackend
+logger = logging.getLogger(__name__)
+
+from . import TTSBackend, STTBackend, LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS
+from .base import (
+ is_model_cached,
+ get_torch_device,
+ combine_voice_prompts as _combine_voice_prompts,
+ model_load_progress,
+)
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
-from ..utils.audio import normalize_audio, load_audio
-from ..utils.progress import get_progress_manager
-from ..utils.hf_progress import HFProgressTracker, create_hf_progress_callback
-from ..utils.tasks import get_task_manager
+from ..utils.audio import load_audio
class PyTorchTTSBackend:
"""PyTorch-based TTS backend using Qwen3-TTS."""
-
+
def __init__(self, model_size: str = "1.7B"):
self.model = None
self.model_size = model_size
self.device = self._get_device()
self._current_model_size = None
-
+
def _get_device(self) -> str:
"""Get the best available device."""
- if torch.cuda.is_available():
- return "cuda"
- elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
- # MPS can have issues, use CPU for stability
- return "cpu"
- return "cpu"
-
+ return get_torch_device(allow_xpu=True, allow_directml=True)
+
def is_loaded(self) -> bool:
"""Check if model is loaded."""
return self.model is not None
-
+
def _get_model_path(self, model_size: str) -> str:
"""
Get the HuggingFace Hub model ID.
-
+
Args:
model_size: Model size (1.7B or 0.6B)
-
+
Returns:
HuggingFace Hub model ID
"""
@@ -52,169 +52,79 @@ class PyTorchTTSBackend:
"1.7B": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
"0.6B": "Qwen/Qwen3-TTS-12Hz-0.6B-Base",
}
-
+
if model_size not in hf_model_map:
raise ValueError(f"Unknown model size: {model_size}")
-
+
return hf_model_map[model_size]
-
+
def _is_model_cached(self, model_size: str) -> bool:
- """
- Check if the model is already cached locally AND fully downloaded.
-
- Args:
- model_size: Model size to check
-
- Returns:
- True if model is fully cached, False if missing or incomplete
- """
- try:
- from huggingface_hub import constants as hf_constants
- model_path = self._get_model_path(model_size)
- repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_path.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 {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():
- has_weights = (
- any(snapshots_dir.rglob("*.safetensors")) or
- any(snapshots_dir.rglob("*.bin"))
- )
- if not has_weights:
- print(f"[_is_model_cached] No model weights found for {model_size}, treating as not cached")
- return False
-
- return True
- except Exception as e:
- print(f"[_is_model_cached] Error checking cache for {model_size}: {e}")
- return False
-
+ return is_model_cached(self._get_model_path(model_size))
+
async def load_model_async(self, model_size: Optional[str] = None):
"""
Lazy load the TTS model with automatic downloading from HuggingFace Hub.
-
+
Args:
model_size: Model size to load (1.7B or 0.6B)
"""
if model_size is None:
model_size = self.model_size
-
+
# If already loaded with correct size, return
if self.model is not None and self._current_model_size == model_size:
return
-
+
# Unload existing model if different size requested
if self.model is not None and self._current_model_size != model_size:
self.unload_model()
-
+
# Run blocking load in thread pool
await asyncio.to_thread(self._load_model_sync, model_size)
-
+
# Alias for compatibility
load_model = load_model_async
-
+
def _load_model_sync(self, model_size: str):
"""Synchronous model loading."""
- try:
- progress_manager = get_progress_manager()
- task_manager = get_task_manager()
- model_name = f"qwen-tts-{model_size}"
+ model_name = f"qwen-tts-{model_size}"
+ is_cached = self._is_model_cached(model_size)
- # Check if model is already cached
- is_cached = self._is_model_cached(model_size)
-
- # Set up progress callback and tracker
- # If cached: filter out non-download progress (like "Segment 1/1" during generation)
- # If not cached: report all progress (we're actually downloading)
- progress_callback = create_hf_progress_callback(model_name, progress_manager)
- tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
-
- # Patch tqdm BEFORE importing qwen_tts
- tracker_context = tracker.patch_download()
- tracker_context.__enter__()
-
- # Import qwen_tts
+ with model_load_progress(model_name, is_cached):
from qwen_tts import Qwen3TTSModel
- # Get model path (local or HuggingFace Hub ID)
model_path = self._get_model_path(model_size)
+ logger.info("Loading TTS model %s on %s...", model_size, self.device)
- print(f"Loading TTS model {model_size} on {self.device}...")
-
- # Only track download progress if model is NOT cached
- if not is_cached:
- # Start tracking download task
- task_manager.start_download(model_name)
-
- # Initialize progress state so SSE endpoint has initial data to send
- progress_manager.update_progress(
- model_name=model_name,
- current=0,
- total=0, # Will be updated once actual total is known
- filename="Connecting to HuggingFace...",
- status="downloading",
+ if self.device == "cpu":
+ self.model = Qwen3TTSModel.from_pretrained(
+ model_path,
+ torch_dtype=torch.float32,
+ low_cpu_mem_usage=False,
)
-
- # Load the model (tqdm is patched, but filters out non-download progress)
- try:
+ else:
self.model = Qwen3TTSModel.from_pretrained(
model_path,
device_map=self.device,
- torch_dtype=torch.float32 if self.device == "cpu" else torch.bfloat16,
+ torch_dtype=torch.bfloat16,
)
- finally:
- # Exit the patch context
- tracker_context.__exit__(None, None, None)
-
- # Only mark download as complete if we were tracking it
- if not is_cached:
- progress_manager.mark_complete(model_name)
- task_manager.complete_download(model_name)
-
- self._current_model_size = model_size
- self.model_size = model_size
-
- print(f"TTS model {model_size} loaded successfully")
-
- except ImportError as e:
- print(f"Error: qwen_tts package not found. Install with: pip install git+https://github.com/QwenLM/Qwen3-TTS.git")
- progress_manager = get_progress_manager()
- task_manager = get_task_manager()
- model_name = f"qwen-tts-{model_size}"
- progress_manager.mark_error(model_name, str(e))
- task_manager.error_download(model_name, str(e))
- raise
- except Exception as e:
- print(f"Error loading TTS model: {e}")
- print(f"Tip: The model will be automatically downloaded from HuggingFace Hub on first use.")
- progress_manager = get_progress_manager()
- task_manager = get_task_manager()
- model_name = f"qwen-tts-{model_size}"
- progress_manager.mark_error(model_name, str(e))
- task_manager.error_download(model_name, str(e))
- raise
-
+
+ self._current_model_size = model_size
+ self.model_size = model_size
+ logger.info("TTS model %s loaded successfully", model_size)
+
def unload_model(self):
"""Unload the model to free memory."""
if self.model is not None:
del self.model
self.model = None
self._current_model_size = None
-
+
if torch.cuda.is_available():
torch.cuda.empty_cache()
-
- print("TTS model unloaded")
-
+
+ logger.info("TTS model unloaded")
+
async def create_voice_prompt(
self,
audio_path: str,
@@ -223,17 +133,17 @@ class PyTorchTTSBackend:
) -> Tuple[dict, bool]:
"""
Create voice prompt from reference audio.
-
+
Args:
audio_path: Path to reference audio file
reference_text: Transcript of reference audio
use_cache: Whether to use cached prompt if available
-
+
Returns:
Tuple of (voice_prompt_dict, was_cached)
"""
await self.load_model_async(None)
-
+
# Check cache if enabled
if use_cache:
cache_key = get_cache_key(audio_path, reference_text)
@@ -249,7 +159,7 @@ class PyTorchTTSBackend:
# Legacy cache format - convert to dict
# This shouldn't happen in practice, but handle it
return {"prompt": cached_prompt}, True
-
+
def _create_prompt_sync():
"""Run synchronous voice prompt creation in thread pool."""
return self.model.create_voice_clone_prompt(
@@ -257,48 +167,24 @@ class PyTorchTTSBackend:
ref_text=reference_text,
x_vector_only_mode=False,
)
-
+
# Run blocking operation in thread pool
voice_prompt_items = await asyncio.to_thread(_create_prompt_sync)
-
+
# Cache if enabled
if use_cache:
cache_key = get_cache_key(audio_path, reference_text)
cache_voice_prompt(cache_key, voice_prompt_items)
-
+
return voice_prompt_items, False
-
+
async def combine_voice_prompts(
self,
audio_paths: List[str],
reference_texts: List[str],
) -> Tuple[np.ndarray, str]:
- """
- Combine multiple reference samples for better quality.
-
- Args:
- audio_paths: List of audio file paths
- reference_texts: List of reference texts
-
- Returns:
- Tuple of (combined_audio, combined_text)
- """
- combined_audio = []
-
- for audio_path in audio_paths:
- audio, sr = load_audio(audio_path)
- audio = normalize_audio(audio)
- combined_audio.append(audio)
-
- # Concatenate audio
- mixed = np.concatenate(combined_audio)
- mixed = normalize_audio(mixed)
-
- # Combine texts
- combined_text = " ".join(reference_texts)
-
- return mixed, combined_text
-
+ return await _combine_voice_prompts(audio_paths, reference_texts)
+
async def generate(
self,
text: str,
@@ -335,6 +221,7 @@ class PyTorchTTSBackend:
wavs, sample_rate = self.model.generate_voice_clone(
text=text,
voice_clone_prompt=voice_prompt,
+ language=LANGUAGE_CODE_TO_NAME.get(language, "auto"),
instruct=instruct,
)
return wavs[0], sample_rate
@@ -347,66 +234,25 @@ class PyTorchTTSBackend:
class PyTorchSTTBackend:
"""PyTorch-based STT backend using Whisper."""
-
+
def __init__(self, model_size: str = "base"):
self.model = None
self.processor = None
self.model_size = model_size
self.device = self._get_device()
-
+
def _get_device(self) -> str:
"""Get the best available device."""
- if torch.cuda.is_available():
- return "cuda"
- elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
- # MPS support for Whisper
- return "cpu" # Use CPU for stability
- return "cpu"
-
+ return get_torch_device(allow_xpu=True, allow_directml=True)
+
def is_loaded(self) -> bool:
"""Check if model is loaded."""
return self.model is not None
-
+
def _is_model_cached(self, model_size: str) -> bool:
- """
- Check if the Whisper model is already cached locally AND fully downloaded.
-
- Args:
- model_size: Model size to check
-
- Returns:
- True if model is fully cached, False if missing or incomplete
- """
- 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("/", "--"))
-
- 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():
- has_weights = (
- any(snapshots_dir.rglob("*.safetensors")) or
- any(snapshots_dir.rglob("*.bin"))
- )
- 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
-
+ hf_repo = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
+ return is_model_cached(hf_repo)
+
async def load_model_async(self, model_size: Optional[str] = None):
"""
Lazy load the Whisper model.
@@ -414,95 +260,35 @@ class PyTorchSTTBackend:
Args:
model_size: Model size (tiny, base, small, medium, large)
"""
- print(f"[DEBUG] load_model_async called with size: {model_size}")
if model_size is None:
model_size = self.model_size
- print(f"[DEBUG] Model already loaded? {self.model is not None}, current size: {self.model_size}, requested: {model_size}")
if self.model is not None and self.model_size == model_size:
- print(f"[DEBUG] Early return - model already loaded")
return
- print(f"[DEBUG] Calling asyncio.to_thread for _load_model_sync")
- # Run blocking load in thread pool
await asyncio.to_thread(self._load_model_sync, model_size)
- print(f"[DEBUG] asyncio.to_thread completed")
-
+
# Alias for compatibility
load_model = load_model_async
-
+
def _load_model_sync(self, model_size: str):
"""Synchronous model loading."""
- print(f"[DEBUG] _load_model_sync called for Whisper {model_size}")
- try:
- progress_manager = get_progress_manager()
- task_manager = get_task_manager()
- progress_model_name = f"whisper-{model_size}"
+ progress_model_name = f"whisper-{model_size}"
+ is_cached = self._is_model_cached(model_size)
- # Check if model is already cached
- is_cached = self._is_model_cached(model_size)
-
- # Set up progress callback and tracker
- # If cached: filter out non-download progress
- # If not cached: report all progress (we're actually downloading)
- progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
- tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
-
- # Patch tqdm BEFORE importing transformers
- print("[DEBUG] Starting tqdm patch BEFORE transformers import")
- tracker_context = tracker.patch_download()
- tracker_context.__enter__()
- print("[DEBUG] tqdm patched, now importing transformers")
-
- # Import transformers
+ with model_load_progress(progress_model_name, is_cached):
from transformers import WhisperProcessor, WhisperForConditionalGeneration
- model_name = f"openai/whisper-{model_size}"
- print(f"[DEBUG] Model name: {model_name}")
+ model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
+ logger.info("Loading Whisper model %s on %s...", model_size, self.device)
- print(f"Loading Whisper model {model_size} on {self.device}...")
+ self.processor = WhisperProcessor.from_pretrained(model_name)
+ self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
- # Only track download progress if model is NOT cached
- if not is_cached:
- # Start tracking download task
- task_manager.start_download(progress_model_name)
+ self.model.to(self.device)
+ self.model_size = model_size
+ logger.info("Whisper model %s loaded successfully", model_size)
- # Initialize progress state so SSE endpoint has initial data to send
- progress_manager.update_progress(
- model_name=progress_model_name,
- current=0,
- total=0, # Will be updated once actual total is known
- filename="Connecting to HuggingFace...",
- status="downloading",
- )
-
- # Load models (tqdm is patched, but filters out non-download progress)
- try:
- self.processor = WhisperProcessor.from_pretrained(model_name)
- self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
- finally:
- # Exit the patch context
- tracker_context.__exit__(None, None, None)
-
- # Only mark download as complete if we were tracking it
- if not is_cached:
- progress_manager.mark_complete(progress_model_name)
- task_manager.complete_download(progress_model_name)
-
- self.model.to(self.device)
- self.model_size = model_size
-
- print(f"Whisper model {model_size} loaded successfully")
-
- except Exception as e:
- print(f"Error loading Whisper model: {e}")
- progress_manager = get_progress_manager()
- task_manager = get_task_manager()
- progress_model_name = f"whisper-{model_size}"
- progress_manager.mark_error(progress_model_name, str(e))
- task_manager.error_download(progress_model_name, str(e))
- raise
-
def unload_model(self):
"""Unload the model to free memory."""
if self.model is not None:
@@ -510,12 +296,12 @@ class PyTorchSTTBackend:
del self.processor
self.model = None
self.processor = None
-
+
if torch.cuda.is_available():
torch.cuda.empty_cache()
-
- print("Whisper model unloaded")
-
+
+ logger.info("Whisper model unloaded")
+
async def transcribe(
self,
audio_path: str,
@@ -523,21 +309,21 @@ class PyTorchSTTBackend:
) -> str:
"""
Transcribe audio to text.
-
+
Args:
audio_path: Path to audio file
language: Optional language hint (en or zh)
-
+
Returns:
Transcribed text
"""
await self.load_model_async(None)
-
+
def _transcribe_sync():
"""Run synchronous transcription in thread pool."""
# Load audio
audio, sr = load_audio(audio_path, sample_rate=16000)
-
+
# Process audio
inputs = self.processor(
audio,
@@ -545,31 +331,30 @@ class PyTorchSTTBackend:
return_tensors="pt",
)
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 transcription
+ generate_kwargs["forced_decoder_ids"] = forced_decoder_ids
+
with torch.no_grad():
predicted_ids = self.model.generate(
inputs["input_features"],
- forced_decoder_ids=forced_decoder_ids,
+ **generate_kwargs,
)
-
+
# Decode
transcription = self.processor.batch_decode(
predicted_ids,
skip_special_tokens=True,
)[0]
-
+
return transcription.strip()
-
+
# Run blocking transcription in thread pool
return await asyncio.to_thread(_transcribe_sync)
diff --git a/backend/build_binary.py b/backend/build_binary.py
index a2973cd4..ce789895 100644
--- a/backend/build_binary.py
+++ b/backend/build_binary.py
@@ -1,108 +1,343 @@
"""
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 logging
import os
import platform
+import sys
from pathlib import Path
+logger = logging.getLogger(__name__)
+
def is_apple_silicon():
"""Check if running on 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',
+ "server.py", # Use server.py as entry point instead of main.py
+ "--onefile",
+ "--name",
+ binary_name,
]
+ # Hide console window on Windows only. On macOS/Linux the sidecar needs
+ # stdout/stderr for Tauri to capture logs.
+ if platform.system() == "Windows":
+ args.append("--noconsole")
+
# Add local qwen_tts path if specified (for editable installs)
- qwen_tts_path = os.getenv('QWEN_TTS_PATH')
+ qwen_tts_path = os.getenv("QWEN_TTS_PATH")
if qwen_tts_path and Path(qwen_tts_path).exists():
- args.extend(['--paths', str(qwen_tts_path)])
- print(f"Using local qwen_tts source from: {qwen_tts_path}")
+ args.extend(["--paths", str(qwen_tts_path)])
+ logger.info("Using local qwen_tts source from: %s", qwen_tts_path)
# Add common hidden imports
- args.extend([
- '--hidden-import', 'backend',
- '--hidden-import', 'backend.main',
- '--hidden-import', 'backend.config',
- '--hidden-import', 'backend.database',
- '--hidden-import', 'backend.models',
- '--hidden-import', 'backend.profiles',
- '--hidden-import', 'backend.history',
- '--hidden-import', 'backend.tts',
- '--hidden-import', 'backend.transcribe',
- '--hidden-import', 'backend.platform_detect',
- '--hidden-import', 'backend.backends',
- '--hidden-import', 'backend.backends.pytorch_backend',
- '--hidden-import', 'backend.utils.audio',
- '--hidden-import', 'backend.utils.cache',
- '--hidden-import', 'backend.utils.progress',
- '--hidden-import', 'backend.utils.hf_progress',
- '--hidden-import', 'backend.utils.validation',
- '--hidden-import', 'torch',
- '--hidden-import', 'transformers',
- '--hidden-import', 'fastapi',
- '--hidden-import', 'uvicorn',
- '--hidden-import', 'sqlalchemy',
- '--hidden-import', 'librosa',
- '--hidden-import', 'soundfile',
- '--hidden-import', 'qwen_tts',
- '--hidden-import', 'qwen_tts.inference',
- '--hidden-import', 'qwen_tts.inference.qwen3_tts_model',
- '--hidden-import', 'qwen_tts.inference.qwen3_tts_tokenizer',
- '--hidden-import', 'qwen_tts.core',
- '--hidden-import', 'qwen_tts.cli',
- '--copy-metadata', 'qwen-tts',
- '--collect-submodules', 'qwen_tts',
- '--collect-data', 'qwen_tts',
- # Fix for pkg_resources and jaraco namespace packages
- '--hidden-import', 'pkg_resources.extern',
- '--collect-submodules', 'jaraco',
- ])
+ args.extend(
+ [
+ "--hidden-import",
+ "backend",
+ "--hidden-import",
+ "backend.main",
+ "--hidden-import",
+ "backend.config",
+ "--hidden-import",
+ "backend.database",
+ "--hidden-import",
+ "backend.models",
+ "--hidden-import",
+ "backend.services.profiles",
+ "--hidden-import",
+ "backend.services.history",
+ "--hidden-import",
+ "backend.services.tts",
+ "--hidden-import",
+ "backend.services.transcribe",
+ "--hidden-import",
+ "backend.utils.platform_detect",
+ "--hidden-import",
+ "backend.backends",
+ "--hidden-import",
+ "backend.backends.pytorch_backend",
+ "--hidden-import",
+ "backend.utils.audio",
+ "--hidden-import",
+ "backend.utils.cache",
+ "--hidden-import",
+ "backend.utils.progress",
+ "--hidden-import",
+ "backend.utils.hf_progress",
+ "--hidden-import",
+ "backend.services.cuda",
+ "--hidden-import",
+ "backend.services.effects",
+ "--hidden-import",
+ "backend.utils.effects",
+ "--hidden-import",
+ "backend.services.versions",
+ "--hidden-import",
+ "pedalboard",
+ "--hidden-import",
+ "chatterbox",
+ "--hidden-import",
+ "chatterbox.tts_turbo",
+ "--hidden-import",
+ "chatterbox.mtl_tts",
+ "--hidden-import",
+ "backend.backends.chatterbox_backend",
+ "--hidden-import",
+ "backend.backends.chatterbox_turbo_backend",
+ "--hidden-import",
+ "backend.backends.luxtts_backend",
+ "--hidden-import",
+ "zipvoice",
+ "--hidden-import",
+ "zipvoice.luxvoice",
+ "--collect-all",
+ "zipvoice",
+ "--collect-all",
+ "linacodec",
+ "--hidden-import",
+ "torch",
+ "--hidden-import",
+ "transformers",
+ "--hidden-import",
+ "fastapi",
+ "--hidden-import",
+ "uvicorn",
+ "--hidden-import",
+ "sqlalchemy",
+ "--hidden-import",
+ "librosa",
+ "--hidden-import",
+ "soundfile",
+ "--hidden-import",
+ "qwen_tts",
+ "--hidden-import",
+ "qwen_tts.inference",
+ "--hidden-import",
+ "qwen_tts.inference.qwen3_tts_model",
+ "--hidden-import",
+ "qwen_tts.inference.qwen3_tts_tokenizer",
+ "--hidden-import",
+ "qwen_tts.core",
+ "--hidden-import",
+ "qwen_tts.cli",
+ "--copy-metadata",
+ "qwen-tts",
+ "--copy-metadata",
+ "requests",
+ "--copy-metadata",
+ "transformers",
+ "--copy-metadata",
+ "huggingface-hub",
+ "--copy-metadata",
+ "tokenizers",
+ "--copy-metadata",
+ "safetensors",
+ "--copy-metadata",
+ "tqdm",
+ "--hidden-import",
+ "requests",
+ "--collect-submodules",
+ "qwen_tts",
+ "--collect-data",
+ "qwen_tts",
+ # Fix for pkg_resources and jaraco namespace packages
+ "--hidden-import",
+ "pkg_resources.extern",
+ "--collect-submodules",
+ "jaraco",
+ # inflect uses typeguard @typechecked which calls inspect.getsource()
+ # at import time — needs .py source files, not just .pyc bytecode
+ "--collect-all",
+ "inflect",
+ # perth ships pretrained watermark model files (hparams.yaml, .pth.tar)
+ # in perth/perth_net/pretrained/ — needed by chatterbox at runtime
+ "--collect-all",
+ "perth",
+ # piper_phonemize ships espeak-ng-data/ (phoneme tables, language dicts)
+ # needed by LuxTTS for text-to-phoneme conversion
+ "--collect-all",
+ "piper_phonemize",
+ ]
+ )
- # Add MLX-specific imports if building on Apple Silicon
- if is_apple_silicon():
- print("Building for Apple Silicon - including MLX dependencies")
- args.extend([
- '--hidden-import', 'backend.backends.mlx_backend',
- '--hidden-import', 'mlx',
- '--hidden-import', 'mlx.core',
- '--hidden-import', 'mlx.nn',
- '--hidden-import', 'mlx_audio',
- '--hidden-import', 'mlx_audio.tts',
- '--hidden-import', 'mlx_audio.stt',
- '--collect-submodules', 'mlx',
- '--collect-submodules', 'mlx_audio',
- # Collect MLX data files including Metal shader libraries (.metallib)
- '--collect-data', 'mlx',
- '--collect-data', 'mlx_audio',
- ])
+ # Add CUDA-specific hidden imports
+ if cuda:
+ logger.info("Building with CUDA support")
+ args.extend(
+ [
+ "--hidden-import",
+ "torch.cuda",
+ "--hidden-import",
+ "torch.backends.cudnn",
+ ]
+ )
else:
- print("Building for non-Apple Silicon platform - PyTorch only")
+ # Exclude NVIDIA CUDA packages from CPU-only builds to keep binary small.
+ # When building from a venv with CUDA torch installed, PyInstaller would
+ # bundle ~3GB of NVIDIA shared libraries. We exclude both the Python
+ # modules and the binary DLLs.
+ nvidia_packages = [
+ "nvidia",
+ "nvidia.cublas",
+ "nvidia.cuda_cupti",
+ "nvidia.cuda_nvrtc",
+ "nvidia.cuda_runtime",
+ "nvidia.cudnn",
+ "nvidia.cufft",
+ "nvidia.curand",
+ "nvidia.cusolver",
+ "nvidia.cusparse",
+ "nvidia.nccl",
+ "nvidia.nvjitlink",
+ "nvidia.nvtx",
+ ]
+ for pkg in nvidia_packages:
+ args.extend(["--exclude-module", pkg])
- args.extend([
- '--noconfirm',
- '--clean',
- ])
+ # Add MLX-specific imports if building on Apple Silicon (never for CUDA builds)
+ if is_apple_silicon() and not cuda:
+ logger.info("Building for Apple Silicon - including MLX dependencies")
+ args.extend(
+ [
+ "--hidden-import",
+ "backend.backends.mlx_backend",
+ "--hidden-import",
+ "mlx",
+ "--hidden-import",
+ "mlx.core",
+ "--hidden-import",
+ "mlx.nn",
+ "--hidden-import",
+ "mlx_audio",
+ "--hidden-import",
+ "mlx_audio.tts",
+ "--hidden-import",
+ "mlx_audio.stt",
+ "--collect-submodules",
+ "mlx",
+ "--collect-submodules",
+ "mlx_audio",
+ # Use --collect-all so PyInstaller bundles both data files AND
+ # native shared libraries (.dylib, .metallib) for MLX.
+ # Previously only --collect-data was used, which caused MLX to
+ # raise OSError at runtime inside the bundled binary because
+ # the Metal shader libraries were missing.
+ "--collect-all",
+ "mlx",
+ "--collect-all",
+ "mlx_audio",
+ ]
+ )
+ elif not cuda:
+ logger.info("Building for non-Apple Silicon platform - PyTorch only")
+
+ dist_dir = str(backend_dir / "dist")
+ build_dir = str(backend_dir / "build")
+
+ args.extend(
+ [
+ "--distpath",
+ dist_dir,
+ "--workpath",
+ build_dir,
+ "--noconfirm",
+ "--clean",
+ ]
+ )
# Change to backend directory
os.chdir(backend_dir)
-
+
+ # For CPU builds on Windows, ensure we're using CPU-only torch.
+ # If CUDA torch is installed (local dev), swap to CPU torch before building,
+ # then restore CUDA torch after. This prevents PyInstaller from bundling
+ # ~3GB of CUDA DLLs into the CPU binary.
+ restore_cuda = False
+ if not cuda and platform.system() == "Windows":
+ import subprocess
+
+ result = subprocess.run(
+ [sys.executable, "-c", "import torch; print(torch.version.cuda or '')"], capture_output=True, text=True
+ )
+ has_cuda_torch = bool(result.stdout.strip())
+ if has_cuda_torch:
+ logger.info("CUDA torch detected — installing CPU torch for CPU build...")
+ subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "pip",
+ "install",
+ "torch",
+ "torchvision",
+ "torchaudio",
+ "--index-url",
+ "https://download.pytorch.org/whl/cpu",
+ "--force-reinstall",
+ "-q",
+ ],
+ check=True,
+ )
+ restore_cuda = True
+
# Run PyInstaller
- PyInstaller.__main__.run(args)
-
- print(f"Binary built in {backend_dir / 'dist' / 'voicebox-server'}")
+ try:
+ PyInstaller.__main__.run(args)
+ finally:
+ # Restore CUDA torch if we swapped it out (even on build failure)
+ if restore_cuda:
+ logger.info("Restoring CUDA torch...")
+ import subprocess
+
+ subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "pip",
+ "install",
+ "torch",
+ "torchvision",
+ "torchaudio",
+ "--index-url",
+ "https://download.pytorch.org/whl/cu126",
+ "--force-reinstall",
+ "-q",
+ ],
+ check=True,
+ )
+
+ logger.info("Binary built in %s", backend_dir / "dist" / binary_name)
-if __name__ == '__main__':
- build_server()
+if __name__ == "__main__":
+ 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/config.py b/backend/config.py
index a4718207..0eb3cbf7 100644
--- a/backend/config.py
+++ b/backend/config.py
@@ -4,11 +4,24 @@ Configuration module for voicebox backend.
Handles data directory configuration for production bundling.
"""
+import logging
+import os
from pathlib import Path
+logger = logging.getLogger(__name__)
+
+# Allow users to override the HuggingFace model download directory.
+# Set VOICEBOX_MODELS_DIR to an absolute path before starting the server.
+# This sets HF_HUB_CACHE so all huggingface_hub downloads go to that path.
+_custom_models_dir = os.environ.get("VOICEBOX_MODELS_DIR")
+if _custom_models_dir:
+ os.environ["HF_HUB_CACHE"] = _custom_models_dir
+ logger.info("Model download path set to: %s", _custom_models_dir)
+
# Default data directory (used in development)
_data_dir = Path("data")
+
def set_data_dir(path: str | Path):
"""
Set the data directory path.
@@ -19,7 +32,8 @@ def set_data_dir(path: str | Path):
global _data_dir
_data_dir = Path(path)
_data_dir.mkdir(parents=True, exist_ok=True)
- print(f"Data directory set to: {_data_dir.absolute()}")
+ logger.info("Data directory set to: %s", _data_dir.absolute())
+
def get_data_dir() -> Path:
"""
@@ -30,28 +44,33 @@ def get_data_dir() -> Path:
"""
return _data_dir
+
def get_db_path() -> Path:
"""Get database file path."""
return _data_dir / "voicebox.db"
+
def get_profiles_dir() -> Path:
"""Get profiles directory path."""
path = _data_dir / "profiles"
path.mkdir(parents=True, exist_ok=True)
return path
+
def get_generations_dir() -> Path:
"""Get generations directory path."""
path = _data_dir / "generations"
path.mkdir(parents=True, exist_ok=True)
return path
+
def get_cache_dir() -> Path:
"""Get cache directory path."""
path = _data_dir / "cache"
path.mkdir(parents=True, exist_ok=True)
return path
+
def get_models_dir() -> Path:
"""Get models directory path."""
path = _data_dir / "models"
diff --git a/backend/database.py b/backend/database.py
deleted file mode 100644
index 3b9c51ee..00000000
--- a/backend/database.py
+++ /dev/null
@@ -1,298 +0,0 @@
-"""
-SQLite database ORM using SQLAlchemy.
-"""
-
-from sqlalchemy import create_engine, Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean
-from sqlalchemy.ext.declarative import declarative_base
-from sqlalchemy.orm import sessionmaker, Session
-from datetime import datetime
-import uuid
-from pathlib import Path
-
-from . import config
-
-Base = declarative_base()
-
-
-class VoiceProfile(Base):
- """Voice profile database model."""
- __tablename__ = "profiles"
-
- id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
- name = Column(String, unique=True, nullable=False)
- description = Column(Text)
- language = Column(String, default="en")
- avatar_path = Column(String, nullable=True)
- created_at = Column(DateTime, default=datetime.utcnow)
- updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
-
-
-class ProfileSample(Base):
- """Voice profile sample database model."""
- __tablename__ = "profile_samples"
-
- id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
- profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
- audio_path = Column(String, nullable=False)
- reference_text = Column(Text, nullable=False)
-
-
-class Generation(Base):
- """Generation history database model."""
- __tablename__ = "generations"
-
- id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
- profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
- text = Column(Text, nullable=False)
- language = Column(String, default="en")
- audio_path = Column(String, nullable=False)
- duration = Column(Float, nullable=False)
- seed = Column(Integer)
- instruct = Column(Text)
- created_at = Column(DateTime, default=datetime.utcnow)
-
-
-class Story(Base):
- """Story database model."""
- __tablename__ = "stories"
-
- id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
- name = Column(String, nullable=False)
- description = Column(Text)
- created_at = Column(DateTime, default=datetime.utcnow)
- updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
-
-
-class StoryItem(Base):
- """Story item database model (links generations to stories)."""
- __tablename__ = "story_items"
-
- id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
- story_id = Column(String, ForeignKey("stories.id"), nullable=False)
- generation_id = Column(String, ForeignKey("generations.id"), nullable=False)
- start_time_ms = Column(Integer, nullable=False, default=0) # Milliseconds from story start
- track = Column(Integer, nullable=False, default=0) # Track number (0 = main track)
- trim_start_ms = Column(Integer, nullable=False, default=0) # Milliseconds trimmed from start
- trim_end_ms = Column(Integer, nullable=False, default=0) # Milliseconds trimmed from end
- created_at = Column(DateTime, default=datetime.utcnow)
-
-
-class Project(Base):
- """Audio studio project database model."""
- __tablename__ = "projects"
-
- id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
- name = Column(String, nullable=False)
- data = Column(Text) # JSON string
- created_at = Column(DateTime, default=datetime.utcnow)
- updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
-
-
-class AudioChannel(Base):
- """Audio channel (bus) database model."""
- __tablename__ = "audio_channels"
-
- id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
- name = Column(String, nullable=False)
- is_default = Column(Boolean, default=False)
- created_at = Column(DateTime, default=datetime.utcnow)
-
-
-class ChannelDeviceMapping(Base):
- """Mapping between channels and OS audio devices."""
- __tablename__ = "channel_device_mappings"
-
- id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
- channel_id = Column(String, ForeignKey("audio_channels.id"), nullable=False)
- device_id = Column(String, nullable=False) # OS device identifier
-
-
-class ProfileChannelMapping(Base):
- """Mapping between voice profiles and audio channels (many-to-many)."""
- __tablename__ = "profile_channel_mappings"
-
- profile_id = Column(String, ForeignKey("profiles.id"), primary_key=True)
- channel_id = Column(String, ForeignKey("audio_channels.id"), primary_key=True)
-
-
-# Database setup will be initialized in init_db()
-engine = None
-SessionLocal = None
-_db_path = None
-
-
-def init_db():
- """Initialize database tables."""
- global engine, SessionLocal, _db_path
-
- _db_path = config.get_db_path()
- _db_path.parent.mkdir(parents=True, exist_ok=True)
-
- engine = create_engine(
- f"sqlite:///{_db_path}",
- connect_args={"check_same_thread": False},
- )
-
- SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
-
- # Run migrations before creating tables
- _run_migrations(engine)
-
- Base.metadata.create_all(bind=engine)
-
- # Create default channel if it doesn't exist
- db = SessionLocal()
- try:
- default_channel = db.query(AudioChannel).filter(AudioChannel.is_default == True).first()
- if not default_channel:
- default_channel = AudioChannel(
- id=str(uuid.uuid4()),
- name="Default",
- is_default=True
- )
- db.add(default_channel)
-
- # Assign all existing profiles to default channel
- profiles = db.query(VoiceProfile).all()
- for profile in profiles:
- mapping = ProfileChannelMapping(
- profile_id=profile.id,
- channel_id=default_channel.id
- )
- db.add(mapping)
-
- db.commit()
- finally:
- db.close()
-
-
-def _run_migrations(engine):
- """Run database migrations."""
- from sqlalchemy import inspect, text
-
- inspector = inspect(engine)
-
- # Check if story_items table exists
- if 'story_items' not in inspector.get_table_names():
- return # Table doesn't exist yet, will be created fresh
-
- # Get columns in story_items table
- columns = {col['name'] for col in inspector.get_columns('story_items')}
-
- # Migration: Remove position column and ensure start_time_ms exists
- # SQLite doesn't support DROP COLUMN easily, so we recreate the table
- if 'position' in columns:
- print("Migrating story_items: removing position column, using start_time_ms")
-
- with engine.connect() as conn:
- # Check if start_time_ms already exists
- has_start_time = 'start_time_ms' in columns
-
- if not has_start_time:
- # First, add the new column temporarily
- conn.execute(text("ALTER TABLE story_items ADD COLUMN start_time_ms INTEGER DEFAULT 0"))
-
- # Calculate timecodes from position ordering
- result = conn.execute(text("""
- SELECT si.id, si.story_id, si.position, g.duration
- FROM story_items si
- JOIN generations g ON si.generation_id = g.id
- ORDER BY si.story_id, si.position
- """))
-
- rows = result.fetchall()
-
- current_story_id = None
- current_time_ms = 0
-
- for row in rows:
- item_id, story_id, position, duration = row
-
- if story_id != current_story_id:
- current_story_id = story_id
- current_time_ms = 0
-
- conn.execute(
- text("UPDATE story_items SET start_time_ms = :time WHERE id = :id"),
- {"time": current_time_ms, "id": item_id}
- )
-
- current_time_ms += int(duration * 1000) + 200
-
- conn.commit()
-
- # Now recreate the table without the position column
- # 1. Create new table
- conn.execute(text("""
- CREATE TABLE story_items_new (
- id VARCHAR PRIMARY KEY,
- story_id VARCHAR NOT NULL,
- generation_id VARCHAR NOT NULL,
- start_time_ms INTEGER NOT NULL DEFAULT 0,
- created_at DATETIME,
- FOREIGN KEY (story_id) REFERENCES stories(id),
- FOREIGN KEY (generation_id) REFERENCES generations(id)
- )
- """))
-
- # 2. Copy data
- conn.execute(text("""
- INSERT INTO story_items_new (id, story_id, generation_id, start_time_ms, created_at)
- SELECT id, story_id, generation_id, start_time_ms, created_at FROM story_items
- """))
-
- # 3. Drop old table
- conn.execute(text("DROP TABLE story_items"))
-
- # 4. Rename new table
- conn.execute(text("ALTER TABLE story_items_new RENAME TO story_items"))
-
- conn.commit()
- print("Migrated story_items table to use start_time_ms (removed position column)")
-
- # Migration: Add track column if it doesn't exist
- # Re-check columns after potential position migration
- columns = {col['name'] for col in inspector.get_columns('story_items')}
- if 'track' not in columns:
- print("Migrating story_items: adding track column")
- with engine.connect() as conn:
- conn.execute(text("ALTER TABLE story_items ADD COLUMN track INTEGER NOT NULL DEFAULT 0"))
- conn.commit()
- print("Added track column to story_items")
-
- # Migration: Add trim columns if they don't exist
- # Re-check columns after potential track migration
- columns = {col['name'] for col in inspector.get_columns('story_items')}
- if 'trim_start_ms' not in columns:
- print("Migrating story_items: adding trim_start_ms column")
- with engine.connect() as conn:
- conn.execute(text("ALTER TABLE story_items ADD COLUMN trim_start_ms INTEGER NOT NULL DEFAULT 0"))
- conn.commit()
- print("Added trim_start_ms column to story_items")
-
- columns = {col['name'] for col in inspector.get_columns('story_items')}
- if 'trim_end_ms' not in columns:
- print("Migrating story_items: adding trim_end_ms column")
- with engine.connect() as conn:
- conn.execute(text("ALTER TABLE story_items ADD COLUMN trim_end_ms INTEGER NOT NULL DEFAULT 0"))
- conn.commit()
- print("Added trim_end_ms column to story_items")
-
- # Migration: Add avatar_path to profiles table
- if 'profiles' in inspector.get_table_names():
- columns = {col['name'] for col in inspector.get_columns('profiles')}
- if 'avatar_path' not in columns:
- print("Migrating profiles: adding avatar_path column")
- with engine.connect() as conn:
- conn.execute(text("ALTER TABLE profiles ADD COLUMN avatar_path VARCHAR"))
- conn.commit()
- print("Added avatar_path column to profiles")
-
-
-def get_db():
- """Get database session (generator for dependency injection)."""
- db = SessionLocal()
- try:
- yield db
- finally:
- db.close()
diff --git a/backend/database/__init__.py b/backend/database/__init__.py
new file mode 100644
index 00000000..636333bc
--- /dev/null
+++ b/backend/database/__init__.py
@@ -0,0 +1,44 @@
+"""Database package — ORM models, session management, and migrations.
+
+Re-exports all public symbols so that ``from .database import get_db``
+and ``from .database import Generation as DBGeneration`` continue to work
+without changing any importers.
+"""
+
+from .models import (
+ Base,
+ AudioChannel,
+ ChannelDeviceMapping,
+ EffectPreset,
+ Generation,
+ GenerationVersion,
+ ProfileChannelMapping,
+ ProfileSample,
+ Project,
+ Story,
+ StoryItem,
+ VoiceProfile,
+)
+from .session import engine, SessionLocal, _db_path, init_db, get_db
+
+__all__ = [
+ # Models
+ "Base",
+ "AudioChannel",
+ "ChannelDeviceMapping",
+ "EffectPreset",
+ "Generation",
+ "GenerationVersion",
+ "ProfileChannelMapping",
+ "ProfileSample",
+ "Project",
+ "Story",
+ "StoryItem",
+ "VoiceProfile",
+ # Session
+ "engine",
+ "SessionLocal",
+ "_db_path",
+ "init_db",
+ "get_db",
+]
diff --git a/backend/database/migrations.py b/backend/database/migrations.py
new file mode 100644
index 00000000..52757a68
--- /dev/null
+++ b/backend/database/migrations.py
@@ -0,0 +1,170 @@
+"""Column-level migrations for the voicebox SQLite database.
+
+Why not Alembic? voicebox is a single-user desktop app shipping as a
+PyInstaller binary. Every user has exactly one SQLite file. Alembic's
+strengths -- migration tracking across environments, rollback, team
+coordination -- don't apply here and would add bundling complexity
+(alembic.ini, env.py, versions/ directory all need to survive
+PyInstaller). The column-existence checks below are idempotent, run in
+<50 ms on startup, and have worked reliably across 12 schema changes.
+If the project ever moves to a server-based deployment or Postgres, this
+decision should be revisited.
+
+Adding a new migration:
+ 1. Append a new ``_migrate_*`` helper at the bottom of this file.
+ 2. Call it from ``run_migrations()`` in the appropriate spot.
+ 3. The helper should check column/table existence before acting
+ (idempotent) and print a short message when it does real work.
+"""
+
+import logging
+
+from sqlalchemy import inspect, text
+
+logger = logging.getLogger(__name__)
+
+
+def run_migrations(engine) -> None:
+ """Run all schema migrations. Safe to call on every startup."""
+ inspector = inspect(engine)
+ tables = set(inspector.get_table_names())
+
+ _migrate_story_items(engine, inspector, tables)
+ _migrate_profiles(engine, inspector, tables)
+ _migrate_generations(engine, inspector, tables)
+ _migrate_effect_presets(engine, inspector, tables)
+ _migrate_generation_versions(engine, inspector, tables)
+
+
+# -- helpers ---------------------------------------------------------------
+
+def _get_columns(inspector, table: str) -> set[str]:
+ return {col["name"] for col in inspector.get_columns(table)}
+
+
+def _add_column(engine, table: str, column_sql: str, label: str) -> None:
+ """Add a column if it doesn't already exist."""
+ with engine.connect() as conn:
+ conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column_sql}"))
+ conn.commit()
+ logger.info("Added %s column to %s", label, table)
+
+
+# -- per-table migrations --------------------------------------------------
+
+def _migrate_story_items(engine, inspector, tables: set[str]) -> None:
+ if "story_items" not in tables:
+ return
+
+ columns = _get_columns(inspector, "story_items")
+
+ # Replace position-based ordering with absolute timecodes
+ if "position" in columns:
+ logger.info("Migrating story_items: removing position column, using start_time_ms")
+ with engine.connect() as conn:
+ if "start_time_ms" not in columns:
+ conn.execute(text(
+ "ALTER TABLE story_items ADD COLUMN start_time_ms INTEGER DEFAULT 0"
+ ))
+ result = conn.execute(text("""
+ SELECT si.id, si.story_id, si.position, g.duration
+ FROM story_items si
+ JOIN generations g ON si.generation_id = g.id
+ ORDER BY si.story_id, si.position
+ """))
+ current_story_id = None
+ current_time_ms = 0
+ for item_id, story_id, _position, duration in result.fetchall():
+ if story_id != current_story_id:
+ current_story_id = story_id
+ current_time_ms = 0
+ conn.execute(
+ text("UPDATE story_items SET start_time_ms = :time WHERE id = :id"),
+ {"time": current_time_ms, "id": item_id},
+ )
+ current_time_ms += int((duration or 0) * 1000) + 200
+ conn.commit()
+
+ # Recreate table without the position column (SQLite lacks DROP COLUMN)
+ conn.execute(text("""
+ CREATE TABLE story_items_new (
+ id VARCHAR PRIMARY KEY,
+ story_id VARCHAR NOT NULL,
+ generation_id VARCHAR NOT NULL,
+ start_time_ms INTEGER NOT NULL DEFAULT 0,
+ track INTEGER NOT NULL DEFAULT 0,
+ trim_start_ms INTEGER NOT NULL DEFAULT 0,
+ trim_end_ms INTEGER NOT NULL DEFAULT 0,
+ version_id VARCHAR,
+ created_at DATETIME,
+ FOREIGN KEY (story_id) REFERENCES stories(id),
+ FOREIGN KEY (generation_id) REFERENCES generations(id)
+ )
+ """))
+ conn.execute(text("""
+ INSERT INTO story_items_new (id, story_id, generation_id, start_time_ms, track, trim_start_ms, trim_end_ms, version_id, created_at)
+ SELECT id, story_id, generation_id, start_time_ms,
+ COALESCE(track, 0), COALESCE(trim_start_ms, 0), COALESCE(trim_end_ms, 0), version_id, created_at
+ FROM story_items
+ """))
+ conn.execute(text("DROP TABLE story_items"))
+ conn.execute(text("ALTER TABLE story_items_new RENAME TO story_items"))
+ conn.commit()
+
+ # Re-read after table recreation
+ columns = _get_columns(inspector, "story_items")
+
+ if "track" not in columns:
+ _add_column(engine, "story_items", "track INTEGER NOT NULL DEFAULT 0", "track")
+ # Re-read so subsequent checks see new columns
+ columns = _get_columns(inspector, "story_items")
+ if "trim_start_ms" not in columns:
+ _add_column(engine, "story_items", "trim_start_ms INTEGER NOT NULL DEFAULT 0", "trim_start_ms")
+ if "trim_end_ms" not in columns:
+ _add_column(engine, "story_items", "trim_end_ms INTEGER NOT NULL DEFAULT 0", "trim_end_ms")
+ if "version_id" not in columns:
+ _add_column(engine, "story_items", "version_id VARCHAR", "version_id")
+
+
+def _migrate_profiles(engine, inspector, tables: set[str]) -> None:
+ if "profiles" not in tables:
+ return
+ columns = _get_columns(inspector, "profiles")
+ if "avatar_path" not in columns:
+ _add_column(engine, "profiles", "avatar_path VARCHAR", "avatar_path")
+ if "effects_chain" not in columns:
+ _add_column(engine, "profiles", "effects_chain TEXT", "effects_chain")
+
+
+def _migrate_generations(engine, inspector, tables: set[str]) -> None:
+ if "generations" not in tables:
+ return
+ columns = _get_columns(inspector, "generations")
+ if "status" not in columns:
+ _add_column(engine, "generations", "status VARCHAR DEFAULT 'completed'", "status")
+ if "error" not in columns:
+ _add_column(engine, "generations", "error TEXT", "error")
+ if "engine" not in columns:
+ _add_column(engine, "generations", "engine VARCHAR DEFAULT 'qwen'", "engine")
+ # Re-read after engine column (variable name shadows outer scope in old code)
+ columns = _get_columns(inspector, "generations")
+ if "model_size" not in columns:
+ _add_column(engine, "generations", "model_size VARCHAR", "model_size")
+ if "is_favorited" not in columns:
+ _add_column(engine, "generations", "is_favorited BOOLEAN DEFAULT 0", "is_favorited")
+
+
+def _migrate_effect_presets(engine, inspector, tables: set[str]) -> None:
+ if "effect_presets" not in tables:
+ return
+ columns = _get_columns(inspector, "effect_presets")
+ if "sort_order" not in columns:
+ _add_column(engine, "effect_presets", "sort_order INTEGER DEFAULT 100", "sort_order")
+
+
+def _migrate_generation_versions(engine, inspector, tables: set[str]) -> None:
+ if "generation_versions" not in tables:
+ return
+ columns = _get_columns(inspector, "generation_versions")
+ if "source_version_id" not in columns:
+ _add_column(engine, "generation_versions", "source_version_id VARCHAR", "source_version_id")
diff --git a/backend/database/models.py b/backend/database/models.py
new file mode 100644
index 00000000..19cefff2
--- /dev/null
+++ b/backend/database/models.py
@@ -0,0 +1,155 @@
+"""ORM model definitions for the voicebox SQLite database."""
+
+from datetime import datetime
+import uuid
+
+from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean
+from sqlalchemy.ext.declarative import declarative_base
+
+Base = declarative_base()
+
+
+class VoiceProfile(Base):
+ """Voice profile."""
+
+ __tablename__ = "profiles"
+
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
+ name = Column(String, unique=True, nullable=False)
+ description = Column(Text)
+ language = Column(String, default="en")
+ avatar_path = Column(String, nullable=True)
+ effects_chain = Column(Text, nullable=True)
+ created_at = Column(DateTime, default=datetime.utcnow)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+
+class ProfileSample(Base):
+ """Audio sample attached to a voice profile."""
+
+ __tablename__ = "profile_samples"
+
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
+ profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
+ audio_path = Column(String, nullable=False)
+ reference_text = Column(Text, nullable=False)
+
+
+class Generation(Base):
+ """A single TTS generation."""
+
+ __tablename__ = "generations"
+
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
+ profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
+ text = Column(Text, nullable=False)
+ language = Column(String, default="en")
+ audio_path = Column(String, nullable=True)
+ duration = Column(Float, nullable=True)
+ seed = Column(Integer)
+ instruct = Column(Text)
+ engine = Column(String, default="qwen")
+ model_size = Column(String, nullable=True)
+ status = Column(String, default="completed")
+ error = Column(Text, nullable=True)
+ is_favorited = Column(Boolean, default=False)
+ created_at = Column(DateTime, default=datetime.utcnow)
+
+
+class Story(Base):
+ """A story that sequences multiple generations."""
+
+ __tablename__ = "stories"
+
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
+ name = Column(String, nullable=False)
+ description = Column(Text)
+ created_at = Column(DateTime, default=datetime.utcnow)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+
+class StoryItem(Base):
+ """Links a generation to a story at a specific timecode."""
+
+ __tablename__ = "story_items"
+
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
+ story_id = Column(String, ForeignKey("stories.id"), nullable=False)
+ generation_id = Column(String, ForeignKey("generations.id"), nullable=False)
+ version_id = Column(String, ForeignKey("generation_versions.id"), nullable=True)
+ start_time_ms = Column(Integer, nullable=False, default=0)
+ track = Column(Integer, nullable=False, default=0)
+ trim_start_ms = Column(Integer, nullable=False, default=0)
+ trim_end_ms = Column(Integer, nullable=False, default=0)
+ created_at = Column(DateTime, default=datetime.utcnow)
+
+
+class Project(Base):
+ """Audio studio project (JSON blob)."""
+
+ __tablename__ = "projects"
+
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
+ name = Column(String, nullable=False)
+ data = Column(Text)
+ created_at = Column(DateTime, default=datetime.utcnow)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+
+class GenerationVersion(Base):
+ """A version of a generation's audio (original, processed, alternate takes)."""
+
+ __tablename__ = "generation_versions"
+
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
+ generation_id = Column(String, ForeignKey("generations.id"), nullable=False)
+ label = Column(String, nullable=False)
+ audio_path = Column(String, nullable=False)
+ effects_chain = Column(Text, nullable=True)
+ source_version_id = Column(String, ForeignKey("generation_versions.id"), nullable=True)
+ is_default = Column(Boolean, default=False)
+ created_at = Column(DateTime, default=datetime.utcnow)
+
+
+class EffectPreset(Base):
+ """Saved effect chain preset."""
+
+ __tablename__ = "effect_presets"
+
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
+ name = Column(String, unique=True, nullable=False)
+ description = Column(Text, nullable=True)
+ effects_chain = Column(Text, nullable=False)
+ is_builtin = Column(Boolean, default=False)
+ sort_order = Column(Integer, default=100)
+ created_at = Column(DateTime, default=datetime.utcnow)
+
+
+class AudioChannel(Base):
+ """Audio output channel (bus)."""
+
+ __tablename__ = "audio_channels"
+
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
+ name = Column(String, nullable=False)
+ is_default = Column(Boolean, default=False)
+ created_at = Column(DateTime, default=datetime.utcnow)
+
+
+class ChannelDeviceMapping(Base):
+ """Mapping between a channel and an OS audio device."""
+
+ __tablename__ = "channel_device_mappings"
+
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
+ channel_id = Column(String, ForeignKey("audio_channels.id"), nullable=False)
+ device_id = Column(String, nullable=False)
+
+
+class ProfileChannelMapping(Base):
+ """Many-to-many mapping between voice profiles and audio channels."""
+
+ __tablename__ = "profile_channel_mappings"
+
+ profile_id = Column(String, ForeignKey("profiles.id"), primary_key=True)
+ channel_id = Column(String, ForeignKey("audio_channels.id"), primary_key=True)
diff --git a/backend/database/seed.py b/backend/database/seed.py
new file mode 100644
index 00000000..b62edc22
--- /dev/null
+++ b/backend/database/seed.py
@@ -0,0 +1,71 @@
+"""Post-migration data seeding and backfills."""
+
+import json
+import logging
+import uuid
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+
+def backfill_generation_versions(SessionLocal, Generation, GenerationVersion) -> None:
+ """Create 'clean' version entries for generations that predate the versions feature."""
+ db = SessionLocal()
+ try:
+ existing_version_gen_ids = {
+ row[0] for row in db.query(GenerationVersion.generation_id).all()
+ }
+ generations = db.query(Generation).filter(
+ Generation.status == "completed",
+ Generation.audio_path.isnot(None),
+ Generation.audio_path != "",
+ ).all()
+
+ count = 0
+ for gen in generations:
+ if gen.id in existing_version_gen_ids:
+ continue
+ if not Path(gen.audio_path).exists():
+ continue
+ version = GenerationVersion(
+ id=str(uuid.uuid4()),
+ generation_id=gen.id,
+ label="clean",
+ audio_path=gen.audio_path,
+ effects_chain=None,
+ is_default=True,
+ )
+ db.add(version)
+ count += 1
+
+ if count > 0:
+ db.commit()
+ logger.info("Backfilled %d generation version entries", count)
+ finally:
+ db.close()
+
+
+def seed_builtin_presets(SessionLocal, EffectPreset) -> None:
+ """Ensure built-in effect presets exist in the database."""
+ from ..utils.effects import BUILTIN_PRESETS
+
+ db = SessionLocal()
+ try:
+ for idx, (_key, preset_data) in enumerate(BUILTIN_PRESETS.items()):
+ sort_order = preset_data.get("sort_order", idx)
+ existing = db.query(EffectPreset).filter_by(name=preset_data["name"]).first()
+ if not existing:
+ preset = EffectPreset(
+ id=str(uuid.uuid4()),
+ name=preset_data["name"],
+ description=preset_data.get("description"),
+ effects_chain=json.dumps(preset_data["effects_chain"]),
+ is_builtin=True,
+ sort_order=sort_order,
+ )
+ db.add(preset)
+ elif existing.sort_order != sort_order:
+ existing.sort_order = sort_order
+ db.commit()
+ finally:
+ db.close()
diff --git a/backend/database/session.py b/backend/database/session.py
new file mode 100644
index 00000000..de4cccd9
--- /dev/null
+++ b/backend/database/session.py
@@ -0,0 +1,78 @@
+"""Engine creation, initialization, and session management."""
+
+import logging
+import uuid
+
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+
+from .. import config
+from .models import (
+ Base,
+ AudioChannel,
+ EffectPreset,
+ Generation,
+ GenerationVersion,
+ ProfileChannelMapping,
+ VoiceProfile,
+)
+from .migrations import run_migrations
+from .seed import backfill_generation_versions, seed_builtin_presets
+
+logger = logging.getLogger(__name__)
+
+# Initialized by init_db()
+engine = None
+SessionLocal = None
+_db_path = None
+
+
+def init_db() -> None:
+ """Initialize the database engine, run migrations, create tables, and seed data."""
+ global engine, SessionLocal, _db_path
+
+ _db_path = config.get_db_path()
+ _db_path.parent.mkdir(parents=True, exist_ok=True)
+
+ engine = create_engine(
+ f"sqlite:///{_db_path}",
+ connect_args={"check_same_thread": False},
+ )
+
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+
+ run_migrations(engine)
+ Base.metadata.create_all(bind=engine)
+
+ # Create default audio channel if it doesn't exist
+ db = SessionLocal()
+ try:
+ default_channel = db.query(AudioChannel).filter(AudioChannel.is_default == True).first()
+ if not default_channel:
+ default_channel = AudioChannel(
+ id=str(uuid.uuid4()),
+ name="Default",
+ is_default=True,
+ )
+ db.add(default_channel)
+
+ for profile in db.query(VoiceProfile).all():
+ db.add(ProfileChannelMapping(
+ profile_id=profile.id,
+ channel_id=default_channel.id,
+ ))
+ db.commit()
+ finally:
+ db.close()
+
+ backfill_generation_versions(SessionLocal, Generation, GenerationVersion)
+ seed_builtin_presets(SessionLocal, EffectPreset)
+
+
+def get_db():
+ """Yield a database session (FastAPI dependency)."""
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
diff --git a/backend/example_usage.py b/backend/example_usage.py
deleted file mode 100644
index 2678af03..00000000
--- a/backend/example_usage.py
+++ /dev/null
@@ -1,221 +0,0 @@
-"""
-Example usage of the voicebox backend API.
-
-This script demonstrates how to:
-1. Create a voice profile
-2. Add samples to the profile
-3. Generate speech
-4. List history
-"""
-
-import requests
-import time
-from pathlib import Path
-
-# API base URL
-BASE_URL = "http://localhost:8000"
-
-
-def check_health():
- """Check if the server is running."""
- response = requests.get(f"{BASE_URL}/health")
- data = response.json()
- print(f"Server status: {data['status']}")
- print(f"Model loaded: {data['model_loaded']}")
- print(f"GPU available: {data['gpu_available']}")
- print()
- return data
-
-
-def create_profile(name: str, description: str = None, language: str = "en"):
- """Create a new voice profile."""
- response = requests.post(
- f"{BASE_URL}/profiles",
- json={
- "name": name,
- "description": description,
- "language": language,
- },
- )
- response.raise_for_status()
- profile = response.json()
- print(f"Created profile: {profile['name']} (ID: {profile['id']})")
- return profile
-
-
-def add_sample(profile_id: str, audio_file: str, reference_text: str):
- """Add a sample to a voice profile."""
- with open(audio_file, "rb") as f:
- files = {"file": f}
- data = {"reference_text": reference_text}
- response = requests.post(
- f"{BASE_URL}/profiles/{profile_id}/samples",
- files=files,
- data=data,
- )
- response.raise_for_status()
- sample = response.json()
- print(f"Added sample: {sample['id']}")
- return sample
-
-
-def generate_speech(profile_id: str, text: str, language: str = "en", seed: int = None):
- """Generate speech using a voice profile."""
- print(f"Generating speech: '{text[:50]}...'")
- start_time = time.time()
-
- response = requests.post(
- f"{BASE_URL}/generate",
- json={
- "profile_id": profile_id,
- "text": text,
- "language": language,
- "seed": seed,
- },
- )
- response.raise_for_status()
- generation = response.json()
-
- elapsed = time.time() - start_time
- print(f"Generated in {elapsed:.2f}s (duration: {generation['duration']:.2f}s)")
- print(f"Generation ID: {generation['id']}")
- return generation
-
-
-def download_audio(generation_id: str, output_file: str):
- """Download generated audio."""
- response = requests.get(f"{BASE_URL}/audio/{generation_id}")
- response.raise_for_status()
-
- with open(output_file, "wb") as f:
- f.write(response.content)
-
- print(f"Saved audio to: {output_file}")
-
-
-def list_profiles():
- """List all voice profiles."""
- response = requests.get(f"{BASE_URL}/profiles")
- response.raise_for_status()
- profiles = response.json()
-
- print(f"Found {len(profiles)} profiles:")
- for profile in profiles:
- print(f" - {profile['name']} (ID: {profile['id']})")
-
- return profiles
-
-
-def list_history(profile_id: str = None, limit: int = 10):
- """List generation history."""
- params = {"limit": limit}
- if profile_id:
- params["profile_id"] = profile_id
-
- response = requests.get(f"{BASE_URL}/history", params=params)
- response.raise_for_status()
- history = response.json()
-
- print(f"Found {len(history)} generations:")
- for gen in history:
- print(f" - {gen['text'][:50]}... ({gen['duration']:.2f}s)")
-
- return history
-
-
-def transcribe_audio(audio_file: str, language: str = None):
- """Transcribe audio file."""
- print(f"Transcribing: {audio_file}")
-
- with open(audio_file, "rb") as f:
- files = {"file": f}
- data = {}
- if language:
- data["language"] = language
-
- response = requests.post(
- f"{BASE_URL}/transcribe",
- files=files,
- data=data,
- )
-
- response.raise_for_status()
- result = response.json()
-
- print(f"Transcription: {result['text']}")
- print(f"Duration: {result['duration']:.2f}s")
- return result
-
-
-def main():
- """Run example workflow."""
- print("=" * 60)
- print("voicebox Backend API Example")
- print("=" * 60)
- print()
-
- # 1. Check health
- print("1. Checking server health...")
- check_health()
-
- # 2. Create a profile
- print("2. Creating voice profile...")
- profile = create_profile(
- name="Example Voice",
- description="A test voice profile",
- language="en",
- )
- profile_id = profile["id"]
- print()
-
- # 3. Add samples (you'll need actual audio files)
- print("3. Adding samples...")
- print(" (Skipping - add your own audio files here)")
- # Uncomment and add your audio file:
- # sample = add_sample(
- # profile_id,
- # "path/to/your/sample.wav",
- # "This is the transcript of the audio",
- # )
- print()
-
- # 4. Generate speech (requires samples to be added first)
- print("4. Generating speech...")
- print(" (Skipping - add samples first)")
- # Uncomment after adding samples:
- # generation = generate_speech(
- # profile_id,
- # "Hello, this is a test of the voice cloning system.",
- # language="en",
- # seed=42,
- # )
- #
- # # 5. Download audio
- # print("\n5. Downloading audio...")
- # download_audio(generation["id"], "output.wav")
- print()
-
- # 6. List profiles
- print("6. Listing all profiles...")
- list_profiles()
- print()
-
- # 7. List history
- print("7. Listing generation history...")
- list_history(limit=5)
- print()
-
- # 8. Transcribe audio (you'll need an audio file)
- print("8. Transcribing audio...")
- print(" (Skipping - add your own audio file here)")
- # Uncomment and add your audio file:
- # transcribe_audio("path/to/audio.wav", language="en")
- print()
-
- print("=" * 60)
- print("Example complete!")
- print("=" * 60)
-
-
-if __name__ == "__main__":
- main()
diff --git a/backend/main.py b/backend/main.py
index 9a90bcec..fa8f78f5 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1,1700 +1,14 @@
-"""
-FastAPI application for voicebox backend.
+"""Entry point for the voicebox backend.
-Handles voice cloning, generation history, and server mode.
+Imports the configured FastAPI app and provides a ``python -m backend.main``
+entry point for development.
"""
-from fastapi import FastAPI, Depends, UploadFile, File, Form, HTTPException
-from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import FileResponse, StreamingResponse
-from fastapi.staticfiles import StaticFiles
-from sqlalchemy.orm import Session
-from typing import List, Optional
-from datetime import datetime
-import asyncio
-import uvicorn
import argparse
-import torch
-import tempfile
-import io
-from pathlib import Path
-import uuid
-import asyncio
-import signal
-import os
+import uvicorn
-from . import database, models, profiles, history, tts, transcribe, config, export_import, channels, stories, __version__
-from .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
-from .utils.progress import get_progress_manager
-from .utils.tasks import get_task_manager
-from .utils.cache import clear_voice_prompt_cache
-from .platform_detect import get_backend_type
-
-app = FastAPI(
- title="voicebox API",
- description="Production-quality Qwen3-TTS voice cloning API",
- version=__version__,
- servers=[
- {"url": "http://localhost:8000", "description": "Local development server"},
- {"url": "http://localhost:17493", "description": "Production server"},
- ],
-)
-
-# CORS middleware
-app.add_middleware(
- CORSMiddleware,
- allow_origins=["*"], # Configure appropriately for production
- allow_credentials=True,
- allow_methods=["*"],
- allow_headers=["*"],
-)
-
-
-# ============================================
-# ROOT & HEALTH ENDPOINTS
-# ============================================
-
-@app.get("/")
-async def root():
- """Root endpoint."""
- return {"message": "voicebox API", "version": __version__}
-
-
-@app.post("/shutdown")
-async def shutdown():
- """Gracefully shutdown the server."""
- async def shutdown_async():
- await asyncio.sleep(0.1) # Give response time to send
- os.kill(os.getpid(), signal.SIGTERM)
-
- asyncio.create_task(shutdown_async())
- return {"message": "Shutting down..."}
-
-
-@app.get("/health", response_model=models.HealthResponse)
-async def health():
- """Health check endpoint."""
- from huggingface_hub import hf_hub_download, constants as hf_constants
- from pathlib import Path
- import os
-
- tts_model = tts.get_tts_model()
- backend_type = get_backend_type()
-
- # Check for GPU availability (CUDA or MPS)
- has_cuda = torch.cuda.is_available()
- has_mps = hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()
- gpu_available = has_cuda or has_mps
-
- gpu_type = None
- if has_cuda:
- gpu_type = f"CUDA ({torch.cuda.get_device_name(0)})"
- elif has_mps:
- gpu_type = "MPS (Apple Silicon)"
- elif backend_type == "mlx":
- gpu_type = "Metal (Apple Silicon via MLX)"
-
- vram_used = None
- if has_cuda:
- vram_used = torch.cuda.memory_allocated() / 1024 / 1024 # MB
-
- # Check if model is loaded - use the same logic as model status endpoint
- model_loaded = False
- model_size = None
- try:
- # Use the same check as model status endpoint
- if tts_model.is_loaded():
- model_loaded = True
- # Get the actual loaded model size
- # Check _current_model_size first (more reliable for actually loaded models)
- model_size = getattr(tts_model, '_current_model_size', None)
- if not model_size:
- # Fallback to model_size attribute (which should be set when model loads)
- model_size = getattr(tts_model, 'model_size', None)
- except Exception:
- # If there's an error checking, assume not loaded
- model_loaded = False
- model_size = None
-
- # Check if default model is downloaded (cached)
- model_downloaded = None
- try:
- # Check if the default model (1.7B) is cached
- # Use different model IDs based on backend
- if backend_type == "mlx":
- default_model_id = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16"
- else:
- default_model_id = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
-
- # Method 1: Try scan_cache_dir if available
- try:
- from huggingface_hub import scan_cache_dir
- cache_info = scan_cache_dir()
- for repo in cache_info.repos:
- if repo.repo_id == default_model_id:
- model_downloaded = True
- break
- except (ImportError, Exception):
- # Method 2: Check cache directory (using HuggingFace's OS-specific cache location)
- cache_dir = hf_constants.HF_HUB_CACHE
- repo_cache = Path(cache_dir) / ("models--" + default_model_id.replace("/", "--"))
- if repo_cache.exists():
- has_model_files = (
- any(repo_cache.rglob("*.bin")) or
- any(repo_cache.rglob("*.safetensors")) or
- any(repo_cache.rglob("*.pt")) or
- any(repo_cache.rglob("*.pth")) or
- any(repo_cache.rglob("*.npz")) # MLX models may use npz
- )
- model_downloaded = has_model_files
- except Exception:
- pass
-
- return models.HealthResponse(
- status="healthy",
- model_loaded=model_loaded,
- model_downloaded=model_downloaded,
- model_size=model_size,
- gpu_available=gpu_available,
- gpu_type=gpu_type,
- vram_used_mb=vram_used,
- backend_type=backend_type,
- )
-
-
-# ============================================
-# VOICE PROFILE ENDPOINTS
-# ============================================
-
-@app.post("/profiles", response_model=models.VoiceProfileResponse)
-async def create_profile(
- data: models.VoiceProfileCreate,
- db: Session = Depends(get_db),
-):
- """Create a new voice profile."""
- try:
- return await profiles.create_profile(data, db)
- except Exception as e:
- raise HTTPException(status_code=400, detail=str(e))
-
-
-@app.get("/profiles", response_model=List[models.VoiceProfileResponse])
-async def list_profiles(db: Session = Depends(get_db)):
- """List all voice profiles."""
- return await profiles.list_profiles(db)
-
-
-@app.post("/profiles/import", response_model=models.VoiceProfileResponse)
-async def import_profile(
- file: UploadFile = File(...),
- db: Session = Depends(get_db),
-):
- """Import a voice profile from a ZIP archive."""
- # Validate file size (max 100MB)
- MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
-
- # Read file content
- content = await file.read()
-
- if len(content) > MAX_FILE_SIZE:
- raise HTTPException(
- status_code=400,
- detail=f"File too large. Maximum size is {MAX_FILE_SIZE / (1024 * 1024)}MB"
- )
-
- try:
- profile = await export_import.import_profile_from_zip(content, db)
- return profile
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
-
-
-@app.get("/profiles/{profile_id}", response_model=models.VoiceProfileResponse)
-async def get_profile(
- profile_id: str,
- db: Session = Depends(get_db),
-):
- """Get a voice profile by ID."""
- profile = await profiles.get_profile(profile_id, db)
- if not profile:
- raise HTTPException(status_code=404, detail="Profile not found")
- return profile
-
-
-@app.put("/profiles/{profile_id}", response_model=models.VoiceProfileResponse)
-async def update_profile(
- profile_id: str,
- data: models.VoiceProfileCreate,
- 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
-
-
-@app.delete("/profiles/{profile_id}")
-async def delete_profile(
- profile_id: str,
- db: Session = Depends(get_db),
-):
- """Delete a voice profile."""
- success = await profiles.delete_profile(profile_id, db)
- if not success:
- raise HTTPException(status_code=404, detail="Profile not found")
- return {"message": "Profile deleted successfully"}
-
-
-@app.post("/profiles/{profile_id}/samples", response_model=models.ProfileSampleResponse)
-async def add_profile_sample(
- profile_id: str,
- file: UploadFile = File(...),
- reference_text: str = Form(...),
- db: Session = Depends(get_db),
-):
- """Add a sample to a voice profile."""
- # Save uploaded file to temporary location
- with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
- content = await file.read()
- tmp.write(content)
- tmp_path = tmp.name
-
- try:
- sample = await profiles.add_profile_sample(
- profile_id,
- tmp_path,
- reference_text,
- db,
- )
- return sample
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- finally:
- # Clean up temp file
- Path(tmp_path).unlink(missing_ok=True)
-
-
-@app.get("/profiles/{profile_id}/samples", response_model=List[models.ProfileSampleResponse])
-async def get_profile_samples(
- profile_id: str,
- db: Session = Depends(get_db),
-):
- """Get all samples for a profile."""
- return await profiles.get_profile_samples(profile_id, db)
-
-
-@app.delete("/profiles/samples/{sample_id}")
-async def delete_profile_sample(
- sample_id: str,
- db: Session = Depends(get_db),
-):
- """Delete a profile sample."""
- success = await profiles.delete_profile_sample(sample_id, db)
- if not success:
- raise HTTPException(status_code=404, detail="Sample not found")
- return {"message": "Sample deleted successfully"}
-
-
-@app.put("/profiles/samples/{sample_id}", response_model=models.ProfileSampleResponse)
-async def update_profile_sample(
- sample_id: str,
- data: models.ProfileSampleUpdate,
- db: Session = Depends(get_db),
-):
- """Update a profile sample's reference text."""
- sample = await profiles.update_profile_sample(sample_id, data.reference_text, db)
- if not sample:
- raise HTTPException(status_code=404, detail="Sample not found")
- return sample
-
-
-@app.post("/profiles/{profile_id}/avatar", response_model=models.VoiceProfileResponse)
-async def upload_profile_avatar(
- profile_id: str,
- file: UploadFile = File(...),
- db: Session = Depends(get_db),
-):
- """Upload or update avatar image for a profile."""
- # Save uploaded file to temp location
- with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename).suffix) as tmp:
- content = await file.read()
- tmp.write(content)
- tmp_path = tmp.name
-
- try:
- profile = await profiles.upload_avatar(profile_id, tmp_path, db)
- return profile
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- finally:
- # Clean up temp file
- Path(tmp_path).unlink(missing_ok=True)
-
-
-@app.get("/profiles/{profile_id}/avatar")
-async def get_profile_avatar(
- profile_id: str,
- db: Session = Depends(get_db),
-):
- """Get avatar image for a profile."""
- profile = await profiles.get_profile(profile_id, db)
- if not profile:
- raise HTTPException(status_code=404, detail="Profile not found")
-
- if not profile.avatar_path:
- raise HTTPException(status_code=404, detail="No avatar found for this profile")
-
- avatar_path = Path(profile.avatar_path)
- if not avatar_path.exists():
- raise HTTPException(status_code=404, detail="Avatar file not found")
-
- return FileResponse(avatar_path)
-
-
-@app.delete("/profiles/{profile_id}/avatar")
-async def delete_profile_avatar(
- profile_id: str,
- db: Session = Depends(get_db),
-):
- """Delete avatar image for a profile."""
- success = await profiles.delete_avatar(profile_id, db)
- if not success:
- raise HTTPException(status_code=404, detail="Profile not found or no avatar to delete")
- return {"message": "Avatar deleted successfully"}
-
-
-@app.get("/profiles/{profile_id}/export")
-async def export_profile(
- profile_id: str,
- db: Session = Depends(get_db),
-):
- """Export a voice profile as a ZIP archive."""
- try:
- # Get profile to get name for filename
- profile = await profiles.get_profile(profile_id, db)
- if not profile:
- raise HTTPException(status_code=404, detail="Profile not found")
-
- # Export to ZIP
- zip_bytes = export_import.export_profile_to_zip(profile_id, db)
-
- # Create safe filename
- safe_name = "".join(c for c in profile.name if c.isalnum() or c in (' ', '-', '_')).strip()
- if not safe_name:
- safe_name = "profile"
- filename = f"profile-{safe_name}.voicebox.zip"
-
- # Return as streaming response
- return StreamingResponse(
- io.BytesIO(zip_bytes),
- media_type="application/zip",
- headers={
- "Content-Disposition": f'attachment; filename="{filename}"'
- }
- )
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
-
-
-# ============================================
-# AUDIO CHANNEL ENDPOINTS
-# ============================================
-
-@app.get("/channels", response_model=List[models.AudioChannelResponse])
-async def list_channels(db: Session = Depends(get_db)):
- """List all audio channels."""
- return await channels.list_channels(db)
-
-
-@app.post("/channels", response_model=models.AudioChannelResponse)
-async def create_channel(
- data: models.AudioChannelCreate,
- db: Session = Depends(get_db),
-):
- """Create a new audio channel."""
- try:
- return await channels.create_channel(data, db)
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
-
-
-@app.get("/channels/{channel_id}", response_model=models.AudioChannelResponse)
-async def get_channel(
- channel_id: str,
- db: Session = Depends(get_db),
-):
- """Get an audio channel by ID."""
- channel = await channels.get_channel(channel_id, db)
- if not channel:
- raise HTTPException(status_code=404, detail="Channel not found")
- return channel
-
-
-@app.put("/channels/{channel_id}", response_model=models.AudioChannelResponse)
-async def update_channel(
- channel_id: str,
- data: models.AudioChannelUpdate,
- db: Session = Depends(get_db),
-):
- """Update an audio channel."""
- try:
- channel = await channels.update_channel(channel_id, data, db)
- if not channel:
- raise HTTPException(status_code=404, detail="Channel not found")
- return channel
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
-
-
-@app.delete("/channels/{channel_id}")
-async def delete_channel(
- channel_id: str,
- db: Session = Depends(get_db),
-):
- """Delete an audio channel."""
- try:
- success = await channels.delete_channel(channel_id, db)
- if not success:
- raise HTTPException(status_code=404, detail="Channel not found")
- return {"message": "Channel deleted successfully"}
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
-
-
-@app.get("/channels/{channel_id}/voices")
-async def get_channel_voices(
- channel_id: str,
- db: Session = Depends(get_db),
-):
- """Get list of profile IDs assigned to a channel."""
- try:
- profile_ids = await channels.get_channel_voices(channel_id, db)
- return {"profile_ids": profile_ids}
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
-
-
-@app.put("/channels/{channel_id}/voices")
-async def set_channel_voices(
- channel_id: str,
- data: models.ChannelVoiceAssignment,
- db: Session = Depends(get_db),
-):
- """Set which voices are assigned to a channel."""
- try:
- await channels.set_channel_voices(channel_id, data, db)
- return {"message": "Channel voices updated successfully"}
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
-
-
-@app.get("/profiles/{profile_id}/channels")
-async def get_profile_channels(
- profile_id: str,
- db: Session = Depends(get_db),
-):
- """Get list of channel IDs assigned to a profile."""
- try:
- channel_ids = await channels.get_profile_channels(profile_id, db)
- return {"channel_ids": channel_ids}
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
-
-
-@app.put("/profiles/{profile_id}/channels")
-async def set_profile_channels(
- profile_id: str,
- data: models.ProfileChannelAssignment,
- db: Session = Depends(get_db),
-):
- """Set which channels a profile is assigned to."""
- try:
- await channels.set_profile_channels(profile_id, data, db)
- return {"message": "Profile channels updated successfully"}
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
-
-
-# ============================================
-# GENERATION ENDPOINTS
-# ============================================
-
-@app.post("/generate", response_model=models.GenerationResponse)
-async def generate_speech(
- data: models.GenerationRequest,
- db: Session = Depends(get_db),
-):
- """Generate speech from text using a voice profile."""
- task_manager = get_task_manager()
- generation_id = str(uuid.uuid4())
-
- try:
- # Start tracking generation
- task_manager.start_generation(
- task_id=generation_id,
- profile_id=data.profile_id,
- text=data.text,
- )
-
- # Get profile
- profile = await profiles.get_profile(data.profile_id, db)
- if not profile:
- raise HTTPException(status_code=404, detail="Profile not found")
-
- # Create voice prompt from profile
- voice_prompt = await profiles.create_voice_prompt_for_profile(
- data.profile_id,
- db,
- )
-
- # Generate audio
- tts_model = tts.get_tts_model()
- # Load the requested model size if different from current (async to not block)
- 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 model_path.startswith("Qwen/"):
- # Model not cached - check if it exists remotely or needs download
- from huggingface_hub import constants as hf_constants
- repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_path.replace("/", "--"))
- if not repo_cache.exists():
- # Start download in background
- 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))
-
- task_manager.start_download(model_name)
- asyncio.create_task(download_model_background())
-
- # Return 202 Accepted with download info
- 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
- }
- )
-
- await tts_model.load_model_async(model_size)
- audio, sample_rate = await tts_model.generate(
- data.text,
- voice_prompt,
- data.language,
- data.seed,
- data.instruct,
- )
-
- # Calculate duration
- duration = len(audio) / sample_rate
-
- # Save audio
- audio_path = config.get_generations_dir() / f"{generation_id}.wav"
-
- from .utils.audio import save_audio
- save_audio(audio, str(audio_path), sample_rate)
-
- # Create history entry
- generation = await history.create_generation(
- profile_id=data.profile_id,
- text=data.text,
- language=data.language,
- audio_path=str(audio_path),
- duration=duration,
- seed=data.seed,
- db=db,
- instruct=data.instruct,
- )
-
- # Mark generation as complete
- task_manager.complete_generation(generation_id)
-
- return generation
-
- except ValueError as e:
- task_manager.complete_generation(generation_id)
- raise HTTPException(status_code=400, detail=str(e))
- except Exception as e:
- task_manager.complete_generation(generation_id)
- raise HTTPException(status_code=500, detail=str(e))
-
-
-# ============================================
-# HISTORY ENDPOINTS
-# ============================================
-
-@app.get("/history", response_model=models.HistoryListResponse)
-async def list_history(
- profile_id: Optional[str] = None,
- search: Optional[str] = None,
- limit: int = 50,
- offset: int = 0,
- db: Session = Depends(get_db),
-):
- """List generation history with optional filters."""
- query = models.HistoryQuery(
- profile_id=profile_id,
- search=search,
- limit=limit,
- offset=offset,
- )
- return await history.list_generations(query, db)
-
-
-@app.get("/history/stats")
-async def get_stats(db: Session = Depends(get_db)):
- """Get generation statistics."""
- return await history.get_generation_stats(db)
-
-
-@app.post("/history/import")
-async def import_generation(
- file: UploadFile = File(...),
- db: Session = Depends(get_db),
-):
- """Import a generation from a ZIP archive."""
- # Validate file size (max 50MB)
- MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
-
- # Read file content
- content = await file.read()
-
- if len(content) > MAX_FILE_SIZE:
- raise HTTPException(
- status_code=400,
- detail=f"File too large. Maximum size is {MAX_FILE_SIZE / (1024 * 1024)}MB"
- )
-
- try:
- result = await export_import.import_generation_from_zip(content, db)
- return result
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
-
-
-@app.get("/history/{generation_id}", response_model=models.HistoryResponse)
-async def get_generation(
- generation_id: str,
- db: Session = Depends(get_db),
-):
- """Get a generation by ID."""
- # Get generation with profile name
- result = db.query(
- DBGeneration,
- DBVoiceProfile.name.label('profile_name')
- ).join(
- DBVoiceProfile,
- DBGeneration.profile_id == DBVoiceProfile.id
- ).filter(
- DBGeneration.id == generation_id
- ).first()
-
- if not result:
- raise HTTPException(status_code=404, detail="Generation not found")
-
- gen, profile_name = result
- return models.HistoryResponse(
- id=gen.id,
- profile_id=gen.profile_id,
- profile_name=profile_name,
- text=gen.text,
- language=gen.language,
- audio_path=gen.audio_path,
- duration=gen.duration,
- seed=gen.seed,
- instruct=gen.instruct,
- created_at=gen.created_at,
- )
-
-
-@app.delete("/history/{generation_id}")
-async def delete_generation(
- generation_id: str,
- db: Session = Depends(get_db),
-):
- """Delete a generation."""
- success = await history.delete_generation(generation_id, db)
- if not success:
- raise HTTPException(status_code=404, detail="Generation not found")
- return {"message": "Generation deleted successfully"}
-
-
-@app.get("/history/{generation_id}/export")
-async def export_generation(
- generation_id: str,
- db: Session = Depends(get_db),
-):
- """Export a generation as a ZIP archive."""
- try:
- # Get generation to create filename
- generation = db.query(DBGeneration).filter_by(id=generation_id).first()
- if not generation:
- raise HTTPException(status_code=404, detail="Generation not found")
-
- # Export to ZIP
- zip_bytes = export_import.export_generation_to_zip(generation_id, db)
-
- # Create safe filename from text
- safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (' ', '-', '_')).strip()
- if not safe_text:
- safe_text = "generation"
- filename = f"generation-{safe_text}.voicebox.zip"
-
- # Return as streaming response
- return StreamingResponse(
- io.BytesIO(zip_bytes),
- media_type="application/zip",
- headers={
- "Content-Disposition": f'attachment; filename="{filename}"'
- }
- )
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
-
-
-@app.get("/history/{generation_id}/export-audio")
-async def export_generation_audio(
- generation_id: str,
- db: Session = Depends(get_db),
-):
- """Export only the audio file from a generation."""
- generation = db.query(DBGeneration).filter_by(id=generation_id).first()
- if not generation:
- raise HTTPException(status_code=404, detail="Generation not found")
-
- audio_path = Path(generation.audio_path)
- if not audio_path.exists():
- raise HTTPException(status_code=404, detail="Audio file not found")
-
- # Create safe filename from text
- safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (' ', '-', '_')).strip()
- if not safe_text:
- safe_text = "generation"
- filename = f"{safe_text}.wav"
-
- return FileResponse(
- audio_path,
- media_type="audio/wav",
- headers={
- "Content-Disposition": f'attachment; filename="{filename}"'
- }
- )
-
-
-# ============================================
-# TRANSCRIPTION ENDPOINTS
-# ============================================
-
-@app.post("/transcribe", response_model=models.TranscriptionResponse)
-async def transcribe_audio(
- file: UploadFile = File(...),
- language: Optional[str] = Form(None),
-):
- """Transcribe audio file to text."""
- # Save uploaded file to temporary location
- with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
- content = await file.read()
- tmp.write(content)
- tmp_path = tmp.name
-
- try:
- # Get audio duration
- from .utils.audio import load_audio
- audio, sr = load_audio(tmp_path)
- duration = len(audio) / sr
-
- # Transcribe
- whisper_model = transcribe.get_whisper_model()
-
- # Check if Whisper model is downloaded (uses default size "base")
- model_size = whisper_model.model_size
- model_name = f"openai/whisper-{model_size}"
-
- # Check if model is cached
- from huggingface_hub import constants as hf_constants
- repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_name.replace("/", "--"))
- if not repo_cache.exists():
- # Start download in background
- progress_model_name = f"whisper-{model_size}"
-
- async def download_whisper_background():
- try:
- await whisper_model.load_model_async(model_size)
- except Exception as e:
- get_task_manager().error_download(progress_model_name, str(e))
-
- get_task_manager().start_download(progress_model_name)
- asyncio.create_task(download_whisper_background())
-
- # Return 202 Accepted
- raise HTTPException(
- status_code=202,
- detail={
- "message": f"Whisper model {model_size} is being downloaded. Please wait and try again.",
- "model_name": progress_model_name,
- "downloading": True
- }
- )
-
- text = await whisper_model.transcribe(tmp_path, language)
-
- return models.TranscriptionResponse(
- text=text,
- duration=duration,
- )
-
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
- finally:
- # Clean up temp file
- Path(tmp_path).unlink(missing_ok=True)
-
-
-# ============================================
-# STORY ENDPOINTS
-# ============================================
-
-@app.get("/stories", response_model=List[models.StoryResponse])
-async def list_stories(db: Session = Depends(get_db)):
- """List all stories."""
- return await stories.list_stories(db)
-
-
-@app.post("/stories", response_model=models.StoryResponse)
-async def create_story(
- data: models.StoryCreate,
- db: Session = Depends(get_db),
-):
- """Create a new story."""
- try:
- return await stories.create_story(data, db)
- except Exception as e:
- raise HTTPException(status_code=400, detail=str(e))
-
-
-@app.get("/stories/{story_id}", response_model=models.StoryDetailResponse)
-async def get_story(
- story_id: str,
- db: Session = Depends(get_db),
-):
- """Get a story with all its items."""
- story = await stories.get_story(story_id, db)
- if not story:
- raise HTTPException(status_code=404, detail="Story not found")
- return story
-
-
-@app.put("/stories/{story_id}", response_model=models.StoryResponse)
-async def update_story(
- story_id: str,
- data: models.StoryCreate,
- db: Session = Depends(get_db),
-):
- """Update a story."""
- story = await stories.update_story(story_id, data, db)
- if not story:
- raise HTTPException(status_code=404, detail="Story not found")
- return story
-
-
-@app.delete("/stories/{story_id}")
-async def delete_story(
- story_id: str,
- db: Session = Depends(get_db),
-):
- """Delete a story."""
- success = await stories.delete_story(story_id, db)
- if not success:
- raise HTTPException(status_code=404, detail="Story not found")
- return {"message": "Story deleted successfully"}
-
-
-@app.post("/stories/{story_id}/items", response_model=models.StoryItemDetail)
-async def add_story_item(
- story_id: str,
- data: models.StoryItemCreate,
- db: Session = Depends(get_db),
-):
- """Add a generation to a story."""
- item = await stories.add_item_to_story(story_id, data, db)
- if not item:
- raise HTTPException(status_code=404, detail="Story or generation not found")
- return item
-
-
-@app.delete("/stories/{story_id}/items/{item_id}")
-async def remove_story_item(
- story_id: str,
- item_id: str,
- db: Session = Depends(get_db),
-):
- """Remove a story item from a story."""
- success = await stories.remove_item_from_story(story_id, item_id, db)
- if not success:
- raise HTTPException(status_code=404, detail="Story item not found")
- return {"message": "Item removed successfully"}
-
-
-@app.put("/stories/{story_id}/items/times")
-async def update_story_item_times(
- story_id: str,
- data: models.StoryItemBatchUpdate,
- db: Session = Depends(get_db),
-):
- """Update story item timecodes."""
- success = await stories.update_story_item_times(story_id, data, db)
- if not success:
- raise HTTPException(status_code=400, detail="Invalid timecode update request")
- return {"message": "Item timecodes updated successfully"}
-
-
-@app.put("/stories/{story_id}/items/reorder", response_model=List[models.StoryItemDetail])
-async def reorder_story_items(
- story_id: str,
- data: models.StoryItemReorder,
- db: Session = Depends(get_db),
-):
- """Reorder story items and recalculate timecodes."""
- items = await stories.reorder_story_items(story_id, data.generation_ids, db)
- if items is None:
- raise HTTPException(status_code=400, detail="Invalid reorder request - ensure all generation IDs belong to this story")
- return items
-
-
-@app.put("/stories/{story_id}/items/{item_id}/move", response_model=models.StoryItemDetail)
-async def move_story_item(
- story_id: str,
- item_id: str,
- data: models.StoryItemMove,
- db: Session = Depends(get_db),
-):
- """Move a story item (update position and/or track)."""
- item = await stories.move_story_item(story_id, item_id, data, db)
- if item is None:
- raise HTTPException(status_code=404, detail="Story item not found")
- return item
-
-
-@app.put("/stories/{story_id}/items/{item_id}/trim", response_model=models.StoryItemDetail)
-async def trim_story_item(
- story_id: str,
- item_id: str,
- data: models.StoryItemTrim,
- db: Session = Depends(get_db),
-):
- """Trim a story item (update trim_start_ms and trim_end_ms)."""
- item = await stories.trim_story_item(story_id, item_id, data, db)
- if item is None:
- raise HTTPException(status_code=404, detail="Story item not found or invalid trim values")
- return item
-
-
-@app.post("/stories/{story_id}/items/{item_id}/split", response_model=List[models.StoryItemDetail])
-async def split_story_item(
- story_id: str,
- item_id: str,
- data: models.StoryItemSplit,
- db: Session = Depends(get_db),
-):
- """Split a story item at a given time, creating two clips."""
- items = await stories.split_story_item(story_id, item_id, data, db)
- if items is None:
- raise HTTPException(status_code=404, detail="Story item not found or invalid split point")
- return items
-
-
-@app.post("/stories/{story_id}/items/{item_id}/duplicate", response_model=models.StoryItemDetail)
-async def duplicate_story_item(
- story_id: str,
- item_id: str,
- db: Session = Depends(get_db),
-):
- """Duplicate a story item, creating a copy with all properties."""
- item = await stories.duplicate_story_item(story_id, item_id, db)
- if item is None:
- raise HTTPException(status_code=404, detail="Story item not found")
- return item
-
-
-@app.get("/stories/{story_id}/export-audio")
-async def export_story_audio(
- story_id: str,
- db: Session = Depends(get_db),
-):
- """Export story as single mixed audio file with timecode-based mixing."""
- try:
- # Get story to create filename
- story = db.query(database.Story).filter_by(id=story_id).first()
- if not story:
- raise HTTPException(status_code=404, detail="Story not found")
-
- # Export audio
- audio_bytes = await stories.export_story_audio(story_id, db)
- if not audio_bytes:
- raise HTTPException(status_code=400, detail="Story has no audio items")
-
- # Create safe filename
- safe_name = "".join(c for c in story.name if c.isalnum() or c in (' ', '-', '_')).strip()
- if not safe_name:
- safe_name = "story"
- filename = f"{safe_name}.wav"
-
- # Return as streaming response
- return StreamingResponse(
- io.BytesIO(audio_bytes),
- media_type="audio/wav",
- headers={
- "Content-Disposition": f'attachment; filename="{filename}"'
- }
- )
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
-
-
-# ============================================
-# FILE SERVING
-# ============================================
-
-@app.get("/audio/{generation_id}")
-async def get_audio(generation_id: str, db: Session = Depends(get_db)):
- """Serve generated audio file."""
- generation = await history.get_generation(generation_id, db)
- if not generation:
- raise HTTPException(status_code=404, detail="Generation not found")
-
- audio_path = Path(generation.audio_path)
- if not audio_path.exists():
- raise HTTPException(status_code=404, detail="Audio file not found")
-
- return FileResponse(
- audio_path,
- media_type="audio/wav",
- filename=f"generation_{generation_id}.wav",
- )
-
-
-@app.get("/samples/{sample_id}")
-async def get_sample_audio(sample_id: str, db: Session = Depends(get_db)):
- """Serve profile sample audio file."""
- from .database import ProfileSample as DBProfileSample
-
- sample = db.query(DBProfileSample).filter_by(id=sample_id).first()
- if not sample:
- raise HTTPException(status_code=404, detail="Sample not found")
-
- audio_path = Path(sample.audio_path)
- if not audio_path.exists():
- raise HTTPException(status_code=404, detail="Audio file not found")
-
- return FileResponse(
- audio_path,
- media_type="audio/wav",
- filename=f"sample_{sample_id}.wav",
- )
-
-
-# ============================================
-# MODEL MANAGEMENT
-# ============================================
-
-@app.post("/models/load")
-async def load_model(model_size: str = "1.7B"):
- """Manually load TTS model."""
- try:
- tts_model = tts.get_tts_model()
- await tts_model.load_model_async(model_size)
- return {"message": f"Model {model_size} loaded successfully"}
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
-
-
-@app.post("/models/unload")
-async def unload_model():
- """Unload TTS model to free memory."""
- try:
- tts.unload_tts_model()
- return {"message": "Model unloaded successfully"}
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
-
-
-@app.get("/models/progress/{model_name}")
-async def get_model_progress(model_name: str):
- """Get model download progress via Server-Sent Events."""
- from fastapi.responses import StreamingResponse
-
- progress_manager = get_progress_manager()
-
- async def event_generator():
- """Generate SSE events for progress updates."""
- async for event in progress_manager.subscribe(model_name):
- yield event
-
- return StreamingResponse(
- event_generator(),
- media_type="text/event-stream",
- headers={
- "Cache-Control": "no-cache",
- "Connection": "keep-alive",
- "X-Accel-Buffering": "no",
- },
- )
-
-
-@app.get("/models/status", response_model=models.ModelStatusListResponse)
-async def get_model_status():
- """Get status of all available models."""
- from huggingface_hub import constants as hf_constants
- from pathlib import Path
-
- backend_type = get_backend_type()
- task_manager = get_task_manager()
-
- # Get set of currently downloading model names
- active_download_names = {task.model_name for task in task_manager.get_active_downloads()}
-
- # Try to import scan_cache_dir (might not be available in older versions)
- try:
- from huggingface_hub import scan_cache_dir
- use_scan_cache = True
- except ImportError:
- use_scan_cache = False
-
- def check_tts_loaded(model_size: str):
- """Check if TTS model is loaded with specific size."""
- try:
- tts_model = tts.get_tts_model()
- return tts_model.is_loaded() and getattr(tts_model, 'model_size', None) == model_size
- except Exception:
- return False
-
- def check_whisper_loaded(model_size: str):
- """Check if Whisper model is loaded with specific size."""
- try:
- whisper_model = transcribe.get_whisper_model()
- return whisper_model.is_loaded() and getattr(whisper_model, 'model_size', None) == model_size
- except Exception:
- return False
-
- # Use backend-specific model IDs
- if backend_type == "mlx":
- tts_1_7b_id = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16"
- tts_0_6b_id = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16" # Fallback to 1.7B
- # MLX backend uses openai/whisper-* models, not mlx-community
- whisper_base_id = "openai/whisper-base"
- whisper_small_id = "openai/whisper-small"
- whisper_medium_id = "openai/whisper-medium"
- whisper_large_id = "openai/whisper-large"
- 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"
-
- model_configs = [
- {
- "model_name": "qwen-tts-1.7B",
- "display_name": "Qwen TTS 1.7B",
- "hf_repo_id": tts_1_7b_id,
- "model_size": "1.7B",
- "check_loaded": lambda: check_tts_loaded("1.7B"),
- },
- {
- "model_name": "qwen-tts-0.6B",
- "display_name": "Qwen TTS 0.6B",
- "hf_repo_id": tts_0_6b_id,
- "model_size": "0.6B",
- "check_loaded": lambda: check_tts_loaded("0.6B"),
- },
- {
- "model_name": "whisper-base",
- "display_name": "Whisper Base",
- "hf_repo_id": whisper_base_id,
- "model_size": "base",
- "check_loaded": lambda: check_whisper_loaded("base"),
- },
- {
- "model_name": "whisper-small",
- "display_name": "Whisper Small",
- "hf_repo_id": whisper_small_id,
- "model_size": "small",
- "check_loaded": lambda: check_whisper_loaded("small"),
- },
- {
- "model_name": "whisper-medium",
- "display_name": "Whisper Medium",
- "hf_repo_id": whisper_medium_id,
- "model_size": "medium",
- "check_loaded": lambda: check_whisper_loaded("medium"),
- },
- {
- "model_name": "whisper-large",
- "display_name": "Whisper Large",
- "hf_repo_id": whisper_large_id,
- "model_size": "large",
- "check_loaded": lambda: check_whisper_loaded("large"),
- },
- ]
-
- # Build a mapping of model_name -> hf_repo_id so we can check if shared repos are downloading
- model_to_repo = {cfg["model_name"]: cfg["hf_repo_id"] for cfg in model_configs}
-
- # Get the set of hf_repo_ids that are currently being downloaded
- # This handles the case where multiple models share the same repo (e.g., 0.6B and 1.7B on MLX)
- active_download_repos = {model_to_repo.get(name) for name in active_download_names if name in model_to_repo}
-
- # Get HuggingFace cache info (if available)
- cache_info = None
- if use_scan_cache:
- try:
- cache_info = scan_cache_dir()
- except Exception:
- # Function failed, continue without it
- pass
-
- statuses = []
-
- for config in model_configs:
- try:
- downloaded = False
- size_mb = None
- loaded = False
-
- # Method 1: Try using scan_cache_dir if available
- if cache_info:
- repo_id = config["hf_repo_id"]
- for repo in cache_info.repos:
- if repo.repo_id == repo_id:
- # Check if actual model weight files exist (not just config files)
- # scan_cache_dir only shows completed files, so check if any are model weights
- has_model_weights = False
- for rev in repo.revisions:
- for f in rev.files:
- fname = f.file_name.lower()
- if fname.endswith(('.safetensors', '.bin', '.pt', '.pth', '.npz')):
- has_model_weights = True
- break
- if has_model_weights:
- break
-
- # Also check for .incomplete files in blobs directory (downloads in progress)
- has_incomplete = False
- try:
- cache_dir = hf_constants.HF_HUB_CACHE
- blobs_dir = Path(cache_dir) / ("models--" + repo_id.replace("/", "--")) / "blobs"
- if blobs_dir.exists():
- has_incomplete = any(blobs_dir.glob("*.incomplete"))
- except Exception:
- pass
-
- # Only mark as downloaded if we have model weights AND no incomplete files
- if has_model_weights and not has_incomplete:
- downloaded = True
- # Calculate size from cache info
- try:
- total_size = sum(revision.size_on_disk for revision in repo.revisions)
- size_mb = total_size / (1024 * 1024)
- except Exception:
- pass
- break
-
- # Method 2: Fallback to checking cache directory directly (using HuggingFace's OS-specific cache location)
- if not downloaded:
- try:
- cache_dir = hf_constants.HF_HUB_CACHE
- repo_cache = Path(cache_dir) / ("models--" + config["hf_repo_id"].replace("/", "--"))
-
- if repo_cache.exists():
- # Check for .incomplete files - if any exist, download is still in progress
- blobs_dir = repo_cache / "blobs"
- has_incomplete = blobs_dir.exists() and any(blobs_dir.glob("*.incomplete"))
-
- if not has_incomplete:
- # Check for actual model weight files (not just index files)
- # in the snapshots directory (symlinks to completed blobs)
- snapshots_dir = repo_cache / "snapshots"
- has_model_files = False
- if snapshots_dir.exists():
- has_model_files = (
- any(snapshots_dir.rglob("*.bin")) or
- any(snapshots_dir.rglob("*.safetensors")) or
- any(snapshots_dir.rglob("*.pt")) or
- any(snapshots_dir.rglob("*.pth")) or
- any(snapshots_dir.rglob("*.npz"))
- )
-
- if has_model_files:
- downloaded = True
- # Calculate size (exclude .incomplete files)
- try:
- total_size = sum(
- f.stat().st_size for f in repo_cache.rglob("*")
- if f.is_file() and not f.name.endswith('.incomplete')
- )
- size_mb = total_size / (1024 * 1024)
- except Exception:
- pass
- except Exception:
- pass
-
- # Method 3 removed - checking for config.json is too lenient
- # Methods 1 and 2 properly verify that model weight files exist
-
- # Check if loaded in memory
- try:
- loaded = config["check_loaded"]()
- except Exception:
- loaded = False
-
- # Check if this model (or its shared repo) is currently being downloaded
- is_downloading = config["hf_repo_id"] in active_download_repos
-
- # If downloading, don't report as downloaded (partial files exist)
- if is_downloading:
- downloaded = False
- size_mb = None # Don't show partial size during download
-
- statuses.append(models.ModelStatus(
- model_name=config["model_name"],
- display_name=config["display_name"],
- downloaded=downloaded,
- downloading=is_downloading,
- size_mb=size_mb,
- loaded=loaded,
- ))
- except Exception as e:
- # If check fails, try to at least check if loaded
- try:
- loaded = config["check_loaded"]()
- except Exception:
- loaded = False
-
- # Check if this model (or its shared repo) is currently being downloaded
- is_downloading = config["hf_repo_id"] in active_download_repos
-
- statuses.append(models.ModelStatus(
- model_name=config["model_name"],
- display_name=config["display_name"],
- downloaded=False, # Assume not downloaded if check failed
- downloading=is_downloading,
- size_mb=None,
- loaded=loaded,
- ))
-
- return models.ModelStatusListResponse(models=statuses)
-
-
-@app.post("/models/download")
-async def trigger_model_download(request: models.ModelDownloadRequest):
- """Trigger download of a specific model."""
- import asyncio
-
- task_manager = get_task_manager()
- progress_manager = get_progress_manager()
-
- model_configs = {
- "qwen-tts-1.7B": {
- "model_size": "1.7B",
- "load_func": lambda: tts.get_tts_model().load_model("1.7B"),
- },
- "qwen-tts-0.6B": {
- "model_size": "0.6B",
- "load_func": lambda: tts.get_tts_model().load_model("0.6B"),
- },
- "whisper-base": {
- "model_size": "base",
- "load_func": lambda: transcribe.get_whisper_model().load_model("base"),
- },
- "whisper-small": {
- "model_size": "small",
- "load_func": lambda: transcribe.get_whisper_model().load_model("small"),
- },
- "whisper-medium": {
- "model_size": "medium",
- "load_func": lambda: transcribe.get_whisper_model().load_model("medium"),
- },
- "whisper-large": {
- "model_size": "large",
- "load_func": lambda: transcribe.get_whisper_model().load_model("large"),
- },
- }
-
- if request.model_name not in model_configs:
- raise HTTPException(status_code=400, detail=f"Unknown model: {request.model_name}")
-
- config = model_configs[request.model_name]
-
- async def download_in_background():
- """Download model in background without blocking the HTTP request."""
- try:
- # Call the load function (which may be async)
- result = config["load_func"]()
- # If it's a coroutine, await it
- if asyncio.iscoroutine(result):
- await result
- task_manager.complete_download(request.model_name)
- except Exception as e:
- task_manager.error_download(request.model_name, str(e))
-
- # Start tracking download
- task_manager.start_download(request.model_name)
-
- # Initialize progress state so SSE endpoint has initial data to send.
- # This fixes a race condition where the frontend connects to SSE before
- # any progress callbacks have fired (especially for large models like Qwen
- # where huggingface_hub takes time to fetch metadata for all files).
- progress_manager.update_progress(
- model_name=request.model_name,
- current=0,
- total=0, # Will be updated once actual total is known
- filename="Connecting to HuggingFace...",
- status="downloading",
- )
-
- # Start download in background task (don't await)
- asyncio.create_task(download_in_background())
-
- # Return immediately - frontend should poll progress endpoint
- return {"message": f"Model {request.model_name} download started"}
-
-
-@app.delete("/models/{model_name}")
-async def delete_model(model_name: str):
- """Delete a downloaded model from the HuggingFace cache."""
- import shutil
- import os
- from huggingface_hub import constants as hf_constants
-
- # Map model names to HuggingFace repo IDs
- model_configs = {
- "qwen-tts-1.7B": {
- "hf_repo_id": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
- "model_size": "1.7B",
- "model_type": "tts",
- },
- "qwen-tts-0.6B": {
- "hf_repo_id": "Qwen/Qwen3-TTS-12Hz-0.6B-Base",
- "model_size": "0.6B",
- "model_type": "tts",
- },
- "whisper-base": {
- "hf_repo_id": "openai/whisper-base",
- "model_size": "base",
- "model_type": "whisper",
- },
- "whisper-small": {
- "hf_repo_id": "openai/whisper-small",
- "model_size": "small",
- "model_type": "whisper",
- },
- "whisper-medium": {
- "hf_repo_id": "openai/whisper-medium",
- "model_size": "medium",
- "model_type": "whisper",
- },
- "whisper-large": {
- "hf_repo_id": "openai/whisper-large",
- "model_size": "large",
- "model_type": "whisper",
- },
- }
-
- if model_name not in model_configs:
- raise HTTPException(status_code=400, detail=f"Unknown model: {model_name}")
-
- config = model_configs[model_name]
- hf_repo_id = config["hf_repo_id"]
-
- try:
- # Check if model is loaded and unload it first
- if config["model_type"] == "tts":
- 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"] == "whisper":
- whisper_model = transcribe.get_whisper_model()
- if whisper_model.is_loaded() and whisper_model.model_size == config["model_size"]:
- transcribe.unload_whisper_model()
-
- # Find and delete the cache directory (using HuggingFace's OS-specific cache location)
- cache_dir = hf_constants.HF_HUB_CACHE
- repo_cache_dir = Path(cache_dir) / ("models--" + hf_repo_id.replace("/", "--"))
-
- # Check if the cache directory exists
- if not repo_cache_dir.exists():
- raise HTTPException(status_code=404, detail=f"Model {model_name} not found in cache")
-
- # Delete the entire cache directory for this model
- try:
- shutil.rmtree(repo_cache_dir)
- except OSError as e:
- raise HTTPException(
- status_code=500,
- detail=f"Failed to delete model cache directory: {str(e)}"
- )
-
- return {"message": f"Model {model_name} deleted successfully"}
-
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=f"Failed to delete model: {str(e)}")
-
-
-@app.post("/cache/clear")
-async def clear_cache():
- """Clear all voice prompt caches (memory and disk)."""
- try:
- deleted_count = clear_voice_prompt_cache()
- return {
- "message": f"Voice prompt cache cleared successfully",
- "files_deleted": deleted_count,
- }
- except Exception as e:
- raise HTTPException(status_code=500, detail=f"Failed to clear cache: {str(e)}")
-
-
-# ============================================
-# TASK MANAGEMENT
-# ============================================
-
-@app.get("/tasks/active", response_model=models.ActiveTasksResponse)
-async def get_active_tasks():
- """Return all currently active downloads and generations."""
- task_manager = get_task_manager()
- progress_manager = get_progress_manager()
-
- # Get active downloads from both task manager and progress manager
- # Task manager tracks which downloads are active
- # Progress manager has the actual progress data
- active_downloads = []
- task_manager_downloads = task_manager.get_active_downloads()
- progress_active = progress_manager.get_all_active()
-
- # Combine data from both sources
- download_map = {task.model_name: task for task in task_manager_downloads}
- progress_map = {p["model_name"]: p for p in progress_active}
-
- # Create unified list
- all_model_names = set(download_map.keys()) | set(progress_map.keys())
- for model_name in all_model_names:
- task = download_map.get(model_name)
- progress = progress_map.get(model_name)
-
- if task:
- active_downloads.append(models.ActiveDownloadTask(
- model_name=model_name,
- status=task.status,
- started_at=task.started_at,
- ))
- elif progress:
- # Progress exists but no task - create from progress data
- timestamp_str = progress.get("timestamp")
- if timestamp_str:
- try:
- started_at = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
- except (ValueError, AttributeError):
- started_at = datetime.utcnow()
- else:
- started_at = datetime.utcnow()
-
- active_downloads.append(models.ActiveDownloadTask(
- model_name=model_name,
- status=progress.get("status", "downloading"),
- started_at=started_at,
- ))
-
- # Get active generations
- active_generations = []
- for gen_task in task_manager.get_active_generations():
- active_generations.append(models.ActiveGenerationTask(
- task_id=gen_task.task_id,
- profile_id=gen_task.profile_id,
- text_preview=gen_task.text_preview,
- started_at=gen_task.started_at,
- ))
-
- return models.ActiveTasksResponse(
- downloads=active_downloads,
- generations=active_generations,
- )
-
-
-# ============================================
-# STARTUP & SHUTDOWN
-# ============================================
-
-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)})"
- elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
- return "MPS (Apple Silicon)"
- elif backend_type == "mlx":
- return "Metal (Apple Silicon via MLX)"
- return "None (CPU only)"
-
-
-@app.on_event("startup")
-async def startup_event():
- """Run on application startup."""
- print("voicebox API starting up...")
- database.init_db()
- print(f"Database initialized at {database._db_path}")
- backend_type = get_backend_type()
- print(f"Backend: {backend_type.upper()}")
- print(f"GPU available: {_get_gpu_status()}")
-
- # Initialize progress manager with main event loop for thread-safe operations
- try:
- progress_manager = get_progress_manager()
- progress_manager._set_main_loop(asyncio.get_running_loop())
- print("Progress manager initialized with event loop")
- except Exception as e:
- print(f"Warning: Could not initialize progress manager event loop: {e}")
-
- # Ensure HuggingFace cache directory exists
- try:
- from huggingface_hub import constants as hf_constants
- cache_dir = Path(hf_constants.HF_HUB_CACHE)
- cache_dir.mkdir(parents=True, exist_ok=True)
- print(f"HuggingFace cache directory: {cache_dir}")
- except Exception as e:
- print(f"Warning: Could not create HuggingFace cache directory: {e}")
- print("Model downloads may fail. Please ensure the directory exists and has write permissions.")
-
-
-@app.on_event("shutdown")
-async def shutdown_event():
- """Run on application shutdown."""
- print("voicebox API shutting down...")
- # Unload models to free memory
- tts.unload_tts_model()
- transcribe.unload_whisper_model()
-
-
-# ============================================
-# MAIN
-# ============================================
+from .app import app # noqa: F401 -- re-export for uvicorn "backend.main:app"
+from . import config, database
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="voicebox backend server")
@@ -1718,16 +32,14 @@ if __name__ == "__main__":
)
args = parser.parse_args()
- # Set data directory if provided
if args.data_dir:
config.set_data_dir(args.data_dir)
- # Initialize database after data directory is set
database.init_db()
uvicorn.run(
"backend.main:app",
host=args.host,
port=args.port,
- reload=False, # Disable reload in production
+ reload=False,
)
diff --git a/backend/migrate_add_instruct.py b/backend/migrate_add_instruct.py
deleted file mode 100644
index 4d899cf3..00000000
--- a/backend/migrate_add_instruct.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""
-Database migration script to add instruct column to generations table.
-
-Run this once to update existing databases:
- python -m backend.migrate_add_instruct
-"""
-
-import sqlite3
-import os
-from pathlib import Path
-
-
-def migrate():
- """Add instruct column to generations table if it doesn't exist."""
- # Get data directory
- data_dir = os.environ.get("VOICEBOX_DATA_DIR")
- if data_dir:
- db_path = Path(data_dir) / "voicebox.db"
- else:
- db_path = Path.cwd() / "data" / "voicebox.db"
-
- if not db_path.exists():
- print(f"Database not found at {db_path}, skipping migration")
- return
-
- conn = sqlite3.connect(db_path)
- cursor = conn.cursor()
-
- # Check if instruct column already exists
- cursor.execute("PRAGMA table_info(generations)")
- columns = [row[1] for row in cursor.fetchall()]
-
- if 'instruct' in columns:
- print("instruct column already exists, skipping migration")
- conn.close()
- return
-
- # Add instruct column
- print("Adding instruct column to generations table...")
- cursor.execute("ALTER TABLE generations ADD COLUMN instruct TEXT")
- conn.commit()
- conn.close()
-
- print("Migration complete!")
-
-
-if __name__ == "__main__":
- migrate()
diff --git a/backend/models.py b/backend/models.py
index 59e45405..ef8e196d 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -9,18 +9,25 @@ from datetime import datetime
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):
"""Response model for voice profile."""
+
id: str
name: str
description: Optional[str]
language: str
avatar_path: Optional[str] = None
+ effects_chain: Optional[List["EffectConfig"]] = None
+ generation_count: int = 0
+ sample_count: int = 0
created_at: datetime
updated_at: datetime
@@ -30,16 +37,19 @@ class VoiceProfileResponse(BaseModel):
class ProfileSampleCreate(BaseModel):
"""Request model for adding a sample to a profile."""
+
reference_text: str = Field(..., min_length=1, max_length=1000)
class ProfileSampleUpdate(BaseModel):
"""Request model for updating a profile sample."""
+
reference_text: str = Field(..., min_length=1, max_length=1000)
class ProfileSampleResponse(BaseModel):
"""Response model for profile sample."""
+
id: str
profile_id: str
audio_path: str
@@ -51,25 +61,45 @@ class ProfileSampleResponse(BaseModel):
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)$")
+ text: str = Field(..., min_length=1, max_length=50000)
+ 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)$")
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)$")
+ max_chunk_chars: int = Field(
+ default=800, ge=100, le=5000, description="Max characters per chunk for long text splitting"
+ )
+ crossfade_ms: int = Field(
+ default=50, ge=0, le=500, description="Crossfade duration in ms between chunks (0 for hard cut)"
+ )
+ normalize: bool = Field(default=True, description="Normalize output audio volume")
+ effects_chain: Optional[List["EffectConfig"]] = Field(
+ None, description="Effects chain to apply after generation (overrides profile default)"
+ )
class GenerationResponse(BaseModel):
"""Response model for voice generation."""
+
id: str
profile_id: str
text: str
language: str
- audio_path: str
- duration: float
- seed: Optional[int]
- instruct: Optional[str]
+ audio_path: Optional[str] = None
+ duration: Optional[float] = None
+ seed: Optional[int] = None
+ instruct: Optional[str] = None
+ engine: Optional[str] = "qwen"
+ model_size: Optional[str] = None
+ status: str = "completed"
+ error: Optional[str] = None
+ is_favorited: bool = False
created_at: datetime
+ versions: Optional[List["GenerationVersionResponse"]] = None
+ active_version_id: Optional[str] = None
class Config:
from_attributes = True
@@ -77,6 +107,7 @@ class GenerationResponse(BaseModel):
class HistoryQuery(BaseModel):
"""Query model for generation history."""
+
profile_id: Optional[str] = None
search: Optional[str] = None
limit: int = Field(default=50, ge=1, le=100)
@@ -85,16 +116,24 @@ class HistoryQuery(BaseModel):
class HistoryResponse(BaseModel):
"""Response model for history entry (includes profile name)."""
+
id: str
profile_id: str
profile_name: str
text: str
language: str
- audio_path: str
- duration: float
- seed: Optional[int]
- instruct: Optional[str]
+ audio_path: Optional[str] = None
+ duration: Optional[float] = None
+ seed: Optional[int] = None
+ instruct: Optional[str] = None
+ engine: Optional[str] = "qwen"
+ model_size: Optional[str] = None
+ status: str = "completed"
+ error: Optional[str] = None
+ is_favorited: bool = False
created_at: datetime
+ versions: Optional[List["GenerationVersionResponse"]] = None
+ active_version_id: Optional[str] = None
class Config:
from_attributes = True
@@ -102,23 +141,27 @@ class HistoryResponse(BaseModel):
class HistoryListResponse(BaseModel):
"""Response model for history list."""
+
items: List[HistoryResponse]
total: int
class TranscriptionRequest(BaseModel):
"""Request model for audio transcription."""
+
language: Optional[str] = Field(None, pattern="^(en|zh)$")
class TranscriptionResponse(BaseModel):
"""Response model for transcription."""
+
text: str
duration: float
class HealthResponse(BaseModel):
"""Response model for health check."""
+
status: str
model_loaded: bool
model_downloaded: Optional[bool] = None # Whether model is cached/downloaded
@@ -127,12 +170,33 @@ 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
@@ -141,23 +205,38 @@ class ModelStatus(BaseModel):
class ModelStatusListResponse(BaseModel):
"""Response model for model status list."""
+
models: List[ModelStatus]
class ModelDownloadRequest(BaseModel):
"""Request model for triggering model download."""
+
model_name: str
+class ModelMigrateRequest(BaseModel):
+ """Request model for migrating models to a new directory."""
+
+ destination: str
+
+
class ActiveDownloadTask(BaseModel):
"""Response model for active download task."""
+
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):
"""Response model for active generation task."""
+
task_id: str
profile_id: str
text_preview: str
@@ -166,24 +245,28 @@ class ActiveGenerationTask(BaseModel):
class ActiveTasksResponse(BaseModel):
"""Response model for active tasks."""
+
downloads: List[ActiveDownloadTask]
generations: List[ActiveGenerationTask]
class AudioChannelCreate(BaseModel):
"""Request model for creating an audio channel."""
+
name: str = Field(..., min_length=1, max_length=100)
device_ids: List[str] = Field(default_factory=list)
class AudioChannelUpdate(BaseModel):
"""Request model for updating an audio channel."""
+
name: Optional[str] = Field(None, min_length=1, max_length=100)
device_ids: Optional[List[str]] = None
class AudioChannelResponse(BaseModel):
"""Response model for audio channel."""
+
id: str
name: str
is_default: bool
@@ -196,22 +279,26 @@ class AudioChannelResponse(BaseModel):
class ChannelVoiceAssignment(BaseModel):
"""Request model for assigning voices to a channel."""
+
profile_ids: List[str]
class ProfileChannelAssignment(BaseModel):
"""Request model for assigning channels to a profile."""
+
channel_ids: List[str]
class StoryCreate(BaseModel):
"""Request model for creating a story."""
+
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
class StoryResponse(BaseModel):
"""Response model for story (list view)."""
+
id: str
name: str
description: Optional[str]
@@ -225,9 +312,11 @@ class StoryResponse(BaseModel):
class StoryItemDetail(BaseModel):
"""Detail model for story item with generation info."""
+
id: str
story_id: str
generation_id: str
+ version_id: Optional[str] = None
start_time_ms: int
track: int = 0
trim_start_ms: int = 0
@@ -243,6 +332,9 @@ class StoryItemDetail(BaseModel):
seed: Optional[int]
instruct: Optional[str]
generation_created_at: datetime
+ # Versions available for this generation
+ versions: Optional[List["GenerationVersionResponse"]] = None
+ active_version_id: Optional[str] = None
class Config:
from_attributes = True
@@ -250,6 +342,7 @@ class StoryItemDetail(BaseModel):
class StoryDetailResponse(BaseModel):
"""Response model for story with items."""
+
id: str
name: str
description: Optional[str]
@@ -263,6 +356,7 @@ class StoryDetailResponse(BaseModel):
class StoryItemCreate(BaseModel):
"""Request model for adding a generation to a story."""
+
generation_id: str
start_time_ms: Optional[int] = None # If not provided, will be calculated automatically
track: Optional[int] = 0 # Track number (0 = main track)
@@ -270,32 +364,146 @@ class StoryItemCreate(BaseModel):
class StoryItemUpdateTime(BaseModel):
"""Request model for updating a story item's timecode."""
+
generation_id: str
start_time_ms: int = Field(..., ge=0)
class StoryItemBatchUpdate(BaseModel):
"""Request model for batch updating story item timecodes."""
+
updates: List[StoryItemUpdateTime]
class StoryItemReorder(BaseModel):
"""Request model for reordering story items."""
+
generation_ids: List[str] = Field(..., min_length=1)
class StoryItemMove(BaseModel):
"""Request model for moving a story item (position and/or track)."""
+
start_time_ms: int = Field(..., ge=0)
track: int = 0
class StoryItemTrim(BaseModel):
"""Request model for trimming a story item."""
+
trim_start_ms: int = Field(..., ge=0)
trim_end_ms: int = Field(..., ge=0)
class StoryItemSplit(BaseModel):
"""Request model for splitting a story item."""
+
split_time_ms: int = Field(..., ge=0) # Time within the clip to split at (relative to clip start)
+
+
+class StoryItemVersionUpdate(BaseModel):
+ """Request model for setting a story item's pinned version."""
+
+ version_id: Optional[str] = None # null = use generation default
+
+
+class EffectConfig(BaseModel):
+ """A single effect in an effects chain."""
+
+ type: str
+ enabled: bool = True
+ params: dict = Field(default_factory=dict)
+
+
+class EffectsChain(BaseModel):
+ """An ordered list of effects to apply."""
+
+ effects: List[EffectConfig] = Field(default_factory=list)
+
+
+class EffectPresetCreate(BaseModel):
+ """Request model for creating an effect preset."""
+
+ name: str = Field(..., min_length=1, max_length=100)
+ description: Optional[str] = Field(None, max_length=500)
+ effects_chain: List[EffectConfig]
+
+
+class EffectPresetUpdate(BaseModel):
+ """Request model for updating an effect preset."""
+
+ name: Optional[str] = Field(None, min_length=1, max_length=100)
+ description: Optional[str] = None
+ effects_chain: Optional[List[EffectConfig]] = None
+
+
+class EffectPresetResponse(BaseModel):
+ """Response model for effect preset."""
+
+ id: str
+ name: str
+ description: Optional[str] = None
+ effects_chain: List[EffectConfig]
+ is_builtin: bool = False
+ created_at: datetime
+
+ class Config:
+ from_attributes = True
+
+
+class GenerationVersionResponse(BaseModel):
+ """Response model for a generation version."""
+
+ id: str
+ generation_id: str
+ label: str
+ audio_path: str
+ effects_chain: Optional[List[EffectConfig]] = None
+ source_version_id: Optional[str] = None
+ is_default: bool
+ created_at: datetime
+
+ class Config:
+ from_attributes = True
+
+
+class ApplyEffectsRequest(BaseModel):
+ """Request to apply effects to an existing generation."""
+
+ effects_chain: List[EffectConfig]
+ source_version_id: Optional[str] = Field(
+ None, description="Version to use as source audio (defaults to clean/original)"
+ )
+ label: Optional[str] = Field(None, max_length=100, description="Label for this version (auto-generated if omitted)")
+ set_as_default: bool = Field(default=True, description="Set this version as the default")
+
+
+class ProfileEffectsUpdate(BaseModel):
+ """Request to update the default effects chain on a profile."""
+
+ effects_chain: Optional[List[EffectConfig]] = Field(None, description="Effects chain (null to remove)")
+
+
+class AvailableEffectParam(BaseModel):
+ """Description of a single effect parameter."""
+
+ default: float
+ min: float
+ max: float
+ step: float
+ description: str
+
+
+class AvailableEffect(BaseModel):
+ """Description of an available effect type."""
+
+ type: str
+ label: str
+ description: str
+ params: dict # param_name -> AvailableEffectParam
+
+
+class AvailableEffectsResponse(BaseModel):
+ """Response listing all available effect types."""
+
+ effects: List[AvailableEffect]
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
new file mode 100644
index 00000000..7476bf89
--- /dev/null
+++ b/backend/pyproject.toml
@@ -0,0 +1,83 @@
+[project]
+name = "voicebox-backend"
+version = "0.2.3"
+requires-python = ">=3.12"
+
+# ---------------------------------------------------------------------------
+# Ruff – linter + formatter
+# ---------------------------------------------------------------------------
+
+[tool.ruff]
+target-version = "py312"
+line-length = 120
+src = ["."]
+
+# Files/dirs to skip entirely.
+extend-exclude = [
+ "voicebox-server.spec",
+ "build_binary.py",
+]
+
+[tool.ruff.lint]
+select = [
+ "F", # pyflakes
+ "E", # pycodestyle errors
+ "W", # pycodestyle warnings
+ "I", # isort
+ "N", # pep8-naming
+ "UP", # pyupgrade (modernize syntax for 3.12)
+ "B", # flake8-bugbear
+ "A", # flake8-builtins (shadowing built-in names)
+ "SIM", # flake8-simplify
+ "T20", # flake8-print (flag print() calls)
+ "RET", # flake8-return
+ "PIE", # misc lints
+ "PT", # flake8-pytest-style
+ "RUF", # ruff-specific rules
+ "ERA", # commented-out code detection
+ "FIX", # flag TODO/FIXME/HACK/XXX for review
+]
+
+ignore = [
+ # Allow print() in existing code -- remove items from this list as files
+ # are migrated to logging during the refactor.
+ "T201", # print() found
+
+ # These conflict with the formatter or are too noisy during migration:
+ "E501", # line too long (formatter handles this)
+ "RET504", # unnecessary assignment before return
+ "SIM108", # use ternary operator (sometimes less readable)
+ "B008", # function call in default argument (FastAPI Depends() pattern)
+ "UP007", # use X | Y for union (auto-fixed by UP, but noisy on big diffs)
+]
+
+# Per-file rule overrides.
+[tool.ruff.lint.per-file-ignores]
+# Tests can use assert, print, and magic values freely.
+"tests/**" = ["S101", "T201", "PLR2004", "ERA001"]
+# __init__.py re-exports are expected to have unused imports.
+"**/__init__.py" = ["F401"]
+# Entry points and scripts legitimately use print.
+"server.py" = ["T201"]
+"main.py" = ["T201"]
+# AMD GPU env vars must be set before torch import.
+"app.py" = ["E402"]
+
+[tool.ruff.lint.isort]
+known-first-party = ["backend"]
+# Group "from backend.*" imports into the first-party section.
+force-single-line = false
+combine-as-imports = true
+
+[tool.ruff.format]
+quote-style = "double"
+indent-style = "space"
+docstring-code-format = true
+
+# ---------------------------------------------------------------------------
+# pytest
+# ---------------------------------------------------------------------------
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+asyncio_mode = "auto"
diff --git a/backend/requirements.txt b/backend/requirements.txt
index e0f6ded5..0d927975 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -9,15 +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
+pedalboard>=0.9.0
+
+# HTTP client (for CUDA backend download)
+httpx>=0.27.0
# Utilities
python-multipart>=0.0.6
diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py
new file mode 100644
index 00000000..2ee2c956
--- /dev/null
+++ b/backend/routes/__init__.py
@@ -0,0 +1,32 @@
+"""Route registration for the voicebox API."""
+
+from fastapi import FastAPI
+
+
+def register_routers(app: FastAPI) -> None:
+ """Include all domain routers on the application."""
+ from .health import router as health_router
+ from .profiles import router as profiles_router
+ from .channels import router as channels_router
+ from .generations import router as generations_router
+ from .history import router as history_router
+ from .transcription import router as transcription_router
+ from .stories import router as stories_router
+ from .effects import router as effects_router
+ from .audio import router as audio_router
+ from .models import router as models_router
+ from .tasks import router as tasks_router
+ from .cuda import router as cuda_router
+
+ app.include_router(health_router)
+ app.include_router(profiles_router)
+ app.include_router(channels_router)
+ app.include_router(generations_router)
+ app.include_router(history_router)
+ app.include_router(transcription_router)
+ app.include_router(stories_router)
+ app.include_router(effects_router)
+ app.include_router(audio_router)
+ app.include_router(models_router)
+ app.include_router(tasks_router)
+ app.include_router(cuda_router)
diff --git a/backend/routes/audio.py b/backend/routes/audio.py
new file mode 100644
index 00000000..682d7aae
--- /dev/null
+++ b/backend/routes/audio.py
@@ -0,0 +1,71 @@
+"""Audio file serving endpoints."""
+
+from pathlib import Path
+
+from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import FileResponse
+from sqlalchemy.orm import Session
+
+from .. import models
+from ..services import history
+from ..database import get_db
+
+router = APIRouter()
+
+
+@router.get("/audio/version/{version_id}")
+async def get_version_audio(version_id: str, db: Session = Depends(get_db)):
+ """Serve audio for a specific version."""
+ from ..services import versions as versions_mod
+
+ version = versions_mod.get_version(version_id, db)
+ if not version:
+ raise HTTPException(status_code=404, detail="Version not found")
+
+ audio_path = Path(version.audio_path)
+ if not audio_path.exists():
+ raise HTTPException(status_code=404, detail="Audio file not found")
+
+ return FileResponse(
+ audio_path,
+ media_type="audio/wav",
+ filename=f"generation_{version.generation_id}_{version.label}.wav",
+ )
+
+
+@router.get("/audio/{generation_id}")
+async def get_audio(generation_id: str, db: Session = Depends(get_db)):
+ """Serve generated audio file (serves the default version)."""
+ generation = await history.get_generation(generation_id, db)
+ if not generation:
+ raise HTTPException(status_code=404, detail="Generation not found")
+
+ audio_path = Path(generation.audio_path)
+ if not audio_path.exists():
+ raise HTTPException(status_code=404, detail="Audio file not found")
+
+ return FileResponse(
+ audio_path,
+ media_type="audio/wav",
+ filename=f"generation_{generation_id}.wav",
+ )
+
+
+@router.get("/samples/{sample_id}")
+async def get_sample_audio(sample_id: str, db: Session = Depends(get_db)):
+ """Serve profile sample audio file."""
+ from ..database import ProfileSample as DBProfileSample
+
+ sample = db.query(DBProfileSample).filter_by(id=sample_id).first()
+ if not sample:
+ raise HTTPException(status_code=404, detail="Sample not found")
+
+ audio_path = Path(sample.audio_path)
+ if not audio_path.exists():
+ raise HTTPException(status_code=404, detail="Audio file not found")
+
+ return FileResponse(
+ audio_path,
+ media_type="audio/wav",
+ filename=f"sample_{sample_id}.wav",
+ )
diff --git a/backend/routes/channels.py b/backend/routes/channels.py
new file mode 100644
index 00000000..c13162fb
--- /dev/null
+++ b/backend/routes/channels.py
@@ -0,0 +1,98 @@
+"""Audio channel endpoints."""
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.orm import Session
+
+from .. import models
+from ..services import channels
+from ..database import get_db
+
+router = APIRouter()
+
+
+@router.get("/channels", response_model=list[models.AudioChannelResponse])
+async def list_channels(db: Session = Depends(get_db)):
+ """List all audio channels."""
+ return await channels.list_channels(db)
+
+
+@router.post("/channels", response_model=models.AudioChannelResponse)
+async def create_channel(
+ data: models.AudioChannelCreate,
+ db: Session = Depends(get_db),
+):
+ """Create a new audio channel."""
+ try:
+ return await channels.create_channel(data, db)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.get("/channels/{channel_id}", response_model=models.AudioChannelResponse)
+async def get_channel(
+ channel_id: str,
+ db: Session = Depends(get_db),
+):
+ """Get an audio channel by ID."""
+ channel = await channels.get_channel(channel_id, db)
+ if not channel:
+ raise HTTPException(status_code=404, detail="Channel not found")
+ return channel
+
+
+@router.put("/channels/{channel_id}", response_model=models.AudioChannelResponse)
+async def update_channel(
+ channel_id: str,
+ data: models.AudioChannelUpdate,
+ db: Session = Depends(get_db),
+):
+ """Update an audio channel."""
+ try:
+ channel = await channels.update_channel(channel_id, data, db)
+ if not channel:
+ raise HTTPException(status_code=404, detail="Channel not found")
+ return channel
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.delete("/channels/{channel_id}")
+async def delete_channel(
+ channel_id: str,
+ db: Session = Depends(get_db),
+):
+ """Delete an audio channel."""
+ try:
+ success = await channels.delete_channel(channel_id, db)
+ if not success:
+ raise HTTPException(status_code=404, detail="Channel not found")
+ return {"message": "Channel deleted successfully"}
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.get("/channels/{channel_id}/voices")
+async def get_channel_voices(
+ channel_id: str,
+ db: Session = Depends(get_db),
+):
+ """Get list of profile IDs assigned to a channel."""
+ try:
+ profile_ids = await channels.get_channel_voices(channel_id, db)
+ return {"profile_ids": profile_ids}
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.put("/channels/{channel_id}/voices")
+async def set_channel_voices(
+ channel_id: str,
+ data: models.ChannelVoiceAssignment,
+ db: Session = Depends(get_db),
+):
+ """Set which voices are assigned to a channel."""
+ try:
+ await channels.set_channel_voices(channel_id, data, db)
+ return {"message": "Channel voices updated successfully"}
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
diff --git a/backend/routes/cuda.py b/backend/routes/cuda.py
new file mode 100644
index 00000000..cd9d5766
--- /dev/null
+++ b/backend/routes/cuda.py
@@ -0,0 +1,82 @@
+"""CUDA backend management endpoints."""
+
+import logging
+
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import StreamingResponse
+
+from ..services.task_queue import create_background_task
+from ..utils.progress import get_progress_manager
+
+router = APIRouter()
+
+logger = logging.getLogger(__name__)
+
+
+@router.get("/backend/cuda-status")
+async def get_cuda_status():
+ """Get CUDA backend download/availability status."""
+ from ..services import cuda
+
+ return cuda.get_cuda_status()
+
+
+@router.post("/backend/download-cuda")
+async def download_cuda_backend():
+ """Download the CUDA backend binary."""
+ from ..services import cuda
+
+ if cuda.get_cuda_binary_path() is not None:
+ raise HTTPException(status_code=409, detail="CUDA backend already downloaded")
+
+ progress_manager = get_progress_manager()
+ existing = progress_manager.get_progress(cuda.PROGRESS_KEY)
+ if existing and existing.get("status") == "downloading":
+ raise HTTPException(status_code=409, detail="CUDA backend download already in progress")
+
+ async def _download():
+ try:
+ await cuda.download_cuda_binary()
+ except Exception as e:
+ logger.error("CUDA download failed: %s", e)
+
+ create_background_task(_download())
+ return {"message": "CUDA backend download started", "progress_key": "cuda-backend"}
+
+
+@router.delete("/backend/cuda")
+async def delete_cuda_backend():
+ """Delete the downloaded CUDA backend binary."""
+ from ..services import cuda
+
+ if cuda.is_cuda_active():
+ raise HTTPException(
+ status_code=409,
+ detail="Cannot delete CUDA backend while it is active. Switch to CPU first.",
+ )
+
+ deleted = await cuda.delete_cuda_binary()
+ if not deleted:
+ raise HTTPException(status_code=404, detail="No CUDA backend found to delete")
+
+ return {"message": "CUDA backend deleted"}
+
+
+@router.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",
+ },
+ )
diff --git a/backend/routes/effects.py b/backend/routes/effects.py
new file mode 100644
index 00000000..8139176d
--- /dev/null
+++ b/backend/routes/effects.py
@@ -0,0 +1,261 @@
+"""Effects presets and generation version endpoints."""
+
+import asyncio
+import io
+import uuid
+from pathlib import Path
+
+from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import StreamingResponse
+from sqlalchemy.orm import Session
+
+from .. import config, models
+from ..services import history
+from ..database import Generation as DBGeneration, get_db
+
+router = APIRouter()
+
+
+@router.post("/effects/preview/{generation_id}")
+async def preview_effects(
+ generation_id: str,
+ data: models.ApplyEffectsRequest,
+ db: Session = Depends(get_db),
+):
+ """Apply effects to a generation's clean audio and stream back without saving."""
+ gen = db.query(DBGeneration).filter_by(id=generation_id).first()
+ if not gen:
+ raise HTTPException(status_code=404, detail="Generation not found")
+ if (gen.status or "completed") != "completed":
+ raise HTTPException(status_code=400, detail="Generation is not completed")
+
+ from ..services import versions as versions_mod
+ from ..utils.effects import apply_effects, validate_effects_chain
+ from ..utils.audio import load_audio
+
+ chain_dicts = [e.model_dump() for e in data.effects_chain]
+ error = validate_effects_chain(chain_dicts)
+ if error:
+ raise HTTPException(status_code=400, detail=error)
+
+ all_versions = versions_mod.list_versions(generation_id, db)
+ clean_version = next((v for v in all_versions if v.effects_chain is None), None)
+ source_path = clean_version.audio_path if clean_version else gen.audio_path
+ if not source_path or not Path(source_path).exists():
+ raise HTTPException(status_code=404, detail="Source audio file not found")
+
+ audio, sample_rate = await asyncio.to_thread(load_audio, source_path)
+ processed = await asyncio.to_thread(apply_effects, audio, sample_rate, chain_dicts)
+
+ import soundfile as sf
+
+ buf = io.BytesIO()
+ await asyncio.to_thread(lambda: sf.write(buf, processed, sample_rate, format="WAV"))
+ buf.seek(0)
+
+ return StreamingResponse(
+ buf,
+ media_type="audio/wav",
+ headers={
+ "Content-Disposition": f'inline; filename="preview_{generation_id}.wav"',
+ "Cache-Control": "no-cache, no-store",
+ },
+ )
+
+
+@router.get("/effects/available", response_model=models.AvailableEffectsResponse)
+async def get_available_effects():
+ """List all available effect types with parameter definitions."""
+ from ..utils.effects import get_available_effects as _get_effects
+
+ return models.AvailableEffectsResponse(effects=[models.AvailableEffect(**e) for e in _get_effects()])
+
+
+@router.get("/effects/presets", response_model=list[models.EffectPresetResponse])
+async def list_effect_presets(db: Session = Depends(get_db)):
+ """List all effect presets (built-in + user-created)."""
+ from ..services import effects as effects_mod
+
+ return effects_mod.list_presets(db)
+
+
+@router.get("/effects/presets/{preset_id}", response_model=models.EffectPresetResponse)
+async def get_effect_preset(preset_id: str, db: Session = Depends(get_db)):
+ """Get a specific effect preset."""
+ from ..services import effects as effects_mod
+
+ preset = effects_mod.get_preset(preset_id, db)
+ if not preset:
+ raise HTTPException(status_code=404, detail="Preset not found")
+ return preset
+
+
+@router.post("/effects/presets", response_model=models.EffectPresetResponse)
+async def create_effect_preset(
+ data: models.EffectPresetCreate,
+ db: Session = Depends(get_db),
+):
+ """Create a new effect preset."""
+ from ..services import effects as effects_mod
+
+ try:
+ return effects_mod.create_preset(data, db)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.put("/effects/presets/{preset_id}", response_model=models.EffectPresetResponse)
+async def update_effect_preset(
+ preset_id: str,
+ data: models.EffectPresetUpdate,
+ db: Session = Depends(get_db),
+):
+ """Update an effect preset."""
+ from ..services import effects as effects_mod
+
+ try:
+ result = effects_mod.update_preset(preset_id, data, db)
+ if not result:
+ raise HTTPException(status_code=404, detail="Preset not found")
+ return result
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.delete("/effects/presets/{preset_id}")
+async def delete_effect_preset(preset_id: str, db: Session = Depends(get_db)):
+ """Delete a user effect preset."""
+ from ..services import effects as effects_mod
+
+ try:
+ if not effects_mod.delete_preset(preset_id, db):
+ raise HTTPException(status_code=404, detail="Preset not found")
+ return {"status": "deleted"}
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.get(
+ "/generations/{generation_id}/versions",
+ response_model=list[models.GenerationVersionResponse],
+)
+async def list_generation_versions(
+ generation_id: str,
+ db: Session = Depends(get_db),
+):
+ """List all versions for a generation."""
+ gen = await history.get_generation(generation_id, db)
+ if not gen:
+ raise HTTPException(status_code=404, detail="Generation not found")
+
+ from ..services import versions as versions_mod
+
+ return versions_mod.list_versions(generation_id, db)
+
+
+@router.post(
+ "/generations/{generation_id}/versions/apply-effects",
+ response_model=models.GenerationVersionResponse,
+)
+async def apply_effects_to_generation(
+ generation_id: str,
+ data: models.ApplyEffectsRequest,
+ db: Session = Depends(get_db),
+):
+ """Apply an effects chain to an existing generation, creating a new version."""
+ gen = db.query(DBGeneration).filter_by(id=generation_id).first()
+ if not gen:
+ raise HTTPException(status_code=404, detail="Generation not found")
+ if (gen.status or "completed") != "completed":
+ raise HTTPException(status_code=400, detail="Generation is not completed")
+
+ from ..services import versions as versions_mod
+ from ..utils.effects import apply_effects, validate_effects_chain
+ from ..utils.audio import load_audio, save_audio
+
+ chain_dicts = [e.model_dump() for e in data.effects_chain]
+ error = validate_effects_chain(chain_dicts)
+ if error:
+ raise HTTPException(status_code=400, detail=error)
+
+ all_versions = versions_mod.list_versions(generation_id, db)
+ source_version_id = data.source_version_id
+ if source_version_id:
+ source_version = next((v for v in all_versions if v.id == source_version_id), None)
+ if not source_version:
+ raise HTTPException(status_code=404, detail="Source version not found")
+ source_path = source_version.audio_path
+ else:
+ clean_version = next((v for v in all_versions if v.effects_chain is None), None)
+ if not clean_version:
+ source_path = gen.audio_path
+ else:
+ source_path = clean_version.audio_path
+ source_version_id = clean_version.id
+
+ if not source_path or not Path(source_path).exists():
+ raise HTTPException(status_code=404, detail="Source audio file not found")
+
+ audio, sample_rate = await asyncio.to_thread(load_audio, source_path)
+ processed_audio = await asyncio.to_thread(apply_effects, audio, sample_rate, chain_dicts)
+
+ version_id = str(uuid.uuid4())
+ processed_path = config.get_generations_dir() / f"{generation_id}_{version_id[:8]}.wav"
+ await asyncio.to_thread(save_audio, processed_audio, str(processed_path), sample_rate)
+
+ label = data.label or f"version-{len(all_versions) + 1}"
+
+ version = versions_mod.create_version(
+ generation_id=generation_id,
+ label=label,
+ audio_path=str(processed_path),
+ db=db,
+ effects_chain=chain_dicts,
+ is_default=data.set_as_default,
+ source_version_id=source_version_id,
+ )
+
+ return version
+
+
+@router.put(
+ "/generations/{generation_id}/versions/{version_id}/set-default",
+ response_model=models.GenerationVersionResponse,
+)
+async def set_default_version(
+ generation_id: str,
+ version_id: str,
+ db: Session = Depends(get_db),
+):
+ """Set a specific version as the default for a generation."""
+ from ..services import versions as versions_mod
+
+ version = versions_mod.get_version(version_id, db)
+ if not version or version.generation_id != generation_id:
+ raise HTTPException(status_code=404, detail="Version not found")
+
+ result = versions_mod.set_default_version(version_id, db)
+ if not result:
+ raise HTTPException(status_code=404, detail="Version not found")
+ return result
+
+
+@router.delete("/generations/{generation_id}/versions/{version_id}")
+async def delete_generation_version(
+ generation_id: str,
+ version_id: str,
+ db: Session = Depends(get_db),
+):
+ """Delete a version. Cannot delete the last remaining version."""
+ from ..services import versions as versions_mod
+
+ version = versions_mod.get_version(version_id, db)
+ if not version or version.generation_id != generation_id:
+ raise HTTPException(status_code=404, detail="Version not found")
+
+ if not versions_mod.delete_version(version_id, db):
+ raise HTTPException(
+ status_code=400,
+ detail="Cannot delete the last remaining version",
+ )
+ return {"status": "deleted"}
diff --git a/backend/routes/generations.py b/backend/routes/generations.py
new file mode 100644
index 00000000..160b0aa1
--- /dev/null
+++ b/backend/routes/generations.py
@@ -0,0 +1,276 @@
+"""TTS generation endpoints."""
+
+import asyncio
+import uuid
+
+from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import StreamingResponse
+from sqlalchemy.orm import Session
+
+from .. import models
+from ..services import history, profiles, tts
+from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db
+from ..services.generation import run_generation
+from ..services.task_queue import enqueue_generation
+from ..utils.tasks import get_task_manager
+
+router = APIRouter()
+
+
+@router.post("/generate", response_model=models.GenerationResponse)
+async def generate_speech(
+ data: models.GenerationRequest,
+ db: Session = Depends(get_db),
+):
+ """Generate speech from text using a voice profile."""
+ task_manager = get_task_manager()
+ generation_id = str(uuid.uuid4())
+
+ profile = await profiles.get_profile(data.profile_id, db)
+ if not profile:
+ raise HTTPException(status_code=404, detail="Profile not found")
+
+ from ..backends import engine_has_model_sizes
+
+ engine = data.engine or "qwen"
+ model_size = (data.model_size or "1.7B") if engine_has_model_sizes(engine) else None
+
+ generation = await history.create_generation(
+ profile_id=data.profile_id,
+ text=data.text,
+ language=data.language,
+ audio_path="",
+ duration=0,
+ seed=data.seed,
+ db=db,
+ instruct=data.instruct,
+ generation_id=generation_id,
+ status="generating",
+ engine=engine,
+ model_size=model_size if engine_has_model_sizes(engine) else None,
+ )
+
+ task_manager.start_generation(
+ task_id=generation_id,
+ profile_id=data.profile_id,
+ text=data.text,
+ )
+
+ effects_chain_config = None
+ if data.effects_chain is not None:
+ effects_chain_config = [e.model_dump() for e in data.effects_chain]
+ else:
+ import json as _json
+
+ profile_obj = db.query(DBVoiceProfile).filter_by(id=data.profile_id).first()
+ if profile_obj and profile_obj.effects_chain:
+ try:
+ effects_chain_config = _json.loads(profile_obj.effects_chain)
+ except Exception:
+ pass
+
+ enqueue_generation(
+ run_generation(
+ generation_id=generation_id,
+ profile_id=data.profile_id,
+ text=data.text,
+ language=data.language,
+ engine=engine,
+ model_size=model_size,
+ seed=data.seed,
+ normalize=data.normalize,
+ effects_chain=effects_chain_config,
+ instruct=data.instruct,
+ mode="generate",
+ max_chunk_chars=data.max_chunk_chars,
+ crossfade_ms=data.crossfade_ms,
+ )
+ )
+
+ return generation
+
+
+@router.post("/generate/{generation_id}/retry", response_model=models.GenerationResponse)
+async def retry_generation(generation_id: str, db: Session = Depends(get_db)):
+ """Retry a failed generation using the same parameters."""
+ gen = db.query(DBGeneration).filter_by(id=generation_id).first()
+ if not gen:
+ raise HTTPException(status_code=404, detail="Generation not found")
+
+ if (gen.status or "completed") != "failed":
+ raise HTTPException(status_code=400, detail="Only failed generations can be retried")
+
+ gen.status = "generating"
+ gen.error = None
+ gen.audio_path = ""
+ gen.duration = 0
+ db.commit()
+ db.refresh(gen)
+
+ task_manager = get_task_manager()
+ task_manager.start_generation(
+ task_id=generation_id,
+ profile_id=gen.profile_id,
+ text=gen.text,
+ )
+
+ enqueue_generation(
+ run_generation(
+ generation_id=generation_id,
+ profile_id=gen.profile_id,
+ text=gen.text,
+ language=gen.language,
+ engine=gen.engine or "qwen",
+ model_size=gen.model_size or "1.7B",
+ seed=gen.seed,
+ instruct=gen.instruct,
+ mode="retry",
+ )
+ )
+
+ return models.GenerationResponse.model_validate(gen)
+
+
+@router.post(
+ "/generate/{generation_id}/regenerate",
+ response_model=models.GenerationResponse,
+)
+async def regenerate_generation(generation_id: str, db: Session = Depends(get_db)):
+ """Re-run TTS with the same parameters and save the result as a new version."""
+ gen = db.query(DBGeneration).filter_by(id=generation_id).first()
+ if not gen:
+ raise HTTPException(status_code=404, detail="Generation not found")
+ if (gen.status or "completed") != "completed":
+ raise HTTPException(status_code=400, detail="Generation must be completed to regenerate")
+
+ gen.status = "generating"
+ gen.error = None
+ db.commit()
+ db.refresh(gen)
+
+ task_manager = get_task_manager()
+ task_manager.start_generation(
+ task_id=generation_id,
+ profile_id=gen.profile_id,
+ text=gen.text,
+ )
+
+ version_id = str(uuid.uuid4())
+
+ enqueue_generation(
+ run_generation(
+ generation_id=generation_id,
+ profile_id=gen.profile_id,
+ text=gen.text,
+ language=gen.language,
+ engine=gen.engine or "qwen",
+ model_size=gen.model_size or "1.7B",
+ seed=gen.seed,
+ instruct=gen.instruct,
+ mode="regenerate",
+ version_id=version_id,
+ )
+ )
+
+ return models.GenerationResponse.model_validate(gen)
+
+
+@router.get("/generate/{generation_id}/status")
+async def get_generation_status(generation_id: str, db: Session = Depends(get_db)):
+ """SSE endpoint that streams generation status updates."""
+ import json
+
+ async def event_stream():
+ while True:
+ db.expire_all()
+ gen = db.query(DBGeneration).filter_by(id=generation_id).first()
+ if not gen:
+ yield f"data: {json.dumps({'status': 'not_found', 'id': generation_id})}\n\n"
+ return
+
+ payload = {
+ "id": gen.id,
+ "status": gen.status or "completed",
+ "duration": gen.duration,
+ "error": gen.error,
+ }
+ yield f"data: {json.dumps(payload)}\n\n"
+
+ if (gen.status or "completed") in ("completed", "failed"):
+ return
+
+ await asyncio.sleep(1)
+
+ return StreamingResponse(
+ event_stream(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ },
+ )
+
+
+@router.post("/generate/stream")
+async def stream_speech(
+ data: models.GenerationRequest,
+ db: Session = Depends(get_db),
+):
+ """Generate speech and stream the WAV audio directly without saving to disk."""
+ from ..backends import get_tts_backend_for_engine, ensure_model_cached_or_raise, load_engine_model, engine_needs_trim
+
+ profile = await profiles.get_profile(data.profile_id, db)
+ if not profile:
+ raise HTTPException(status_code=404, detail="Profile not found")
+
+ engine = data.engine or "qwen"
+ tts_model = get_tts_backend_for_engine(engine)
+ model_size = data.model_size or "1.7B"
+
+ await ensure_model_cached_or_raise(engine, model_size)
+ await load_engine_model(engine, model_size)
+
+ voice_prompt = await profiles.create_voice_prompt_for_profile(
+ data.profile_id,
+ db,
+ engine=engine,
+ )
+
+ from ..utils.chunked_tts import generate_chunked
+
+ trim_fn = None
+ if engine_needs_trim(engine):
+ from ..utils.audio import trim_tts_output
+
+ trim_fn = trim_tts_output
+
+ audio, sample_rate = await generate_chunked(
+ tts_model,
+ data.text,
+ voice_prompt,
+ language=data.language,
+ seed=data.seed,
+ instruct=data.instruct,
+ max_chunk_chars=data.max_chunk_chars,
+ crossfade_ms=data.crossfade_ms,
+ trim_fn=trim_fn,
+ )
+
+ if data.normalize:
+ from ..utils.audio import normalize_audio
+
+ audio = normalize_audio(audio)
+
+ wav_bytes = tts.audio_to_wav_bytes(audio, sample_rate)
+
+ async def _wav_stream():
+ chunk_size = 64 * 1024
+ for i in range(0, len(wav_bytes), chunk_size):
+ yield wav_bytes[i : i + chunk_size]
+
+ return StreamingResponse(
+ _wav_stream(),
+ media_type="audio/wav",
+ headers={"Content-Disposition": 'attachment; filename="speech.wav"'},
+ )
diff --git a/backend/routes/health.py b/backend/routes/health.py
new file mode 100644
index 00000000..e48d5689
--- /dev/null
+++ b/backend/routes/health.py
@@ -0,0 +1,225 @@
+"""Health and infrastructure endpoints."""
+
+import asyncio
+import os
+import signal
+
+import torch
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+
+from .. import config, models
+from ..services import tts
+from ..database import get_db
+from ..utils.platform_detect import get_backend_type
+
+router = APIRouter()
+
+
+@router.get("/")
+async def root():
+ """Root endpoint."""
+ from .. import __version__
+
+ return {"message": "voicebox API", "version": __version__}
+
+
+@router.post("/shutdown")
+async def shutdown():
+ """Gracefully shutdown the server."""
+
+ async def shutdown_async():
+ await asyncio.sleep(0.1)
+ os.kill(os.getpid(), signal.SIGTERM)
+
+ asyncio.create_task(shutdown_async())
+ return {"message": "Shutting down..."}
+
+
+@router.post("/watchdog/disable")
+async def watchdog_disable():
+ """Disable the parent process watchdog so the server keeps running."""
+ from backend.server import disable_watchdog
+
+ disable_watchdog()
+ return {"message": "Watchdog disabled"}
+
+
+@router.get("/health", response_model=models.HealthResponse)
+async def health():
+ """Health check endpoint."""
+ from huggingface_hub import constants as hf_constants
+ from pathlib import Path
+
+ tts_model = tts.get_tts_model()
+ backend_type = get_backend_type()
+
+ has_cuda = torch.cuda.is_available()
+ has_mps = hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
+
+ has_xpu = False
+ xpu_name = None
+ try:
+ import intel_extension_for_pytorch as ipex # noqa: F401 -- side-effect import enables XPU
+
+ if hasattr(torch, "xpu") and torch.xpu.is_available():
+ has_xpu = True
+ try:
+ xpu_name = torch.xpu.get_device_name(0)
+ except Exception:
+ xpu_name = "Intel GPU"
+ except ImportError:
+ pass
+
+ has_directml = False
+ directml_name = None
+ try:
+ import torch_directml
+
+ if torch_directml.device_count() > 0:
+ has_directml = True
+ try:
+ directml_name = torch_directml.device_name(0)
+ except Exception:
+ directml_name = "DirectML GPU"
+ except ImportError:
+ pass
+
+ gpu_available = has_cuda or has_mps or has_xpu or has_directml or backend_type == "mlx"
+
+ gpu_type = None
+ if has_cuda:
+ gpu_type = f"CUDA ({torch.cuda.get_device_name(0)})"
+ elif has_mps:
+ gpu_type = "MPS (Apple Silicon)"
+ elif backend_type == "mlx":
+ gpu_type = "Metal (Apple Silicon via MLX)"
+ elif has_xpu:
+ gpu_type = f"XPU ({xpu_name})"
+ elif has_directml:
+ gpu_type = f"DirectML ({directml_name})"
+
+ vram_used = None
+ if has_cuda:
+ vram_used = torch.cuda.memory_allocated() / 1024 / 1024
+
+ model_loaded = False
+ model_size = None
+ try:
+ if tts_model.is_loaded():
+ model_loaded = True
+ model_size = getattr(tts_model, "_current_model_size", None)
+ if not model_size:
+ model_size = getattr(tts_model, "model_size", None)
+ except Exception:
+ model_loaded = False
+ model_size = None
+
+ model_downloaded = None
+ try:
+ from ..backends import get_model_config
+
+ default_config = get_model_config("qwen-tts-1.7B")
+ default_model_id = default_config.hf_repo_id if default_config else "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
+
+ try:
+ from huggingface_hub import scan_cache_dir
+
+ cache_info = scan_cache_dir()
+ for repo in cache_info.repos:
+ if repo.repo_id == default_model_id:
+ model_downloaded = True
+ break
+ except (ImportError, Exception):
+ cache_dir = hf_constants.HF_HUB_CACHE
+ repo_cache = Path(cache_dir) / ("models--" + default_model_id.replace("/", "--"))
+ if repo_cache.exists():
+ has_model_files = (
+ any(repo_cache.rglob("*.bin"))
+ or any(repo_cache.rglob("*.safetensors"))
+ or any(repo_cache.rglob("*.pt"))
+ or any(repo_cache.rglob("*.pth"))
+ or any(repo_cache.rglob("*.npz"))
+ )
+ model_downloaded = has_model_files
+ except Exception:
+ pass
+
+ return models.HealthResponse(
+ status="healthy",
+ model_loaded=model_loaded,
+ model_downloaded=model_downloaded,
+ model_size=model_size,
+ gpu_available=gpu_available,
+ gpu_type=gpu_type,
+ vram_used_mb=vram_used,
+ backend_type=backend_type,
+ backend_variant=os.environ.get("VOICEBOX_BACKEND_VARIANT", "cuda" if torch.cuda.is_available() else "cpu"),
+ )
+
+
+@router.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 = 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_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,
+ )
diff --git a/backend/routes/history.py b/backend/routes/history.py
new file mode 100644
index 00000000..5435e299
--- /dev/null
+++ b/backend/routes/history.py
@@ -0,0 +1,178 @@
+"""Generation history endpoints."""
+
+import io
+from pathlib import Path
+
+from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
+from fastapi.responses import FileResponse, StreamingResponse
+from sqlalchemy.orm import Session
+
+from .. import models
+from ..services import export_import, history
+from ..app import safe_content_disposition
+from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db
+
+router = APIRouter()
+
+
+@router.get("/history", response_model=models.HistoryListResponse)
+async def list_history(
+ profile_id: str | None = None,
+ search: str | None = None,
+ limit: int = 50,
+ offset: int = 0,
+ db: Session = Depends(get_db),
+):
+ """List generation history with optional filters."""
+ query = models.HistoryQuery(
+ profile_id=profile_id,
+ search=search,
+ limit=limit,
+ offset=offset,
+ )
+ return await history.list_generations(query, db)
+
+
+@router.get("/history/stats")
+async def get_stats(db: Session = Depends(get_db)):
+ """Get generation statistics."""
+ return await history.get_generation_stats(db)
+
+
+@router.post("/history/import")
+async def import_generation(
+ file: UploadFile = File(...),
+ db: Session = Depends(get_db),
+):
+ """Import a generation from a ZIP archive."""
+ MAX_FILE_SIZE = 50 * 1024 * 1024
+
+ content = await file.read()
+
+ if len(content) > MAX_FILE_SIZE:
+ raise HTTPException(
+ status_code=400, detail=f"File too large. Maximum size is {MAX_FILE_SIZE / (1024 * 1024)}MB"
+ )
+
+ try:
+ result = await export_import.import_generation_from_zip(content, db)
+ return result
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/history/{generation_id}", response_model=models.HistoryResponse)
+async def get_generation(
+ generation_id: str,
+ db: Session = Depends(get_db),
+):
+ """Get a generation by ID."""
+ result = (
+ db.query(DBGeneration, DBVoiceProfile.name.label("profile_name"))
+ .join(DBVoiceProfile, DBGeneration.profile_id == DBVoiceProfile.id)
+ .filter(DBGeneration.id == generation_id)
+ .first()
+ )
+
+ if not result:
+ raise HTTPException(status_code=404, detail="Generation not found")
+
+ gen, profile_name = result
+ return models.HistoryResponse(
+ id=gen.id,
+ profile_id=gen.profile_id,
+ profile_name=profile_name,
+ text=gen.text,
+ language=gen.language,
+ audio_path=gen.audio_path,
+ duration=gen.duration,
+ seed=gen.seed,
+ instruct=gen.instruct,
+ created_at=gen.created_at,
+ )
+
+
+@router.post("/history/{generation_id}/favorite")
+async def toggle_favorite(
+ generation_id: str,
+ db: Session = Depends(get_db),
+):
+ """Toggle the favorite status of a generation."""
+ gen = db.query(DBGeneration).filter_by(id=generation_id).first()
+ if not gen:
+ raise HTTPException(status_code=404, detail="Generation not found")
+ gen.is_favorited = not gen.is_favorited
+ db.commit()
+ return {"is_favorited": gen.is_favorited}
+
+
+@router.delete("/history/{generation_id}")
+async def delete_generation(
+ generation_id: str,
+ db: Session = Depends(get_db),
+):
+ """Delete a generation."""
+ success = await history.delete_generation(generation_id, db)
+ if not success:
+ raise HTTPException(status_code=404, detail="Generation not found")
+ return {"message": "Generation deleted successfully"}
+
+
+@router.get("/history/{generation_id}/export")
+async def export_generation(
+ generation_id: str,
+ db: Session = Depends(get_db),
+):
+ """Export a generation as a ZIP archive."""
+ generation = db.query(DBGeneration).filter_by(id=generation_id).first()
+ if not generation:
+ raise HTTPException(status_code=404, detail="Generation not found")
+
+ try:
+ zip_bytes = export_import.export_generation_to_zip(generation_id, db)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+ safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (" ", "-", "_")).strip()
+ if not safe_text:
+ safe_text = "generation"
+ filename = f"generation-{safe_text}.voicebox.zip"
+
+ return StreamingResponse(
+ io.BytesIO(zip_bytes),
+ media_type="application/zip",
+ headers={"Content-Disposition": safe_content_disposition("attachment", filename)},
+ )
+
+
+@router.get("/history/{generation_id}/export-audio")
+async def export_generation_audio(
+ generation_id: str,
+ db: Session = Depends(get_db),
+):
+ """Export only the audio file from a generation."""
+ generation = db.query(DBGeneration).filter_by(id=generation_id).first()
+ if not generation:
+ raise HTTPException(status_code=404, detail="Generation not found")
+
+ if not generation.audio_path:
+ raise HTTPException(status_code=404, detail="Generation has no audio file")
+
+ audio_path = Path(generation.audio_path)
+ if not audio_path.is_file():
+ raise HTTPException(status_code=404, detail="Audio file not found")
+
+ safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (" ", "-", "_")).strip()
+ if not safe_text:
+ safe_text = "generation"
+ filename = f"{safe_text}.wav"
+
+ return FileResponse(
+ audio_path,
+ media_type="audio/wav",
+ headers={"Content-Disposition": safe_content_disposition("attachment", filename)},
+ )
diff --git a/backend/routes/models.py b/backend/routes/models.py
new file mode 100644
index 00000000..d9ee1e8f
--- /dev/null
+++ b/backend/routes/models.py
@@ -0,0 +1,474 @@
+"""Model management endpoints."""
+
+import asyncio
+import shutil
+from pathlib import Path
+
+from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import StreamingResponse
+from sqlalchemy.orm import Session
+
+from .. import models
+from ..utils.platform_detect import get_backend_type
+from ..services.task_queue import create_background_task
+from ..utils.progress import get_progress_manager
+from ..utils.tasks import get_task_manager
+
+router = APIRouter()
+
+
+def _get_dir_size(path: Path) -> int:
+ """Get total size of a directory in bytes."""
+ total = 0
+ for f in path.rglob("*"):
+ if f.is_file():
+ total += f.stat().st_size
+ return total
+
+
+def _copy_with_progress(src: Path, dst: Path, progress_manager, copied_so_far: int, total_bytes: int) -> int:
+ """Copy a directory tree with byte-level progress tracking."""
+ dst.mkdir(parents=True, exist_ok=True)
+ for item in src.iterdir():
+ dest_item = dst / item.name
+ if item.is_dir():
+ copied_so_far = _copy_with_progress(item, dest_item, progress_manager, copied_so_far, total_bytes)
+ else:
+ size = item.stat().st_size
+ shutil.copy2(str(item), str(dest_item))
+ copied_so_far += size
+ progress_manager.update_progress(
+ "migration",
+ copied_so_far,
+ total_bytes,
+ filename=item.name,
+ status="downloading",
+ )
+ return copied_so_far
+
+
+@router.post("/models/load")
+async def load_model(model_size: str = "1.7B"):
+ """Manually load TTS model."""
+ from ..services import tts
+
+ try:
+ tts_model = tts.get_tts_model()
+ await tts_model.load_model_async(model_size)
+ return {"message": f"Model {model_size} loaded successfully"}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/models/unload")
+async def unload_model():
+ """Unload the default Qwen TTS model to free memory."""
+ from ..services import tts
+
+ try:
+ tts.unload_tts_model()
+ return {"message": "Model unloaded successfully"}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/models/{model_name}/unload")
+async def unload_model_by_name(model_name: str):
+ """Unload a specific model from memory without deleting it from disk."""
+ from ..backends import get_model_config, unload_model_by_config
+
+ config = get_model_config(model_name)
+ if not config:
+ raise HTTPException(status_code=400, detail=f"Unknown model: {model_name}")
+
+ try:
+ was_loaded = unload_model_by_config(config)
+ if not was_loaded:
+ return {"message": f"Model {model_name} is not loaded"}
+ return {"message": f"Model {model_name} unloaded successfully"}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e)) from e
+
+
+@router.get("/models/progress/{model_name}")
+async def get_model_progress(model_name: str):
+ """Get model download progress via Server-Sent Events."""
+ progress_manager = get_progress_manager()
+
+ async def event_generator():
+ async for event in progress_manager.subscribe(model_name):
+ yield event
+
+ return StreamingResponse(
+ event_generator(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ },
+ )
+
+
+@router.get("/models/cache-dir")
+async def get_models_cache_dir():
+ """Get the path to the HuggingFace model cache directory."""
+ from huggingface_hub import constants as hf_constants
+
+ return {"path": str(Path(hf_constants.HF_HUB_CACHE))}
+
+
+@router.post("/models/migrate")
+async def migrate_models(request: models.ModelMigrateRequest):
+ """Move all downloaded models to a new directory with byte-level progress via SSE."""
+ from huggingface_hub import constants as hf_constants
+
+ source = Path(hf_constants.HF_HUB_CACHE)
+ destination = Path(request.destination)
+
+ if not source.exists():
+ raise HTTPException(status_code=404, detail="Current model cache directory not found")
+
+ if source.resolve() == destination.resolve():
+ raise HTTPException(status_code=400, detail="Source and destination are the same directory")
+
+ if destination.resolve().is_relative_to(source.resolve()):
+ raise HTTPException(status_code=400, detail="Destination cannot be inside the current cache directory")
+
+ model_dirs = [d for d in source.iterdir() if d.name.startswith("models--") and d.is_dir()]
+ if not model_dirs:
+ return {"moved": 0, "errors": [], "source": str(source), "destination": str(destination)}
+
+ destination.mkdir(parents=True, exist_ok=True)
+
+ progress_manager = get_progress_manager()
+
+ same_fs = False
+ try:
+ same_fs = source.stat().st_dev == destination.stat().st_dev
+ except OSError:
+ pass
+
+ async def migrate_background():
+ moved = 0
+ errors = []
+ try:
+ if same_fs:
+ total = len(model_dirs)
+ for i, item in enumerate(model_dirs):
+ dest_item = destination / item.name
+ try:
+ if dest_item.exists():
+ shutil.rmtree(dest_item)
+ shutil.move(str(item), str(dest_item))
+ moved += 1
+ progress_manager.update_progress(
+ "migration",
+ i + 1,
+ total,
+ filename=item.name,
+ status="downloading",
+ )
+ except Exception as e:
+ errors.append(f"{item.name}: {str(e)}")
+ else:
+ total_bytes = sum(_get_dir_size(d) for d in model_dirs)
+ progress_manager.update_progress(
+ "migration", 0, total_bytes, filename="Calculating...", status="downloading"
+ )
+
+ copied = 0
+ for item in model_dirs:
+ dest_item = destination / item.name
+ try:
+ if dest_item.exists():
+ shutil.rmtree(dest_item)
+ copied = await asyncio.to_thread(
+ _copy_with_progress, item, dest_item, progress_manager, copied, total_bytes
+ )
+ await asyncio.to_thread(shutil.rmtree, str(item))
+ moved += 1
+ except Exception as e:
+ errors.append(f"{item.name}: {str(e)}")
+
+ progress_manager.update_progress("migration", 1, 1, status="complete")
+ progress_manager.mark_complete("migration")
+ except Exception as e:
+ progress_manager.update_progress("migration", 0, 0, status="error")
+ progress_manager.mark_error("migration", str(e))
+
+ create_background_task(migrate_background())
+
+ return {"source": str(source), "destination": str(destination)}
+
+
+@router.get("/models/migrate/progress")
+async def get_migration_progress():
+ """Get model migration progress via Server-Sent Events."""
+ progress_manager = get_progress_manager()
+
+ async def event_generator():
+ async for event in progress_manager.subscribe("migration"):
+ yield event
+
+ return StreamingResponse(
+ event_generator(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ },
+ )
+
+
+@router.get("/models/status", response_model=models.ModelStatusListResponse)
+async def get_model_status():
+ """Get status of all available models."""
+ from huggingface_hub import constants as hf_constants
+
+ backend_type = get_backend_type()
+ task_manager = get_task_manager()
+
+ active_download_names = {task.model_name for task in task_manager.get_active_downloads()}
+
+ try:
+ from huggingface_hub import scan_cache_dir
+
+ use_scan_cache = True
+ except ImportError:
+ use_scan_cache = False
+
+ from ..backends import get_all_model_configs, check_model_loaded
+
+ registry_configs = get_all_model_configs()
+ model_configs = [
+ {
+ "model_name": cfg.model_name,
+ "display_name": cfg.display_name,
+ "hf_repo_id": cfg.hf_repo_id,
+ "model_size": cfg.model_size,
+ "check_loaded": lambda c=cfg: check_model_loaded(c),
+ }
+ for cfg in registry_configs
+ ]
+
+ model_to_repo = {cfg["model_name"]: cfg["hf_repo_id"] for cfg in model_configs}
+ active_download_repos = {model_to_repo.get(name) for name in active_download_names if name in model_to_repo}
+
+ cache_info = None
+ if use_scan_cache:
+ try:
+ cache_info = scan_cache_dir()
+ except Exception:
+ pass
+
+ statuses = []
+
+ for config in model_configs:
+ try:
+ downloaded = False
+ size_mb = None
+ loaded = False
+
+ if cache_info:
+ repo_id = config["hf_repo_id"]
+ for repo in cache_info.repos:
+ if repo.repo_id == repo_id:
+ has_model_weights = False
+ for rev in repo.revisions:
+ for f in rev.files:
+ fname = f.file_name.lower()
+ if fname.endswith((".safetensors", ".bin", ".pt", ".pth", ".npz")):
+ has_model_weights = True
+ break
+ if has_model_weights:
+ break
+
+ has_incomplete = False
+ try:
+ cache_dir = hf_constants.HF_HUB_CACHE
+ blobs_dir = Path(cache_dir) / ("models--" + repo_id.replace("/", "--")) / "blobs"
+ if blobs_dir.exists():
+ has_incomplete = any(blobs_dir.glob("*.incomplete"))
+ except Exception:
+ pass
+
+ if has_model_weights and not has_incomplete:
+ downloaded = True
+ try:
+ total_size = sum(revision.size_on_disk for revision in repo.revisions)
+ size_mb = total_size / (1024 * 1024)
+ except Exception:
+ pass
+ break
+
+ if not downloaded:
+ try:
+ cache_dir = hf_constants.HF_HUB_CACHE
+ repo_cache = Path(cache_dir) / ("models--" + config["hf_repo_id"].replace("/", "--"))
+
+ if repo_cache.exists():
+ blobs_dir = repo_cache / "blobs"
+ has_incomplete = blobs_dir.exists() and any(blobs_dir.glob("*.incomplete"))
+
+ if not has_incomplete:
+ snapshots_dir = repo_cache / "snapshots"
+ has_model_files = False
+ if snapshots_dir.exists():
+ has_model_files = (
+ any(snapshots_dir.rglob("*.bin"))
+ or any(snapshots_dir.rglob("*.safetensors"))
+ or any(snapshots_dir.rglob("*.pt"))
+ or any(snapshots_dir.rglob("*.pth"))
+ or any(snapshots_dir.rglob("*.npz"))
+ )
+
+ if has_model_files:
+ downloaded = True
+ try:
+ total_size = sum(
+ f.stat().st_size
+ for f in repo_cache.rglob("*")
+ if f.is_file() and not f.name.endswith(".incomplete")
+ )
+ size_mb = total_size / (1024 * 1024)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ try:
+ loaded = config["check_loaded"]()
+ except Exception:
+ loaded = False
+
+ is_downloading = config["hf_repo_id"] in active_download_repos
+
+ if is_downloading:
+ downloaded = False
+ size_mb = None
+
+ 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,
+ loaded=loaded,
+ )
+ )
+ except Exception:
+ try:
+ loaded = config["check_loaded"]()
+ except Exception:
+ loaded = False
+
+ is_downloading = config["hf_repo_id"] in active_download_repos
+
+ statuses.append(
+ models.ModelStatus(
+ model_name=config["model_name"],
+ display_name=config["display_name"],
+ hf_repo_id=config["hf_repo_id"],
+ downloaded=False,
+ downloading=is_downloading,
+ size_mb=None,
+ loaded=loaded,
+ )
+ )
+
+ return models.ModelStatusListResponse(models=statuses)
+
+
+@router.post("/models/download")
+async def trigger_model_download(request: models.ModelDownloadRequest):
+ """Trigger download of a specific model."""
+ from ..backends import get_model_config, get_model_load_func
+
+ task_manager = get_task_manager()
+ progress_manager = get_progress_manager()
+
+ config = get_model_config(request.model_name)
+ if not config:
+ raise HTTPException(status_code=400, detail=f"Unknown model: {request.model_name}")
+
+ load_func = get_model_load_func(config)
+
+ async def download_in_background():
+ try:
+ result = load_func()
+ if asyncio.iscoroutine(result):
+ await result
+ task_manager.complete_download(request.model_name)
+ except Exception as e:
+ task_manager.error_download(request.model_name, str(e))
+
+ task_manager.start_download(request.model_name)
+
+ progress_manager.update_progress(
+ model_name=request.model_name,
+ current=0,
+ total=0,
+ filename="Connecting to HuggingFace...",
+ status="downloading",
+ )
+
+ create_background_task(download_in_background())
+
+ return {"message": f"Model {request.model_name} download started"}
+
+
+@router.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)
+
+ 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}"}
+
+
+@router.delete("/models/{model_name}")
+async def delete_model(model_name: str):
+ """Delete a downloaded model from the HuggingFace cache."""
+ from huggingface_hub import constants as hf_constants
+ from ..backends import get_model_config, unload_model_by_config
+
+ config = get_model_config(model_name)
+ if not config:
+ raise HTTPException(status_code=400, detail=f"Unknown model: {model_name}")
+
+ hf_repo_id = config.hf_repo_id
+
+ try:
+ unload_model_by_config(config)
+
+ cache_dir = hf_constants.HF_HUB_CACHE
+ repo_cache_dir = Path(cache_dir) / ("models--" + hf_repo_id.replace("/", "--"))
+
+ if not repo_cache_dir.exists():
+ raise HTTPException(status_code=404, detail=f"Model {model_name} not found in cache")
+
+ try:
+ shutil.rmtree(repo_cache_dir)
+ except OSError as e:
+ raise HTTPException(status_code=500, detail=f"Failed to delete model cache directory: {str(e)}")
+
+ return {"message": f"Model {model_name} deleted successfully"}
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Failed to delete model: {str(e)}")
diff --git a/backend/routes/profiles.py b/backend/routes/profiles.py
new file mode 100644
index 00000000..6b7b4509
--- /dev/null
+++ b/backend/routes/profiles.py
@@ -0,0 +1,309 @@
+"""Voice profile endpoints."""
+
+import io
+import tempfile
+from datetime import datetime
+from pathlib import Path
+
+from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
+from fastapi.responses import FileResponse, StreamingResponse
+from sqlalchemy.orm import Session
+
+from .. import config, models
+from ..app import safe_content_disposition
+from ..database import VoiceProfile as DBVoiceProfile, get_db
+from ..services import channels, export_import, profiles
+from ..services.profiles import _profile_to_response
+
+router = APIRouter()
+
+
+@router.post("/profiles", response_model=models.VoiceProfileResponse)
+async def create_profile(
+ data: models.VoiceProfileCreate,
+ db: Session = Depends(get_db),
+):
+ """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:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.get("/profiles", response_model=list[models.VoiceProfileResponse])
+async def list_profiles(db: Session = Depends(get_db)):
+ """List all voice profiles."""
+ return await profiles.list_profiles(db)
+
+
+@router.post("/profiles/import", response_model=models.VoiceProfileResponse)
+async def import_profile(
+ file: UploadFile = File(...),
+ db: Session = Depends(get_db),
+):
+ """Import a voice profile from a ZIP archive."""
+ MAX_FILE_SIZE = 100 * 1024 * 1024
+
+ content = await file.read()
+
+ if len(content) > MAX_FILE_SIZE:
+ raise HTTPException(
+ status_code=400, detail=f"File too large. Maximum size is {MAX_FILE_SIZE / (1024 * 1024)}MB"
+ )
+
+ try:
+ profile = await export_import.import_profile_from_zip(content, db)
+ return profile
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/profiles/{profile_id}", response_model=models.VoiceProfileResponse)
+async def get_profile(
+ profile_id: str,
+ db: Session = Depends(get_db),
+):
+ """Get a voice profile by ID."""
+ profile = await profiles.get_profile(profile_id, db)
+ if not profile:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ return profile
+
+
+@router.put("/profiles/{profile_id}", response_model=models.VoiceProfileResponse)
+async def update_profile(
+ profile_id: str,
+ data: models.VoiceProfileCreate,
+ db: Session = Depends(get_db),
+):
+ """Update a voice 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))
+
+
+@router.delete("/profiles/{profile_id}")
+async def delete_profile(
+ profile_id: str,
+ db: Session = Depends(get_db),
+):
+ """Delete a voice profile."""
+ success = await profiles.delete_profile(profile_id, db)
+ if not success:
+ raise HTTPException(status_code=404, detail="Profile not found")
+ return {"message": "Profile deleted successfully"}
+
+
+@router.post("/profiles/{profile_id}/samples", response_model=models.ProfileSampleResponse)
+async def add_profile_sample(
+ profile_id: str,
+ file: UploadFile = File(...),
+ reference_text: str = Form(...),
+ db: Session = Depends(get_db),
+):
+ """Add a sample to a voice profile."""
+ _allowed_audio_exts = {".wav", ".mp3", ".m4a", ".ogg", ".flac", ".aac", ".webm", ".opus"}
+ _uploaded_ext = Path(file.filename or "").suffix.lower()
+ file_suffix = _uploaded_ext if _uploaded_ext in _allowed_audio_exts else ".wav"
+
+ with tempfile.NamedTemporaryFile(suffix=file_suffix, delete=False) as tmp:
+ content = await file.read()
+ tmp.write(content)
+ tmp_path = tmp.name
+
+ try:
+ sample = await profiles.add_profile_sample(
+ profile_id,
+ tmp_path,
+ reference_text,
+ db,
+ )
+ return sample
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Failed to process audio file: {str(e)}")
+ finally:
+ Path(tmp_path).unlink(missing_ok=True)
+
+
+@router.get("/profiles/{profile_id}/samples", response_model=list[models.ProfileSampleResponse])
+async def get_profile_samples(
+ profile_id: str,
+ db: Session = Depends(get_db),
+):
+ """Get all samples for a profile."""
+ return await profiles.get_profile_samples(profile_id, db)
+
+
+@router.delete("/profiles/samples/{sample_id}")
+async def delete_profile_sample(
+ sample_id: str,
+ db: Session = Depends(get_db),
+):
+ """Delete a profile sample."""
+ success = await profiles.delete_profile_sample(sample_id, db)
+ if not success:
+ raise HTTPException(status_code=404, detail="Sample not found")
+ return {"message": "Sample deleted successfully"}
+
+
+@router.put("/profiles/samples/{sample_id}", response_model=models.ProfileSampleResponse)
+async def update_profile_sample(
+ sample_id: str,
+ data: models.ProfileSampleUpdate,
+ db: Session = Depends(get_db),
+):
+ """Update a profile sample's reference text."""
+ sample = await profiles.update_profile_sample(sample_id, data.reference_text, db)
+ if not sample:
+ raise HTTPException(status_code=404, detail="Sample not found")
+ return sample
+
+
+@router.post("/profiles/{profile_id}/avatar", response_model=models.VoiceProfileResponse)
+async def upload_profile_avatar(
+ profile_id: str,
+ file: UploadFile = File(...),
+ db: Session = Depends(get_db),
+):
+ """Upload or update avatar image for a profile."""
+ with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename).suffix) as tmp:
+ content = await file.read()
+ tmp.write(content)
+ tmp_path = tmp.name
+
+ try:
+ profile = await profiles.upload_avatar(profile_id, tmp_path, db)
+ return profile
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ finally:
+ Path(tmp_path).unlink(missing_ok=True)
+
+
+@router.get("/profiles/{profile_id}/avatar")
+async def get_profile_avatar(
+ profile_id: str,
+ db: Session = Depends(get_db),
+):
+ """Get avatar image for a profile."""
+ profile = await profiles.get_profile(profile_id, db)
+ if not profile:
+ raise HTTPException(status_code=404, detail="Profile not found")
+
+ if not profile.avatar_path:
+ raise HTTPException(status_code=404, detail="No avatar found for this profile")
+
+ avatar_path = Path(profile.avatar_path)
+ if not avatar_path.exists():
+ raise HTTPException(status_code=404, detail="Avatar file not found")
+
+ return FileResponse(avatar_path)
+
+
+@router.delete("/profiles/{profile_id}/avatar")
+async def delete_profile_avatar(
+ profile_id: str,
+ db: Session = Depends(get_db),
+):
+ """Delete avatar image for a profile."""
+ success = await profiles.delete_avatar(profile_id, db)
+ if not success:
+ raise HTTPException(status_code=404, detail="Profile not found or no avatar to delete")
+ return {"message": "Avatar deleted successfully"}
+
+
+@router.get("/profiles/{profile_id}/export")
+async def export_profile(
+ profile_id: str,
+ db: Session = Depends(get_db),
+):
+ """Export a voice profile as a ZIP archive."""
+ try:
+ profile = await profiles.get_profile(profile_id, db)
+ if not profile:
+ raise HTTPException(status_code=404, detail="Profile not found")
+
+ zip_bytes = export_import.export_profile_to_zip(profile_id, db)
+
+ safe_name = "".join(c for c in profile.name if c.isalnum() or c in (" ", "-", "_")).strip()
+ if not safe_name:
+ safe_name = "profile"
+ filename = f"profile-{safe_name}.voicebox.zip"
+
+ return StreamingResponse(
+ io.BytesIO(zip_bytes),
+ media_type="application/zip",
+ headers={"Content-Disposition": safe_content_disposition("attachment", filename)},
+ )
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/profiles/{profile_id}/channels")
+async def get_profile_channels(
+ profile_id: str,
+ db: Session = Depends(get_db),
+):
+ """Get list of channel IDs assigned to a profile."""
+ try:
+ channel_ids = await channels.get_profile_channels(profile_id, db)
+ return {"channel_ids": channel_ids}
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.put("/profiles/{profile_id}/channels")
+async def set_profile_channels(
+ profile_id: str,
+ data: models.ProfileChannelAssignment,
+ db: Session = Depends(get_db),
+):
+ """Set which channels a profile is assigned to."""
+ try:
+ await channels.set_profile_channels(profile_id, data, db)
+ return {"message": "Profile channels updated successfully"}
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.put("/profiles/{profile_id}/effects", response_model=models.VoiceProfileResponse)
+async def update_profile_effects(
+ profile_id: str,
+ data: models.ProfileEffectsUpdate,
+ db: Session = Depends(get_db),
+):
+ """Set or clear the default effects chain for a voice profile."""
+ import json as _json
+
+ profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
+ if not profile:
+ raise HTTPException(status_code=404, detail="Profile not found")
+
+ if data.effects_chain is not None:
+ from ..utils.effects import validate_effects_chain
+
+ chain_dicts = [e.model_dump() for e in data.effects_chain]
+ error = validate_effects_chain(chain_dicts)
+ if error:
+ raise HTTPException(status_code=400, detail=error)
+ profile.effects_chain = _json.dumps(chain_dicts)
+ else:
+ profile.effects_chain = None
+
+ profile.updated_at = datetime.utcnow()
+ db.commit()
+ db.refresh(profile)
+
+ return _profile_to_response(profile)
diff --git a/backend/routes/stories.py b/backend/routes/stories.py
new file mode 100644
index 00000000..74af7a50
--- /dev/null
+++ b/backend/routes/stories.py
@@ -0,0 +1,223 @@
+"""Story endpoints."""
+
+import io
+
+from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import StreamingResponse
+from sqlalchemy.orm import Session
+
+from .. import database, models
+from ..services import stories
+from ..app import safe_content_disposition
+from ..database import get_db
+
+router = APIRouter()
+
+
+@router.get("/stories", response_model=list[models.StoryResponse])
+async def list_stories(db: Session = Depends(get_db)):
+ """List all stories."""
+ return await stories.list_stories(db)
+
+
+@router.post("/stories", response_model=models.StoryResponse)
+async def create_story(
+ data: models.StoryCreate,
+ db: Session = Depends(get_db),
+):
+ """Create a new story."""
+ try:
+ return await stories.create_story(data, db)
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.get("/stories/{story_id}", response_model=models.StoryDetailResponse)
+async def get_story(
+ story_id: str,
+ db: Session = Depends(get_db),
+):
+ """Get a story with all its items."""
+ story = await stories.get_story(story_id, db)
+ if not story:
+ raise HTTPException(status_code=404, detail="Story not found")
+ return story
+
+
+@router.put("/stories/{story_id}", response_model=models.StoryResponse)
+async def update_story(
+ story_id: str,
+ data: models.StoryCreate,
+ db: Session = Depends(get_db),
+):
+ """Update a story."""
+ story = await stories.update_story(story_id, data, db)
+ if not story:
+ raise HTTPException(status_code=404, detail="Story not found")
+ return story
+
+
+@router.delete("/stories/{story_id}")
+async def delete_story(
+ story_id: str,
+ db: Session = Depends(get_db),
+):
+ """Delete a story."""
+ success = await stories.delete_story(story_id, db)
+ if not success:
+ raise HTTPException(status_code=404, detail="Story not found")
+ return {"message": "Story deleted successfully"}
+
+
+@router.post("/stories/{story_id}/items", response_model=models.StoryItemDetail)
+async def add_story_item(
+ story_id: str,
+ data: models.StoryItemCreate,
+ db: Session = Depends(get_db),
+):
+ """Add a generation to a story."""
+ item = await stories.add_item_to_story(story_id, data, db)
+ if not item:
+ raise HTTPException(status_code=404, detail="Story or generation not found")
+ return item
+
+
+@router.delete("/stories/{story_id}/items/{item_id}")
+async def remove_story_item(
+ story_id: str,
+ item_id: str,
+ db: Session = Depends(get_db),
+):
+ """Remove a story item from a story."""
+ success = await stories.remove_item_from_story(story_id, item_id, db)
+ if not success:
+ raise HTTPException(status_code=404, detail="Story item not found")
+ return {"message": "Item removed successfully"}
+
+
+@router.put("/stories/{story_id}/items/times")
+async def update_story_item_times(
+ story_id: str,
+ data: models.StoryItemBatchUpdate,
+ db: Session = Depends(get_db),
+):
+ """Update story item timecodes."""
+ success = await stories.update_story_item_times(story_id, data, db)
+ if not success:
+ raise HTTPException(status_code=400, detail="Invalid timecode update request")
+ return {"message": "Item timecodes updated successfully"}
+
+
+@router.put("/stories/{story_id}/items/reorder", response_model=list[models.StoryItemDetail])
+async def reorder_story_items(
+ story_id: str,
+ data: models.StoryItemReorder,
+ db: Session = Depends(get_db),
+):
+ """Reorder story items and recalculate timecodes."""
+ items = await stories.reorder_story_items(story_id, data.generation_ids, db)
+ if items is None:
+ raise HTTPException(
+ status_code=400, detail="Invalid reorder request - ensure all generation IDs belong to this story"
+ )
+ return items
+
+
+@router.put("/stories/{story_id}/items/{item_id}/move", response_model=models.StoryItemDetail)
+async def move_story_item(
+ story_id: str,
+ item_id: str,
+ data: models.StoryItemMove,
+ db: Session = Depends(get_db),
+):
+ """Move a story item (update position and/or track)."""
+ item = await stories.move_story_item(story_id, item_id, data, db)
+ if item is None:
+ raise HTTPException(status_code=404, detail="Story item not found")
+ return item
+
+
+@router.put("/stories/{story_id}/items/{item_id}/trim", response_model=models.StoryItemDetail)
+async def trim_story_item(
+ story_id: str,
+ item_id: str,
+ data: models.StoryItemTrim,
+ db: Session = Depends(get_db),
+):
+ """Trim a story item."""
+ item = await stories.trim_story_item(story_id, item_id, data, db)
+ if item is None:
+ raise HTTPException(status_code=404, detail="Story item not found or invalid trim values")
+ return item
+
+
+@router.post("/stories/{story_id}/items/{item_id}/split", response_model=list[models.StoryItemDetail])
+async def split_story_item(
+ story_id: str,
+ item_id: str,
+ data: models.StoryItemSplit,
+ db: Session = Depends(get_db),
+):
+ """Split a story item at a given time, creating two clips."""
+ items = await stories.split_story_item(story_id, item_id, data, db)
+ if items is None:
+ raise HTTPException(status_code=404, detail="Story item not found or invalid split point")
+ return items
+
+
+@router.post("/stories/{story_id}/items/{item_id}/duplicate", response_model=models.StoryItemDetail)
+async def duplicate_story_item(
+ story_id: str,
+ item_id: str,
+ db: Session = Depends(get_db),
+):
+ """Duplicate a story item."""
+ item = await stories.duplicate_story_item(story_id, item_id, db)
+ if item is None:
+ raise HTTPException(status_code=404, detail="Story item not found")
+ return item
+
+
+@router.put("/stories/{story_id}/items/{item_id}/version", response_model=models.StoryItemDetail)
+async def set_story_item_version(
+ story_id: str,
+ item_id: str,
+ data: models.StoryItemVersionUpdate,
+ db: Session = Depends(get_db),
+):
+ """Pin a story item to a specific generation version."""
+ item = await stories.set_story_item_version(story_id, item_id, data, db)
+ if item is None:
+ raise HTTPException(status_code=404, detail="Story item or version not found")
+ return item
+
+
+@router.get("/stories/{story_id}/export-audio")
+async def export_story_audio(
+ story_id: str,
+ db: Session = Depends(get_db),
+):
+ """Export story as single mixed audio file."""
+ try:
+ story = db.query(database.Story).filter_by(id=story_id).first()
+ if not story:
+ raise HTTPException(status_code=404, detail="Story not found")
+
+ audio_bytes = await stories.export_story_audio(story_id, db)
+ if not audio_bytes:
+ raise HTTPException(status_code=400, detail="Story has no audio items")
+
+ safe_name = "".join(c for c in story.name if c.isalnum() or c in (" ", "-", "_")).strip()
+ if not safe_name:
+ safe_name = "story"
+ filename = f"{safe_name}.wav"
+
+ return StreamingResponse(
+ io.BytesIO(audio_bytes),
+ media_type="audio/wav",
+ headers={"Content-Disposition": safe_content_disposition("attachment", filename)},
+ )
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
diff --git a/backend/routes/tasks.py b/backend/routes/tasks.py
new file mode 100644
index 00000000..c3fc5a91
--- /dev/null
+++ b/backend/routes/tasks.py
@@ -0,0 +1,125 @@
+"""Task and cache management endpoints."""
+
+from datetime import datetime
+
+from fastapi import APIRouter
+
+from .. import models
+from ..utils.cache import clear_voice_prompt_cache
+from ..utils.progress import get_progress_manager
+from ..utils.tasks import get_task_manager
+from fastapi import HTTPException
+
+router = APIRouter()
+
+
+@router.post("/tasks/clear")
+async def clear_all_tasks():
+ """Clear all download tasks and progress state."""
+ 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"}
+
+
+@router.post("/cache/clear")
+async def clear_cache():
+ """Clear all voice prompt caches (memory and disk)."""
+ try:
+ deleted_count = clear_voice_prompt_cache()
+ return {
+ "message": "Voice prompt cache cleared successfully",
+ "files_deleted": deleted_count,
+ }
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Failed to clear cache: {str(e)}")
+
+
+@router.get("/tasks/active", response_model=models.ActiveTasksResponse)
+async def get_active_tasks():
+ """Return all currently active downloads and generations."""
+ task_manager = get_task_manager()
+ progress_manager = get_progress_manager()
+
+ active_downloads = []
+ task_manager_downloads = task_manager.get_active_downloads()
+ progress_active = progress_manager.get_all_active()
+
+ download_map = {task.model_name: task for task in task_manager_downloads}
+ progress_map = {p["model_name"]: p for p in progress_active}
+
+ all_model_names = set(download_map.keys()) | set(progress_map.keys())
+ for model_name in all_model_names:
+ task = download_map.get(model_name)
+ progress = progress_map.get(model_name)
+
+ if task:
+ 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")
+ 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:
+ timestamp_str = progress.get("timestamp")
+ if timestamp_str:
+ try:
+ started_at = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
+ except (ValueError, AttributeError):
+ started_at = datetime.utcnow()
+ else:
+ started_at = datetime.utcnow()
+
+ active_downloads.append(
+ models.ActiveDownloadTask(
+ 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"),
+ )
+ )
+
+ active_generations = []
+ for gen_task in task_manager.get_active_generations():
+ active_generations.append(
+ models.ActiveGenerationTask(
+ task_id=gen_task.task_id,
+ profile_id=gen_task.profile_id,
+ text_preview=gen_task.text_preview,
+ started_at=gen_task.started_at,
+ )
+ )
+
+ return models.ActiveTasksResponse(
+ downloads=active_downloads,
+ generations=active_generations,
+ )
diff --git a/backend/routes/transcription.py b/backend/routes/transcription.py
new file mode 100644
index 00000000..90cb1c95
--- /dev/null
+++ b/backend/routes/transcription.py
@@ -0,0 +1,74 @@
+"""Transcription endpoints."""
+
+import asyncio
+import tempfile
+from pathlib import Path
+
+from fastapi import APIRouter, File, Form, HTTPException, UploadFile
+
+from .. import models
+from ..services import transcribe
+from ..services.task_queue import create_background_task
+from ..utils.tasks import get_task_manager
+
+router = APIRouter()
+
+UPLOAD_CHUNK_SIZE = 1024 * 1024 # 1MB
+
+
+@router.post("/transcribe", response_model=models.TranscriptionResponse)
+async def transcribe_audio(
+ file: UploadFile = File(...),
+ language: str | None = Form(None),
+):
+ """Transcribe audio file to text."""
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
+ while chunk := await file.read(UPLOAD_CHUNK_SIZE):
+ tmp.write(chunk)
+ tmp_path = tmp.name
+
+ try:
+ from ..utils.audio import load_audio
+
+ audio, sr = await asyncio.to_thread(load_audio, tmp_path)
+ duration = len(audio) / sr
+
+ whisper_model = transcribe.get_whisper_model()
+ model_size = whisper_model.model_size
+
+ if not whisper_model.is_loaded() and not whisper_model._is_model_cached(model_size):
+ progress_model_name = f"whisper-{model_size}"
+ task_manager = get_task_manager()
+
+ async def download_whisper_background():
+ try:
+ await whisper_model.load_model_async(model_size)
+ task_manager.complete_download(progress_model_name)
+ except Exception as e:
+ task_manager.error_download(progress_model_name, str(e))
+
+ task_manager.start_download(progress_model_name)
+ create_background_task(download_whisper_background())
+
+ raise HTTPException(
+ status_code=202,
+ detail={
+ "message": f"Whisper model {model_size} is being downloaded. Please wait and try again.",
+ "model_name": progress_model_name,
+ "downloading": True,
+ },
+ )
+
+ text = await whisper_model.transcribe(tmp_path, language)
+
+ return models.TranscriptionResponse(
+ text=text,
+ duration=duration,
+ )
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+ finally:
+ Path(tmp_path).unlink(missing_ok=True)
diff --git a/backend/server.py b/backend/server.py
index b5621cd1..bc6a81b2 100644
--- a/backend/server.py
+++ b/backend/server.py
@@ -6,6 +6,47 @@ absolute imports instead of relative imports.
"""
import sys
+import os
+
+# On Windows with --noconsole (PyInstaller), sys.stdout/stderr are None.
+# They can also be broken file objects in some edge cases.
+# Redirect to devnull to prevent crashes from print()/tqdm/logging.
+def _is_writable(stream):
+ """Check if a stream is usable for writing."""
+ if stream is None:
+ return False
+ try:
+ stream.write("")
+ return True
+ except Exception:
+ return False
+
+if not _is_writable(sys.stdout):
+ sys.stdout = open(os.devnull, 'w')
+if not _is_writable(sys.stderr):
+ sys.stderr = open(os.devnull, 'w')
+
+# PyInstaller + multiprocessing: child processes re-execute the frozen binary
+# with internal arguments. freeze_support() handles this and exits early.
+import multiprocessing
+multiprocessing.freeze_support()
+
+# In frozen builds, piper_phonemize's espeak-ng C library falls back to
+# /usr/share/espeak-ng-data/ which doesn't exist. Point it at the bundled
+# data directory instead.
+if getattr(sys, 'frozen', False):
+ _meipass = getattr(sys, '_MEIPASS', os.path.dirname(sys.executable))
+ _espeak_data = os.path.join(_meipass, 'piper_phonemize', 'espeak-ng-data')
+ if os.path.isdir(_espeak_data):
+ os.environ.setdefault('ESPEAK_DATA_PATH', _espeak_data)
+
+# Fast path: handle --version before any heavy imports so the Rust
+# version check doesn't block for 30+ seconds loading torch etc.
+if "--version" in sys.argv:
+ from backend import __version__
+ print(f"voicebox-server {__version__}")
+ sys.exit(0)
+
import logging
# Set up logging FIRST, before any imports that might fail
@@ -43,6 +84,115 @@ except Exception as e:
logger.error(f"Failed to import required modules: {e}", exc_info=True)
sys.exit(1)
+_watchdog_disabled = False
+
+
+def disable_watchdog():
+ """Disable the parent watchdog so the server keeps running after parent exits."""
+ global _watchdog_disabled
+ _watchdog_disabled = True
+ # Ignore SIGHUP so the server survives when the parent Tauri process exits.
+ # On Unix, child processes receive SIGHUP when the parent's session leader
+ # exits, which would kill the server even though we want it to persist.
+ if sys.platform != "win32":
+ import signal
+ signal.signal(signal.SIGHUP, signal.SIG_IGN)
+
+
+def _start_parent_watchdog(parent_pid, data_dir=None):
+ """Monitor parent process and exit if it dies.
+
+ This is the clean shutdown mechanism: instead of the Tauri app trying to
+ forcefully kill the server (which spawns console windows on Windows),
+ the server monitors its parent and shuts itself down gracefully.
+ """
+ import os
+ import signal
+ import threading
+ import time
+
+ # Set up a file logger so we can debug in production
+ watchdog_logger = logging.getLogger("watchdog")
+ if data_dir:
+ try:
+ log_dir = os.path.join(data_dir, "logs")
+ os.makedirs(log_dir, exist_ok=True)
+ fh = logging.FileHandler(os.path.join(log_dir, "watchdog.log"))
+ fh.setFormatter(logging.Formatter('%(asctime)s - %(message)s'))
+ watchdog_logger.addHandler(fh)
+ except Exception:
+ pass
+ watchdog_logger.setLevel(logging.INFO)
+
+ def _is_pid_alive(pid):
+ """Check if a process with the given PID exists (cross-platform)."""
+ try:
+ if sys.platform == "win32":
+ import ctypes
+ kernel32 = ctypes.windll.kernel32
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
+ handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
+ if handle:
+ # Check if process has actually exited
+ STILL_ACTIVE = 259
+ exit_code = ctypes.c_ulong()
+ result = kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code))
+ kernel32.CloseHandle(handle)
+ if result and exit_code.value == STILL_ACTIVE:
+ return True
+ watchdog_logger.info(f"PID {pid}: exited with code {exit_code.value}")
+ return False
+ # OpenProcess failed — check if it's an access error (process exists
+ # but we can't open it) vs process not found
+ error = ctypes.GetLastError()
+ ACCESS_DENIED = 5
+ if error == ACCESS_DENIED:
+ return True # process exists, we just can't open it
+ watchdog_logger.info(f"PID {pid}: OpenProcess failed, error={error}")
+ return False
+ else:
+ os.kill(pid, 0)
+ return True
+ except (OSError, PermissionError):
+ return False
+
+ def _watch():
+ watchdog_logger.info(f"Parent watchdog started, monitoring PID {parent_pid}, server PID {os.getpid()}")
+ # Verify parent is alive before starting the loop
+ alive = _is_pid_alive(parent_pid)
+ watchdog_logger.info(f"Parent PID {parent_pid} initial check: alive={alive}")
+ if not alive:
+ watchdog_logger.warning(f"Parent PID {parent_pid} not found on first check — disabling watchdog")
+ return
+ while True:
+ if _watchdog_disabled:
+ watchdog_logger.info("Watchdog disabled (keep server running), stopping monitor")
+ return
+ if not _is_pid_alive(parent_pid):
+ # Parent is gone. Before shutting down, give the app a moment
+ # to send /watchdog/disable — there is a race where the Tauri
+ # RunEvent::Exit handler sends the disable request while we are
+ # mid-iteration (already past the _watchdog_disabled check above).
+ watchdog_logger.info(f"Parent process {parent_pid} gone, waiting for possible disable request...")
+ time.sleep(1)
+ if _watchdog_disabled:
+ watchdog_logger.info("Watchdog was disabled during grace period, keeping server alive")
+ return
+ watchdog_logger.info("Watchdog still enabled after grace period, shutting down server...")
+ if sys.platform == "win32":
+ # sys.exit triggers SystemExit, allowing uvicorn to run
+ # shutdown handlers. os.kill(SIGTERM) on Windows calls
+ # TerminateProcess which hard-kills without cleanup.
+ os._exit(0)
+ else:
+ os.kill(os.getpid(), signal.SIGTERM)
+ return
+ time.sleep(2)
+
+ t = threading.Thread(target=_watch, daemon=True)
+ t.start()
+
+
if __name__ == "__main__":
try:
parser = argparse.ArgumentParser(description="voicebox backend server")
@@ -64,7 +214,41 @@ if __name__ == "__main__":
default=None,
help="Data directory for database, profiles, and generated audio",
)
+ parser.add_argument(
+ "--parent-pid",
+ type=int,
+ default=None,
+ help="PID of parent process to monitor; server exits when parent dies",
+ )
+ parser.add_argument(
+ "--version",
+ action="store_true",
+ help="Print version and exit (handled above, kept for argparse help)",
+ )
args = parser.parse_args()
+
+ if args.parent_pid is not None and args.parent_pid <= 0:
+ parser.error("--parent-pid must be a positive integer")
+
+ # Detect backend variant from binary name
+ # voicebox-server-cuda → sets VOICEBOX_BACKEND_VARIANT=cuda
+ import os
+ binary_name = os.path.basename(sys.executable).lower()
+ if "cuda" in binary_name:
+ os.environ["VOICEBOX_BACKEND_VARIANT"] = "cuda"
+ logger.info("Backend variant: CUDA")
+ else:
+ os.environ["VOICEBOX_BACKEND_VARIANT"] = "cpu"
+ logger.info("Backend variant: CPU")
+
+ # Register parent watchdog to start after server is fully ready
+ if args.parent_pid is not None:
+ _parent_pid = args.parent_pid
+ _data_dir = args.data_dir
+ @app.on_event("startup")
+ async def _on_startup():
+ _start_parent_watchdog(_parent_pid, _data_dir)
+
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/services/__init__.py b/backend/services/__init__.py
new file mode 100644
index 00000000..47adec90
--- /dev/null
+++ b/backend/services/__init__.py
@@ -0,0 +1 @@
+# Services layer — generation orchestration and background task management.
diff --git a/backend/channels.py b/backend/services/channels.py
similarity index 99%
rename from backend/channels.py
rename to backend/services/channels.py
index 146c003d..f7d9d788 100644
--- a/backend/channels.py
+++ b/backend/services/channels.py
@@ -7,14 +7,14 @@ from datetime import datetime
import uuid
from sqlalchemy.orm import Session
-from .models import (
+from ..models import (
AudioChannelCreate,
AudioChannelUpdate,
AudioChannelResponse,
ChannelVoiceAssignment,
ProfileChannelAssignment,
)
-from .database import (
+from ..database import (
AudioChannel as DBAudioChannel,
ChannelDeviceMapping as DBChannelDeviceMapping,
ProfileChannelMapping as DBProfileChannelMapping,
diff --git a/backend/services/cuda.py b/backend/services/cuda.py
new file mode 100644
index 00000000..7e4d9602
--- /dev/null
+++ b/backend/services/cuda.py
@@ -0,0 +1,259 @@
+"""
+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}")
+
+ # Get total size across all parts by issuing HEAD requests
+ total_size = 0
+ for part_name in parts:
+ try:
+ head_resp = await client.head(f"{base_url}/{part_name}")
+ content_length = int(head_resp.headers.get("content-length", 0))
+ total_size += content_length
+ except Exception:
+ pass
+ logger.info(f"Total download size: {total_size / 1024 / 1024:.1f} MB")
+
+ # 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=total_size,
+ filename=f"Downloading CUDA backend ({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
+
+
+def get_cuda_binary_version() -> Optional[str]:
+ """Get the version of the installed CUDA binary, or None if not installed."""
+ import subprocess
+ cuda_path = get_cuda_binary_path()
+ if not cuda_path:
+ return None
+ try:
+ result = subprocess.run(
+ [str(cuda_path), "--version"],
+ capture_output=True, text=True, timeout=30,
+ )
+ # Output format: "voicebox-server 0.2.0"
+ for line in result.stdout.strip().splitlines():
+ if "voicebox-server" in line:
+ return line.split()[-1]
+ except Exception as e:
+ logger.warning(f"Could not get CUDA binary version: {e}")
+ return None
+
+
+async def check_and_update_cuda_binary():
+ """Check if the CUDA binary is outdated and auto-download if so.
+
+ Called on server startup. If a CUDA binary exists but its version
+ doesn't match the current app version, triggers a background download
+ of the updated CUDA binary. The download progress is visible to the
+ frontend via the existing SSE progress endpoint.
+ """
+ cuda_path = get_cuda_binary_path()
+ if not cuda_path:
+ return # No CUDA binary installed, nothing to update
+
+ cuda_version = get_cuda_binary_version()
+ current_version = __version__
+
+ if cuda_version == current_version:
+ logger.info(f"CUDA binary is up to date (v{current_version})")
+ return
+
+ logger.info(
+ f"CUDA binary version mismatch: binary=v{cuda_version}, app=v{current_version}. "
+ f"Auto-downloading updated CUDA backend..."
+ )
+
+ try:
+ await download_cuda_binary()
+ except Exception as e:
+ logger.error(f"Auto-update of CUDA binary failed: {e}")
+
+
+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/services/effects.py b/backend/services/effects.py
new file mode 100644
index 00000000..29ca5918
--- /dev/null
+++ b/backend/services/effects.py
@@ -0,0 +1,120 @@
+"""
+Effect presets CRUD operations.
+"""
+
+from __future__ import annotations
+
+import json
+import uuid
+from typing import List, Optional
+
+from sqlalchemy.orm import Session
+from sqlalchemy.exc import IntegrityError
+
+from ..database import EffectPreset as DBEffectPreset
+from ..models import EffectPresetResponse, EffectPresetCreate, EffectPresetUpdate, EffectConfig
+
+
+def _preset_response(p: DBEffectPreset) -> EffectPresetResponse:
+ """Convert a DB preset row to a Pydantic response."""
+ effects_chain = [EffectConfig(**e) for e in json.loads(p.effects_chain)]
+ return EffectPresetResponse(
+ id=p.id,
+ name=p.name,
+ description=p.description,
+ effects_chain=effects_chain,
+ is_builtin=p.is_builtin or False,
+ created_at=p.created_at,
+ )
+
+
+def list_presets(db: Session) -> List[EffectPresetResponse]:
+ """List all effect presets (built-in + user-created)."""
+ presets = db.query(DBEffectPreset).order_by(DBEffectPreset.sort_order, DBEffectPreset.name).all()
+ return [_preset_response(p) for p in presets]
+
+
+def get_preset(preset_id: str, db: Session) -> Optional[EffectPresetResponse]:
+ """Get a preset by ID."""
+ p = db.query(DBEffectPreset).filter_by(id=preset_id).first()
+ if not p:
+ return None
+ return _preset_response(p)
+
+
+def get_preset_by_name(name: str, db: Session) -> Optional[EffectPresetResponse]:
+ """Get a preset by name."""
+ p = db.query(DBEffectPreset).filter_by(name=name).first()
+ if not p:
+ return None
+ return _preset_response(p)
+
+
+def create_preset(data: EffectPresetCreate, db: Session) -> EffectPresetResponse:
+ """Create a new user effect preset."""
+ from .utils.effects import validate_effects_chain
+
+ chain_dicts = [e.model_dump() for e in data.effects_chain]
+ error = validate_effects_chain(chain_dicts)
+ if error:
+ raise ValueError(error)
+
+ # Check for duplicate name before insert
+ existing = db.query(DBEffectPreset).filter_by(name=data.name).first()
+ if existing:
+ raise ValueError(f"A preset named '{data.name}' already exists")
+
+ preset = DBEffectPreset(
+ id=str(uuid.uuid4()),
+ name=data.name,
+ description=data.description,
+ effects_chain=json.dumps(chain_dicts),
+ is_builtin=False,
+ )
+ db.add(preset)
+ try:
+ db.commit()
+ except IntegrityError:
+ db.rollback()
+ raise ValueError(f"A preset named '{data.name}' already exists")
+ db.refresh(preset)
+ return _preset_response(preset)
+
+
+def update_preset(preset_id: str, data: EffectPresetUpdate, db: Session) -> Optional[EffectPresetResponse]:
+ """Update a user effect preset. Cannot modify built-in presets."""
+ preset = db.query(DBEffectPreset).filter_by(id=preset_id).first()
+ if not preset:
+ return None
+ if preset.is_builtin:
+ raise ValueError("Cannot modify built-in presets")
+
+ if data.name is not None:
+ preset.name = data.name
+ if data.description is not None:
+ preset.description = data.description
+ if data.effects_chain is not None:
+ from .utils.effects import validate_effects_chain
+
+ chain_dicts = [e.model_dump() for e in data.effects_chain]
+ error = validate_effects_chain(chain_dicts)
+ if error:
+ raise ValueError(error)
+ preset.effects_chain = json.dumps(chain_dicts)
+
+ db.commit()
+ db.refresh(preset)
+ return _preset_response(preset)
+
+
+def delete_preset(preset_id: str, db: Session) -> bool:
+ """Delete a user effect preset. Cannot delete built-in presets."""
+ preset = db.query(DBEffectPreset).filter_by(id=preset_id).first()
+ if not preset:
+ return False
+ if preset.is_builtin:
+ raise ValueError("Cannot delete built-in presets")
+
+ db.delete(preset)
+ db.commit()
+ return True
diff --git a/backend/export_import.py b/backend/services/export_import.py
similarity index 89%
rename from backend/export_import.py
rename to backend/services/export_import.py
index 6d705aa4..93252f50 100644
--- a/backend/export_import.py
+++ b/backend/services/export_import.py
@@ -12,16 +12,11 @@ from pathlib import Path
from typing import Optional
from sqlalchemy.orm import Session
-from .models import VoiceProfileResponse
-from .database import VoiceProfile as DBVoiceProfile, ProfileSample as DBProfileSample, Generation as DBGeneration
+from ..models import VoiceProfileResponse
+from ..database import VoiceProfile as DBVoiceProfile, ProfileSample as DBProfileSample, Generation as DBGeneration, GenerationVersion as DBGenerationVersion
from .profiles import create_profile, add_profile_sample
-from .models import VoiceProfileCreate
-from . import config
-
-
-def _get_profiles_dir() -> Path:
- """Get profiles directory from config."""
- return config.get_profiles_dir()
+from ..models import VoiceProfileCreate
+from .. import config
def _get_unique_profile_name(name: str, db: Session) -> str:
@@ -99,7 +94,7 @@ def export_profile_to_zip(profile_id: str, db: Session) -> bytes:
# Create samples.json mapping
samples_data = {}
- profile_dir = _get_profiles_dir() / profile_id
+ profile_dir = config.get_profiles_dir() / profile_id
for sample in samples:
# Get filename from audio_path (should be {sample_id}.wav)
@@ -181,7 +176,7 @@ async def import_profile_from_zip(file_bytes: bytes, db: Session) -> VoiceProfil
profile = await create_profile(profile_create, db)
# Extract and add samples
- profile_dir = _get_profiles_dir() / profile.id
+ profile_dir = config.get_profiles_dir() / profile.id
profile_dir.mkdir(parents=True, exist_ok=True)
# Handle avatar if present
@@ -269,16 +264,33 @@ def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
if not profile:
raise ValueError(f"Profile {generation.profile_id} not found")
- # Get audio file
- audio_path = Path(generation.audio_path)
- if not audio_path.exists():
- raise ValueError(f"Audio file not found: {audio_path}")
-
+ # Get all versions for this generation
+ versions = (
+ db.query(DBGenerationVersion)
+ .filter_by(generation_id=generation_id)
+ .order_by(DBGenerationVersion.created_at)
+ .all()
+ )
+
# Create ZIP in memory
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
- # Create manifest.json
+ # Build version manifest entries
+ version_entries = []
+ for v in versions:
+ v_path = Path(v.audio_path)
+ effects_chain = None
+ if v.effects_chain:
+ effects_chain = json.loads(v.effects_chain)
+ version_entries.append({
+ "id": v.id,
+ "label": v.label,
+ "is_default": v.is_default,
+ "effects_chain": effects_chain,
+ "filename": v_path.name,
+ })
+
manifest = {
"version": "1.0",
"generation": {
@@ -295,13 +307,22 @@ def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
"name": profile.name,
"description": profile.description,
"language": profile.language,
- }
+ },
+ "versions": version_entries,
}
zip_file.writestr("manifest.json", json.dumps(manifest, indent=2))
- # Add audio file
- filename = audio_path.name
- zip_file.write(audio_path, f"audio/{filename}")
+ # Add all version audio files
+ for v in versions:
+ v_path = Path(v.audio_path)
+ if v_path.exists():
+ zip_file.write(v_path, f"audio/{v_path.name}")
+
+ # Fallback: if no versions exist, include the generation's main audio
+ if not versions:
+ audio_path = Path(generation.audio_path)
+ if audio_path.exists():
+ zip_file.write(audio_path, f"audio/{audio_path.name}")
zip_buffer.seek(0)
return zip_buffer.read()
@@ -325,7 +346,7 @@ async def import_generation_from_zip(file_bytes: bytes, db: Session) -> dict:
import tempfile
import shutil
from datetime import datetime
- from . import config
+ from .. import config
zip_buffer = io.BytesIO(file_bytes)
diff --git a/backend/services/generation.py b/backend/services/generation.py
new file mode 100644
index 00000000..d8d5214d
--- /dev/null
+++ b/backend/services/generation.py
@@ -0,0 +1,253 @@
+"""
+Unified TTS generation orchestration.
+
+Replaces the three near-identical closures (_run_generation, _run_retry,
+_run_regenerate) that lived in main.py with a single ``run_generation()``
+function parameterized by *mode*.
+
+Mode differences:
+ - "generate" : full pipeline -- save clean version, optionally apply
+ effects and create a processed version.
+ - "retry" : re-runs a failed generation with the same seed.
+ No effects, no version creation.
+ - "regenerate" : re-runs with seed=None for variation. Creates a new
+ version with an auto-incremented "take-N" label.
+"""
+
+from __future__ import annotations
+
+import traceback
+from typing import Literal, Optional
+
+from .. import config
+from . import history, profiles
+from ..database import get_db
+from ..utils.tasks import get_task_manager
+
+
+async def run_generation(
+ *,
+ generation_id: str,
+ profile_id: str,
+ text: str,
+ language: str,
+ engine: str,
+ model_size: str,
+ seed: Optional[int],
+ normalize: bool = False,
+ effects_chain: Optional[list] = None,
+ instruct: Optional[str] = None,
+ mode: Literal["generate", "retry", "regenerate"],
+ max_chunk_chars: Optional[int] = None,
+ crossfade_ms: Optional[int] = None,
+ version_id: Optional[str] = None,
+) -> None:
+ """Execute TTS inference and persist the result.
+
+ This is the single entry point for all background generation work.
+ It is designed to be enqueued via ``services.task_queue.enqueue_generation``.
+ """
+ from ..backends import load_engine_model, get_tts_backend_for_engine, engine_needs_trim
+ from ..utils.chunked_tts import generate_chunked
+ from ..utils.audio import normalize_audio, save_audio, trim_tts_output
+
+ task_manager = get_task_manager()
+ bg_db = next(get_db())
+
+ try:
+ tts_model = get_tts_backend_for_engine(engine)
+
+ if not tts_model.is_loaded():
+ await history.update_generation_status(generation_id, "loading_model", bg_db)
+
+ await load_engine_model(engine, model_size)
+
+ voice_prompt = await profiles.create_voice_prompt_for_profile(
+ profile_id,
+ bg_db,
+ use_cache=True,
+ engine=engine,
+ )
+
+ await history.update_generation_status(generation_id, "generating", bg_db)
+ trim_fn = trim_tts_output if engine_needs_trim(engine) else None
+
+ gen_kwargs: dict = dict(
+ language=language,
+ seed=seed if mode != "regenerate" else None,
+ instruct=instruct,
+ trim_fn=trim_fn,
+ )
+ if max_chunk_chars is not None:
+ gen_kwargs["max_chunk_chars"] = max_chunk_chars
+ if crossfade_ms is not None:
+ gen_kwargs["crossfade_ms"] = crossfade_ms
+
+ audio, sample_rate = await generate_chunked(tts_model, text, voice_prompt, **gen_kwargs)
+
+ # --- Normalize (generate and regenerate always; retry skips) -----
+ if normalize or mode == "regenerate":
+ audio = normalize_audio(audio)
+
+ duration = len(audio) / sample_rate
+
+ # --- Persist audio and update status -----------------------------
+ if mode == "generate":
+ final_path = _save_generate(
+ generation_id=generation_id,
+ audio=audio,
+ sample_rate=sample_rate,
+ effects_chain=effects_chain,
+ save_audio=save_audio,
+ db=bg_db,
+ )
+ elif mode == "retry":
+ final_path = _save_retry(
+ generation_id=generation_id,
+ audio=audio,
+ sample_rate=sample_rate,
+ save_audio=save_audio,
+ )
+ elif mode == "regenerate":
+ final_path = _save_regenerate(
+ generation_id=generation_id,
+ version_id=version_id,
+ audio=audio,
+ sample_rate=sample_rate,
+ save_audio=save_audio,
+ db=bg_db,
+ )
+
+ await history.update_generation_status(
+ generation_id=generation_id,
+ status="completed",
+ db=bg_db,
+ audio_path=final_path,
+ duration=duration,
+ )
+
+ except Exception as e:
+ traceback.print_exc()
+ await history.update_generation_status(
+ generation_id=generation_id,
+ status="failed",
+ db=bg_db,
+ error=str(e),
+ )
+ finally:
+ task_manager.complete_generation(generation_id)
+ bg_db.close()
+
+
+def _save_generate(
+ *,
+ generation_id: str,
+ audio,
+ sample_rate: int,
+ effects_chain: Optional[list],
+ save_audio,
+ db,
+) -> str:
+ """Save clean version and optionally an effects-processed version.
+
+ Returns the final audio path (processed if effects were applied,
+ otherwise clean).
+ """
+ from . import versions as versions_mod
+
+ clean_audio_path = config.get_generations_dir() / f"{generation_id}.wav"
+ save_audio(audio, str(clean_audio_path), sample_rate)
+
+ has_effects = effects_chain and any(e.get("enabled", True) for e in effects_chain)
+
+ versions_mod.create_version(
+ generation_id=generation_id,
+ label="original",
+ audio_path=str(clean_audio_path),
+ db=db,
+ effects_chain=None,
+ is_default=not has_effects,
+ )
+
+ final_audio_path = str(clean_audio_path)
+
+ if has_effects:
+ from ..utils.effects import apply_effects, validate_effects_chain
+
+ error_msg = validate_effects_chain(effects_chain)
+ if error_msg:
+ import logging
+ logging.getLogger(__name__).warning("invalid effects chain, skipping: %s", error_msg)
+ versions_mod.set_default_version(
+ versions_mod.list_versions(generation_id, db)[0].id, db
+ )
+ else:
+ processed_audio = apply_effects(audio, sample_rate, effects_chain)
+ processed_path = config.get_generations_dir() / f"{generation_id}_processed.wav"
+ save_audio(processed_audio, str(processed_path), sample_rate)
+ final_audio_path = str(processed_path)
+ versions_mod.create_version(
+ generation_id=generation_id,
+ label="version-2",
+ audio_path=str(processed_path),
+ db=db,
+ effects_chain=effects_chain,
+ is_default=True,
+ )
+
+ return final_audio_path
+
+
+def _save_retry(
+ *,
+ generation_id: str,
+ audio,
+ sample_rate: int,
+ save_audio,
+) -> str:
+ """Save retry output -- single file, no versions.
+
+ Returns the audio path.
+ """
+ audio_path = config.get_generations_dir() / f"{generation_id}.wav"
+ save_audio(audio, str(audio_path), sample_rate)
+ return str(audio_path)
+
+
+def _save_regenerate(
+ *,
+ generation_id: str,
+ version_id: Optional[str],
+ audio,
+ sample_rate: int,
+ save_audio,
+ db,
+) -> str:
+ """Save regeneration output as a new version with auto-label.
+
+ Returns the audio path.
+ """
+ from . import versions as versions_mod
+
+ import uuid as _uuid
+
+ suffix = _uuid.uuid4().hex[:8]
+ audio_path = config.get_generations_dir() / f"{generation_id}_{suffix}.wav"
+ save_audio(audio, str(audio_path), sample_rate)
+
+ # Count via DB query rather than list length to avoid TOCTOU race
+ from ..database import GenerationVersion as DBGenerationVersion
+
+ count = db.query(DBGenerationVersion).filter_by(generation_id=generation_id).count()
+ label = f"take-{count + 1}"
+
+ versions_mod.create_version(
+ generation_id=generation_id,
+ label=label,
+ audio_path=str(audio_path),
+ db=db,
+ effects_chain=None,
+ is_default=True,
+ )
+
+ return str(audio_path)
diff --git a/backend/history.py b/backend/services/history.py
similarity index 61%
rename from backend/history.py
rename to backend/services/history.py
index 64834d30..8f45d48f 100644
--- a/backend/history.py
+++ b/backend/services/history.py
@@ -10,14 +10,46 @@ from pathlib import Path
from sqlalchemy.orm import Session
from sqlalchemy import or_
-from .models import GenerationRequest, GenerationResponse, HistoryQuery, HistoryResponse, HistoryListResponse
-from .database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile
-from . import config
+from ..models import GenerationRequest, GenerationResponse, HistoryQuery, HistoryResponse, HistoryListResponse, GenerationVersionResponse, EffectConfig
+from ..database import Generation as DBGeneration, GenerationVersion as DBGenerationVersion, VoiceProfile as DBVoiceProfile
+from .. import config
-def _get_generations_dir() -> Path:
- """Get generations directory from config."""
- return config.get_generations_dir()
+def _get_versions_for_generation(generation_id: str, db: Session) -> tuple:
+ """Get versions list and active version ID for a generation."""
+ import json
+ versions_rows = (
+ db.query(DBGenerationVersion)
+ .filter_by(generation_id=generation_id)
+ .order_by(DBGenerationVersion.created_at)
+ .all()
+ )
+ if not versions_rows:
+ return None, None
+
+ versions = []
+ active_version_id = None
+ for v in versions_rows:
+ effects_chain = None
+ if v.effects_chain:
+ try:
+ raw = json.loads(v.effects_chain)
+ effects_chain = [EffectConfig(**e) for e in raw]
+ except Exception:
+ pass
+ versions.append(GenerationVersionResponse(
+ id=v.id,
+ generation_id=v.generation_id,
+ label=v.label,
+ audio_path=v.audio_path,
+ effects_chain=effects_chain,
+ is_default=v.is_default,
+ created_at=v.created_at,
+ ))
+ if v.is_default:
+ active_version_id = v.id
+
+ return versions, active_version_id
async def create_generation(
@@ -29,6 +61,10 @@ async def create_generation(
seed: Optional[int],
db: Session,
instruct: Optional[str] = None,
+ generation_id: Optional[str] = None,
+ status: str = "completed",
+ engine: Optional[str] = "qwen",
+ model_size: Optional[str] = None,
) -> GenerationResponse:
"""
Create a new generation history entry.
@@ -42,12 +78,16 @@ async def create_generation(
seed: Random seed used (if any)
db: Database session
instruct: Natural language instruction used (if any)
+ generation_id: Pre-assigned ID (for async generation flow)
+ status: Generation status (generating, completed, failed)
+ engine: TTS engine used (qwen, luxtts, chatterbox, chatterbox_turbo)
+ model_size: Model size variant (1.7B, 0.6B) — only relevant for qwen
Returns:
Created generation entry
"""
db_generation = DBGeneration(
- id=str(uuid.uuid4()),
+ id=generation_id or str(uuid.uuid4()),
profile_id=profile_id,
text=text,
language=language,
@@ -55,6 +95,9 @@ async def create_generation(
duration=duration,
seed=seed,
instruct=instruct,
+ engine=engine,
+ model_size=model_size,
+ status=status,
created_at=datetime.utcnow(),
)
@@ -65,6 +108,32 @@ async def create_generation(
return GenerationResponse.model_validate(db_generation)
+async def update_generation_status(
+ generation_id: str,
+ status: str,
+ db: Session,
+ audio_path: Optional[str] = None,
+ duration: Optional[float] = None,
+ error: Optional[str] = None,
+) -> Optional[GenerationResponse]:
+ """Update the status of a generation (used by async generation flow)."""
+ generation = db.query(DBGeneration).filter_by(id=generation_id).first()
+ if not generation:
+ return None
+
+ generation.status = status
+ if audio_path is not None:
+ generation.audio_path = audio_path
+ if duration is not None:
+ generation.duration = duration
+ if error is not None:
+ generation.error = error
+
+ db.commit()
+ db.refresh(generation)
+ return GenerationResponse.model_validate(generation)
+
+
async def get_generation(
generation_id: str,
db: Session,
@@ -133,6 +202,7 @@ async def list_generations(
# Convert to HistoryResponse with profile_name
items = []
for generation, profile_name in results:
+ versions, active_version_id = _get_versions_for_generation(generation.id, db)
items.append(HistoryResponse(
id=generation.id,
profile_id=generation.profile_id,
@@ -143,7 +213,14 @@ async def list_generations(
duration=generation.duration,
seed=generation.seed,
instruct=generation.instruct,
+ engine=generation.engine or "qwen",
+ model_size=generation.model_size,
+ status=generation.status or "completed",
+ error=generation.error,
+ is_favorited=bool(generation.is_favorited),
created_at=generation.created_at,
+ versions=versions,
+ active_version_id=active_version_id,
))
return HistoryListResponse(
@@ -169,12 +246,17 @@ async def delete_generation(
generation = db.query(DBGeneration).filter_by(id=generation_id).first()
if not generation:
return False
-
- # Delete audio file
- audio_path = Path(generation.audio_path)
- if audio_path.exists():
- audio_path.unlink()
-
+
+ # Delete all version files and records
+ from . import versions as versions_mod
+ versions_mod.delete_versions_for_generation(generation_id, db)
+
+ # Delete main audio file (if not already removed by version cleanup)
+ if generation.audio_path:
+ audio_path = Path(generation.audio_path)
+ if audio_path.exists():
+ audio_path.unlink()
+
# Delete from database
db.delete(generation)
db.commit()
diff --git a/backend/profiles.py b/backend/services/profiles.py
similarity index 72%
rename from backend/profiles.py
rename to backend/services/profiles.py
index cda6bee0..46180f3c 100644
--- a/backend/profiles.py
+++ b/backend/services/profiles.py
@@ -8,28 +8,55 @@ import uuid
import shutil
from pathlib import Path
from sqlalchemy.orm import Session
-from sqlalchemy import select
+from sqlalchemy import func, select
-from .models import (
+from ..models import (
VoiceProfileCreate,
VoiceProfileResponse,
ProfileSampleCreate,
ProfileSampleResponse,
)
-from .database import (
+from ..database import (
VoiceProfile as DBVoiceProfile,
ProfileSample as DBProfileSample,
+ Generation as DBGeneration,
)
-from .utils.audio import validate_reference_audio, load_audio, save_audio
-from .utils.images import validate_image, process_avatar
-from .utils.cache import _get_cache_dir, clear_profile_cache
+from ..models import EffectConfig
+from ..utils.audio import validate_reference_audio, load_audio, save_audio
+from ..utils.images import validate_image, process_avatar
+from ..utils.cache import _get_cache_dir, clear_profile_cache
from .tts import get_tts_model
-from . import config
+from .. import config
+import json as _json
-def _get_profiles_dir() -> Path:
- """Get profiles directory from config."""
- return config.get_profiles_dir()
+def _profile_to_response(
+ profile: DBVoiceProfile,
+ generation_count: int = 0,
+ sample_count: int = 0,
+) -> VoiceProfileResponse:
+ """Convert a DB profile to a VoiceProfileResponse, deserializing effects_chain."""
+ effects_chain = None
+ if profile.effects_chain:
+ try:
+ raw = _json.loads(profile.effects_chain)
+ effects_chain = [EffectConfig(**e) for e in raw]
+ except Exception as e:
+ import logging
+
+ logging.warning(f"Failed to parse effects_chain for profile {profile.id}: {e}")
+ return VoiceProfileResponse(
+ id=profile.id,
+ name=profile.name,
+ description=profile.description,
+ language=profile.language,
+ avatar_path=profile.avatar_path,
+ effects_chain=effects_chain,
+ generation_count=generation_count,
+ sample_count=sample_count,
+ created_at=profile.created_at,
+ updated_at=profile.updated_at,
+ )
async def create_profile(
@@ -38,15 +65,21 @@ 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
"""
- # Create profile in database
+ 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.")
+
db_profile = DBVoiceProfile(
id=str(uuid.uuid4()),
name=data.name,
@@ -55,16 +88,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 = config.get_profiles_dir() / db_profile.id
profile_dir.mkdir(parents=True, exist_ok=True)
-
- return VoiceProfileResponse.model_validate(db_profile)
+
+ return _profile_to_response(db_profile)
async def add_profile_sample(
@@ -75,56 +107,50 @@ async def add_profile_sample(
) -> ProfileSampleResponse:
"""
Add a sample to a voice profile.
-
+
Args:
profile_id: Profile ID
audio_path: Path to temporary audio file
reference_text: Transcript of audio
db: Database session
-
+
Returns:
Created sample
"""
- # Validate profile exists
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile:
raise ValueError(f"Profile {profile_id} not found")
-
- # Validate audio
+
is_valid, error_msg = validate_reference_audio(audio_path)
if not is_valid:
raise ValueError(f"Invalid reference audio: {error_msg}")
-
- # Create sample ID and directory
+
sample_id = str(uuid.uuid4())
- profile_dir = _get_profiles_dir() / profile_id
+ profile_dir = config.get_profiles_dir() / profile_id
profile_dir.mkdir(parents=True, exist_ok=True)
-
- # Copy audio file to profile directory
+
dest_path = profile_dir / f"{sample_id}.wav"
audio, sr = load_audio(audio_path)
save_audio(audio, str(dest_path), sr)
-
- # Create database entry
+
db_sample = DBProfileSample(
id=sample_id,
profile_id=profile_id,
audio_path=str(dest_path),
reference_text=reference_text,
)
-
+
db.add(db_sample)
-
- # Update profile timestamp
+
profile.updated_at = datetime.utcnow()
-
+
db.commit()
db.refresh(db_sample)
-
+
# Invalidate combined audio cache for this profile
# Since a new sample was added, any cached combined audio is now stale
clear_profile_cache(profile_id)
-
+
return ProfileSampleResponse.model_validate(db_sample)
@@ -134,19 +160,19 @@ async def get_profile(
) -> Optional[VoiceProfileResponse]:
"""
Get a voice profile by ID.
-
+
Args:
profile_id: Profile ID
db: Database session
-
+
Returns:
Profile or None if not found
"""
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile:
return None
-
- return VoiceProfileResponse.model_validate(profile)
+
+ return _profile_to_response(profile)
async def get_profile_samples(
@@ -155,11 +181,11 @@ async def get_profile_samples(
) -> List[ProfileSampleResponse]:
"""
Get all samples for a profile.
-
+
Args:
profile_id: Profile ID
db: Database session
-
+
Returns:
List of samples
"""
@@ -169,19 +195,39 @@ async def get_profile_samples(
async def list_profiles(db: Session) -> List[VoiceProfileResponse]:
"""
- List all voice profiles.
-
+ List all voice profiles with generation and sample counts.
+
Args:
db: Database session
-
+
Returns:
List of profiles
"""
- profiles = db.query(DBVoiceProfile).order_by(
- DBVoiceProfile.created_at.desc()
- ).all()
-
- return [VoiceProfileResponse.model_validate(p) for p in profiles]
+ profiles = db.query(DBVoiceProfile).order_by(DBVoiceProfile.created_at.desc()).all()
+
+ if not profiles:
+ return []
+
+ # Batch-fetch generation counts
+ gen_counts_rows = (
+ db.query(DBGeneration.profile_id, func.count(DBGeneration.id)).group_by(DBGeneration.profile_id).all()
+ )
+ gen_counts = {row[0]: row[1] for row in gen_counts_rows}
+
+ # Batch-fetch sample counts
+ sample_counts_rows = (
+ db.query(DBProfileSample.profile_id, func.count(DBProfileSample.id)).group_by(DBProfileSample.profile_id).all()
+ )
+ sample_counts = {row[0]: row[1] for row in sample_counts_rows}
+
+ return [
+ _profile_to_response(
+ p,
+ generation_count=gen_counts.get(p.id, 0),
+ sample_count=sample_counts.get(p.id, 0),
+ )
+ for p in profiles
+ ]
async def update_profile(
@@ -191,29 +237,36 @@ 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
-
- # Update fields
+
+ 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.")
+
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)
+
+ return _profile_to_response(profile)
async def delete_profile(
@@ -222,33 +275,30 @@ async def delete_profile(
) -> bool:
"""
Delete a voice profile and all associated data.
-
+
Args:
profile_id: Profile ID
db: Database session
-
+
Returns:
True if deleted, False if not found
"""
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile:
return False
-
- # Delete samples from database
+
db.query(DBProfileSample).filter_by(profile_id=profile_id).delete()
-
- # Delete profile from database
+
db.delete(profile)
db.commit()
-
- # Delete profile directory
- profile_dir = _get_profiles_dir() / profile_id
+
+ profile_dir = config.get_profiles_dir() / profile_id
if profile_dir.exists():
shutil.rmtree(profile_dir)
-
+
# Clean up combined audio cache files for this profile
clear_profile_cache(profile_id)
-
+
return True
@@ -258,34 +308,32 @@ async def delete_profile_sample(
) -> bool:
"""
Delete a profile sample.
-
+
Args:
sample_id: Sample ID
db: Database session
-
+
Returns:
True if deleted, False if not found
"""
sample = db.query(DBProfileSample).filter_by(id=sample_id).first()
if not sample:
return False
-
+
# Store profile_id before deleting
profile_id = sample.profile_id
-
- # Delete audio file
+
audio_path = Path(sample.audio_path)
if audio_path.exists():
audio_path.unlink()
-
- # Delete from database
+
db.delete(sample)
db.commit()
-
+
# Invalidate combined audio cache for this profile
# Since the sample set changed, any cached combined audio is now stale
clear_profile_cache(profile_id)
-
+
return True
@@ -296,30 +344,30 @@ async def update_profile_sample(
) -> Optional[ProfileSampleResponse]:
"""
Update a profile sample's reference text.
-
+
Args:
sample_id: Sample ID
reference_text: Updated reference text
db: Database session
-
+
Returns:
Updated sample or None if not found
"""
sample = db.query(DBProfileSample).filter_by(id=sample_id).first()
if not sample:
return None
-
+
# Store profile_id before updating
profile_id = sample.profile_id
-
+
sample.reference_text = reference_text
db.commit()
db.refresh(sample)
-
+
# Invalidate combined audio cache for this profile
# Since the reference text changed, cache keys and combined text are now stale
clear_profile_cache(profile_id)
-
+
return ProfileSampleResponse.model_validate(sample)
@@ -327,6 +375,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,20 +384,21 @@ 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
"""
- # Get all samples for profile
+ from ..backends import get_tts_backend_for_engine
+
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
sample = samples[0]
voice_prompt, _ = await tts_model.create_voice_prompt(
sample.audio_path,
@@ -357,11 +407,9 @@ async def create_voice_prompt_for_profile(
)
return voice_prompt
else:
- # Multiple samples - combine them
audio_paths = [s.audio_path for s in samples]
reference_texts = [s.reference_text for s in samples]
- # Combine audio
combined_audio, combined_text = await tts_model.combine_voice_prompts(
audio_paths,
reference_texts,
@@ -370,18 +418,16 @@ async def create_voice_prompt_for_profile(
# Save combined audio to cache directory (persistent)
# Create a hash of sample IDs to identify this specific combination
import hashlib
+
sample_ids_str = "-".join(sorted([s.id for s in samples]))
combination_hash = hashlib.md5(sample_ids_str.encode()).hexdigest()[:12]
-
- # Store in cache directory
+
cache_dir = _get_cache_dir()
cache_dir.mkdir(parents=True, exist_ok=True)
combined_path = cache_dir / f"combined_{profile_id}_{combination_hash}.wav"
-
- # Save combined audio
+
save_audio(combined_audio, str(combined_path), 24000)
- # Create prompt from combined audio
voice_prompt, _ = await tts_model.create_voice_prompt(
str(combined_path),
combined_text,
@@ -406,17 +452,14 @@ async def upload_avatar(
Returns:
Updated profile
"""
- # Validate profile exists
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
if not profile:
raise ValueError(f"Profile {profile_id} not found")
- # Validate image
is_valid, error_msg = validate_image(image_path)
if not is_valid:
raise ValueError(error_msg)
- # Delete existing avatar if present
if profile.avatar_path:
old_avatar = Path(profile.avatar_path)
if old_avatar.exists():
@@ -424,34 +467,29 @@ async def upload_avatar(
# Determine file extension from uploaded file
from PIL import Image
+
with Image.open(image_path) as img:
# Normalize JPEG variants (MPO is multi-picture format from some cameras)
img_format = img.format
- if img_format in ('MPO', 'JPG'):
- img_format = 'JPEG'
-
- ext_map = {
- 'PNG': '.png',
- 'JPEG': '.jpg',
- 'WEBP': '.webp'
- }
- ext = ext_map.get(img_format, '.png')
+ if img_format in ("MPO", "JPG"):
+ img_format = "JPEG"
- # Save processed image to profile directory
- profile_dir = _get_profiles_dir() / profile_id
+ ext_map = {"PNG": ".png", "JPEG": ".jpg", "WEBP": ".webp"}
+ ext = ext_map.get(img_format, ".png")
+
+ profile_dir = config.get_profiles_dir() / profile_id
profile_dir.mkdir(parents=True, exist_ok=True)
output_path = profile_dir / f"avatar{ext}"
process_avatar(image_path, str(output_path))
- # Update database
profile.avatar_path = str(output_path)
profile.updated_at = datetime.utcnow()
db.commit()
db.refresh(profile)
- return VoiceProfileResponse.model_validate(profile)
+ return _profile_to_response(profile)
async def delete_avatar(
@@ -472,12 +510,10 @@ async def delete_avatar(
if not profile or not profile.avatar_path:
return False
- # Delete avatar file
avatar_path = Path(profile.avatar_path)
if avatar_path.exists():
avatar_path.unlink()
- # Update database
profile.avatar_path = None
profile.updated_at = datetime.utcnow()
diff --git a/backend/stories.py b/backend/services/stories.py
similarity index 64%
rename from backend/stories.py
rename to backend/services/stories.py
index 2a2f5abb..ac8e22bd 100644
--- a/backend/stories.py
+++ b/backend/services/stories.py
@@ -10,7 +10,7 @@ from pathlib import Path
from sqlalchemy.orm import Session
from sqlalchemy import func
-from .models import (
+from ..models import (
StoryCreate,
StoryResponse,
StoryDetailResponse,
@@ -20,12 +20,60 @@ from .models import (
StoryItemMove,
StoryItemTrim,
StoryItemSplit,
+ StoryItemVersionUpdate,
)
-from .database import Story as DBStory, StoryItem as DBStoryItem, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
-from .utils.audio import load_audio, save_audio
+from ..database import (
+ Story as DBStory,
+ StoryItem as DBStoryItem,
+ Generation as DBGeneration,
+ VoiceProfile as DBVoiceProfile,
+)
+from .history import _get_versions_for_generation
+from ..utils.audio import load_audio, save_audio
import numpy as np
+def _build_item_detail(
+ item: DBStoryItem,
+ generation: DBGeneration,
+ profile_name: str,
+ db: Session,
+) -> StoryItemDetail:
+ """Build a StoryItemDetail with version info from a story item and its generation."""
+ versions, active_version_id = _get_versions_for_generation(generation.id, db)
+
+ # Resolve the audio path: if version_id is set, use that version's audio
+ audio_path = generation.audio_path
+ if item.version_id and versions:
+ for v in versions:
+ if v.id == item.version_id:
+ audio_path = v.audio_path
+ break
+
+ return StoryItemDetail(
+ id=item.id,
+ story_id=item.story_id,
+ generation_id=item.generation_id,
+ version_id=getattr(item, "version_id", None),
+ start_time_ms=item.start_time_ms,
+ track=item.track,
+ trim_start_ms=getattr(item, "trim_start_ms", 0),
+ trim_end_ms=getattr(item, "trim_end_ms", 0),
+ created_at=item.created_at,
+ profile_id=generation.profile_id,
+ profile_name=profile_name,
+ text=generation.text,
+ language=generation.language,
+ audio_path=audio_path,
+ duration=generation.duration,
+ seed=generation.seed,
+ instruct=generation.instruct,
+ generation_created_at=generation.created_at,
+ versions=versions,
+ active_version_id=active_version_id,
+ )
+
+
async def create_story(
data: StoryCreate,
db: Session,
@@ -52,10 +100,7 @@ async def create_story(
db.commit()
db.refresh(db_story)
- # Get item count
- item_count = db.query(func.count(DBStoryItem.id)).filter(
- DBStoryItem.story_id == db_story.id
- ).scalar()
+ item_count = db.query(func.count(DBStoryItem.id)).filter(DBStoryItem.story_id == db_story.id).scalar()
response = StoryResponse.model_validate(db_story)
response.item_count = item_count
@@ -75,17 +120,15 @@ async def list_stories(
List of stories with item counts
"""
stories = db.query(DBStory).order_by(DBStory.updated_at.desc()).all()
-
+
result = []
for story in stories:
- item_count = db.query(func.count(DBStoryItem.id)).filter(
- DBStoryItem.story_id == story.id
- ).scalar()
-
+ item_count = db.query(func.count(DBStoryItem.id)).filter(DBStoryItem.story_id == story.id).scalar()
+
response = StoryResponse.model_validate(story)
response.item_count = item_count
result.append(response)
-
+
return result
@@ -107,44 +150,18 @@ async def get_story(
if not story:
return None
- # Get all items ordered by start_time_ms
- items = db.query(
- DBStoryItem,
- DBGeneration,
- DBVoiceProfile.name.label('profile_name')
- ).join(
- DBGeneration,
- DBStoryItem.generation_id == DBGeneration.id
- ).join(
- DBVoiceProfile,
- DBGeneration.profile_id == DBVoiceProfile.id
- ).filter(
- DBStoryItem.story_id == story_id
- ).order_by(DBStoryItem.start_time_ms).all()
+ items = (
+ db.query(DBStoryItem, DBGeneration, DBVoiceProfile.name.label("profile_name"))
+ .join(DBGeneration, DBStoryItem.generation_id == DBGeneration.id)
+ .join(DBVoiceProfile, DBGeneration.profile_id == DBVoiceProfile.id)
+ .filter(DBStoryItem.story_id == story_id)
+ .order_by(DBStoryItem.start_time_ms)
+ .all()
+ )
- # Build item details
item_details = []
for item, generation, profile_name in items:
- item_detail = StoryItemDetail(
- id=item.id,
- story_id=item.story_id,
- generation_id=item.generation_id,
- start_time_ms=item.start_time_ms,
- track=item.track,
- trim_start_ms=getattr(item, 'trim_start_ms', 0),
- trim_end_ms=getattr(item, 'trim_end_ms', 0),
- created_at=item.created_at,
- profile_id=generation.profile_id,
- profile_name=profile_name,
- text=generation.text,
- language=generation.language,
- audio_path=generation.audio_path,
- duration=generation.duration,
- seed=generation.seed,
- instruct=generation.instruct,
- generation_created_at=generation.created_at,
- )
- item_details.append(item_detail)
+ item_details.append(_build_item_detail(item, generation, profile_name, db))
response = StoryDetailResponse.model_validate(story)
response.items = item_details
@@ -178,10 +195,7 @@ async def update_story(
db.commit()
db.refresh(story)
- # Get item count
- item_count = db.query(func.count(DBStoryItem.id)).filter(
- DBStoryItem.story_id == story.id
- ).scalar()
+ item_count = db.query(func.count(DBStoryItem.id)).filter(DBStoryItem.story_id == story.id).scalar()
response = StoryResponse.model_validate(story)
response.item_count = item_count
@@ -243,63 +257,40 @@ async def add_item_to_story(
return None
# Check if generation is already in story
- existing = db.query(DBStoryItem).filter_by(
- story_id=story_id,
- generation_id=data.generation_id
- ).first()
+ existing = db.query(DBStoryItem).filter_by(story_id=story_id, generation_id=data.generation_id).first()
if existing:
# Return existing item
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
- return StoryItemDetail(
- id=existing.id,
- story_id=existing.story_id,
- generation_id=existing.generation_id,
- start_time_ms=existing.start_time_ms,
- track=existing.track,
- trim_start_ms=getattr(existing, 'trim_start_ms', 0),
- trim_end_ms=getattr(existing, 'trim_end_ms', 0),
- created_at=existing.created_at,
- profile_id=generation.profile_id,
- profile_name=profile.name if profile else "Unknown",
- text=generation.text,
- language=generation.language,
- audio_path=generation.audio_path,
- duration=generation.duration,
- seed=generation.seed,
- instruct=generation.instruct,
- generation_created_at=generation.created_at,
- )
+ return _build_item_detail(existing, generation, profile.name if profile else "Unknown", db)
+
+ # Get track from data or default to 0
+ track = data.track if data.track is not None else 0
# Calculate start_time_ms if not provided
if data.start_time_ms is not None:
start_time_ms = data.start_time_ms
else:
- # Find the maximum end time (start_time_ms + duration_ms) of existing items
- existing_items = db.query(
- DBStoryItem,
- DBGeneration
- ).join(
- DBGeneration,
- DBStoryItem.generation_id == DBGeneration.id
- ).filter(
- DBStoryItem.story_id == story_id
- ).all()
-
+ existing_items = (
+ db.query(DBStoryItem, DBGeneration)
+ .join(DBGeneration, DBStoryItem.generation_id == DBGeneration.id)
+ .filter(
+ DBStoryItem.story_id == story_id,
+ DBStoryItem.track == track,
+ )
+ .all()
+ )
+
if not existing_items:
- # First item starts at 0
start_time_ms = 0
else:
max_end_time_ms = 0
for item, gen in existing_items:
item_end_ms = item.start_time_ms + int(gen.duration * 1000)
max_end_time_ms = max(max_end_time_ms, item_end_ms)
-
+
# Add 200ms gap after the last item
start_time_ms = max_end_time_ms + 200
- # Get track from data or default to 0
- track = data.track if data.track is not None else 0
-
# Create item
item = DBStoryItem(
id=str(uuid.uuid4()),
@@ -311,35 +302,17 @@ async def add_item_to_story(
)
db.add(item)
-
+
# Update story updated_at
story.updated_at = datetime.utcnow()
-
+
db.commit()
db.refresh(item)
# Get profile name
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
- return StoryItemDetail(
- id=item.id,
- story_id=item.story_id,
- generation_id=item.generation_id,
- start_time_ms=item.start_time_ms,
- track=item.track,
- trim_start_ms=getattr(item, 'trim_start_ms', 0),
- trim_end_ms=getattr(item, 'trim_end_ms', 0),
- created_at=item.created_at,
- profile_id=generation.profile_id,
- profile_name=profile.name if profile else "Unknown",
- text=generation.text,
- language=generation.language,
- audio_path=generation.audio_path,
- duration=generation.duration,
- seed=generation.seed,
- instruct=generation.instruct,
- generation_created_at=generation.created_at,
- )
+ return _build_item_detail(item, generation, profile.name if profile else "Unknown", db)
async def move_story_item(
@@ -361,10 +334,14 @@ async def move_story_item(
Updated item detail or None if not found
"""
# Get the item
- item = db.query(DBStoryItem).filter_by(
- id=item_id,
- story_id=story_id,
- ).first()
+ item = (
+ db.query(DBStoryItem)
+ .filter_by(
+ id=item_id,
+ story_id=story_id,
+ )
+ .first()
+ )
if not item:
return None
@@ -388,25 +365,7 @@ async def move_story_item(
# Get profile name
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
- return StoryItemDetail(
- id=item.id,
- story_id=item.story_id,
- generation_id=item.generation_id,
- start_time_ms=item.start_time_ms,
- track=item.track,
- trim_start_ms=getattr(item, 'trim_start_ms', 0),
- trim_end_ms=getattr(item, 'trim_end_ms', 0),
- created_at=item.created_at,
- profile_id=generation.profile_id,
- profile_name=profile.name if profile else "Unknown",
- text=generation.text,
- language=generation.language,
- audio_path=generation.audio_path,
- duration=generation.duration,
- seed=generation.seed,
- instruct=generation.instruct,
- generation_created_at=generation.created_at,
- )
+ return _build_item_detail(item, generation, profile.name if profile else "Unknown", db)
async def remove_item_from_story(
@@ -425,10 +384,14 @@ async def remove_item_from_story(
Returns:
True if removed, False if not found
"""
- item = db.query(DBStoryItem).filter_by(
- id=item_id,
- story_id=story_id,
- ).first()
+ item = (
+ db.query(DBStoryItem)
+ .filter_by(
+ id=item_id,
+ story_id=story_id,
+ )
+ .first()
+ )
if not item:
return False
@@ -463,10 +426,14 @@ async def trim_story_item(
Updated item detail or None if not found
"""
# Get the item
- item = db.query(DBStoryItem).filter_by(
- id=item_id,
- story_id=story_id,
- ).first()
+ item = (
+ db.query(DBStoryItem)
+ .filter_by(
+ id=item_id,
+ story_id=story_id,
+ )
+ .first()
+ )
if not item:
return None
@@ -495,25 +462,7 @@ async def trim_story_item(
# Get profile name
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
- return StoryItemDetail(
- id=item.id,
- story_id=item.story_id,
- generation_id=item.generation_id,
- start_time_ms=item.start_time_ms,
- track=item.track,
- trim_start_ms=item.trim_start_ms,
- trim_end_ms=item.trim_end_ms,
- created_at=item.created_at,
- profile_id=generation.profile_id,
- profile_name=profile.name if profile else "Unknown",
- text=generation.text,
- language=generation.language,
- audio_path=generation.audio_path,
- duration=generation.duration,
- seed=generation.seed,
- instruct=generation.instruct,
- generation_created_at=generation.created_at,
- )
+ return _build_item_detail(item, generation, profile.name if profile else "Unknown", db)
async def split_story_item(
@@ -535,10 +484,14 @@ async def split_story_item(
List of two updated item details (original and new) or None if not found/invalid
"""
# Get the item
- item = db.query(DBStoryItem).filter_by(
- id=item_id,
- story_id=story_id,
- ).first()
+ item = (
+ db.query(DBStoryItem)
+ .filter_by(
+ id=item_id,
+ story_id=story_id,
+ )
+ .first()
+ )
if not item:
return None
@@ -548,8 +501,8 @@ async def split_story_item(
return None
# Calculate effective duration and validate split point
- current_trim_start = getattr(item, 'trim_start_ms', 0)
- current_trim_end = getattr(item, 'trim_end_ms', 0)
+ current_trim_start = getattr(item, "trim_start_ms", 0)
+ current_trim_end = getattr(item, "trim_end_ms", 0)
original_duration_ms = int(generation.duration * 1000)
effective_duration_ms = original_duration_ms - current_trim_start - current_trim_end
@@ -568,6 +521,7 @@ async def split_story_item(
id=str(uuid.uuid4()),
story_id=story_id,
generation_id=item.generation_id, # Same generation, different trim
+ version_id=getattr(item, "version_id", None), # Preserve pinned version
start_time_ms=item.start_time_ms + data.split_time_ms,
track=item.track,
trim_start_ms=absolute_split_ms,
@@ -590,48 +544,10 @@ async def split_story_item(
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
profile_name = profile.name if profile else "Unknown"
- # Build response items
- original_item_detail = StoryItemDetail(
- id=item.id,
- story_id=item.story_id,
- generation_id=item.generation_id,
- start_time_ms=item.start_time_ms,
- track=item.track,
- trim_start_ms=item.trim_start_ms,
- trim_end_ms=item.trim_end_ms,
- created_at=item.created_at,
- profile_id=generation.profile_id,
- profile_name=profile_name,
- text=generation.text,
- language=generation.language,
- audio_path=generation.audio_path,
- duration=generation.duration,
- seed=generation.seed,
- instruct=generation.instruct,
- generation_created_at=generation.created_at,
- )
-
- new_item_detail = StoryItemDetail(
- id=new_item.id,
- story_id=new_item.story_id,
- generation_id=new_item.generation_id,
- start_time_ms=new_item.start_time_ms,
- track=new_item.track,
- trim_start_ms=new_item.trim_start_ms,
- trim_end_ms=new_item.trim_end_ms,
- created_at=new_item.created_at,
- profile_id=generation.profile_id,
- profile_name=profile_name,
- text=generation.text,
- language=generation.language,
- audio_path=generation.audio_path,
- duration=generation.duration,
- seed=generation.seed,
- instruct=generation.instruct,
- generation_created_at=generation.created_at,
- )
-
- return [original_item_detail, new_item_detail]
+ return [
+ _build_item_detail(item, generation, profile_name, db),
+ _build_item_detail(new_item, generation, profile_name, db),
+ ]
async def duplicate_story_item(
@@ -651,10 +567,14 @@ async def duplicate_story_item(
New item detail or None if not found
"""
# Get the original item
- original_item = db.query(DBStoryItem).filter_by(
- id=item_id,
- story_id=story_id,
- ).first()
+ original_item = (
+ db.query(DBStoryItem)
+ .filter_by(
+ id=item_id,
+ story_id=story_id,
+ )
+ .first()
+ )
if not original_item:
return None
@@ -664,8 +584,8 @@ async def duplicate_story_item(
return None
# Calculate effective duration
- current_trim_start = getattr(original_item, 'trim_start_ms', 0)
- current_trim_end = getattr(original_item, 'trim_end_ms', 0)
+ current_trim_start = getattr(original_item, "trim_start_ms", 0)
+ current_trim_end = getattr(original_item, "trim_end_ms", 0)
original_duration_ms = int(generation.duration * 1000)
effective_duration_ms = original_duration_ms - current_trim_start - current_trim_end
@@ -674,6 +594,7 @@ async def duplicate_story_item(
id=str(uuid.uuid4()),
story_id=story_id,
generation_id=original_item.generation_id, # Same generation as original
+ version_id=getattr(original_item, "version_id", None), # Preserve pinned version
start_time_ms=original_item.start_time_ms + effective_duration_ms + 200, # 200ms gap
track=original_item.track,
trim_start_ms=current_trim_start,
@@ -694,25 +615,7 @@ async def duplicate_story_item(
# Get profile name
profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
- return StoryItemDetail(
- id=new_item.id,
- story_id=new_item.story_id,
- generation_id=new_item.generation_id,
- start_time_ms=new_item.start_time_ms,
- track=new_item.track,
- trim_start_ms=new_item.trim_start_ms,
- trim_end_ms=new_item.trim_end_ms,
- created_at=new_item.created_at,
- profile_id=generation.profile_id,
- profile_name=profile.name if profile else "Unknown",
- text=generation.text,
- language=generation.language,
- audio_path=generation.audio_path,
- duration=generation.duration,
- seed=generation.seed,
- instruct=generation.instruct,
- generation_created_at=generation.created_at,
- )
+ return _build_item_detail(new_item, generation, profile.name if profile else "Unknown", db)
async def update_story_item_times(
@@ -775,19 +678,13 @@ async def reorder_story_items(
return None
# Get all items for this story with their generation data
- items_with_gen = db.query(
- DBStoryItem,
- DBGeneration,
- DBVoiceProfile.name.label('profile_name')
- ).join(
- DBGeneration,
- DBStoryItem.generation_id == DBGeneration.id
- ).join(
- DBVoiceProfile,
- DBGeneration.profile_id == DBVoiceProfile.id
- ).filter(
- DBStoryItem.story_id == story_id
- ).all()
+ items_with_gen = (
+ db.query(DBStoryItem, DBGeneration, DBVoiceProfile.name.label("profile_name"))
+ .join(DBGeneration, DBStoryItem.generation_id == DBGeneration.id)
+ .join(DBVoiceProfile, DBGeneration.profile_id == DBVoiceProfile.id)
+ .filter(DBStoryItem.story_id == story_id)
+ .all()
+ )
# Create maps for quick lookup
item_map = {item.generation_id: (item, gen, profile_name) for item, gen, profile_name in items_with_gen}
@@ -802,36 +699,18 @@ async def reorder_story_items(
for gen_id in generation_ids:
item, generation, profile_name = item_map[gen_id]
-
+
# Update the item's start time
item.start_time_ms = current_time_ms
-
+
# Calculate the duration in ms
duration_ms = int(generation.duration * 1000)
-
+
# Move to next position (current end + gap)
current_time_ms += duration_ms + gap_ms
# Build the response item
- updated_items.append(StoryItemDetail(
- id=item.id,
- story_id=item.story_id,
- generation_id=item.generation_id,
- start_time_ms=item.start_time_ms,
- track=item.track,
- trim_start_ms=getattr(item, 'trim_start_ms', 0),
- trim_end_ms=getattr(item, 'trim_end_ms', 0),
- created_at=item.created_at,
- profile_id=generation.profile_id,
- profile_name=profile_name,
- text=generation.text,
- language=generation.language,
- audio_path=generation.audio_path,
- duration=generation.duration,
- seed=generation.seed,
- instruct=generation.instruct,
- generation_created_at=generation.created_at,
- ))
+ updated_items.append(_build_item_detail(item, generation, profile_name, db))
# Update story updated_at
story.updated_at = datetime.utcnow()
@@ -840,6 +719,69 @@ async def reorder_story_items(
return updated_items
+async def set_story_item_version(
+ story_id: str,
+ item_id: str,
+ data: StoryItemVersionUpdate,
+ db: Session,
+) -> Optional[StoryItemDetail]:
+ """
+ Pin a story item to a specific generation version.
+
+ Args:
+ story_id: Story ID
+ item_id: Story item ID
+ data: Version update data (version_id or null for default)
+ db: Database session
+
+ Returns:
+ Updated item detail or None if not found
+ """
+ item = (
+ db.query(DBStoryItem)
+ .filter_by(
+ id=item_id,
+ story_id=story_id,
+ )
+ .first()
+ )
+ if not item:
+ return None
+
+ generation = db.query(DBGeneration).filter_by(id=item.generation_id).first()
+ if not generation:
+ return None
+
+ # Validate version_id belongs to this generation if provided
+ if data.version_id:
+ from ..database import GenerationVersion as DBGenerationVersion
+
+ version = (
+ db.query(DBGenerationVersion)
+ .filter_by(
+ id=data.version_id,
+ generation_id=item.generation_id,
+ )
+ .first()
+ )
+ if not version:
+ return None
+
+ item.version_id = data.version_id
+
+ # Update story updated_at
+ story = db.query(DBStory).filter_by(id=story_id).first()
+ if story:
+ story.updated_at = datetime.utcnow()
+
+ db.commit()
+ db.refresh(item)
+
+ profile = db.query(DBVoiceProfile).filter_by(id=generation.profile_id).first()
+
+ return _build_item_detail(item, generation, profile.name if profile else "Unknown", db)
+
+
async def export_story_audio(
story_id: str,
db: Session,
@@ -859,15 +801,13 @@ async def export_story_audio(
return None
# Get all items ordered by start_time_ms
- items = db.query(
- DBStoryItem,
- DBGeneration
- ).join(
- DBGeneration,
- DBStoryItem.generation_id == DBGeneration.id
- ).filter(
- DBStoryItem.story_id == story_id
- ).order_by(DBStoryItem.start_time_ms).all()
+ items = (
+ db.query(DBStoryItem, DBGeneration)
+ .join(DBGeneration, DBStoryItem.generation_id == DBGeneration.id)
+ .filter(DBStoryItem.story_id == story_id)
+ .order_by(DBStoryItem.start_time_ms)
+ .all()
+ )
if not items:
return None
@@ -877,40 +817,53 @@ async def export_story_audio(
sample_rate = 24000 # Default sample rate
for item, generation in items:
- audio_path = Path(generation.audio_path)
+ # Resolve audio path: use pinned version if set, otherwise generation default
+ resolved_audio_path = generation.audio_path
+ if getattr(item, "version_id", None):
+ from ..database import GenerationVersion as DBGenerationVersion
+
+ version = db.query(DBGenerationVersion).filter_by(id=item.version_id).first()
+ if version:
+ resolved_audio_path = version.audio_path
+
+ audio_path = Path(resolved_audio_path)
if not audio_path.exists():
continue
try:
audio, sr = load_audio(str(audio_path), sample_rate=sample_rate)
sample_rate = sr # Use actual sample rate from first file
-
+
# Get trim values
- trim_start_ms = getattr(item, 'trim_start_ms', 0)
- trim_end_ms = getattr(item, 'trim_end_ms', 0)
-
+ trim_start_ms = getattr(item, "trim_start_ms", 0)
+ trim_end_ms = getattr(item, "trim_end_ms", 0)
+
# Calculate effective duration
original_duration_ms = int(generation.duration * 1000)
effective_duration_ms = original_duration_ms - trim_start_ms - trim_end_ms
-
+
# Slice audio based on trim values
trim_start_sample = int((trim_start_ms / 1000.0) * sample_rate)
trim_end_sample = int((trim_end_ms / 1000.0) * sample_rate)
-
+
# Extract the trimmed portion
if trim_end_ms > 0:
- trimmed_audio = audio[trim_start_sample:-trim_end_sample] if trim_end_sample > 0 else audio[trim_start_sample:]
+ trimmed_audio = (
+ audio[trim_start_sample:-trim_end_sample] if trim_end_sample > 0 else audio[trim_start_sample:]
+ )
else:
trimmed_audio = audio[trim_start_sample:]
-
+
# Store audio with its timecode info
start_time_ms = item.start_time_ms
-
- audio_data.append({
- 'audio': trimmed_audio,
- 'start_time_ms': start_time_ms,
- 'duration_ms': effective_duration_ms,
- })
+
+ audio_data.append(
+ {
+ "audio": trimmed_audio,
+ "start_time_ms": start_time_ms,
+ "duration_ms": effective_duration_ms,
+ }
+ )
except Exception:
# Skip files that can't be loaded
continue
@@ -919,33 +872,30 @@ async def export_story_audio(
return None
# Calculate total duration: max(start_time_ms + duration_ms)
- max_end_time_ms = max(
- (data['start_time_ms'] + data['duration_ms'] for data in audio_data),
- default=0
- )
-
+ max_end_time_ms = max((data["start_time_ms"] + data["duration_ms"] for data in audio_data), default=0)
+
# Convert to samples
total_samples = int((max_end_time_ms / 1000.0) * sample_rate)
-
+
# Create output buffer initialized to zeros
final_audio = np.zeros(total_samples, dtype=np.float32)
# Mix each audio segment at its timecode position
for data in audio_data:
- audio = data['audio']
- start_time_ms = data['start_time_ms']
-
+ audio = data["audio"]
+ start_time_ms = data["start_time_ms"]
+
# Calculate start sample index
start_sample = int((start_time_ms / 1000.0) * sample_rate)
-
+
# Ensure we don't exceed buffer bounds
audio_length = len(audio)
end_sample = min(start_sample + audio_length, total_samples)
-
+
if start_sample < total_samples:
# Trim audio if it extends beyond buffer
- audio_to_mix = audio[:end_sample - start_sample]
-
+ audio_to_mix = audio[: end_sample - start_sample]
+
# Mix: add audio to existing buffer (overlapping audio will sum)
# Normalize to prevent clipping (simple approach: divide by max)
final_audio[start_sample:end_sample] += audio_to_mix
@@ -956,14 +906,14 @@ async def export_story_audio(
final_audio = final_audio / max_val
# Save to temporary file
- with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
tmp_path = tmp.name
try:
save_audio(final_audio, tmp_path, sample_rate)
# Read file bytes
- with open(tmp_path, 'rb') as f:
+ with open(tmp_path, "rb") as f:
audio_bytes = f.read()
return audio_bytes
diff --git a/backend/services/task_queue.py b/backend/services/task_queue.py
new file mode 100644
index 00000000..fbd9638e
--- /dev/null
+++ b/backend/services/task_queue.py
@@ -0,0 +1,48 @@
+"""
+Serial generation queue — ensures only one TTS inference runs at a time
+to avoid GPU contention.
+"""
+
+import asyncio
+import traceback
+
+# Keep references to fire-and-forget background tasks to prevent GC
+_background_tasks: set = set()
+
+# Generation queue — serializes TTS inference to avoid GPU contention
+_generation_queue: asyncio.Queue = None # type: ignore # initialized at startup
+
+
+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
+
+
+async def _generation_worker():
+ """Worker that processes generation tasks one at a time."""
+ while True:
+ coro = await _generation_queue.get()
+ try:
+ await coro
+ except Exception:
+ traceback.print_exc()
+ finally:
+ _generation_queue.task_done()
+
+
+def enqueue_generation(coro):
+ """Add a generation coroutine to the serial queue."""
+ _generation_queue.put_nowait(coro)
+
+
+def init_queue():
+ """Initialize the generation queue and start the worker.
+
+ Must be called once during application startup (inside a running event loop).
+ """
+ global _generation_queue
+ _generation_queue = asyncio.Queue()
+ create_background_task(_generation_worker())
diff --git a/backend/transcribe.py b/backend/services/transcribe.py
similarity index 89%
rename from backend/transcribe.py
rename to backend/services/transcribe.py
index 4d735fce..e400dbd5 100644
--- a/backend/transcribe.py
+++ b/backend/services/transcribe.py
@@ -3,7 +3,7 @@ STT (Speech-to-Text) module - delegates to backend abstraction layer.
"""
from typing import Optional
-from .backends import get_stt_backend, STTBackend
+from ..backends import get_stt_backend, STTBackend
def get_whisper_model() -> STTBackend:
diff --git a/backend/tts.py b/backend/services/tts.py
similarity index 71%
rename from backend/tts.py
rename to backend/services/tts.py
index 98db3412..d4f90ff3 100644
--- a/backend/tts.py
+++ b/backend/services/tts.py
@@ -7,7 +7,7 @@ import numpy as np
import io
import soundfile as sf
-from .backends import get_tts_backend, TTSBackend
+from ..backends import get_tts_backend, TTSBackend
def get_tts_model() -> TTSBackend:
@@ -32,11 +32,3 @@ def audio_to_wav_bytes(audio: np.ndarray, sample_rate: int) -> bytes:
sf.write(buffer, audio, sample_rate, format="WAV")
buffer.seek(0)
return buffer.read()
-
-
-def audio_to_wav_bytes(audio: np.ndarray, sample_rate: int) -> bytes:
- """Convert audio array to WAV bytes."""
- buffer = io.BytesIO()
- sf.write(buffer, audio, sample_rate, format="WAV")
- buffer.seek(0)
- return buffer.read()
diff --git a/backend/services/versions.py b/backend/services/versions.py
new file mode 100644
index 00000000..1743a25c
--- /dev/null
+++ b/backend/services/versions.py
@@ -0,0 +1,211 @@
+"""
+Generation versions management module.
+
+Each generation can have multiple audio versions: a clean (unprocessed)
+version and any number of processed versions with different effects chains.
+"""
+
+from __future__ import annotations
+
+import json
+import uuid
+from pathlib import Path
+from typing import List, Optional
+
+from sqlalchemy.orm import Session
+
+from ..database import (
+ GenerationVersion as DBGenerationVersion,
+ Generation as DBGeneration,
+)
+from ..models import GenerationVersionResponse, EffectConfig
+from .. import config
+
+
+def _version_response(v: DBGenerationVersion) -> GenerationVersionResponse:
+ """Convert a DB version row to a Pydantic response."""
+ effects_chain = None
+ if v.effects_chain:
+ raw = json.loads(v.effects_chain)
+ effects_chain = [EffectConfig(**e) for e in raw]
+ return GenerationVersionResponse(
+ id=v.id,
+ generation_id=v.generation_id,
+ label=v.label,
+ audio_path=v.audio_path,
+ effects_chain=effects_chain,
+ source_version_id=v.source_version_id,
+ is_default=v.is_default,
+ created_at=v.created_at,
+ )
+
+
+def list_versions(generation_id: str, db: Session) -> List[GenerationVersionResponse]:
+ """List all versions for a generation."""
+ versions = (
+ db.query(DBGenerationVersion)
+ .filter_by(generation_id=generation_id)
+ .order_by(DBGenerationVersion.created_at)
+ .all()
+ )
+ return [_version_response(v) for v in versions]
+
+
+def get_version(version_id: str, db: Session) -> Optional[GenerationVersionResponse]:
+ """Get a specific version by ID."""
+ v = db.query(DBGenerationVersion).filter_by(id=version_id).first()
+ if not v:
+ return None
+ return _version_response(v)
+
+
+def get_default_version(generation_id: str, db: Session) -> Optional[GenerationVersionResponse]:
+ """Get the default version for a generation."""
+ v = (
+ db.query(DBGenerationVersion)
+ .filter_by(generation_id=generation_id, is_default=True)
+ .first()
+ )
+ if not v:
+ # Fallback: return the first version
+ v = (
+ db.query(DBGenerationVersion)
+ .filter_by(generation_id=generation_id)
+ .order_by(DBGenerationVersion.created_at)
+ .first()
+ )
+ if not v:
+ return None
+ return _version_response(v)
+
+
+def create_version(
+ generation_id: str,
+ label: str,
+ audio_path: str,
+ db: Session,
+ effects_chain: Optional[List[dict]] = None,
+ is_default: bool = False,
+ source_version_id: Optional[str] = None,
+) -> GenerationVersionResponse:
+ """Create a new version for a generation.
+
+ If ``is_default`` is True, all other versions for this generation
+ are un-defaulted first.
+ """
+ if is_default:
+ _clear_defaults(generation_id, db)
+
+ version = DBGenerationVersion(
+ id=str(uuid.uuid4()),
+ generation_id=generation_id,
+ label=label,
+ audio_path=audio_path,
+ effects_chain=json.dumps(effects_chain) if effects_chain else None,
+ source_version_id=source_version_id,
+ is_default=is_default,
+ )
+ db.add(version)
+ db.commit()
+ db.refresh(version)
+
+ # If this version is the default, update the generation's audio_path
+ if is_default:
+ gen = db.query(DBGeneration).filter_by(id=generation_id).first()
+ if gen:
+ gen.audio_path = audio_path
+ db.commit()
+
+ return _version_response(version)
+
+
+def set_default_version(version_id: str, db: Session) -> Optional[GenerationVersionResponse]:
+ """Set a version as the default for its generation."""
+ version = db.query(DBGenerationVersion).filter_by(id=version_id).first()
+ if not version:
+ return None
+
+ _clear_defaults(version.generation_id, db)
+ version.is_default = True
+ db.commit()
+ db.refresh(version)
+
+ # Update generation's audio_path to point to this version
+ gen = db.query(DBGeneration).filter_by(id=version.generation_id).first()
+ if gen:
+ gen.audio_path = version.audio_path
+ db.commit()
+
+ return _version_response(version)
+
+
+def delete_version(version_id: str, db: Session) -> bool:
+ """Delete a version. Cannot delete the last remaining version."""
+ version = db.query(DBGenerationVersion).filter_by(id=version_id).first()
+ if not version:
+ return False
+
+ # Don't allow deleting the last version
+ count = (
+ db.query(DBGenerationVersion)
+ .filter_by(generation_id=version.generation_id)
+ .count()
+ )
+ if count <= 1:
+ return False
+
+ was_default = version.is_default
+ gen_id = version.generation_id
+
+ # Delete audio file
+ audio_path = Path(version.audio_path)
+ if audio_path.exists():
+ audio_path.unlink()
+
+ db.delete(version)
+ db.commit()
+
+ # If this was the default, promote the first remaining version
+ if was_default:
+ first = (
+ db.query(DBGenerationVersion)
+ .filter_by(generation_id=gen_id)
+ .order_by(DBGenerationVersion.created_at)
+ .first()
+ )
+ if first:
+ first.is_default = True
+ db.commit()
+ gen = db.query(DBGeneration).filter_by(id=gen_id).first()
+ if gen:
+ gen.audio_path = first.audio_path
+ db.commit()
+
+ return True
+
+
+def delete_versions_for_generation(generation_id: str, db: Session) -> int:
+ """Delete all versions for a generation (used when deleting a generation)."""
+ versions = (
+ db.query(DBGenerationVersion)
+ .filter_by(generation_id=generation_id)
+ .all()
+ )
+ count = 0
+ for v in versions:
+ audio_path = Path(v.audio_path)
+ if audio_path.exists():
+ audio_path.unlink()
+ db.delete(v)
+ count += 1
+ if count > 0:
+ db.commit()
+ return count
+
+
+def _clear_defaults(generation_id: str, db: Session) -> None:
+ """Clear the is_default flag on all versions for a generation."""
+ db.query(DBGenerationVersion).filter_by(
+ generation_id=generation_id, is_default=True
+ ).update({"is_default": False})
+ db.flush()
diff --git a/backend/studio.py b/backend/studio.py
deleted file mode 100644
index 027a9b65..00000000
--- a/backend/studio.py
+++ /dev/null
@@ -1,66 +0,0 @@
-"""
-Audio studio module for timeline editing.
-"""
-
-from typing import List, Dict, Optional
-import numpy as np
-
-
-class AudioStudio:
- """Audio editing and timeline management."""
-
- async def get_word_timestamps(
- self,
- audio_path: str,
- text: str,
- ) -> List[Dict[str, float]]:
- """
- Get word-level timestamps for audio.
-
- Args:
- audio_path: Path to audio file
- text: Corresponding text
-
- Returns:
- List of word timestamps: [{"word": "...", "start": 0.0, "end": 0.5}, ...]
- """
- # TODO: Implement Whisper alignment
- raise NotImplementedError("Word timestamps not yet implemented")
-
- async def mix_audio(
- self,
- audio_paths: List[str],
- volumes: Optional[List[float]] = None,
- ) -> bytes:
- """
- Mix multiple audio files together.
-
- Args:
- audio_paths: List of audio file paths
- volumes: Optional volume levels (0.0-1.0) for each track
-
- Returns:
- Mixed audio bytes (WAV format)
- """
- # TODO: Implement audio mixing
- raise NotImplementedError("Audio mixing not yet implemented")
-
- async def trim_audio(
- self,
- audio_path: str,
- start: float,
- end: float,
- ) -> bytes:
- """
- Trim audio to specified time range.
-
- Args:
- audio_path: Path to audio file
- start: Start time in seconds
- end: End time in seconds
-
- Returns:
- Trimmed audio bytes (WAV format)
- """
- # TODO: Implement audio trimming
- raise NotImplementedError("Audio trimming not yet implemented")
diff --git a/backend/tests/test_cors.py b/backend/tests/test_cors.py
new file mode 100644
index 00000000..ae999c95
--- /dev/null
+++ b/backend/tests/test_cors.py
@@ -0,0 +1,162 @@
+"""
+Tests for CORS origin restrictions.
+
+Validates that the CORS middleware only allows known local origins
+and respects the VOICEBOX_CORS_ORIGINS environment variable.
+
+Uses a minimal FastAPI app that mirrors the exact CORS configuration
+from backend/main.py, so tests run without heavy ML dependencies.
+
+Usage:
+ pip install httpx pytest fastapi starlette
+ python -m pytest backend/tests/test_cors.py -v
+"""
+
+import os
+import pytest
+from unittest.mock import patch
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from starlette.testclient import TestClient
+
+
+def _build_app(env_origins: str = "") -> FastAPI:
+ """
+ Build a minimal FastAPI app with the same CORS logic as backend/main.py.
+
+ This mirrors the exact code in main.py so the test validates the real
+ configuration without needing torch/numpy/transformers installed.
+ """
+ app = FastAPI()
+
+ _default_origins = [
+ "http://localhost:5173",
+ "http://127.0.0.1:5173",
+ "http://localhost:17493",
+ "http://127.0.0.1:17493",
+ "tauri://localhost",
+ "https://tauri.localhost",
+ ]
+ _cors_origins = _default_origins + [o.strip() for o in env_origins.split(",") if o.strip()]
+
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=_cors_origins,
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+ @app.get("/health")
+ async def health():
+ return {"status": "ok"}
+
+ return app
+
+
+@pytest.fixture()
+def client():
+ return TestClient(_build_app())
+
+
+@pytest.fixture()
+def client_with_custom_origins():
+ return TestClient(_build_app("https://custom.example.com,https://other.example.com"))
+
+
+def _get_with_origin(client: TestClient, origin: str) -> dict:
+ """Send a GET with Origin header, return response headers."""
+ response = client.get("/health", headers={"Origin": origin})
+ return dict(response.headers)
+
+
+def _preflight(client: TestClient, origin: str) -> dict:
+ """Send CORS preflight OPTIONS request, return response headers."""
+ response = client.options(
+ "/health",
+ headers={
+ "Origin": origin,
+ "Access-Control-Request-Method": "GET",
+ },
+ )
+ return dict(response.headers)
+
+
+class TestCORSDefaultOrigins:
+ """CORS should allow known local origins and block everything else."""
+
+ @pytest.mark.parametrize("origin", [
+ "http://localhost:5173",
+ "http://127.0.0.1:5173",
+ "http://localhost:17493",
+ "http://127.0.0.1:17493",
+ "tauri://localhost",
+ "https://tauri.localhost",
+ ])
+ def test_allowed_origins(self, client, origin):
+ headers = _get_with_origin(client, origin)
+ assert headers.get("access-control-allow-origin") == origin
+
+ @pytest.mark.parametrize("origin", [
+ "http://evil.com",
+ "http://localhost:9999",
+ "https://attacker.example.com",
+ "null",
+ ])
+ def test_blocked_origins(self, client, origin):
+ headers = _get_with_origin(client, origin)
+ assert "access-control-allow-origin" not in headers
+
+ def test_preflight_allowed(self, client):
+ headers = _preflight(client, "http://localhost:5173")
+ assert headers.get("access-control-allow-origin") == "http://localhost:5173"
+
+ def test_preflight_blocked(self, client):
+ headers = _preflight(client, "http://evil.com")
+ assert "access-control-allow-origin" not in headers
+
+ def test_credentials_header_present(self, client):
+ headers = _get_with_origin(client, "http://localhost:5173")
+ assert headers.get("access-control-allow-credentials") == "true"
+
+
+class TestCORSCustomOrigins:
+ """VOICEBOX_CORS_ORIGINS env var should extend the allowlist."""
+
+ def test_custom_origin_allowed(self, client_with_custom_origins):
+ headers = _get_with_origin(client_with_custom_origins, "https://custom.example.com")
+ assert headers.get("access-control-allow-origin") == "https://custom.example.com"
+
+ def test_other_custom_origin_allowed(self, client_with_custom_origins):
+ headers = _get_with_origin(client_with_custom_origins, "https://other.example.com")
+ assert headers.get("access-control-allow-origin") == "https://other.example.com"
+
+ def test_default_origins_still_work(self, client_with_custom_origins):
+ headers = _get_with_origin(client_with_custom_origins, "http://localhost:5173")
+ assert headers.get("access-control-allow-origin") == "http://localhost:5173"
+
+ def test_unlisted_origin_still_blocked(self, client_with_custom_origins):
+ headers = _get_with_origin(client_with_custom_origins, "http://evil.com")
+ assert "access-control-allow-origin" not in headers
+
+
+class TestCORSEnvVarParsing:
+ """Edge cases for VOICEBOX_CORS_ORIGINS parsing."""
+
+ def test_empty_env_var(self):
+ app = _build_app("")
+ client = TestClient(app)
+ headers = _get_with_origin(client, "http://evil.com")
+ assert "access-control-allow-origin" not in headers
+
+ def test_whitespace_trimmed(self):
+ app = _build_app(" https://spaced.example.com ")
+ client = TestClient(app)
+ headers = _get_with_origin(client, "https://spaced.example.com")
+ assert headers.get("access-control-allow-origin") == "https://spaced.example.com"
+
+ def test_trailing_comma_ignored(self):
+ app = _build_app("https://one.example.com,")
+ client = TestClient(app)
+ headers = _get_with_origin(client, "https://one.example.com")
+ assert headers.get("access-control-allow-origin") == "https://one.example.com"
diff --git a/backend/tests/test_generation_download.py b/backend/tests/test_generation_download.py
index 5cbe3fdf..19618ca4 100644
--- a/backend/tests/test_generation_download.py
+++ b/backend/tests/test_generation_download.py
@@ -37,11 +37,10 @@ async def monitor_sse_stream(model_name: str, timeout: int = 120):
if line.startswith("data: "):
try:
data = json.loads(line[6:])
- print(f"[{timestamp}] → SSE Event: {data['status']:12} {data.get('progress', 0):6.1f}% {data.get('filename', '')}")
- events.append({
- **data,
- "_timestamp": timestamp
- })
+ print(
+ f"[{timestamp}] → SSE Event: {data['status']:12} {data.get('progress', 0):6.1f}% {data.get('filename', '')}"
+ )
+ events.append({**data, "_timestamp": timestamp})
# Stop if complete or error
if data.get("status") in ("complete", "error"):
@@ -74,12 +73,15 @@ async def trigger_generation(profile_id: str, text: str, model_size: str = "1.7B
try:
async with httpx.AsyncClient(timeout=120) as client:
- response = await client.post(url, json={
- "profile_id": profile_id,
- "text": text,
- "language": "en",
- "model_size": model_size,
- })
+ response = await client.post(
+ url,
+ json={
+ "profile_id": profile_id,
+ "text": text,
+ "language": "en",
+ "model_size": model_size,
+ },
+ )
print(f"[{_timestamp()}] Response: {response.status_code}")
@@ -140,7 +142,7 @@ def _timestamp():
async def test_generation_with_cached_model():
"""
Test Case 1: Generation when model is already cached.
-
+
This should NOT show any download progress events.
If it does, that's the UX bug we're trying to fix.
"""
@@ -194,7 +196,7 @@ async def test_generation_with_cached_model():
async def test_generation_with_fresh_download():
"""
Test Case 2: Generation when model needs to be downloaded.
-
+
This SHOULD show download progress events.
"""
print("\n" + "=" * 80)
@@ -292,24 +294,6 @@ async def main():
print(" Users see progress events even when the model is already cached,")
print(" making them think the model is downloading again.")
- # Test Case 2: Fresh download (optional, commented out by default)
- # Uncomment if you want to test download progress
- # print("\n" + "🧪 " * 20)
- # events_download = await test_generation_with_fresh_download()
- #
- # print("\n" + "=" * 80)
- # print("TEST CASE 2 RESULTS: Generation with Model Download")
- # print("=" * 80)
- #
- # if not events_download:
- # print("ℹ Model was already cached, no download occurred")
- # else:
- # print(f"✓ Received {len(events_download)} download progress events")
- # print("\nDownload Timeline:")
- # for i, event in enumerate(events_download, 1):
- # timestamp = event.pop("_timestamp", "??:??:??.???")
- # print(f" {i}. [{timestamp}] {event}")
-
print("\n" + "=" * 80)
print("Test Complete!")
print("=" * 80)
diff --git a/backend/tests/test_profile_duplicate_names.py b/backend/tests/test_profile_duplicate_names.py
new file mode 100644
index 00000000..55ee8587
--- /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."""
+ from backend import config
+ monkeypatch.setattr(config, '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..2c6de760 100644
--- a/backend/utils/audio.py
+++ b/backend/utils/audio.py
@@ -70,14 +70,133 @@ 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 (explicit format since .tmp
+ # extension is not recognised by soundfile)
+ sf.write(temp_path, audio, sample_rate, format='WAV')
+
+ # 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/cache.py b/backend/utils/cache.py
index 1c420ba0..cace2bdd 100644
--- a/backend/utils/cache.py
+++ b/backend/utils/cache.py
@@ -3,12 +3,15 @@ Voice prompt caching utilities.
"""
import hashlib
+import logging
import torch
from pathlib import Path
from typing import Optional, Union, Dict, Any
from .. import config
+logger = logging.getLogger(__name__)
+
def _get_cache_dir() -> Path:
"""Get cache directory from config."""
@@ -93,17 +96,17 @@ def cache_voice_prompt(
def clear_voice_prompt_cache() -> int:
"""
Clear all voice prompt caches (memory and disk).
-
+
Returns:
Number of cache files deleted
"""
# Clear memory cache
_memory_cache.clear()
-
+
# Clear disk cache
cache_dir = _get_cache_dir()
deleted_count = 0
-
+
if cache_dir.exists():
# Delete prompt cache files
for cache_file in cache_dir.glob("*.prompt"):
@@ -111,32 +114,32 @@ def clear_voice_prompt_cache() -> int:
cache_file.unlink()
deleted_count += 1
except Exception as e:
- print(f"Failed to delete cache file {cache_file}: {e}")
-
+ logger.warning("Failed to delete cache file %s: %s", cache_file, e)
+
# Delete combined audio files
for audio_file in cache_dir.glob("combined_*.wav"):
try:
audio_file.unlink()
deleted_count += 1
except Exception as e:
- print(f"Failed to delete combined audio file {audio_file}: {e}")
-
+ logger.warning("Failed to delete combined audio file %s: %s", audio_file, e)
+
return deleted_count
def clear_profile_cache(profile_id: str) -> int:
"""
Clear cache files for a specific profile.
-
+
Args:
profile_id: Profile ID
-
+
Returns:
Number of cache files deleted
"""
cache_dir = _get_cache_dir()
deleted_count = 0
-
+
if cache_dir.exists():
# Delete combined audio files for this profile
pattern = f"combined_{profile_id}_*.wav"
@@ -145,6 +148,6 @@ def clear_profile_cache(profile_id: str) -> int:
audio_file.unlink()
deleted_count += 1
except Exception as e:
- print(f"Failed to delete combined audio file {audio_file}: {e}")
-
+ logger.warning("Failed to delete combined audio file %s: %s", audio_file, e)
+
return deleted_count
diff --git a/backend/utils/chunked_tts.py b/backend/utils/chunked_tts.py
new file mode 100644
index 00000000..1f43379e
--- /dev/null
+++ b/backend/utils/chunked_tts.py
@@ -0,0 +1,299 @@
+"""
+Chunked TTS generation utilities.
+
+Splits long text into sentence-boundary chunks, generates audio per-chunk
+via any TTSBackend, and concatenates with crossfade. All logic is
+engine-agnostic — it wraps the standard ``TTSBackend.generate()`` interface.
+
+Short text (≤ max_chunk_chars) uses the single-shot fast path with zero
+overhead.
+"""
+
+import logging
+import re
+from typing import List, Tuple
+
+import numpy as np
+
+logger = logging.getLogger("voicebox.chunked-tts")
+
+# Default chunk size in characters. Can be overridden per-request via
+# the ``max_chunk_chars`` field on GenerationRequest.
+DEFAULT_MAX_CHUNK_CHARS = 800
+
+# Common abbreviations that should NOT be treated as sentence endings.
+# Lowercase for case-insensitive matching.
+_ABBREVIATIONS = frozenset(
+ {
+ "mr",
+ "mrs",
+ "ms",
+ "dr",
+ "prof",
+ "sr",
+ "jr",
+ "st",
+ "ave",
+ "blvd",
+ "inc",
+ "ltd",
+ "corp",
+ "dept",
+ "est",
+ "approx",
+ "vs",
+ "etc",
+ "e.g",
+ "i.e",
+ "a.m",
+ "p.m",
+ "u.s",
+ "u.s.a",
+ "u.k",
+ }
+)
+
+# Paralinguistic tags used by Chatterbox Turbo. The splitter must never
+# cut inside one of these.
+_PARA_TAG_RE = re.compile(r"\[[^\]]*\]")
+
+
+def split_text_into_chunks(text: str, max_chars: int = DEFAULT_MAX_CHUNK_CHARS) -> List[str]:
+ """Split *text* at natural boundaries into chunks of at most *max_chars*.
+
+ Priority: sentence-end (``.!?`` not preceded by an abbreviation and not
+ inside brackets) → clause boundary (``;:,—``) → whitespace → hard cut.
+
+ Paralinguistic tags like ``[laugh]`` are treated as atomic and will not
+ be split across chunks.
+ """
+ text = text.strip()
+ if not text:
+ return []
+ if len(text) <= max_chars:
+ return [text]
+
+ chunks: List[str] = []
+ remaining = text
+
+ while remaining:
+ remaining = remaining.lstrip()
+ if not remaining:
+ break
+ if len(remaining) <= max_chars:
+ chunks.append(remaining)
+ break
+
+ segment = remaining[:max_chars]
+
+ # Try to split at the last real sentence ending
+ split_pos = _find_last_sentence_end(segment)
+ if split_pos == -1:
+ split_pos = _find_last_clause_boundary(segment)
+ if split_pos == -1:
+ split_pos = segment.rfind(" ")
+ if split_pos == -1:
+ # Absolute fallback: hard cut but avoid splitting inside a tag
+ split_pos = _safe_hard_cut(segment, max_chars)
+
+ chunk = remaining[: split_pos + 1].strip()
+ if chunk:
+ chunks.append(chunk)
+ remaining = remaining[split_pos + 1 :]
+
+ return chunks
+
+
+def _find_last_sentence_end(text: str) -> int:
+ """Return the index of the last sentence-ending punctuation in *text*.
+
+ Skips periods that follow common abbreviations (``Dr.``, ``Mr.``, etc.)
+ and periods inside bracket tags (``[laugh]``). Also handles CJK
+ sentence-ending punctuation (``。!?``).
+ """
+ best = -1
+ # ASCII sentence ends
+ for m in re.finditer(r"[.!?](?:\s|$)", text):
+ pos = m.start()
+ char = text[pos]
+ # Skip periods after abbreviations
+ if char == ".":
+ # Walk backwards to find the preceding word
+ word_start = pos - 1
+ while word_start >= 0 and text[word_start].isalpha():
+ word_start -= 1
+ word = text[word_start + 1 : pos].lower()
+ if word in _ABBREVIATIONS:
+ continue
+ # Skip decimal numbers (digit immediately before the period)
+ if word_start >= 0 and text[word_start].isdigit():
+ continue
+ # Skip if we're inside a bracket tag
+ if _inside_bracket_tag(text, pos):
+ continue
+ best = pos
+ # CJK sentence-ending punctuation
+ for m in re.finditer(r"[\u3002\uff01\uff1f]", text):
+ if m.start() > best:
+ best = m.start()
+ return best
+
+
+def _find_last_clause_boundary(text: str) -> int:
+ """Return the index of the last clause-boundary punctuation."""
+ best = -1
+ for m in re.finditer(r"[;:,\u2014](?:\s|$)", text):
+ pos = m.start()
+ # Skip if inside a bracket tag
+ if _inside_bracket_tag(text, pos):
+ continue
+ best = pos
+ return best
+
+
+def _inside_bracket_tag(text: str, pos: int) -> bool:
+ """Return True if *pos* falls inside a ``[...]`` tag."""
+ for m in _PARA_TAG_RE.finditer(text):
+ if m.start() < pos < m.end():
+ return True
+ return False
+
+
+def _safe_hard_cut(segment: str, max_chars: int) -> int:
+ """Find a hard-cut position that doesn't split a ``[tag]``."""
+ cut = max_chars - 1
+ # Check if the cut falls inside a bracket tag; if so, move before it
+ for m in _PARA_TAG_RE.finditer(segment):
+ if m.start() < cut < m.end():
+ return m.start() - 1 if m.start() > 0 else cut
+ return cut
+
+
+def concatenate_audio_chunks(
+ chunks: List[np.ndarray],
+ sample_rate: int,
+ crossfade_ms: int = 50,
+) -> np.ndarray:
+ """Concatenate audio arrays with a short crossfade to eliminate clicks.
+
+ Each chunk is expected to be a 1-D float32 ndarray at *sample_rate* Hz.
+ """
+ if not chunks:
+ return np.array([], dtype=np.float32)
+ if len(chunks) == 1:
+ return chunks[0]
+
+ crossfade_samples = int(sample_rate * crossfade_ms / 1000)
+ result = np.array(chunks[0], dtype=np.float32, copy=True)
+
+ for chunk in chunks[1:]:
+ if len(chunk) == 0:
+ continue
+ overlap = min(crossfade_samples, len(result), len(chunk))
+ if overlap > 0:
+ fade_out = np.linspace(1.0, 0.0, overlap, dtype=np.float32)
+ fade_in = np.linspace(0.0, 1.0, overlap, dtype=np.float32)
+ result[-overlap:] = result[-overlap:] * fade_out + chunk[:overlap] * fade_in
+ result = np.concatenate([result, chunk[overlap:]])
+ else:
+ result = np.concatenate([result, chunk])
+
+ return result
+
+
+async def generate_chunked(
+ backend,
+ text: str,
+ voice_prompt: dict,
+ language: str = "en",
+ seed: int | None = None,
+ instruct: str | None = None,
+ max_chunk_chars: int = DEFAULT_MAX_CHUNK_CHARS,
+ crossfade_ms: int = 50,
+ trim_fn=None,
+) -> Tuple[np.ndarray, int]:
+ """Generate audio with automatic chunking for long text.
+
+ For text shorter than *max_chunk_chars* this is a thin wrapper around
+ ``backend.generate()`` with zero overhead.
+
+ For longer text the input is split at natural sentence boundaries,
+ each chunk is generated independently, optionally trimmed (useful for
+ Chatterbox engines that hallucinate trailing noise), and the results
+ are concatenated with a crossfade (or hard cut if *crossfade_ms* is 0).
+
+ Parameters
+ ----------
+ backend : TTSBackend
+ Any backend implementing the ``generate()`` protocol.
+ text : str
+ Input text (may be arbitrarily long).
+ voice_prompt, language, seed, instruct
+ Forwarded to ``backend.generate()`` verbatim.
+ max_chunk_chars : int
+ Maximum characters per chunk (default 800).
+ crossfade_ms : int
+ Crossfade duration in milliseconds between chunks. 0 for a hard
+ cut with no overlap (default 50).
+ trim_fn : callable | None
+ Optional ``(audio, sample_rate) -> audio`` post-processing
+ function applied to each chunk before concatenation (e.g.
+ ``trim_tts_output`` for Chatterbox engines).
+
+ Returns
+ -------
+ (audio, sample_rate) : Tuple[np.ndarray, int]
+ """
+ chunks = split_text_into_chunks(text, max_chunk_chars)
+
+ if len(chunks) <= 1:
+ # Short text — single-shot fast path
+ audio, sample_rate = await backend.generate(
+ text,
+ voice_prompt,
+ language,
+ seed,
+ instruct,
+ )
+ if trim_fn is not None:
+ audio = trim_fn(audio, sample_rate)
+ return audio, sample_rate
+
+ # Long text — chunked generation
+ logger.info(
+ "Splitting %d chars into %d chunks (max %d chars each)",
+ len(text),
+ len(chunks),
+ max_chunk_chars,
+ )
+ audio_chunks: List[np.ndarray] = []
+ sample_rate: int | None = None
+
+ for i, chunk_text in enumerate(chunks):
+ logger.info(
+ "Generating chunk %d/%d (%d chars)",
+ i + 1,
+ len(chunks),
+ len(chunk_text),
+ )
+ # Vary the seed per chunk to avoid correlated RNG artefacts,
+ # but keep it deterministic so the same (text, seed) pair
+ # always produces the same output.
+ chunk_seed = (seed + i) if seed is not None else None
+
+ chunk_audio, chunk_sr = await backend.generate(
+ chunk_text,
+ voice_prompt,
+ language,
+ chunk_seed,
+ instruct,
+ )
+ if trim_fn is not None:
+ chunk_audio = trim_fn(chunk_audio, chunk_sr)
+
+ audio_chunks.append(np.asarray(chunk_audio, dtype=np.float32))
+ if sample_rate is None:
+ sample_rate = chunk_sr
+
+ audio = concatenate_audio_chunks(audio_chunks, sample_rate, crossfade_ms=crossfade_ms)
+ return audio, sample_rate
diff --git a/backend/utils/effects.py b/backend/utils/effects.py
new file mode 100644
index 00000000..afeefdde
--- /dev/null
+++ b/backend/utils/effects.py
@@ -0,0 +1,373 @@
+"""
+Audio post-processing effects engine.
+
+Uses Spotify's pedalboard library to apply professional-grade DSP effects
+to generated audio. Effects are described as a JSON-serializable chain
+(list of effect dicts) so they can be stored in the database and sent
+over the API.
+
+Supported effect types:
+ - chorus (flanger-style with short delays)
+ - reverb (room reverb)
+ - delay (echo / delay line)
+ - compressor (dynamic range compression)
+ - gain (volume adjustment in dB)
+ - highpass (high-pass filter)
+ - lowpass (low-pass filter)
+ - pitch_shift (semitone pitch shifting)
+"""
+
+from __future__ import annotations
+
+import numpy as np
+from typing import Any, Dict, List, Optional
+
+from pedalboard import (
+ Pedalboard,
+ Chorus,
+ Reverb,
+ Compressor,
+ Gain,
+ HighpassFilter,
+ LowpassFilter,
+ Delay,
+ PitchShift,
+)
+
+
+# Each param definition: (default, min, max, description)
+EFFECT_REGISTRY: Dict[str, Dict[str, Any]] = {
+ "chorus": {
+ "cls": Chorus,
+ "label": "Chorus / Flanger",
+ "description": "Modulated delay for flanging or chorus effects. Short centre_delay_ms (<10) gives flanger; longer gives chorus.",
+ "params": {
+ "rate_hz": {"default": 1.0, "min": 0.01, "max": 20.0, "step": 0.01, "description": "LFO speed (Hz)"},
+ "depth": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Modulation depth"},
+ "feedback": {"default": 0.0, "min": 0.0, "max": 0.95, "step": 0.01, "description": "Feedback amount"},
+ "centre_delay_ms": {
+ "default": 7.0,
+ "min": 0.5,
+ "max": 50.0,
+ "step": 0.1,
+ "description": "Centre delay (ms)",
+ },
+ "mix": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet/dry mix"},
+ },
+ },
+ "reverb": {
+ "cls": Reverb,
+ "label": "Reverb",
+ "description": "Room reverb effect.",
+ "params": {
+ "room_size": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Room size"},
+ "damping": {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "description": "High frequency damping"},
+ "wet_level": {"default": 0.33, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet level"},
+ "dry_level": {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Dry level"},
+ "width": {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Stereo width"},
+ },
+ },
+ "delay": {
+ "cls": Delay,
+ "label": "Delay",
+ "description": "Echo / delay line.",
+ "params": {
+ "delay_seconds": {
+ "default": 0.3,
+ "min": 0.01,
+ "max": 2.0,
+ "step": 0.01,
+ "description": "Delay time (seconds)",
+ },
+ "feedback": {"default": 0.3, "min": 0.0, "max": 0.95, "step": 0.01, "description": "Feedback amount"},
+ "mix": {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.01, "description": "Wet/dry mix"},
+ },
+ },
+ "compressor": {
+ "cls": Compressor,
+ "label": "Compressor",
+ "description": "Dynamic range compression for consistent loudness.",
+ "params": {
+ "threshold_db": {"default": -20.0, "min": -60.0, "max": 0.0, "step": 0.5, "description": "Threshold (dB)"},
+ "ratio": {"default": 4.0, "min": 1.0, "max": 20.0, "step": 0.1, "description": "Compression ratio"},
+ "attack_ms": {"default": 10.0, "min": 0.1, "max": 100.0, "step": 0.1, "description": "Attack time (ms)"},
+ "release_ms": {
+ "default": 100.0,
+ "min": 10.0,
+ "max": 1000.0,
+ "step": 1.0,
+ "description": "Release time (ms)",
+ },
+ },
+ },
+ "gain": {
+ "cls": Gain,
+ "label": "Gain",
+ "description": "Volume adjustment in decibels.",
+ "params": {
+ "gain_db": {"default": 0.0, "min": -40.0, "max": 40.0, "step": 0.5, "description": "Gain (dB)"},
+ },
+ },
+ "highpass": {
+ "cls": HighpassFilter,
+ "label": "High-Pass Filter",
+ "description": "Removes frequencies below the cutoff.",
+ "params": {
+ "cutoff_frequency_hz": {
+ "default": 80.0,
+ "min": 20.0,
+ "max": 8000.0,
+ "step": 1.0,
+ "description": "Cutoff frequency (Hz)",
+ },
+ },
+ },
+ "lowpass": {
+ "cls": LowpassFilter,
+ "label": "Low-Pass Filter",
+ "description": "Removes frequencies above the cutoff.",
+ "params": {
+ "cutoff_frequency_hz": {
+ "default": 8000.0,
+ "min": 200.0,
+ "max": 20000.0,
+ "step": 1.0,
+ "description": "Cutoff frequency (Hz)",
+ },
+ },
+ },
+ "pitch_shift": {
+ "cls": PitchShift,
+ "label": "Pitch Shift",
+ "description": "Shift pitch up or down by semitones.",
+ "params": {
+ "semitones": {"default": 0.0, "min": -12.0, "max": 12.0, "step": 0.5, "description": "Semitones to shift"},
+ },
+ },
+}
+
+
+BUILTIN_PRESETS: Dict[str, Dict[str, Any]] = {
+ "robotic": {
+ "name": "Robotic",
+ "sort_order": 0,
+ "description": "Metallic robotic voice (flanger with slow LFO and high feedback)",
+ "effects_chain": [
+ {
+ "type": "chorus",
+ "enabled": True,
+ "params": {
+ "rate_hz": 0.2,
+ "depth": 1.0,
+ "feedback": 0.35,
+ "centre_delay_ms": 7.0,
+ "mix": 0.5,
+ },
+ },
+ ],
+ },
+ "radio": {
+ "name": "Radio",
+ "sort_order": 1,
+ "description": "Thin AM-radio voice with band-pass filtering and light compression",
+ "effects_chain": [
+ {
+ "type": "highpass",
+ "enabled": True,
+ "params": {"cutoff_frequency_hz": 300.0},
+ },
+ {
+ "type": "lowpass",
+ "enabled": True,
+ "params": {"cutoff_frequency_hz": 3500.0},
+ },
+ {
+ "type": "compressor",
+ "enabled": True,
+ "params": {
+ "threshold_db": -15.0,
+ "ratio": 6.0,
+ "attack_ms": 5.0,
+ "release_ms": 50.0,
+ },
+ },
+ {
+ "type": "gain",
+ "enabled": True,
+ "params": {"gain_db": 6.0},
+ },
+ ],
+ },
+ "echo_chamber": {
+ "name": "Echo Chamber",
+ "sort_order": 2,
+ "description": "Spacious reverb with trailing echo",
+ "effects_chain": [
+ {
+ "type": "reverb",
+ "enabled": True,
+ "params": {
+ "room_size": 0.85,
+ "damping": 0.3,
+ "wet_level": 0.45,
+ "dry_level": 0.55,
+ "width": 1.0,
+ },
+ },
+ {
+ "type": "delay",
+ "enabled": True,
+ "params": {
+ "delay_seconds": 0.25,
+ "feedback": 0.3,
+ "mix": 0.2,
+ },
+ },
+ ],
+ },
+ "deep_voice": {
+ "name": "Deep Voice",
+ "sort_order": 99,
+ "description": "Lower pitch with added warmth",
+ "effects_chain": [
+ {
+ "type": "pitch_shift",
+ "enabled": True,
+ "params": {"semitones": -3.0},
+ },
+ {
+ "type": "lowpass",
+ "enabled": True,
+ "params": {"cutoff_frequency_hz": 6000.0},
+ },
+ {
+ "type": "compressor",
+ "enabled": True,
+ "params": {
+ "threshold_db": -18.0,
+ "ratio": 3.0,
+ "attack_ms": 10.0,
+ "release_ms": 150.0,
+ },
+ },
+ ],
+ },
+}
+
+
+def get_available_effects() -> List[Dict[str, Any]]:
+ """Return the list of available effect types with their parameter definitions.
+
+ Used by the frontend to build the effects chain editor UI.
+ """
+ result = []
+ for effect_type, info in EFFECT_REGISTRY.items():
+ result.append(
+ {
+ "type": effect_type,
+ "label": info["label"],
+ "description": info["description"],
+ "params": {name: {k: v for k, v in pdef.items()} for name, pdef in info["params"].items()},
+ }
+ )
+ return result
+
+
+def get_builtin_presets() -> Dict[str, Dict[str, Any]]:
+ """Return all built-in effect presets."""
+ return BUILTIN_PRESETS
+
+
+def validate_effects_chain(effects_chain: List[Dict[str, Any]]) -> Optional[str]:
+ """Validate an effects chain configuration.
+
+ Returns None if valid, or an error message string.
+ """
+ if not isinstance(effects_chain, list):
+ return "effects_chain must be a list"
+
+ for i, effect in enumerate(effects_chain):
+ if not isinstance(effect, dict):
+ return f"Effect at index {i} must be a dict"
+
+ effect_type = effect.get("type")
+ if effect_type not in EFFECT_REGISTRY:
+ return f"Unknown effect type '{effect_type}' at index {i}. Available: {list(EFFECT_REGISTRY.keys())}"
+
+ params = effect.get("params", {})
+ if not isinstance(params, dict):
+ return f"Effect '{effect_type}' at index {i}: params must be a dict"
+
+ registry = EFFECT_REGISTRY[effect_type]
+ for param_name, value in params.items():
+ if param_name not in registry["params"]:
+ return f"Effect '{effect_type}' at index {i}: unknown param '{param_name}'"
+
+ pdef = registry["params"][param_name]
+ if not isinstance(value, (int, float)):
+ return f"Effect '{effect_type}' at index {i}: param '{param_name}' must be a number"
+ if value < pdef["min"] or value > pdef["max"]:
+ return (
+ f"Effect '{effect_type}' at index {i}: param '{param_name}' "
+ f"must be between {pdef['min']} and {pdef['max']} (got {value})"
+ )
+
+ return None
+
+
+def build_pedalboard(effects_chain: List[Dict[str, Any]]) -> Pedalboard:
+ """Build a Pedalboard instance from an effects chain config.
+
+ Skips effects where ``enabled`` is ``False``.
+ """
+ plugins = []
+ for effect in effects_chain:
+ if not effect.get("enabled", True):
+ continue
+
+ effect_type = effect["type"]
+ registry = EFFECT_REGISTRY[effect_type]
+ cls = registry["cls"]
+
+ # Merge defaults with provided params
+ params = {}
+ for pname, pdef in registry["params"].items():
+ params[pname] = effect.get("params", {}).get(pname, pdef["default"])
+
+ plugins.append(cls(**params))
+
+ return Pedalboard(plugins)
+
+
+def apply_effects(
+ audio: np.ndarray,
+ sample_rate: int,
+ effects_chain: List[Dict[str, Any]],
+) -> np.ndarray:
+ """Apply an effects chain to audio data.
+
+ Args:
+ audio: Input audio array (1-D mono float32).
+ sample_rate: Sample rate in Hz.
+ effects_chain: List of effect configuration dicts.
+
+ Returns:
+ Processed audio array.
+ """
+ if not effects_chain:
+ return audio
+
+ board = build_pedalboard(effects_chain)
+
+ # pedalboard expects shape (channels, samples)
+ if audio.ndim == 1:
+ audio_2d = audio[np.newaxis, :]
+ else:
+ audio_2d = audio
+
+ processed = board(audio_2d.astype(np.float32), sample_rate)
+
+ # Return same dimensionality as input
+ if audio.ndim == 1:
+ return processed[0]
+ return processed
diff --git a/backend/utils/hf_offline_patch.py b/backend/utils/hf_offline_patch.py
new file mode 100644
index 00000000..51a99a16
--- /dev/null
+++ b/backend/utils/hf_offline_patch.py
@@ -0,0 +1,88 @@
+"""Monkey-patch huggingface_hub to force offline mode with cached models.
+
+Prevents mlx_audio from making network requests when models are already
+downloaded. Must be imported BEFORE mlx_audio.
+"""
+
+import logging
+import os
+from pathlib import Path
+from typing import Optional, Union
+
+logger = logging.getLogger(__name__)
+
+
+def patch_huggingface_hub_offline():
+ """Monkey-patch huggingface_hub to force offline mode."""
+ try:
+ import huggingface_hub # noqa: F401 -- need the package loaded
+ from huggingface_hub import constants as hf_constants
+ from huggingface_hub.file_download import _try_to_load_from_cache
+
+ 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,
+ ):
+ result = original_try_load(
+ repo_id=repo_id,
+ filename=filename,
+ cache_dir=cache_dir,
+ revision=revision,
+ repo_type=repo_type,
+ )
+
+ if result is None:
+ cache_path = Path(hf_constants.HF_HUB_CACHE) / f"models--{repo_id.replace('/', '--')}"
+ logger.debug("file not cached: %s/%s (expected at %s)", repo_id, filename, cache_path)
+ else:
+ logger.debug("cache hit: %s/%s", repo_id, filename)
+
+ return result
+
+ import huggingface_hub.file_download as fd
+
+ fd._try_to_load_from_cache = _patched_try_to_load_from_cache
+ logger.debug("huggingface_hub patched for offline mode")
+
+ except ImportError:
+ logger.debug("huggingface_hub not available, skipping offline patch")
+ except Exception:
+ logger.exception("failed to patch huggingface_hub for offline mode")
+
+
+def ensure_original_qwen_config_cached():
+ """Symlink the original Qwen repo cache to the MLX community version.
+
+ mlx_audio may try to fetch config from the original Qwen repo. If only
+ the MLX community variant is cached, create a symlink so the cache lookup
+ succeeds without a network request.
+ """
+ try:
+ from huggingface_hub import constants as hf_constants
+ except ImportError:
+ return
+
+ 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 not original_path.exists() and mlx_path.exists():
+ try:
+ original_path.parent.mkdir(parents=True, exist_ok=True)
+ original_path.symlink_to(mlx_path, target_is_directory=True)
+ logger.info("created cache symlink: %s -> %s", original_repo, mlx_repo)
+ except Exception:
+ logger.warning("could not create cache symlink for %s", original_repo, exc_info=True)
+
+
+if os.environ.get("VOICEBOX_OFFLINE_PATCH", "1") != "0":
+ patch_huggingface_hub_offline()
+ ensure_original_qwen_config_cached()
diff --git a/backend/utils/hf_progress.py b/backend/utils/hf_progress.py
index 7fc88edd..be979923 100644
--- a/backend/utils/hf_progress.py
+++ b/backend/utils/hf_progress.py
@@ -4,13 +4,16 @@ HuggingFace Hub download progress tracking.
from typing import Optional, Callable
from contextlib import contextmanager
+import logging
import threading
import sys
+logger = logging.getLogger(__name__)
+
class HFProgressTracker:
"""Tracks HuggingFace Hub download progress by intercepting tqdm."""
-
+
def __init__(self, progress_callback: Optional[Callable] = None, filter_non_downloads: bool = False):
self.progress_callback = progress_callback
self.filter_non_downloads = filter_non_downloads # Only filter if True
@@ -23,12 +26,12 @@ class HFProgressTracker:
self._current_filename = ""
self._active_tqdms = {} # Track active tqdm instances
self._hf_tqdm_original_update = None # For monkey-patching hf's tqdm
-
+
def _create_tracked_tqdm_class(self):
"""Create a tqdm subclass that tracks progress."""
tracker = self
original_tqdm = self._original_tqdm_class
-
+
class TrackedTqdm(original_tqdm):
"""A tqdm subclass that reports progress to our tracker."""
@@ -39,7 +42,7 @@ class HFProgressTracker:
first_arg = args[0]
if isinstance(first_arg, str):
desc = first_arg
-
+
filename = ""
if desc:
# Try to extract filename from description
@@ -48,38 +51,68 @@ class HFProgressTracker:
filename = desc.split(":")[0].strip()
else:
filename = desc.strip()
-
+
# Filter out non-standard kwargs that huggingface_hub might pass
# These are custom kwargs that tqdm doesn't understand
filtered_kwargs = {}
# Known tqdm kwargs - pass these through
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'
+ "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",
}
for key, value in kwargs.items():
if key in tqdm_kwargs:
filtered_kwargs[key] = value
-
+
+ # Force-enable the progress bar — we're tracking progress ourselves,
+ # we don't need tqdm to render to a terminal, but we DO need
+ # self.n to be updated when update() is called.
+ filtered_kwargs["disable"] = False
+
# Try to initialize with filtered kwargs, fall back to all kwargs if that fails
try:
super().__init__(*args, **filtered_kwargs)
except TypeError:
# If filtering failed, try with all kwargs (maybe tqdm version accepts them)
+ kwargs["disable"] = False
super().__init__(*args, **kwargs)
-
+
self._tracker_filename = filename or "unknown"
-
+
with tracker._lock:
if filename:
tracker._current_filename = filename
tracker._active_tqdms[id(self)] = {
"filename": self._tracker_filename,
}
-
+
def update(self, n=1):
result = super().update(n)
@@ -89,95 +122,97 @@ class HFProgressTracker:
filename = tracker._active_tqdms[id(self)]["filename"]
current = getattr(self, "n", 0)
total = getattr(self, "total", 0)
-
+
if total and total > 0:
# Always filter out non-byte progress bars (e.g., "Fetching 12 files")
# These cause crazy percentages because they're counting files, not bytes
if self._is_non_byte_progress(filename):
return result
-
+
# When model is cached, also filter out generation-related progress
if tracker.filter_non_downloads:
if not self._is_download_progress(filename):
return result
-
+
# Update per-file tracking
tracker._file_sizes[filename] = total
tracker._file_downloaded[filename] = current
-
+
# Calculate totals across all files
tracker._total_size = sum(tracker._file_sizes.values())
tracker._total_downloaded = sum(tracker._file_downloaded.values())
-
+
# Only report progress once we have a meaningful total (at least 1MB)
# This avoids the "100% at 0MB" issue when small config
# files are counted before the real model files
MIN_TOTAL_BYTES = 1_000_000 # 1MB
if tracker._total_size < MIN_TOTAL_BYTES:
return result
-
+
# Call progress callback
if tracker.progress_callback:
- tracker.progress_callback(
- tracker._total_downloaded,
- tracker._total_size,
- filename
- )
-
+ tracker.progress_callback(tracker._total_downloaded, tracker._total_size, filename)
+
return result
-
+
def _is_non_byte_progress(self, filename: str) -> bool:
"""Check if this progress bar should be SKIPPED (returns True to skip).
-
+
We want to track byte-based progress bars. This method identifies
progress bars that count files/items instead of bytes, which would
cause crazy percentages if mixed with our byte counting.
-
+
Returns:
True = SKIP this bar (it's not byte-based)
False = TRACK this bar (it counts bytes)
"""
if not filename:
return False
-
+
filename_lower = filename.lower()
-
+
# Skip "Fetching X files" - it counts files (total=12), not bytes
# Don't skip "Downloading (incomplete total...)" - that IS byte-based
skip_patterns = [
- 'fetching', # "Fetching 12 files" has total=12 files, not bytes
+ "fetching", # "Fetching 12 files" has total=12 files, not bytes
]
return any(pattern in filename_lower for pattern in skip_patterns)
-
+
def _is_download_progress(self, filename: str) -> bool:
"""Check if this is a real file download progress bar vs internal processing."""
if not filename or filename == "unknown":
return False
-
+
# Real downloads have file extensions
download_extensions = [
- '.safetensors', '.bin', '.pt', '.pth', # Model weights
- '.json', '.txt', '.py', # Config files
- '.msgpack', '.h5', # Other formats
+ ".safetensors",
+ ".bin",
+ ".pt",
+ ".pth", # Model weights
+ ".json",
+ ".txt",
+ ".py", # Config files
+ ".msgpack",
+ ".h5", # Other formats
]
-
+
filename_lower = filename.lower()
has_extension = any(filename_lower.endswith(ext) for ext in download_extensions)
-
+
# Skip generation-related progress indicators
- skip_patterns = ['segment', 'processing', 'generating', 'loading']
+ skip_patterns = ["segment", "processing", "generating", "loading"]
has_skip_pattern = any(pattern in filename_lower for pattern in skip_patterns)
-
+
return has_extension and not has_skip_pattern
-
+
def close(self):
with tracker._lock:
if id(self) in tracker._active_tqdms:
del tracker._active_tqdms[id(self)]
return super().close()
-
+
return TrackedTqdm
-
+
@contextmanager
def patch_download(self):
"""Context manager to patch tqdm for progress tracking."""
@@ -186,7 +221,7 @@ class HFProgressTracker:
# Store original tqdm class
self._original_tqdm_class = tqdm_module.tqdm
-
+
# Reset totals
with self._lock:
self._total_downloaded = 0
@@ -195,7 +230,7 @@ class HFProgressTracker:
self._file_downloaded = {}
self._current_filename = ""
self._active_tqdms = {}
-
+
# Create our tracked tqdm class
tracked_tqdm = self._create_tracked_tqdm_class()
@@ -207,13 +242,13 @@ class HFProgressTracker:
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 to catch already-imported references
# huggingface_hub uses: from tqdm.auto import tqdm as base_tqdm
# So we need to patch both 'tqdm' and 'base_tqdm' attributes
self._patched_modules = {}
- tqdm_attr_names = ['tqdm', 'base_tqdm', 'old_tqdm'] # Various names used
-
+ tqdm_attr_names = ["tqdm", "base_tqdm", "old_tqdm"] # Various names used
+
patched_count = 0
for module_name in list(sys.modules.keys()):
if "huggingface" in module_name or module_name.startswith("tqdm"):
@@ -224,10 +259,13 @@ class HFProgressTracker:
attr = getattr(module, attr_name)
# Only patch if it's a tqdm class (not already patched)
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")) # tqdm classes have update method
+ 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")
+ ) # tqdm classes have update method
)
if is_tqdm_class:
key = f"{module_name}.{attr_name}"
@@ -236,31 +274,33 @@ class HFProgressTracker:
patched_count += 1
except (AttributeError, TypeError):
pass
-
+
# ALSO monkey-patch the update method on huggingface_hub's tqdm class
# This is needed because the class was already defined at import time
self._hf_tqdm_original_update = None
try:
from huggingface_hub.utils import tqdm as hf_tqdm_module
- if hasattr(hf_tqdm_module, 'tqdm'):
+
+ if hasattr(hf_tqdm_module, "tqdm"):
hf_tqdm_class = hf_tqdm_module.tqdm
self._hf_tqdm_original_update = hf_tqdm_class.update
-
+
# Create a wrapper that calls our tracking
tracker = self # Reference to HFProgressTracker instance
+
def patched_update(tqdm_self, n=1):
result = tracker._hf_tqdm_original_update(tqdm_self, n)
-
+
# Track this progress
with tracker._lock:
- desc = getattr(tqdm_self, 'desc', '') or ''
- current = getattr(tqdm_self, 'n', 0)
- total = getattr(tqdm_self, 'total', 0) or 0
-
+ desc = getattr(tqdm_self, "desc", "") or ""
+ current = getattr(tqdm_self, "n", 0)
+ total = getattr(tqdm_self, "total", 0) or 0
+
# Skip non-byte progress bars
- if 'fetching' in desc.lower():
+ if "fetching" in desc.lower():
return result
-
+
# Skip until we have a meaningful total (at least 1MB)
# This avoids the "100% at 0MB" issue when small config
# files are counted before the real model files
@@ -268,22 +308,22 @@ class HFProgressTracker:
if total >= MIN_TOTAL_BYTES:
tracker._total_downloaded = current
tracker._total_size = total
-
+
if tracker.progress_callback:
tracker.progress_callback(current, total, desc)
-
+
return result
-
+
hf_tqdm_class.update = patched_update
patched_count += 1
- print(f"[HFProgressTracker] Monkey-patched huggingface_hub.utils.tqdm.tqdm.update")
+ logger.debug("Monkey-patched huggingface_hub.utils.tqdm.tqdm.update")
except (ImportError, AttributeError) as e:
- print(f"[HFProgressTracker] Could not monkey-patch hf_tqdm: {e}")
-
- print(f"[HFProgressTracker] Patched {patched_count} tqdm references")
-
+ logger.warning("Could not monkey-patch hf_tqdm: %s", e)
+
+ logger.debug("Patched %d tqdm references", patched_count)
+
yield
-
+
except ImportError:
# If tqdm not available, just yield without patching
yield
@@ -292,11 +332,12 @@ class HFProgressTracker:
if self._original_tqdm_class:
try:
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
-
+
# Restore patched modules
for key, (module, attr_name, original) in self._patched_modules.items():
try:
@@ -305,26 +346,28 @@ class HFProgressTracker:
except (AttributeError, TypeError):
pass
self._patched_modules = {}
-
+
# Restore hf_tqdm's original update method
if self._hf_tqdm_original_update:
try:
from huggingface_hub.utils import tqdm as hf_tqdm_module
- if hasattr(hf_tqdm_module, 'tqdm'):
+
+ if hasattr(hf_tqdm_module, "tqdm"):
hf_tqdm_module.tqdm.update = self._hf_tqdm_original_update
except (ImportError, AttributeError):
pass
self._hf_tqdm_original_update = None
-
+
except (ImportError, AttributeError):
pass
def create_hf_progress_callback(model_name: str, progress_manager):
"""Create a progress callback for HuggingFace downloads."""
+
def callback(downloaded: int, total: int, filename: str = ""):
"""Progress callback.
-
+
Note: We send updates even when total=0 (unknown) to provide feedback
during the "incomplete total" phase of huggingface_hub downloads.
The frontend handles total=0 gracefully.
@@ -336,4 +379,5 @@ def create_hf_progress_callback(model_name: str, progress_manager):
filename=filename or "",
status="downloading",
)
+
return callback
diff --git a/backend/platform_detect.py b/backend/utils/platform_detect.py
similarity index 59%
rename from backend/platform_detect.py
rename to backend/utils/platform_detect.py
index c4db19dc..1ec2980a 100644
--- a/backend/platform_detect.py
+++ b/backend/utils/platform_detect.py
@@ -19,15 +19,17 @@ def is_apple_silicon() -> bool:
def get_backend_type() -> Literal["mlx", "pytorch"]:
"""
Detect the best backend for the current platform.
-
+
Returns:
- "mlx" on Apple Silicon (if MLX is available), "pytorch" otherwise
+ "mlx" on Apple Silicon (if MLX is available and functional), "pytorch" otherwise
"""
if is_apple_silicon():
try:
- import mlx
+ import mlx.core # noqa: F401 — triggers native lib loading
return "mlx"
- except ImportError:
- # MLX not installed, fallback to PyTorch
+ except (ImportError, OSError, RuntimeError):
+ # MLX not installed, or native libraries failed to load inside a
+ # PyInstaller bundle (OSError on missing .dylib / .metallib).
+ # Fall through to PyTorch.
return "pytorch"
return "pytorch"
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/backend/utils/validation.py b/backend/utils/validation.py
deleted file mode 100644
index 3637da86..00000000
--- a/backend/utils/validation.py
+++ /dev/null
@@ -1,66 +0,0 @@
-"""
-Input validation utilities.
-"""
-
-from typing import Tuple, Optional
-from pathlib import Path
-
-
-def validate_text(text: str, max_length: int = 5000) -> Tuple[bool, Optional[str]]:
- """
- Validate text input.
-
- Args:
- text: Text to validate
- max_length: Maximum length
-
- Returns:
- Tuple of (is_valid, error_message)
- """
- if not text or not text.strip():
- return False, "Text cannot be empty"
-
- if len(text) > max_length:
- return False, f"Text too long (maximum {max_length} characters)"
-
- return True, None
-
-
-def validate_language(language: str) -> Tuple[bool, Optional[str]]:
- """
- Validate language code.
-
- Supported languages for Qwen3-TTS:
- Chinese, English, Japanese, Korean, German, French, Russian, Portuguese, Spanish, Italian
-
- Args:
- language: Language code
-
- Returns:
- Tuple of (is_valid, error_message)
- """
- valid_languages = ["zh", "en", "ja", "ko", "de", "fr", "ru", "pt", "es", "it"]
- if language not in valid_languages:
- return False, f"Invalid language (must be one of: {', '.join(valid_languages)})"
-
- return True, None
-
-
-def validate_file_path(path: str) -> Tuple[bool, Optional[str]]:
- """
- Validate file path exists.
-
- Args:
- path: File path
-
- Returns:
- Tuple of (is_valid, error_message)
- """
- file_path = Path(path)
- if not file_path.exists():
- return False, f"File not found: {path}"
-
- if not file_path.is_file():
- return False, f"Path is not a file: {path}"
-
- return True, None
diff --git a/backend/voicebox-server.spec b/backend/voicebox-server.spec
index 5d6bb317..6a7dc6d3 100644
--- a/backend/voicebox-server.spec
+++ b/backend/voicebox-server.spec
@@ -1,30 +1,44 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_data_files
from PyInstaller.utils.hooks import collect_submodules
+from PyInstaller.utils.hooks import collect_all
from PyInstaller.utils.hooks import copy_metadata
datas = []
-hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'pkg_resources.extern', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
+binaries = []
+hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'backend.cuda_download', 'backend.effects', 'backend.utils.effects', 'backend.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
datas += collect_data_files('qwen_tts')
-datas += collect_data_files('mlx')
-datas += collect_data_files('mlx_audio')
datas += copy_metadata('qwen-tts')
+datas += copy_metadata('requests')
+datas += copy_metadata('transformers')
+datas += copy_metadata('huggingface-hub')
+datas += copy_metadata('tokenizers')
+datas += copy_metadata('safetensors')
+datas += copy_metadata('tqdm')
hiddenimports += collect_submodules('qwen_tts')
hiddenimports += collect_submodules('jaraco')
hiddenimports += collect_submodules('mlx')
hiddenimports += collect_submodules('mlx_audio')
+tmp_ret = collect_all('zipvoice')
+datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
+tmp_ret = collect_all('linacodec')
+datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
+tmp_ret = collect_all('mlx')
+datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
+tmp_ret = collect_all('mlx_audio')
+datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
a = Analysis(
['server.py'],
pathex=[],
- binaries=[],
+ binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
- excludes=[],
+ excludes=['nvidia', 'nvidia.cublas', 'nvidia.cuda_cupti', 'nvidia.cuda_nvrtc', 'nvidia.cuda_runtime', 'nvidia.cudnn', 'nvidia.cufft', 'nvidia.curand', 'nvidia.cusolver', 'nvidia.cusparse', 'nvidia.nccl', 'nvidia.nvjitlink', 'nvidia.nvtx'],
noarchive=False,
optimize=0,
)
diff --git a/bun.lock b/bun.lock
index 9e08a825..d6b63ee8 100644
--- a/bun.lock
+++ b/bun.lock
@@ -4,6 +4,10 @@
"workspaces": {
"": {
"name": "voicebox",
+ "dependencies": {
+ "loaders.css": "^0.1.2",
+ "react-loaders": "^3.0.1",
+ },
"devDependencies": {
"@biomejs/biome": "2.3.12",
"@types/node": "^20.0.0",
@@ -13,7 +17,7 @@
},
"app": {
"name": "@voicebox/app",
- "version": "0.1.11",
+ "version": "0.2.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -68,13 +72,15 @@
},
"landing": {
"name": "@voicebox/landing",
- "version": "0.1.11",
+ "version": "0.2.0",
"dependencies": {
+ "@fontsource/space-grotesk": "^5.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"autoprefixer": "^10.4.17",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "framer-motion": "^12.36.0",
"lucide-react": "^0.316.0",
"next": "^16.1.3",
"postcss": "^8.4.33",
@@ -83,6 +89,7 @@
"tailwind-merge": "^3.4.0",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
+ "wavesurfer.js": "^7.12.2",
},
"devDependencies": {
"@types/node": "^20.11.5",
@@ -93,7 +100,7 @@
},
"tauri": {
"name": "@voicebox/tauri",
- "version": "0.1.11",
+ "version": "0.2.0",
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
@@ -116,7 +123,7 @@
},
"web": {
"name": "@voicebox/web",
- "version": "0.1.11",
+ "version": "0.2.0",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"react": "^18.3.0",
@@ -125,6 +132,7 @@
"zustand": "^4.5.0",
},
"devDependencies": {
+ "@tailwindcss/vite": "^4.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
@@ -269,6 +277,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
+ "@fontsource/space-grotesk": ["@fontsource/space-grotesk@5.2.10", "", {}, "sha512-XNXEbT74OIITPqw2H6HXwPDp85fy43uxfBwFR5PU+9sLnjuLj12KlhVM9nZVN6q6dlKjkuN8JisW/OBxwxgUew=="],
+
"@hookform/resolvers": ["@hookform/resolvers@3.10.0", "", { "peerDependencies": { "react-hook-form": "^7.0.0" } }, "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag=="],
"@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.13.0", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="],
@@ -677,6 +687,8 @@
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
+ "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
+
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -873,6 +885,8 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
+ "loaders.css": ["loaders.css@0.1.2", "", {}, "sha512-Rhowlq24ey1VOeor+3wYOt9+MjaxBOJm1u4KlQgNC3+0xJ0LS4wq4iG57D/BPzvuD/7HHDGQOWJ+81oR2EI9bQ=="],
+
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
@@ -959,6 +973,8 @@
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
+ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
+
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
@@ -969,6 +985,10 @@
"react-hook-form": ["react-hook-form@7.71.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w=="],
+ "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
+
+ "react-loaders": ["react-loaders@3.0.1", "", { "dependencies": { "classnames": "^2.2.3" }, "peerDependencies": { "prop-types": ">=15.6.0", "react": ">=15" } }, "sha512-4igMNqs9Fb3d4Z+0UHIGQNJsw/37gX0nUO8QxupnEKRn1dtyYC1LGwk5GuaoDciMQCQc/MmPwb4Fn6ZfdoX1FQ=="],
+
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
@@ -1137,12 +1157,16 @@
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
+ "@voicebox/landing/framer-motion": ["framer-motion@12.36.0", "", { "dependencies": { "motion-dom": "^12.36.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-4PqYHAT7gev0ke0wos+PyrcFxI0HScjm3asgU8nSYa8YzJFuwgIvdj3/s3ZaxLq0bUSboIn19A2WS/MHwLCvfw=="],
+
"@voicebox/landing/lucide-react": ["lucide-react@0.316.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, "sha512-dTmYX1H4IXsRfVcj/KUxworV6814ApTl7iXaS21AimK2RUEl4j4AfOmqD3VR8phe5V91m4vEJ8tCK4uT1jE5nA=="],
"@voicebox/landing/tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"@voicebox/landing/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
+ "@voicebox/landing/wavesurfer.js": ["wavesurfer.js@7.12.2", "", {}, "sha512-akVYISAHCw2gNw/7n8Pk/zH1Zz91WJyL/2MaNQCLD1XV3A226gKlWoDHWp9UdWqQ3zXnWttDf9ewZQQ3cxbOmQ=="],
+
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -1154,5 +1178,9 @@
"tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
+
+ "@voicebox/landing/framer-motion/motion-dom": ["motion-dom@12.36.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-Ep1pq8P88rGJ75om8lTCA13zqd7ywPGwCqwuWwin6BKc0hMLkVfcS6qKlRqEo2+t0DwoUcgGJfXwaiFn4AOcQA=="],
+
+ "@voicebox/landing/framer-motion/motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
}
}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000..87784771
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,41 @@
+services:
+ voicebox:
+ build: .
+ container_name: voicebox
+ restart: unless-stopped
+
+ ports:
+ # Bind to localhost only for security
+ - "127.0.0.1:17493:17493"
+
+ volumes:
+ # Bind-mount for generated audio (customize the host path as needed)
+ # Host side: ./output/
+ # Container side: /app/data/generations/
+ - ./output:/app/data/generations
+
+ # Named volume for profiles, DB, cache (persists across container restarts)
+ - voicebox-data:/app/data
+
+ # HuggingFace model cache (so models aren't re-downloaded on rebuild)
+ - huggingface-cache:/home/voicebox/.cache/huggingface
+
+ environment:
+ - LOG_LEVEL=info
+
+ networks:
+ - voicebox-net
+
+ deploy:
+ resources:
+ limits:
+ cpus: '4'
+ memory: 8G
+
+networks:
+ voicebox-net:
+ driver: bridge
+
+volumes:
+ voicebox-data:
+ huggingface-cache:
diff --git a/docs/RELEASE_v0.2.0.md b/docs/RELEASE_v0.2.0.md
new file mode 100644
index 00000000..9d2d8e7b
--- /dev/null
+++ b/docs/RELEASE_v0.2.0.md
@@ -0,0 +1,163 @@
+# Voicebox v0.2.0 -- Release Notes
+
+## The story
+
+Voicebox v0.1.x shipped as a single-engine voice cloning app built around Qwen3-TTS. It worked, but it was limited: one model family, 10 languages, English-centric emotion, a synchronous generation pipeline that locked the UI, and a hard ceiling on how much text you could generate at once.
+
+v0.2.0 is a ground-up rethink. Voicebox is now a **multi-engine voice cloning platform**. Four TTS engines. 23 languages. Expressive paralinguistic controls. A full post-processing effects pipeline. Unlimited generation length. Asynchronous everything. And it runs on every major GPU vendor -- NVIDIA, AMD, Intel Arc, Apple Silicon -- plus Docker for headless deployment.
+
+This is the release where Voicebox stops being a proof of concept and starts being a real tool.
+
+---
+
+## Major New Features
+
+### Multi-Engine Architecture
+Voicebox now supports **four TTS engines**, each with different strengths. Switch between them per-generation from a single unified interface:
+
+| Engine | Languages | Strengths |
+|--------|-----------|-----------|
+| **Qwen3-TTS** (0.6B / 1.7B) | 10 | High-quality multilingual cloning, delivery instructions ("speak slowly", "whisper") |
+| **LuxTTS** | English | Lightweight (~1GB VRAM), 48kHz output, 150x realtime on CPU |
+| **Chatterbox Multilingual** | 23 | Broadest language coverage -- Arabic, Danish, Finnish, Greek, Hebrew, Hindi, Malay, Norwegian, Polish, Swahili, Swedish, Turkish and more |
+| **Chatterbox Turbo** | English | Fast 350M model with paralinguistic emotion/sound tags |
+
+### Emotions and Paralinguistic Tags (Chatterbox Turbo)
+Type `/` in the text input to open an autocomplete for **9 expressive tags** that the model synthesizes inline with speech:
+
+`[laugh]` `[chuckle]` `[gasp]` `[cough]` `[sigh]` `[groan]` `[sniff]` `[shush]` `[clear throat]`
+
+Tags render as inline badges in a rich text editor and serialize cleanly to the API. This makes generated speech sound natural and expressive in a way that plain TTS can't.
+
+### 23 Languages via Chatterbox Multilingual
+The Chatterbox Multilingual engine brings zero-shot voice cloning to **23 languages**: Arabic, Chinese, Danish, Dutch, English, Finnish, French, German, Greek, Hebrew, Hindi, Italian, Japanese, Korean, Malay, Norwegian, Polish, Portuguese, Russian, Spanish, Swahili, Swedish, and Turkish. The language dropdown dynamically filters to show only languages supported by the selected engine.
+
+### Unlimited Generation Length (Auto-Chunking)
+Previously, long text would hit model context limits and degrade. Now, text is **automatically split at sentence boundaries** and each chunk is generated independently, then crossfaded back together. This is fully engine-agnostic and works with all four engines.
+
+- **Auto-chunking limit slider** (100-5,000 chars, default 800) -- controls when text gets split
+- **Crossfade slider** (0-200ms, default 50ms) -- blends chunk boundaries smoothly, or set to 0 for a hard cut
+- **Max text length raised to 50,000 characters** -- generate entire scripts, chapters, or articles in one go
+- Smart splitting respects abbreviations (Dr., e.g., a.m.), CJK punctuation, and never breaks inside paralinguistic `[tags]`
+
+### Asynchronous Generation Queue
+Generation is now fully **non-blocking**. Submit a generation and immediately start typing the next one -- no more frozen UI waiting for inference to complete.
+
+- Serial execution queue prevents GPU contention across all backends
+- Real-time SSE status streaming (`generating` -> `completed` / `failed`)
+- Failed generations can be retried without re-entering text
+- Stale generations from crashes are auto-recovered on startup
+- Generating status pill shown inline in the story editor
+
+### Post-Processing Effects Pipeline
+A full audio effects system powered by Spotify's `pedalboard` library. Apply effects after generation, preview them in real time, and build reusable presets -- all without leaving the app.
+
+**8 effects available:**
+
+| Effect | What it does |
+|--------|-------------|
+| **Pitch Shift** | Shift pitch up or down by up to 12 semitones |
+| **Reverb** | Room reverb with configurable size, damping, and wet/dry mix |
+| **Delay** | Echo with adjustable delay time, feedback, and mix |
+| **Chorus / Flanger** | Modulated delay -- short for metallic flanger, longer for lush chorus |
+| **Compressor** | Dynamic range compression with threshold, ratio, attack, and release |
+| **Gain** | Volume adjustment from -40 to +40 dB |
+| **High-Pass Filter** | Remove low frequencies below a configurable cutoff |
+| **Low-Pass Filter** | Remove high frequencies above a configurable cutoff |
+
+**Effects presets** -- Four built-in presets ship out of the box (Robotic, Radio, Echo Chamber, Deep Voice), and you can create unlimited custom presets. Presets are drag-and-drop chains of effects with per-parameter sliders.
+
+**Per-profile default effects** -- Assign an effects chain to a voice profile and it applies automatically to every generation with that voice. Override per-generation from the generate box.
+
+**Live preview** -- Audition any effects chain against an existing generation before committing. The preview streams processed audio without saving anything.
+
+### Generation Versions
+Every generation now supports **multiple versions** with full provenance tracking:
+
+- **Original** -- the clean, unprocessed TTS output (always preserved)
+- **Effects versions** -- apply different effects chains to create new versions from any source version
+- **Takes** -- regenerate with the same text and voice but a new seed for variation
+- **Source tracking** -- each version records which version it was derived from
+- **Version pinning in stories** -- pin a specific version to a track clip in the story editor, independent of the generation's default
+- **Favorites** -- star generations to mark them for quick access
+
+---
+
+## New Platform Support
+
+### Linux (Native)
+Full Linux support with `.deb` and `.rpm` packages. Includes PulseAudio/PipeWire audio capture for voice sample recording.
+
+### AMD ROCm GPU Acceleration
+AMD GPU users now get hardware-accelerated inference via ROCm, with automatic `HSA_OVERRIDE_GFX_VERSION` configuration for GPUs not officially in the ROCm compatibility list (e.g., RX 6600).
+
+### NVIDIA CUDA Backend Swap
+The CPU-only release can download and swap in a CUDA-accelerated backend binary from within the app -- no reinstall required. Handles GitHub's 2GB asset limit by downloading split parts and verifying SHA-256 checksums.
+
+### Intel Arc (XPU) and DirectML
+PyTorch backend also supports Intel Arc GPUs via IPEX/XPU and Windows any-GPU via DirectML.
+
+### Docker + Web Deployment
+Run Voicebox headless as a Docker container with the full web UI:
+```bash
+docker compose up
+```
+3-stage build, non-root runtime, health checks, persistent model cache across rebuilds. Binds to localhost only by default.
+
+---
+
+## Model Management
+- **Per-model unload** -- free GPU memory without deleting downloaded models
+- **Custom models directory** -- set `VOICEBOX_MODELS_DIR` to store models anywhere
+- **Model folder migration** -- move all models to a new location with progress tracking
+- **Whisper Turbo** -- added `openai/whisper-large-v3-turbo` as a transcription model option
+- **Download cancel/clear UI** -- cancel in-progress downloads, VS Code-style problems panel for errors
+
+---
+
+## Security
+- **CORS hardening** -- replaced wildcard `*` with an explicit allowlist of local origins; extensible via `VOICEBOX_CORS_ORIGINS` env var
+- **Network access toggle** -- fully disable outbound network requests for air-gapped deployments
+
+## Accessibility
+- Comprehensive screen reader support (tested with NVDA/Narrator) across all major UI surfaces
+- Keyboard navigation for voice cards, history rows, model management, and story editor
+- State-aware `aria-label` attributes on all interactive controls
+
+## Reliability
+- **Atomic audio saves** -- two-phase write prevents corrupted files on crash/interrupt
+- **Filesystem health endpoint** -- proactive disk space and directory writability checks
+- **Errno-specific error messages** -- clear feedback for permission denied, disk full, missing directory
+
+## UX Polish
+- Responsive layout with horizontal-scroll voice cards on mobile
+- App version shown in sidebar
+- Voice card heights normalized
+- Audio player title hidden at narrow widths to prevent overflow
+
+---
+
+## Installation
+
+| Platform | Download |
+|----------|----------|
+| **macOS (Apple Silicon)** | `Voicebox_0.2.0_aarch64.dmg` |
+| **macOS (Intel)** | `Voicebox_0.2.0_x64.dmg` |
+| **Windows** | `Voicebox_0.2.0_x64_en-US.msi` or `x64-setup.exe` |
+| **Linux** | `.deb` / `.rpm` packages |
+| **Docker** | `docker compose up` |
+
+The app includes automatic updates -- future patches will be installed automatically.
+
+---
+
+## Video Script Beats
+
+For the marketing video, focus on these six beats:
+
+1. **"Four engines, one app"** -- show the engine dropdown switching between Qwen, LuxTTS, Chatterbox, and Turbo
+2. **"23 languages"** -- generate the same voice clone in Arabic, Japanese, Hindi, etc.
+3. **"Make it expressive"** -- type `/laugh` and `/sigh` with Chatterbox Turbo, play back the result
+4. **"Shape your sound"** -- apply the Robotic or Deep Voice preset, preview it live, then build a custom effects chain with drag-and-drop
+5. **"No limits"** -- paste a long script, show it auto-chunk and generate seamlessly
+6. **"Queue and go"** -- fire off multiple generations back-to-back without waiting
diff --git a/docs/content/docs/TROUBLESHOOTING.md b/docs/content/docs/TROUBLESHOOTING.md
index 0fef0c09..d74d0bf7 100644
--- a/docs/content/docs/TROUBLESHOOTING.md
+++ b/docs/content/docs/TROUBLESHOOTING.md
@@ -165,7 +165,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**
@@ -173,7 +173,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
@@ -279,7 +279,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/content/docs/overview/troubleshooting.mdx b/docs/content/docs/overview/troubleshooting.mdx
index f57c29f2..e2b86ef0 100644
--- a/docs/content/docs/overview/troubleshooting.mdx
+++ b/docs/content/docs/overview/troubleshooting.mdx
@@ -48,7 +48,7 @@ Windows SmartScreen may warn that the app is unrecognized.
lsof -i :17493
# Windows
- netstat -ano | findstr :17493
+ powershell -Command "Get-NetTCPConnection -LocalPort 17493 -State Listen"
```
Kill the process using the port:
diff --git a/docs/content/docs/plans/ADDING_TTS_ENGINES.md b/docs/content/docs/plans/ADDING_TTS_ENGINES.md
new file mode 100644
index 00000000..f07f941a
--- /dev/null
+++ b/docs/content/docs/plans/ADDING_TTS_ENGINES.md
@@ -0,0 +1,363 @@
+# Adding a TTS Engine to Voicebox
+
+Guide for adding new TTS model backends. Based on the implementation of LuxTTS (#254), Chatterbox Multilingual (#257), Chatterbox Turbo (#258), and the PyInstaller fixes in v0.2.3.
+
+---
+
+## Overview
+
+Adding an engine touches ~10 files across 4 layers. The backend protocol work is straightforward — the real time sink is dependency hell, upstream library bugs, and PyInstaller bundling.
+
+The backend is split into layers: `routes/` (thin HTTP handlers), `services/` (business logic), `backends/` (engine implementations), and `utils/` (shared utilities). New engines only need to touch `backends/` and `models.py` on the backend side — the route and service layers use a model config registry that handles dispatch automatically.
+
+---
+
+## Phase 1: Backend Implementation
+
+### 1.1 Create the backend file
+
+`backend/backends/_backend.py` (~200-300 lines)
+
+Implement the `TTSBackend` protocol from `backend/backends/__init__.py`:
+
+```python
+class YourBackend:
+ """Must satisfy the TTSBackend protocol."""
+
+ async def load_model(self, model_size: str = "default") -> None: ...
+ async def create_voice_prompt(self, audio_path: str, reference_text: str, use_cache: bool = True) -> tuple[dict, bool]: ...
+ async def combine_voice_prompts(self, audio_paths: list[str], ref_texts: list[str]) -> tuple[np.ndarray, str]: ...
+ async def generate(self, text: str, voice_prompt: dict, language: str = "en", seed: int | None = None, instruct: str | None = None) -> tuple[np.ndarray, int]: ...
+ def unload_model(self) -> None: ...
+ def is_loaded(self) -> bool: ...
+ def _get_model_path(self, model_size: str) -> str: ...
+```
+
+Key decisions per engine:
+
+| Decision | Options | Examples |
+|----------|---------|---------|
+| **Voice prompt storage** | Pre-computed tensors vs deferred file paths | Qwen PyTorch stores tensor dicts; Chatterbox stores `{"ref_audio": path, "ref_text": text}` |
+| **Caching** | Use voice prompt cache or skip it | LuxTTS caches with `luxtts_` prefix; Chatterbox skips caching entirely |
+| **Device selection** | CUDA / MPS / CPU | Chatterbox forces CPU on macOS (MPS tensor bugs); LuxTTS supports MPS |
+| **Model download** | Library handles it vs manual `snapshot_download` | Turbo uses manual download to bypass upstream `token=True` bug |
+| **Sample rate** | Engine-specific | LuxTTS outputs 48kHz, everything else is 24kHz |
+
+### 1.2 Voice prompt patterns
+
+There are three patterns in use. Pick the one that fits your model:
+
+**Pattern A: Pre-computed tensors** (Qwen PyTorch, LuxTTS)
+```python
+# create_voice_prompt returns opaque dict of tensors
+# Cached via torch.save(), reused across generations
+encoded = model.encode_prompt(audio_path)
+return encoded, False # (prompt_dict, was_cached)
+```
+
+**Pattern B: Deferred file paths** (Chatterbox, MLX)
+```python
+# Just store paths, process at generation time
+return {"ref_audio": audio_path, "ref_text": reference_text}, False
+```
+
+**Pattern C: Hybrid** (possible for new engines)
+```python
+# Pre-compute speaker embeddings, store alongside paths
+embedding = model.extract_speaker(audio_path)
+return {"embedding": embedding, "ref_audio": audio_path}, False
+```
+
+If caching, prefix your cache keys to avoid collisions with other engines using the same reference audio:
+```python
+cache_key = "yourengine_" + get_cache_key(audio_path, reference_text)
+```
+
+### 1.3 Register the engine
+
+In `backend/backends/__init__.py`, three things:
+
+**1. Add a `ModelConfig` entry** in `_get_non_qwen_tts_configs()`:
+
+```python
+ModelConfig(
+ model_name="your-engine",
+ display_name="Your Engine",
+ engine="your_engine",
+ hf_repo_id="org/model-repo",
+ size_mb=3200,
+ needs_trim=False, # set True if output needs trim_tts_output()
+ languages=["en", "fr", "de"],
+),
+```
+
+This single entry replaces what used to be 6+ scattered dicts in `main.py`. The registry helpers (`get_model_config()`, `check_model_loaded()`, `engine_needs_trim()`, etc.) all derive from this config automatically.
+
+**2. Add to `TTS_ENGINES` dict:**
+
+```python
+TTS_ENGINES = {
+ ...
+ "your_engine": "Your Engine",
+}
+```
+
+**3. Add an elif branch in `get_tts_backend_for_engine()`:**
+
+```python
+elif engine == "your_engine":
+ from .your_backend import YourBackend
+ backend = YourBackend()
+```
+
+The import is deferred so platform-specific deps aren't loaded until the engine is first requested.
+
+### 1.4 Update request models
+
+In `backend/models.py`:
+
+- Add engine name to `GenerationRequest.engine` regex pattern
+- Add any new language codes to the language regex on both `GenerationRequest` and `VoiceProfileCreate`
+
+---
+
+## Phase 2: Route and Service Integration
+
+With the model config registry, the route and service layers have **zero per-engine dispatch points**. All endpoints use registry helpers like `get_model_config()`, `load_engine_model()`, `engine_needs_trim()`, `check_model_loaded()`, etc.
+
+**You don't need to touch any route or service files** unless your engine needs custom behavior in the generate pipeline (e.g. a new post-processing step beyond `trim_tts_output`).
+
+### 2.1 What the registry handles automatically
+
+| Route file | Registry function used |
+|------------|----------------------|
+| `routes/generations.py` | `load_engine_model(engine, size)` + `engine_needs_trim(engine)` |
+| `routes/models.py` | `get_all_model_configs()` + `check_model_loaded(config)` |
+| `routes/models.py` | `get_model_config(name)` + `get_model_load_func(config)` |
+| `services/generation.py` | `get_tts_backend_for_engine()` + `ensure_model_cached_or_raise()` |
+
+### 2.2 Post-processing
+
+If your model produces trailing silence or hallucinated audio, set `needs_trim=True` on your `ModelConfig`. The generation service checks `engine_needs_trim(engine)` and applies `trim_tts_output()` automatically.
+
+---
+
+## Phase 3: Frontend Integration
+
+### 3.1 TypeScript types
+
+In `app/src/lib/api/types.ts`:
+- Add to the `engine` union type on `GenerationRequest`
+
+### 3.2 Language maps
+
+In `app/src/lib/constants/languages.ts`:
+- Add entry to `ENGINE_LANGUAGES` record
+- Add any new language codes to `ALL_LANGUAGES` if needed
+
+### 3.3 Engine/model selector (shared component)
+
+The model selector is a shared component — update one file:
+
+- `app/src/components/Generation/EngineModelSelector.tsx`
+
+Add an entry to `ENGINE_OPTIONS` and `ENGINE_DESCRIPTIONS`. If the engine is English-only, add it to `ENGLISH_ONLY_ENGINES`. The `handleEngineChange()` function handles language validation automatically (resets to first available language if the current one isn't supported).
+
+Both `GenerationForm.tsx` and `FloatingGenerateBox.tsx` use `` — no changes needed in either.
+
+Handle engine-specific UI conditionals in the form components if needed:
+- Hide instruct field for engines that don't support it
+- Show engine-specific controls (e.g. `ParalinguisticInput` for Turbo)
+
+### 3.4 Form hook
+
+In `app/src/lib/hooks/useGenerationForm.ts`:
+- Add to Zod schema enum for `engine`
+- Add engine-to-model-name mapping (e.g. `"your_engine"` → `"your-engine"`)
+- Update payload construction to conditionally include engine-specific fields
+
+### 3.5 Model management
+
+In `app/src/components/ServerSettings/ModelManagement.tsx`:
+- Add description to `MODEL_DESCRIPTIONS` record
+- The model list auto-renders from `/models/status` data
+
+---
+
+## Phase 4: Dependencies
+
+### 4.1 Python dependencies
+
+Add to `backend/requirements.txt`. Watch for:
+
+**Pinned dependency conflicts** — If the model package pins old versions of numpy, torch, or transformers, install with `--no-deps` and list sub-dependencies manually. This is what Chatterbox requires:
+```
+# In justfile (NOT requirements.txt):
+pip install --no-deps chatterbox-tts
+
+# In requirements.txt — list the transitive deps:
+conformer
+diffusers
+omegaconf
+# ... etc
+```
+
+**Non-PyPI packages** — Some deps only exist as git repos:
+```
+linacodec @ git+https://github.com/user/repo.git
+Zipvoice @ git+https://github.com/user/repo.git
+```
+
+**Custom package indexes** — Some packages need `--find-links`:
+```
+--find-links https://k2-fsa.github.io/icefall/piper_phonemize.html
+```
+
+### 4.2 Identifying hidden sub-dependencies
+
+When using `--no-deps`, you need to manually figure out what the package actually imports. There's no shortcut:
+
+1. Install the package normally in a throwaway venv
+2. Run `pip show ` to get its `Requires:` list
+3. Cross-reference against what's already in our requirements.txt
+4. Test that the engine loads and generates without import errors
+
+---
+
+## Phase 5: PyInstaller Bundling
+
+This is where most of the pain lives. If your model's Python package or its dependencies use any of the following at runtime, PyInstaller won't bundle them automatically:
+
+### 5.1 Common PyInstaller issues
+
+| Issue | Symptom | Fix |
+|-------|---------|-----|
+| **`inspect.getsource()` at import time** | "could not get source code" | `--collect-all ` (bundles `.py` source files, not just bytecode) |
+| **Data files (yaml, .pth.tar, lang dicts)** | FileNotFoundError at runtime | `--collect-all ` or `--collect-data ` |
+| **Native data paths (espeak-ng, etc.)** | Library looks at `/usr/share/...` | Set env var in frozen builds: `os.environ["ESPEAK_DATA_PATH"] = bundled_path` |
+| **`importlib.metadata` lookups** | "No package metadata found" | `--copy-metadata ` |
+| **Dynamic imports** | ModuleNotFoundError | `--hidden-import ` |
+| **`typeguard` / `@typechecked`** | Calls `inspect.getsource()` on decorated functions | `--collect-all` for the decorated package |
+
+### 5.2 Testing frozen builds
+
+You can't skip this. Models that work in `python -m uvicorn` will break in the PyInstaller binary. The flow:
+
+1. Build the binary: `just build` or the PyInstaller spec
+2. Run it and try to download + load + generate with the new engine
+3. Check stderr for the actual error (macOS/Linux: stdout/stderr go to Tauri sidecar logs)
+4. Fix, rebuild, repeat
+
+### 5.3 Real examples from v0.2.3
+
+These were all models that worked perfectly in dev:
+
+- **LuxTTS**: `typeguard`'s `@typechecked` calls `inspect.getsource()` at import → needed `--collect-all inflect`. `piper_phonemize` bundles `espeak-ng-data/` → needed `--collect-all piper_phonemize` + `ESPEAK_DATA_PATH` env var
+- **Chatterbox**: `resemble-perth` bundles a pretrained watermark model (`.pth.tar`, `hparams.yaml`) → needed `--collect-all perth`
+- **Both**: `huggingface_hub` silently disables tqdm based on logger level → progress bars showed 0% in frozen builds until we force-enabled the internal counter
+
+---
+
+## Phase 6: Common Upstream Workarounds
+
+Almost every model library has bugs you'll need to work around. Here's the catalog:
+
+### 6.1 torch.load device mismatch
+
+If model weights were saved on CUDA but you're loading on CPU/MPS:
+```python
+_original_torch_load = torch.load
+def _patched_torch_load(*args, **kwargs):
+ kwargs.setdefault("map_location", "cpu")
+ return _original_torch_load(*args, **kwargs)
+torch.load = _patched_torch_load
+```
+Used by both Chatterbox backends. Use a threading lock if patching globally.
+
+### 6.2 Float64/Float32 dtype mismatch
+
+`librosa` returns float64, model weights are float32. Patch the offending methods:
+```python
+original_fn = SomeClass.some_method
+def patched_fn(self, *args, **kwargs):
+ result = original_fn(self, *args, **kwargs)
+ return result.float() # float64 → float32
+SomeClass.some_method = patched_fn
+```
+Used by Chatterbox for `S3Tokenizer.log_mel_spectrogram` and `VoiceEncoder.forward`.
+
+### 6.3 Transformers attention implementation
+
+If the model uses `output_attentions=True` with transformers >= 4.36:
+```python
+for module in model.modules():
+ if hasattr(module, '_attn_implementation'):
+ module._attn_implementation = "eager"
+```
+SDPA (the new default) doesn't support `output_attentions`. Force eager attention.
+
+### 6.4 HuggingFace token bug
+
+Some models' `from_pretrained()` passes `token=True` which requires a stored HF token even for public repos:
+```python
+from huggingface_hub import snapshot_download
+local_path = snapshot_download(repo_id=REPO, token=None)
+model = ModelClass.from_local(local_path, device=device)
+```
+Used by Chatterbox Turbo.
+
+### 6.5 MPS tensor issues
+
+MPS (Apple Silicon GPU) has incomplete operator coverage. If generation crashes on MPS:
+```python
+def _get_device(self):
+ if torch.cuda.is_available():
+ return "cuda"
+ return "cpu" # Skip MPS entirely
+```
+Used by both Chatterbox backends. LuxTTS works fine on MPS.
+
+### 6.6 HuggingFace progress tracking
+
+To get download progress bars in the UI, wrap model loading with `HFProgressTracker`:
+```python
+from ..utils.hf_progress import HFProgressTracker
+tracker = HFProgressTracker(model_name, progress_manager)
+with tracker.patch_download():
+ model = ModelClass.from_pretrained(repo_id)
+```
+The tracker monkey-patches tqdm to intercept HuggingFace's internal progress bars. Must be set up BEFORE importing the model library if it imports HF at module level.
+
+---
+
+## Checklist
+
+### Backend
+- [ ] `backend/backends/_backend.py` — implements TTSBackend protocol
+- [ ] `backend/backends/__init__.py` — `ModelConfig` entry + `TTS_ENGINES` + `get_tts_backend_for_engine()` elif
+- [ ] `backend/models.py` — engine name in regex, any new language codes
+- [ ] `backend/requirements.txt` — dependencies added (check for `--no-deps` needs)
+- [ ] `justfile` — `--no-deps` install step if needed
+
+### Routes and services
+No changes needed — the model config registry handles all dispatch automatically.
+
+### Frontend
+- [ ] `app/src/lib/api/types.ts` — engine union type
+- [ ] `app/src/lib/constants/languages.ts` — `ENGINE_LANGUAGES` entry
+- [ ] `app/src/components/Generation/EngineModelSelector.tsx` — `ENGINE_OPTIONS` + `ENGINE_DESCRIPTIONS` + `ENGLISH_ONLY_ENGINES`
+- [ ] `app/src/lib/hooks/useGenerationForm.ts` — Zod schema + model mapping
+- [ ] `app/src/components/ServerSettings/ModelManagement.tsx` — model description
+
+### Production
+- [ ] PyInstaller spec — `--collect-all`, `--hidden-import`, `--copy-metadata` as needed
+- [ ] Test in frozen binary — download, load, generate all work
+- [ ] Download progress — `HFProgressTracker` wired up, progress shows in UI
+
+### Upstream workarounds (check which apply)
+- [ ] torch.load device mapping (CUDA weights on CPU)
+- [ ] Float64→Float32 patches (librosa interaction)
+- [ ] Eager attention forcing (transformers >= 4.36)
+- [ ] HF token bypass (snapshot_download + from_local)
+- [ ] MPS skip (if operators not supported)
+- [ ] espeak-ng / native data path env vars
diff --git a/docs/content/docs/plans/CUDA_BACKEND_SWAP.md b/docs/content/docs/plans/CUDA_BACKEND_SWAP.md
new file mode 100644
index 00000000..b270e962
--- /dev/null
+++ b/docs/content/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/content/docs/plans/CUDA_BACKEND_SWAP_FINAL.md b/docs/content/docs/plans/CUDA_BACKEND_SWAP_FINAL.md
new file mode 100644
index 00000000..ebd22534
--- /dev/null
+++ b/docs/content/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/content/docs/plans/EXTERNAL_PROVIDERS.md b/docs/content/docs/plans/EXTERNAL_PROVIDERS.md
deleted file mode 100644
index a8023bde..00000000
--- a/docs/content/docs/plans/EXTERNAL_PROVIDERS.md
+++ /dev/null
@@ -1,438 +0,0 @@
----
-title: "External Provider Support"
-description: "External provider support for Voicebox (Planned)"
----
-
-**Status:** Planned for v0.2.0
-**Discussion:** [Reddit Thread](https://reddit.com/r/LocalLLaMA/...)
-
-## Overview
-
-External provider support allows you to connect Voicebox to remotely-hosted TTS and Whisper services instead of running models locally. This is useful for:
-
-- **Existing GPU Infrastructure**: You already have Qwen3-TTS running on a GPU server
-- **AMD GPU Users**: Run models on your AMD hardware, use Voicebox as the UI
-- **Cloud Deployments**: Host models on Modal, Replicate, RunPod, etc.
-- **Team Sharing**: Multiple users share one GPU server running models
-- **Mixed Deployments**: Local Whisper + remote TTS, or vice versa
-
-## Architecture
-
-```
-┌─────────────────┐ HTTP/API ┌──────────────────┐
-│ Voicebox UI │ ───────────────────────> │ Your TTS Server │
-│ + Backend │ │ (Qwen3-TTS on │
-│ │ <─────────────────────── │ AMD/NVIDIA GPU)│
-│ - Profiles │ Audio + Metadata └──────────────────┘
-│ - History │
-│ - Audio Edit │ HTTP/API ┌──────────────────┐
-│ - UI │ ───────────────────────> │ Whisper Service │
-└─────────────────┘ │ (OpenAI API or │
- │ self-hosted) │
- └──────────────────┘
-```
-
-**What Voicebox Still Handles:**
-- Voice profile management
-- Generation history
-- Audio trimming/editing
-- Multi-track story editor
-- UI/UX layer
-
-**What External Providers Handle:**
-- Model inference (TTS generation, transcription)
-- GPU allocation
-- Model loading/caching
-
-## Configuration
-
-### Environment Variables
-
-```bash
-# TTS Provider
-TTS_MODE=remote # local | remote
-TTS_REMOTE_URL=http://192.168.1.100:8000 # Your TTS server URL
-TTS_API_KEY=your-api-key # Optional authentication
-
-# Whisper Provider
-WHISPER_MODE=openai-api # local | openai-api | remote
-WHISPER_REMOTE_URL=http://localhost:9000 # For self-hosted Whisper
-OPENAI_API_KEY=sk-... # For OpenAI Whisper API
-```
-
-### Voicebox Config UI (Planned)
-
-Settings page will include:
-- Provider selection dropdowns
-- URL/API key inputs
-- Connection test button
-- Latency/status indicators
-
-## Hosting External Services
-
-### Option 1: Simple FastAPI Server (Recommended)
-
-Create a lightweight server to expose your local Qwen3-TTS model:
-
-```python
-# tts_server.py
-from fastapi import FastAPI, UploadFile, File
-from qwen_tts import Qwen3TTSModel
-import numpy as np
-import base64
-
-app = FastAPI()
-model = Qwen3TTSModel.from_pretrained(
- "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
- device_map="cuda" # or "cpu" for AMD ROCm: use torch+rocm
-)
-
-@app.post("/v1/generate")
-async def generate(
- text: str,
- voice_prompt: dict,
- language: str = "en",
- seed: int = None
-):
- """Generate speech from text using voice prompt."""
- audio, sample_rate = model.generate_voice_clone(
- text=text,
- voice_clone_prompt=voice_prompt,
- )
-
- # Return as base64 for transport
- audio_bytes = audio.tobytes()
- return {
- "audio": base64.b64encode(audio_bytes).decode(),
- "sample_rate": sample_rate,
- "dtype": str(audio.dtype)
- }
-
-@app.post("/v1/create_voice_prompt")
-async def create_voice_prompt(
- audio: UploadFile = File(...),
- reference_text: str = ""
-):
- """Create voice prompt from reference audio."""
- # Save uploaded audio temporarily
- audio_path = f"/tmp/{audio.filename}"
- with open(audio_path, "wb") as f:
- f.write(await audio.read())
-
- # Create voice prompt
- voice_prompt = model.create_voice_clone_prompt(
- ref_audio=audio_path,
- ref_text=reference_text,
- )
-
- return {"voice_prompt": voice_prompt}
-
-@app.get("/health")
-async def health():
- return {
- "status": "healthy",
- "model": "Qwen3-TTS-12Hz-1.7B-Base",
- "device": str(model.device)
- }
-
-if __name__ == "__main__":
- import uvicorn
- uvicorn.run(app, host="0.0.0.0", port=8000)
-```
-
-**Run it:**
-```bash
-# Install dependencies
-pip install fastapi uvicorn qwen-tts torch
-
-# For AMD GPUs, use ROCm PyTorch:
-pip install torch --index-url https://download.pytorch.org/whl/rocm6.4
-
-# Start server
-python tts_server.py
-```
-
-### Option 2: vLLM (If Supported)
-
-```bash
-vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-Base \
- --host 0.0.0.0 \
- --port 8000 \
- --gpu-memory-utilization 0.9
-```
-
-### Option 3: Cloud Platforms
-
-**Modal.com Example:**
-```python
-import modal
-
-app = modal.App("qwen-tts")
-image = modal.Image.debian_slim().pip_install("qwen-tts", "torch")
-
-@app.function(gpu="A10G", image=image)
-@modal.web_endpoint(method="POST")
-def generate(text: str, voice_prompt: dict):
- from qwen_tts import Qwen3TTSModel
- model = Qwen3TTSModel.from_pretrained("Qwen/Qwen3-TTS-12Hz-1.7B-Base")
- audio, sr = model.generate_voice_clone(text, voice_prompt)
- return {"audio": audio.tolist(), "sample_rate": sr}
-```
-
-Deploy: `modal deploy tts_server.py`
-Get URL: `https://yourapp--generate.modal.run`
-
-## API Specification
-
-External TTS providers must implement these endpoints:
-
-### `POST /v1/generate`
-
-Generate speech from text.
-
-**Request:**
-```json
-{
- "text": "Hello, this is a test.",
- "voice_prompt": { /* voice prompt object */ },
- "language": "en",
- "seed": 12345
-}
-```
-
-**Response:**
-```json
-{
- "audio": "base64-encoded-audio-bytes",
- "sample_rate": 24000,
- "dtype": "float32"
-}
-```
-
-### `POST /v1/create_voice_prompt`
-
-Create a voice prompt from reference audio.
-
-**Request:** (multipart/form-data)
-- `audio`: Audio file upload
-- `reference_text`: Transcript of the audio
-
-**Response:**
-```json
-{
- "voice_prompt": { /* voice prompt object */ }
-}
-```
-
-### `GET /health`
-
-Health check endpoint.
-
-**Response:**
-```json
-{
- "status": "healthy",
- "model": "Qwen3-TTS-12Hz-1.7B-Base",
- "device": "cuda:0"
-}
-```
-
-## Whisper External Providers
-
-### OpenAI Whisper API
-
-Simply set:
-```bash
-WHISPER_MODE=openai-api
-OPENAI_API_KEY=sk-...
-```
-
-Voicebox will use OpenAI's Whisper API automatically.
-
-### Self-Hosted Whisper
-
-Run your own Whisper server:
-
-```python
-# whisper_server.py
-from fastapi import FastAPI, UploadFile, File
-from transformers import WhisperProcessor, WhisperForConditionalGeneration
-import librosa
-
-app = FastAPI()
-processor = WhisperProcessor.from_pretrained("openai/whisper-base")
-model = WhisperForConditionalGeneration.from_pretrained("openai/whisper-base")
-
-@app.post("/v1/transcribe")
-async def transcribe(audio: UploadFile = File(...), language: str = None):
- # Load audio
- audio_path = f"/tmp/{audio.filename}"
- with open(audio_path, "wb") as f:
- f.write(await audio.read())
-
- audio_data, sr = librosa.load(audio_path, sr=16000)
-
- # Process
- inputs = processor(audio_data, sampling_rate=16000, return_tensors="pt")
- predicted_ids = model.generate(inputs["input_features"])
- transcription = processor.batch_decode(predicted_ids, skip_special_tokens=True)[0]
-
- return {"text": transcription}
-```
-
-Configure Voicebox:
-```bash
-WHISPER_MODE=remote
-WHISPER_REMOTE_URL=http://localhost:9000
-```
-
-## Use Cases
-
-### 1. AMD GPU User with Existing Setup
-
-**Scenario:** You have a Radeon 7900 XTX running Qwen3-TTS on Linux.
-
-**Setup:**
-1. Run `tts_server.py` on your AMD box (ROCm PyTorch)
-2. Configure Voicebox: `TTS_MODE=remote`, `TTS_REMOTE_URL=http://amd-box:8000`
-3. Use Voicebox UI for profiles, generation, editing
-4. TTS happens on your AMD GPU
-
-### 2. Team Deployment
-
-**Scenario:** 5 team members, 1 GPU server.
-
-**Setup:**
-1. Deploy TTS server on shared GPU box
-2. Each person runs Voicebox desktop app locally
-3. All point to same `TTS_REMOTE_URL`
-4. Profiles and history stay local per user
-5. GPU usage is shared
-
-### 3. Hybrid Local/Remote
-
-**Scenario:** Fast local Whisper, heavy TTS on cloud.
-
-**Setup:**
-```bash
-TTS_MODE=remote
-TTS_REMOTE_URL=https://your-modal-app.modal.run
-
-WHISPER_MODE=local # Fast transcription on your CPU
-```
-
-### 4. OpenAI Whisper + Self-Hosted TTS
-
-**Scenario:** Use OpenAI's API for transcription, run TTS locally.
-
-**Setup:**
-```bash
-TTS_MODE=local
-
-WHISPER_MODE=openai-api
-OPENAI_API_KEY=sk-...
-```
-
-## Security Considerations
-
-### Authentication
-
-Add API key authentication to your external server:
-
-```python
-from fastapi import Header, HTTPException
-
-API_KEY = "your-secret-key"
-
-async def verify_api_key(x_api_key: str = Header(...)):
- if x_api_key != API_KEY:
- raise HTTPException(status_code=401, detail="Invalid API key")
-
-@app.post("/v1/generate", dependencies=[Depends(verify_api_key)])
-async def generate(...):
- ...
-```
-
-Configure Voicebox:
-```bash
-TTS_API_KEY=your-secret-key
-```
-
-### Network Security
-
-- **VPN/Tailscale**: Use private network for remote servers
-- **HTTPS**: Use reverse proxy (nginx/Caddy) with SSL certificates
-- **Firewall**: Restrict access to known IPs
-
-### Rate Limiting
-
-Protect your external server:
-
-```python
-from slowapi import Limiter
-from slowapi.util import get_remote_address
-
-limiter = Limiter(key_func=get_remote_address)
-app.state.limiter = limiter
-
-@app.post("/v1/generate")
-@limiter.limit("10/minute")
-async def generate(...):
- ...
-```
-
-## Performance Considerations
-
-### Latency
-
-External providers add network latency:
-- **Local network**: ~10-50ms overhead (negligible)
-- **Same datacenter**: ~1-5ms overhead
-- **Cross-region cloud**: 50-200ms+ overhead
-
-For real-time applications, keep TTS server on local network or same cloud region.
-
-### Caching
-
-Implement response caching on external server:
-
-```python
-from functools import lru_cache
-
-@lru_cache(maxsize=1000)
-def get_cached_generation(text, voice_prompt_hash, language, seed):
- return model.generate_voice_clone(text, voice_prompt)
-```
-
-### Load Balancing
-
-For high-traffic deployments, run multiple TTS servers behind a load balancer:
-
-```
-Voicebox ──> Load Balancer ──> TTS Server 1 (GPU 1)
- ├──> TTS Server 2 (GPU 2)
- └──> TTS Server 3 (GPU 3)
-```
-
-## Future Enhancements
-
-- [ ] **Provider Marketplace**: Built-in directory of compatible providers
-- [ ] **Automatic Fallback**: If remote fails, fallback to local
-- [ ] **Cost Tracking**: Monitor API usage and costs
-- [ ] **Performance Metrics**: Latency, throughput dashboards
-- [ ] **Multi-Provider**: Use different providers for different voices/languages
-
-## Contributing
-
-If you build an external provider, please share:
-1. Server implementation
-2. Performance benchmarks
-3. Deployment guide
-
-Submit to: [GitHub Discussions](https://github.com/jamiepine/voicebox/discussions)
-
-## Questions?
-
-- **Discord**: [Join the community](https://discord.gg/...)
-- **GitHub**: [Open an issue](https://github.com/jamiepine/voicebox/issues)
-- **Docs**: [Full documentation](https://voicebox.sh/docs)
diff --git a/docs/content/docs/plans/MLX_AUDIO.md b/docs/content/docs/plans/MLX_AUDIO.md
deleted file mode 100644
index 97acfa1a..00000000
--- a/docs/content/docs/plans/MLX_AUDIO.md
+++ /dev/null
@@ -1,399 +0,0 @@
----
-title: "MLX Audio Integration"
-description: "MLX Audio integration for Voicebox (Validated)"
----
-
-**Status:** Validated ✅
-**Context:** [mlx-audio v0.3.1 release](https://github.com/Blaizzy/mlx-audio)
-
-## Validation Results
-
-We validated mlx-audio in an isolated environment (`mlx-test/`). Key findings:
-
-| Metric | Result |
-|--------|--------|
-| MLX Version | 0.30.4 |
-| Model Load Time | ~1s (after initial download) |
-| Generation RTF | **0.5-0.6x** (1.7-2x faster than real-time) |
-| Test Hardware | Apple Silicon Mac |
-
-### Model Mapping
-
-| voicebox (PyTorch) | mlx-audio (MLX) |
-|--------------------|-----------------|
-| `Qwen/Qwen3-TTS-12Hz-1.7B-Base` | `mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16` |
-| `Qwen/Qwen3-TTS-12Hz-0.6B-Base` | (not yet converted) |
-
-### mlx-audio API
-
-The API uses a **generator-based streaming pattern**:
-
-```python
-from mlx_audio.tts import load
-
-model = load("mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16")
-
-# generate() yields GenerationResult objects
-for result in model.generate("Hello world"):
- audio = result.audio # numpy array of samples
- sample_rate = result.sample_rate # 24000
- rtf = result.real_time_factor # e.g., 0.55
-```
-
-### Known Warnings (harmless)
-
-```
-You are using a model of type qwen3_tts to instantiate a model of type .
-The tokenizer you are loading... with an incorrect regex pattern...
-```
-
-These warnings appear but don't affect functionality or output quality.
-
-### Demo Script
-
-Run `mlx-test/demo.py` to test:
-```bash
-cd mlx-test && source venv/bin/activate && python demo.py "Your text here"
-```
-
-## Problem
-
-Apple Silicon users are stuck on CPU inference while Windows and Linux users get CUDA acceleration. The current PyTorch MPS backend has stability issues (lines 34-36 in `backend/tts.py` and `backend/transcribe.py`), forcing a CPU fallback that makes voicebox significantly slower on M1/M2/M3 Macs.
-
-This creates a poor experience for a large portion of users who bought Apple Silicon specifically for ML workloads.
-
-## Solution
-
-Integrate [mlx-audio](https://github.com/Blaizzy/mlx-audio) as the inference engine for macOS Apple Silicon builds. MLX is Apple's native ML framework, optimized for Metal and the unified memory architecture. It's fast, stable, and already supports the same Qwen3-TTS models we use.
-
-**Key wins:**
-- Native GPU acceleration on Apple Silicon (no more CPU fallback)
-- Streaming TTS support (faster perceived latency)
-- Memory optimizations (run larger models on less RAM)
-- Fixed 0.6B silence bug that we currently ship
-- Same Qwen3-TTS models (zero migration cost for users)
-
-## Architecture
-
-### Current Stack
-```
-┌─────────────────────────┐
-│ PyTorch + Qwen3-TTS │
-│ (CPU only on macOS) │
-└─────────────────────────┘
-```
-
-### Proposed Stack
-```
-┌─────────────────────────────────────────┐
-│ Platform Detection at Runtime │
-└─────────────────────────────────────────┘
- │
- ├─── Apple Silicon (aarch64-darwin)
- │ ┌─────────────────────────┐
- │ │ MLX Audio Backend │
- │ │ - Qwen3-TTS (mlx) │
- │ │ - Whisper (mlx) │
- │ │ - Streaming support │
- │ └─────────────────────────┘
- │
- └─── Other (x86_64, Windows, Linux)
- ┌─────────────────────────┐
- │ PyTorch Backend │
- │ - Qwen3-TTS (pytorch) │
- │ - Whisper (pytorch) │
- │ - CUDA if available │
- └─────────────────────────┘
-```
-
-## Implementation Phases
-
-### Phase 1: Platform Detection & Dependency Management
-
-Create a backend that switches between PyTorch and MLX based on runtime platform detection.
-
-**New files:**
-- `backend/platform.py` - Detect Apple Silicon, return backend type
-- `backend/backends/__init__.py` - Backend factory pattern
-- `backend/requirements-mlx.txt` - MLX-specific deps (macOS only)
-
-**Modified files:**
-- `backend/requirements.txt` - Keep PyTorch as default
-- `backend/main.py` - Import from backend factory instead of direct imports
-
-**Platform detection logic:**
-```python
-def get_backend_type() -> str:
- """Detect best backend for current platform."""
- if platform.system() == "Darwin" and platform.machine() == "arm64":
- # Apple Silicon detected
- try:
- import mlx
- return "mlx"
- except ImportError:
- return "pytorch" # Fallback if mlx not installed
- return "pytorch"
-```
-
-### Phase 2: MLX Backend Implementation
-
-Create parallel implementations of TTS and STT using mlx-audio.
-
-**New files:**
-- `backend/backends/mlx_backend.py` - MLX inference engine
-- `backend/backends/pytorch_backend.py` - Refactor current code into backend
-
-**Interface both backends must implement:**
-```python
-class TTSBackend(Protocol):
- async def load_model(self, model_size: str) -> None: ...
- async def create_voice_prompt(self, audio_path: str, reference_text: str) -> dict: ...
- async def generate(self, text: str, voice_prompt: dict, **kwargs) -> Tuple[np.ndarray, int]: ...
- async def generate_streaming(self, text: str, voice_prompt: dict, **kwargs) -> AsyncIterator[bytes]: ...
- def unload_model(self) -> None: ...
-
-class STTBackend(Protocol):
- async def load_model(self, model_size: str) -> None: ...
- async def transcribe(self, audio_path: str, language: Optional[str]) -> str: ...
- def unload_model(self) -> None: ...
-```
-
-**MLX backend implementation notes:**
-
-mlx-audio's `generate()` returns a generator by default (streaming is built-in):
-
-```python
-# MLX backend wrapper
-from mlx_audio.tts import load
-
-class MLXTTSBackend:
- def __init__(self):
- self.model = None
-
- async def load_model(self, model_size: str) -> None:
- model_map = {
- "1.7B": "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16",
- # "0.6B": needs conversion to mlx format
- }
- self.model = load(model_map[model_size])
-
- async def generate(self, text: str, voice_prompt: dict, **kwargs) -> Tuple[np.ndarray, int]:
- # Collect all chunks from generator
- chunks = []
- for result in self.model.generate(text): # TODO: add voice_prompt support
- chunks.append(np.array(result.audio))
- return np.concatenate(chunks), 24000
-```
-
-**MLX-specific features to expose:**
-- Streaming TTS (new endpoint: `/api/generate/stream`)
-- Memory-optimized model loading
-- Qwen3-ASR for transcription (in addition to Whisper)
-
-### Phase 3: API Layer Updates
-
-Update FastAPI endpoints to support new streaming capabilities and maintain backward compatibility.
-
-**Modified files:**
-- `backend/main.py` - Add streaming endpoints
-- `backend/tts.py` - Refactor to use backend abstraction
-- `backend/transcribe.py` - Refactor to use backend abstraction
-
-**New endpoints:**
-```python
-@app.post("/api/generate/stream")
-async def generate_stream(...) -> StreamingResponse:
- """Stream TTS chunks as they're generated (MLX only)."""
- backend = get_backend()
- if not hasattr(backend, 'generate_streaming'):
- raise HTTPException(501, "Streaming not supported on this backend")
- return StreamingResponse(backend.generate_streaming(...), media_type="audio/wav")
-```
-
-**Backward compatibility:**
-- Keep all existing `/api/generate` endpoints unchanged
-- PyTorch backend users see no behavior change
-- MLX users automatically get faster inference, streaming is opt-in
-
-### Phase 4: Frontend Integration
-
-Add UI indicators for backend type and streaming progress.
-
-**Modified files:**
-- `app/src/hooks/useGenerationForm.tsx` - Add streaming support
-- `app/src/components/GenerationForm.tsx` - Show backend badge, streaming toggle
-- `app/src/lib/api.ts` - Add streaming API client
-
-**UI additions:**
-- Badge showing current backend ("MLX" or "PyTorch")
-- Toggle for streaming mode (disabled if PyTorch)
-- Real-time streaming playback (WaveSurfer progressive loading)
-
-### Phase 5: Build & Distribution
-
-Create separate installers for MLX (Apple Silicon) and PyTorch (Universal).
-
-**Modified files:**
-- `tauri/src-tauri/tauri.conf.json` - Add target-specific builds
-- `.github/workflows/release.yml` - Build both variants
-
-**Build matrix:**
-```yaml
-- target: aarch64-apple-darwin
- backend: mlx
- installer: voicebox-macos-silicon-{version}.dmg
-
-- target: x86_64-apple-darwin
- backend: pytorch
- installer: voicebox-macos-intel-{version}.dmg
-
-- target: x86_64-pc-windows-msvc
- backend: pytorch
- installer: voicebox-windows-{version}.exe
-```
-
-**Installation flow:**
-- Auto-detect architecture, recommend correct installer
-- MLX installer includes `mlx-audio` in embedded Python
-- PyTorch installer includes `torch` in embedded Python
-- Both can coexist (different backend, same profile format)
-
-### Phase 6: Testing & Validation
-
-Ensure both backends produce compatible outputs.
-
-**New files:**
-- `backend/tests/test_backend_parity.py` - Verify both backends produce similar audio
-- `backend/tests/test_streaming.py` - Streaming-specific tests
-
-**Test scenarios:**
-- Same voice prompt on both backends → similar (not identical) audio output
-- Profile created on MLX → loads on PyTorch (and vice versa)
-- Streaming chunks assemble into valid WAV file
-- Model downloads work on both backends
-- Memory usage stays within bounds
-
-### Phase 7: Documentation
-
-Update user-facing docs and developer guides.
-
-**New files:**
-- `docs/developer/BACKENDS.md` - Guide for adding new backends
-- `docs/overview/performance.md` - Backend comparison benchmarks
-
-**Modified files:**
-- `README.md` - Note Apple Silicon acceleration
-- `docs/TROUBLESHOOTING.md` - Add MLX-specific issues
-
-**Key docs to write:**
-- Which installer to download (architecture detection)
-- Performance comparison (MLX vs PyTorch on same M2 hardware)
-- How streaming mode works
-- How to force PyTorch on Apple Silicon (for debugging)
-
-## Technical Decisions
-
-### Why Dual Backend Instead of MLX-Only?
-
-**Pros of dual backend:**
-- Windows and Intel Mac users unaffected
-- Easier testing (can compare outputs)
-- Fallback if MLX has issues
-
-**Cons of dual backend:**
-- More code to maintain
-- Two dependency trees
-- Build complexity (separate installers)
-
-**Decision:** Dual backend. The maintenance cost is worth it to avoid breaking existing users and to have a fallback.
-
-### Why Separate Installers Instead of Runtime Detection?
-
-**Pros of separate installers:**
-- Smaller bundle size (don't ship both PyTorch and MLX)
-- Clearer to users which version they have
-- Easier to debug (no "which backend am I running?" confusion)
-- Can optimize each build for its target
-
-**Cons:**
-- More installers to build and test
-- Users might download the wrong one
-
-**Decision:** Separate installers. Bundle size matters (PyTorch + MLX would be huge), and we can auto-detect architecture on the download page.
-
-### Streaming vs Batch Generation
-
-MLX supports streaming, PyTorch doesn't (without significant work). Should streaming be:
-1. MLX-only feature (✅ chosen)
-2. Implemented for both (lots of work)
-3. Not exposed at all (wasted opportunity)
-
-**Decision:** MLX-only. Expose as opt-in feature with graceful degradation (button disabled on PyTorch backend).
-
-## Migration Path
-
-Nothing needs migrating, macos users will just notice a speed-boost in inference
-
-**Data format compatibility:**
-- Profiles (SQLite) → no schema changes needed
-- Voice prompts (cached) → backend-agnostic (just numpy arrays)
-- Audio files → unchanged
-
-## Performance Expectations
-
-### Measured Results (from validation)
-
-| Metric | MLX (measured) | PyTorch CPU (estimated) |
-|--------|----------------|-------------------------|
-| **6s audio generation** | ~3-4s | ~10-15s |
-| **Real-time factor** | 0.5-0.6x | 2-3x |
-| **Model load (cached)** | ~1s | ~3-5s |
-
-### TTS Generation (1.7B model, ~20s output)
-- **PyTorch CPU (M2 Max):** ~45-60s (slower than real-time)
-- **MLX (M2 Max):** ~8-12s (faster than real-time)
-- **Improvement:** ~4-5x faster
-
-### Whisper Transcription (10s audio clip)
-- **PyTorch CPU:** ~5-8s
-- **MLX:** ~1-2s
-- **Improvement:** ~3-4x faster
-
-### Memory Usage (1.7B model)
-- **PyTorch:** ~8-10GB (no GPU offload, so CPU RAM)
-- **MLX:** ~4-6GB (unified memory, better optimization)
-- **Improvement:** ~40% less RAM
-
-Full benchmarks will be in `docs/overview/performance.md` after Phase 6.
-
-## Open Questions
-
-- **Should we support Qwen3-ASR (MLX-only) in addition to Whisper?** Adds another model option but increases complexity. Probably phase 8+. - Sure
-- **Should we backport streaming to PyTorch?** Would require chunking and callback-based generation. Probably not worth it given mlx-audio already has it. - No
-- **What's the auto-update UX for migrating PyTorch→MLX users?** Needs design. Don't want to force reinstall, but also want to make upgrade obvious. - it just updates, users see nothing
-- **Do we expose backend selection in settings or hide it?** Leaning toward auto-detect only, with env var override for power users.
-
-## Success Metrics
-
-How we'll know this worked:
-
-1. **Performance:** Apple Silicon users report generation faster than real-time
-2. **Adoption:** >80% of macOS downloads are MLX build within 1 month
-3. **Stability:** <5% increase in bug reports (backend abstraction doesn't introduce regressions)
-4. **Feedback:** Positive sentiment in Discord/GitHub about macOS performance
-
-## Related Work
-
-- [PyTorch MPS tracking issue](https://github.com/pytorch/pytorch/issues/77764) - Why we can't use MPS directly
-- [mlx-audio server implementation](https://github.com/Blaizzy/mlx-audio/blob/main/examples/server.py) - Reference for streaming API
-- [MLX Whisper benchmarks](https://github.com/ml-explore/mlx-examples/tree/main/whisper) - Performance data
-
-## Next Steps
-
-1. ~~Validate mlx-audio can load Qwen3-TTS models (quick test)~~ ✅ Done - see `mlx-test/`
-2. Get approval on dual-backend architecture
-3. Start Phase 1 (platform detection)
-
-## Questions?
-
-Feedback welcome in GitHub discussions or Discord.
diff --git a/docs/content/docs/plans/PROJECT_STATUS.md b/docs/content/docs/plans/PROJECT_STATUS.md
new file mode 100644
index 00000000..628dfa0a
--- /dev/null
+++ b/docs/content/docs/plans/PROJECT_STATUS.md
@@ -0,0 +1,497 @@
+# 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 (~2850 lines) |
+| TTS protocol | `backend/backends/__init__.py:32-101` | `TTSBackend` Protocol definition |
+| Model registry | `backend/backends/__init__.py:17-29,153-366` | `ModelConfig` dataclass + registry helpers |
+| TTS factory | `backend/backends/__init__.py:382-426` | 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 |
+| Engine selector | `app/src/components/Generation/EngineModelSelector.tsx` | Shared engine/model dropdown |
+| 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)
+- Instruct parameter UI exists but is non-functional across all backends (see #224, Known Limitations)
+- Single flat model dropdown (Qwen 1.7B, Qwen 0.6B, LuxTTS, Chatterbox, Chatterbox Turbo)
+- Centralized model config registry (`ModelConfig` dataclass) — no per-engine dispatch maps in `main.py`
+- Shared `EngineModelSelector` component — engine/model dropdown defined once, used in both generation forms
+
+**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 | Instruct Support |
+|--------|-----------|-----------|------|-------------|-----------------|
+| Qwen3-TTS 1.7B | `qwen-tts-1.7B` | 10 (zh, en, ja, ko, de, fr, ru, pt, es, it) | ~3.5 GB | Highest quality, voice cloning | None (Base model has no instruct path) |
+| Qwen3-TTS 0.6B | `qwen-tts-0.6B` | 10 | ~1.2 GB | Lighter, faster | None |
+| LuxTTS | `luxtts` | English | ~300 MB | CPU-friendly, 48 kHz, fast | None |
+| Chatterbox | `chatterbox-tts` | 23 (incl. Hebrew, Arabic, Hindi, etc.) | ~3.2 GB | Zero-shot cloning, multilingual | Partial — `exaggeration` float (0-1) for expressiveness |
+| Chatterbox Turbo | `chatterbox-turbo` | English | ~1.5 GB | Paralinguistic tags ([laugh], [cough]), 350M params, low latency | Partial — inline tags only, no separate instruct param |
+
+### 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`.
+- **Instruct parameter is non-functional** (#224): The UI exposes an instruct text field, but it's silently dropped by every backend. The Qwen3-TTS Base model we ship only supports voice cloning — instruct requires the separate CustomVoice model variant (`Qwen3-TTS-12Hz-1.7B-CustomVoice`), which uses predefined speakers instead of ref audio. The instruct UI should be hidden until a backend with real support is integrated.
+- **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 — updated March 13)
+
+| Model | Cloning | Speed | Sample Rate | Languages | VRAM | Instruct Support | Integration Ease | Status |
+|-------|---------|-------|-------------|-----------|------|-----------------|-----------------|--------|
+| **Qwen3-TTS** | 10s zero-shot | Medium | 24 kHz | 10 | Medium | None (Base); Yes (CustomVoice variant, predefined speakers only) | **Shipped** | v0.1.13 |
+| **LuxTTS** | 3s zero-shot | 150x RT, CPU ok | 48 kHz | English | <1 GB | None | **Shipped** | PR #254 |
+| **Chatterbox MTL** | 5s zero-shot | Medium | 24 kHz | 23 | Medium | Partial — `exaggeration` float | **Shipped** | PR #257 |
+| **Chatterbox Turbo** | 5s zero-shot | Fast | 24 kHz | English | Low | Partial — inline tags only | **PR #258** | In review |
+| **CosyVoice2-0.5B** | 3-10s zero-shot | Very fast | 24 kHz | Multilingual | Low | **Yes** — `inference_instruct2()`, works with cloning | Ready | Best instruct candidate |
+| **Fish Speech** | 10-30s few-shot | Real-time | 24-44 kHz | 50+ | Medium | **Yes** — inline text descriptions, word-level control | Ready | Multi-engine arch in place |
+| **MOSS-TTS Family** | Zero-shot | — | — | Multilingual | Medium | **Yes** — text prompts for style + timbre design | Needs vetting | Apache 2.0, multi-speaker dialogue |
+| **HumeAI TADA 1B/3B** | Zero-shot | 5× faster than LLM-TTS | — | EN (1B), Multilingual (3B) | Medium | Partial — automatic prosody from text context | Needs vetting | MIT, 700s+ coherent, synced transcript output |
+| **VoxCPM 1.5** | Zero-shot (seconds) | ~0.15 RTF streaming | — | Bilingual (EN/ZH) | Medium | Partial — automatic context-aware prosody | Needs vetting | Apache 2.0, tokenizer-free continuous diffusion |
+| **Kokoro-82M** | 3s instant | CPU realtime | 24 kHz | English | Tiny (82M) | Partial — automatic style inference | Ready | Apache 2.0, multi-engine arch in place |
+| **XTTS-v2** | 6s zero-shot | Mid-GPU | 24 kHz | 17+ | Medium | Partial — style transfer from ref audio only | Ready | Multi-engine arch in place |
+| **Pocket TTS** | Zero-shot + streaming | >1× RT on CPU | — | English | ~100M params, CPU-first | None | Needs vetting | MIT, Kyutai Labs, no GPU required |
+
+#### Notes on New Candidates (March 2026)
+
+- **CosyVoice2-0.5B** — Best candidate for instruct support. `inference_instruct2()` accepts a text instruct parameter for emotions, speed, volume, dialects — and it works alongside voice cloning. This is the closest match to what users expect from our instruct UI. [HF: FunAudioLLM/CosyVoice2-0.5B](https://huggingface.co/FunAudioLLM/CosyVoice2-0.5B)
+- **HumeAI TADA** — Text-Audio Dual Alignment arch. Near-zero hallucinations/drift, free synced transcript. 700+ seconds coherent audio. Best candidate for Stories long-form reliability. Prosody/emotion is automatic from text context, not user-controllable. [HF: HumeAI/tada-1b](https://huggingface.co/HumeAI/tada-1b) | [GitHub: HumeAI/tada](https://github.com/HumeAI/tada)
+- **MOSS-TTS** — Modular suite: flagship cloning, MOSS-TTSD (multi-speaker dialogue), MOSS-VoiceGenerator (create voices from text descriptions). VoiceGenerator unifies timbre design and style control via text prompts, usable as a layer for downstream TTS including cloning. [HF: OpenMOSS-Team/MOSS-VoiceGenerator](https://huggingface.co/OpenMOSS-Team/MOSS-VoiceGenerator) | [GitHub: OpenMOSS/MOSS-TTS](https://github.com/OpenMOSS/MOSS-TTS)
+- **Fish Speech** — Word-level fine-grained control using plain language descriptions inline in the script. Works with cloning. Note: Fish Audio S2 has a restrictive research license (commercial use requires approval), but the open-source Fish Speech model may differ. Needs license clarification. [fish.audio blog](https://fish.audio/blog/fish-audio-s2-fine-grained-ai-voice-control-at-the-word-level)
+- **VoxCPM 1.5** — Tokenizer-free continuous diffusion + autoregressive. No discrete token artifacts. Prosody/emotion is context-aware but automatic, not explicitly controllable via text prompt. Real-time streaming, LoRA fine-tuning. Trained on 1.8M+ hours. [GitHub: OpenBMB/VoxCPM](https://github.com/OpenBMB/VoxCPM)
+- **Pocket TTS** — 100M param CPU-first model from Kyutai Labs (Moshi team). Runs >1× realtime without GPU. No style control. Broadens hardware support significantly. [GitHub: kyutai-labs/pocket-tts](https://github.com/kyutai-labs/pocket-tts)
+- **Watch list:** MioTTS-2.6B (fast LLM-based EN/JP, vLLM compatible), Oolel-Voices (Soynade Research, expressive modular control)
+
+### Adding a New Engine (Now Straightforward)
+
+With the model config registry and shared `EngineModelSelector` component, 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 `ModelConfig` entry + `TTS_ENGINES` entry + factory elif
+3. **Update `backend/models.py`** — add engine name to regex
+4. **Update frontend** — add to engine union type, `EngineModelSelector` options, form schema, language map (4 files)
+
+`main.py` requires **zero changes** — the registry handles all dispatch automatically.
+
+Total effort: **~1 day** for a well-documented model with a PyPI package. See `docs/plans/ADDING_TTS_ENGINES.md` for the full guide.
+
+---
+
+## 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` Dispatch Point Duplication~~ — RESOLVED
+
+Previously, each engine required updates to 6+ hardcoded dispatch maps across `main.py` (~320 lines of if/elif chains). A model config registry in `backend/backends/__init__.py` now centralizes all model metadata (`ModelConfig` dataclass) with helper functions (`load_engine_model()`, `check_model_loaded()`, `engine_needs_trim()`, etc.). Adding a new engine requires zero changes to `main.py`.
+
+### ~~3. Model Config is Scattered~~ — RESOLVED
+
+Model identifiers, HF repo IDs, display names, and engine metadata are now consolidated in the `ModelConfig` registry. Backend-aware branching (e.g. MLX vs PyTorch Qwen repo IDs) happens inside the registry. Frontend model options are centralized in `EngineModelSelector.tsx`.
+
+### 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 dispatch duplication in main.py~~ | **Done** |
+| 5 | **#225** — Custom HuggingFace models | User-supplied models | High (needs rework for multi-engine) |
+
+### Tier 3 — Future (v0.3.0+)
+
+| Priority | Item | Notes |
+|----------|------|-------|
+| 1 | **HumeAI TADA** | Long-form reliability for Stories, synced transcripts. Addresses #234, #203, #191, #111, #69. Needs API vetting. |
+| 2 | **Pocket TTS** (Kyutai) | CPU-first 100M model, broadens hardware support. Kyutai ships clean code. Needs API vetting. |
+| 3 | **MOSS-TTS** | Text-to-voice design (no ref audio) is unique. Multi-speaker dialogue for Stories. Needs thorough API vetting. |
+| 4 | **Kokoro-82M** | 82M params, CPU realtime, Apache 2.0. Easy win. |
+| 5 | ~~**Model config registry refactor**~~ | **Done** — consolidated in `backend/backends/__init__.py` + `EngineModelSelector.tsx` |
+| 6 | XTTS-v2 / Fish Speech / CosyVoice | Multi-engine arch is ready; just needs backend implementation |
+| 7 | **VoxCPM 1.5** | Tokenizer-free streaming, interesting but uncertain integration surface |
+| 8 | OpenAI-compatible API (plan doc exists) | Low effort once API is stable |
+| 9 | LoRA fine-tuning (PR #195) | Complex, needs rework for multi-engine |
+| 10 | External/remote providers | Depends on use case demand |
+| 11 | GGUF support (#226) | Depends on model ecosystem maturity |
+| 12 | Queue system (#234) | Batch generation |
+| 13 | Streaming for non-MLX engines | Currently MLX-only |
+
+---
+
+## 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/docs/issue-pain-points.md b/docs/issue-pain-points.md
new file mode 100644
index 00000000..54346cfd
--- /dev/null
+++ b/docs/issue-pain-points.md
@@ -0,0 +1,67 @@
+# Voicebox Issue Pain Points (Snapshot)
+
+## Scope
+
+- Dataset: **128 total issues** (**107 open**, **21 closed**)
+- Source: GitHub issues in `jamiepine/voicebox`
+- Classification: keyword/theme clustering
+- Note: counts below are **non-exclusive** (one issue can belong to multiple pain points)
+
+## Most Common Pain Points (Open Issues)
+
+| Rank | Pain Point | Open Issues | What users are reporting |
+|---|---|---:|---|
+| 1 | Model download & offline reliability | **32** | Downloads failing/stalling, cache/offline behavior inconsistent, wrong model size selected, Errno issues |
+| 2 | GPU/backend compatibility | **22** | GPU not detected, backend fallback surprises, platform-specific runtime failures (Windows/Mac) |
+| 3 | Export/save/file persistence | **15** | Export fails, "failed to fetch/download audio", samples/profiles not saving |
+| 4 | Language/accent quality & coverage | **14** | Missing language support, accent mismatch, robotic outputs |
+| 5 | Update/restart safety + long-op controls | **4** | Auto-restart without warning, update confusion, lack of cancel/pause controls |
+
+## Representative Issues by Pain Point
+
+### 1) Model download & offline reliability (32)
+
+- [#159](https://github.com/jamiepine/voicebox/issues/159) - Qwen download fails with Errno 22
+- [#151](https://github.com/jamiepine/voicebox/issues/151) - Model loading hangs / server crashes
+- [#150](https://github.com/jamiepine/voicebox/issues/150) - Internet required despite downloaded models
+- [#149](https://github.com/jamiepine/voicebox/issues/149) - Cancel/pause controls for large downloads
+- [#96](https://github.com/jamiepine/voicebox/issues/96) - 0.6B selection still uses/downloads 1.7B
+
+### 2) GPU/backend compatibility (22)
+
+- [#164](https://github.com/jamiepine/voicebox/issues/164) - Windows: no GPU usage + multiple breakages
+- [#141](https://github.com/jamiepine/voicebox/issues/141) - Using CPU only, GPU not used
+- [#131](https://github.com/jamiepine/voicebox/issues/131) - Numpy ABI mismatch in bundled app
+- [#130](https://github.com/jamiepine/voicebox/issues/130) - Intel Mac tensor/padding generation error
+- [#127](https://github.com/jamiepine/voicebox/issues/127) - GPU not found
+
+### 3) Export/save/file persistence (15)
+
+- [#148](https://github.com/jamiepine/voicebox/issues/148) - Japanese export fails on 0.1.12
+- [#143](https://github.com/jamiepine/voicebox/issues/143) - Samples not saving
+- [#134](https://github.com/jamiepine/voicebox/issues/134) - Can't save profile
+- [#105](https://github.com/jamiepine/voicebox/issues/105) - Export audio fails (failed to fetch)
+- [#49](https://github.com/jamiepine/voicebox/issues/49) - Export filename/location ignored on Windows
+
+### 4) Language/accent quality & coverage (14)
+
+- [#162](https://github.com/jamiepine/voicebox/issues/162) - Persian audio request/problem
+- [#117](https://github.com/jamiepine/voicebox/issues/117) - Arabic language support
+- [#113](https://github.com/jamiepine/voicebox/issues/113) - Polish language support
+- [#109](https://github.com/jamiepine/voicebox/issues/109) - Ukrainian support
+- [#100](https://github.com/jamiepine/voicebox/issues/100) - Non-US accent quality issues
+
+### 5) Update/restart safety + controls (4)
+
+- [#164](https://github.com/jamiepine/voicebox/issues/164) - Update behavior + usability failures
+- [#136](https://github.com/jamiepine/voicebox/issues/136) - Auto-restart without warning
+- [#86](https://github.com/jamiepine/voicebox/issues/86) - Unexpected restart with no confirmation
+- [#149](https://github.com/jamiepine/voicebox/issues/149) - Need pause/cancel and pre-download confirmation
+
+## Additional Signal
+
+- There is also a large **feature-request/misc** bucket (**36 open**) that is competing with stability triage (audiobook, Linux build, additional ASR/TTS models, integrations).
+
+## Takeaway
+
+Most user pain is concentrated in four stability areas: **download/offline path**, **GPU/backend detection**, **save/export reliability**, and **language/accent correctness**. Addressing those first should reduce the majority of current support friction.
diff --git a/justfile b/justfile
new file mode 100644
index 00000000..796e3ddd
--- /dev/null
+++ b/justfile
@@ -0,0 +1,394 @@
+# 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"
+
+# Platform-aware paths
+venv_bin := if os() == "windows" { venv / "Scripts" } else { venv / "bin" }
+python := if os() == "windows" { venv_bin / "python.exe" } else { venv_bin / "python" }
+pip := if os() == "windows" { venv_bin / "pip.exe" } else { venv_bin / "pip" }
+
+# Shell selection: use powershell on Windows, bash elsewhere
+set windows-shell := ["powershell", "-NoProfile", "-Command"]
+
+# Detect best python for venv creation (platform-aware)
+system_python := if os() == "windows" { "python" } else { `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
+[unix]
+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
+ {{ pip }} install pyinstaller ruff pytest pytest-asyncio -q
+ echo "Python environment ready."
+
+[windows]
+setup-python:
+ if (-not (Test-Path "{{ venv }}")) { \
+ Write-Host "Creating Python virtual environment..."; \
+ $pyMinor = & {{ system_python }} -c "import sys; print(sys.version_info[1])"; \
+ if ([int]$pyMinor -gt 13) { \
+ Write-Host "Warning: Python 3.$pyMinor detected. ML packages may not be compatible."; \
+ }; \
+ & {{ system_python }} -m venv {{ venv }}; \
+ }
+ Write-Host "Installing Python dependencies..."
+ & "{{ python }}" -m pip install --upgrade pip -q
+ $hasNvidia = $null -ne (Get-WmiObject Win32_VideoController | Where-Object { $_.Name -match 'NVIDIA' })
+ if ($hasNvidia) { \
+ Write-Host "NVIDIA GPU detected — installing PyTorch with CUDA support..."; \
+ & "{{ pip }}" install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu126; \
+ }
+ & "{{ pip }}" install -r {{ backend_dir }}/requirements.txt
+ & "{{ pip }}" install --no-deps chatterbox-tts
+ & "{{ pip }}" install git+https://github.com/QwenLM/Qwen3-TTS.git
+ & "{{ pip }}" install pyinstaller ruff pytest pytest-asyncio -q
+ Write-Host "Python environment ready."
+
+# Install JavaScript dependencies
+setup-js:
+ bun install
+
+# ─── Development ──────────────────────────────────────────────────────
+
+# Start backend (if not already running) + frontend for development
+[unix]
+dev: _ensure-venv _ensure-sidecar
+ #!/usr/bin/env bash
+ set -euo pipefail
+
+ backend_pid=""
+ if curl -sf http://127.0.0.1:17493/health > /dev/null 2>&1; then
+ echo "Backend already running on http://localhost:17493"
+ else
+ echo "Starting backend on http://localhost:17493 ..."
+ {{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 &
+ backend_pid=$!
+ sleep 2
+ fi
+
+ trap '[ -n "$backend_pid" ] && kill "$backend_pid" 2>/dev/null; wait' EXIT
+
+ echo "Starting Tauri desktop app..."
+ cd {{ tauri_dir }} && bun run tauri dev
+
+[windows]
+dev: _ensure-venv _ensure-sidecar
+ $backendJob = $null; \
+ try { $null = Invoke-WebRequest -Uri "http://127.0.0.1:17493/health" -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop; Write-Host "Backend already running on http://localhost:17493" } catch { \
+ Write-Host "Starting backend on http://localhost:17493 ..."; \
+ $backendJob = Start-Process -PassThru -NoNewWindow -FilePath "{{ python }}" -ArgumentList "-m","uvicorn","backend.main:app","--reload","--port","17493"; \
+ Start-Sleep -Seconds 2; \
+ }; \
+ Write-Host "Starting Tauri desktop app..."; \
+ try { Set-Location "{{ tauri_dir }}"; bun run tauri dev } finally { if ($backendJob) { taskkill /PID $backendJob.Id /T /F 2>$null | Out-Null } }
+
+# Start backend only
+[unix]
+dev-backend: _ensure-venv
+ {{ venv_bin }}/uvicorn backend.main:app --reload --port 17493
+
+[windows]
+dev-backend: _ensure-venv
+ & "{{ python }}" -m uvicorn backend.main:app --reload --port 17493
+
+# Start Tauri desktop app only (backend must be running separately)
+[unix]
+dev-frontend: _ensure-sidecar
+ cd {{ tauri_dir }} && bun run tauri dev
+
+[windows]
+dev-frontend: _ensure-sidecar
+ Set-Location "{{ tauri_dir }}"; bun run tauri dev
+
+# Start backend (if not already running) + web app (no Tauri)
+[unix]
+dev-web: _ensure-venv
+ #!/usr/bin/env bash
+ set -euo pipefail
+
+ backend_pid=""
+ if curl -sf http://127.0.0.1:17493/health > /dev/null 2>&1; then
+ echo "Backend already running on http://localhost:17493"
+ else
+ echo "Starting backend on http://localhost:17493 ..."
+ {{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 &
+ backend_pid=$!
+ sleep 2
+ fi
+
+ trap '[ -n "$backend_pid" ] && kill "$backend_pid" 2>/dev/null; wait' EXIT
+
+ cd {{ web_dir }} && bun run dev
+
+[windows]
+dev-web: _ensure-venv
+ $backendJob = $null; \
+ try { $null = Invoke-WebRequest -Uri "http://127.0.0.1:17493/health" -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop; Write-Host "Backend already running on http://localhost:17493" } catch { \
+ Write-Host "Starting backend on http://localhost:17493 ..."; \
+ $backendJob = Start-Process -PassThru -NoNewWindow -FilePath "{{ python }}" -ArgumentList "-m","uvicorn","backend.main:app","--reload","--port","17493"; \
+ Start-Sleep -Seconds 2; \
+ }; \
+ Write-Host "Starting web app..."; \
+ try { Set-Location "{{ web_dir }}"; bun run dev } finally { if ($backendJob) { taskkill /PID $backendJob.Id /T /F 2>$null | Out-Null } }
+
+# Kill all dev processes
+[unix]
+kill:
+ -pkill -f "uvicorn backend.main:app" 2>/dev/null || true
+ -pkill -f "vite" 2>/dev/null || true
+ @echo "Dev processes killed."
+
+[windows]
+kill:
+ Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -like '*uvicorn*backend.main*' -or $_.CommandLine -like '*vite*' } | Stop-Process -Force -ErrorAction SilentlyContinue
+ Write-Host "Dev processes killed."
+
+# ─── Build ────────────────────────────────────────────────────────────
+
+# Build everything (server binary + desktop app)
+build: build-server build-tauri
+
+# Build Python server binary (CPU)
+[unix]
+build-server: _ensure-venv
+ PATH="{{ venv_bin }}:$PATH" ./scripts/build-server.sh
+
+[windows]
+build-server: _ensure-venv
+ $ErrorActionPreference = "Stop"; \
+ $env:PATH = "{{ venv_bin }};$env:PATH"; \
+ & "{{ python }}" backend/build_binary.py; \
+ if ($LASTEXITCODE -ne 0) { throw "build_binary.py failed with exit code $LASTEXITCODE" }; \
+ $triple = (rustc --print host-tuple); \
+ New-Item -ItemType Directory -Path "{{ tauri_dir }}/src-tauri/binaries" -Force | Out-Null; \
+ Copy-Item "backend/dist/voicebox-server.exe" "{{ tauri_dir }}/src-tauri/binaries/voicebox-server-$triple.exe" -Force; \
+ Write-Host "Copied sidecar: voicebox-server-$triple.exe"
+
+# Build CUDA server binary and place in app data dir for local testing
+[windows]
+build-server-cuda: _ensure-venv
+ $ErrorActionPreference = "Stop"; \
+ $env:PATH = "{{ venv_bin }};$env:PATH"; \
+ & "{{ python }}" backend/build_binary.py --cuda; \
+ if ($LASTEXITCODE -ne 0) { throw "build_binary.py --cuda failed with exit code $LASTEXITCODE" }; \
+ $dest = "$env:APPDATA/com.voicebox.app/backends"; \
+ New-Item -ItemType Directory -Path $dest -Force | Out-Null; \
+ Copy-Item "backend/dist/voicebox-server-cuda.exe" "$dest/voicebox-server-cuda.exe" -Force; \
+ Write-Host "Copied CUDA binary to $dest"
+
+# Build everything locally: CPU server + CUDA server + installable Tauri app
+[windows]
+build-local: build-server build-server-cuda build-tauri
+
+# Build Tauri desktop app
+[unix]
+build-tauri:
+ cd {{ tauri_dir }} && bun run tauri build
+
+[windows]
+build-tauri:
+ Set-Location "{{ tauri_dir }}"; bun run tauri build
+
+# Build web app
+[unix]
+build-web:
+ cd {{ web_dir }} && bun run build
+
+[windows]
+build-web:
+ Set-Location "{{ web_dir }}"; bun run build
+
+# ─── Code Quality ────────────────────────────────────────────────────
+
+# Run all checks (JS + Python lint + format)
+check: check-js check-python
+
+# JS/TS: lint + format + typecheck (Biome)
+check-js:
+ bun run check
+
+# Python: lint + format check (ruff)
+check-python: _ensure-venv
+ {{ venv_bin }}/ruff check {{ backend_dir }}
+ {{ venv_bin }}/ruff format --check {{ backend_dir }}
+
+# Lint with Biome (JS) + ruff (Python)
+lint: _ensure-venv
+ bun run lint
+ {{ venv_bin }}/ruff check {{ backend_dir }}
+
+# Format with Biome (JS) + ruff (Python)
+format: _ensure-venv
+ bun run format
+ {{ venv_bin }}/ruff format {{ backend_dir }}
+
+# Fix lint + format issues (JS + Python)
+fix: _ensure-venv
+ bun run check:fix
+ {{ venv_bin }}/ruff check {{ backend_dir }} --fix
+ {{ venv_bin }}/ruff format {{ backend_dir }}
+
+# Python lint only
+lint-python: _ensure-venv
+ {{ venv_bin }}/ruff check {{ backend_dir }}
+
+# Python format only
+format-python: _ensure-venv
+ {{ venv_bin }}/ruff format {{ backend_dir }}
+
+# Python auto-fix lint issues
+fix-python: _ensure-venv
+ {{ venv_bin }}/ruff check {{ backend_dir }} --fix
+ {{ venv_bin }}/ruff format {{ backend_dir }}
+
+# Run Python tests
+test: _ensure-venv
+ {{ venv_bin }}/python -m pytest {{ backend_dir }}/tests -v
+
+# ─── Database ─────────────────────────────────────────────────────────
+
+# Initialize SQLite database
+[unix]
+db-init: _ensure-venv
+ {{ python }} -c "from backend.database import init_db; init_db()"
+
+[windows]
+db-init: _ensure-venv
+ & "{{ python }}" -c "from backend.database import init_db; init_db()"
+
+# Reset database (delete + reinit)
+[unix]
+db-reset:
+ rm -f {{ backend_dir }}/data/voicebox.db
+ just db-init
+
+[windows]
+db-reset:
+ if (Test-Path "{{ backend_dir }}/data/voicebox.db") { Remove-Item -Force "{{ backend_dir }}/data/voicebox.db" }
+ just db-init
+
+# ─── Utilities ────────────────────────────────────────────────────────
+
+# Generate TypeScript API client (backend must be running)
+[unix]
+generate-api:
+ ./scripts/generate-api.sh
+
+[windows]
+generate-api:
+ bash scripts/generate-api.sh
+
+# Open API docs in browser
+[unix]
+docs:
+ open http://localhost:17493/docs 2>/dev/null || xdg-open http://localhost:17493/docs
+
+[windows]
+docs:
+ Start-Process "http://localhost:17493/docs"
+
+# Tail backend logs
+[unix]
+logs:
+ tail -f {{ backend_dir }}/logs/*.log 2>/dev/null || echo "No log files found"
+
+[windows]
+logs:
+ Get-ChildItem {{ backend_dir }}/logs/*.log -ErrorAction SilentlyContinue | ForEach-Object { Get-Content $_.FullName -Tail 50 -Wait } ; if (-not $?) { Write-Host "No log files found" }
+
+# ─── Clean ────────────────────────────────────────────────────────────
+
+# Clean build artifacts
+[unix]
+clean:
+ rm -rf {{ tauri_dir }}/src-tauri/target/release
+ rm -rf {{ web_dir }}/dist
+ rm -rf {{ app_dir }}/dist
+
+[windows]
+clean:
+ if (Test-Path "{{ tauri_dir }}/src-tauri/target/release") { Remove-Item -Recurse -Force "{{ tauri_dir }}/src-tauri/target/release" }
+ if (Test-Path "{{ web_dir }}/dist") { Remove-Item -Recurse -Force "{{ web_dir }}/dist" }
+ if (Test-Path "{{ app_dir }}/dist") { Remove-Item -Recurse -Force "{{ app_dir }}/dist" }
+
+# Clean Python venv and cache
+[unix]
+clean-python:
+ rm -rf {{ venv }}
+ find {{ backend_dir }} -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
+
+[windows]
+clean-python:
+ if (Test-Path "{{ venv }}") { Remove-Item -Recurse -Force "{{ venv }}" }
+ Get-ChildItem -Path "{{ backend_dir }}" -Directory -Recurse -Filter "__pycache__" -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force
+
+# Nuclear clean (everything including node_modules)
+[unix]
+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
+
+[windows]
+clean-all: clean clean-python
+ if (Test-Path "node_modules") { Remove-Item -Recurse -Force "node_modules" }
+ if (Test-Path "{{ app_dir }}/node_modules") { Remove-Item -Recurse -Force "{{ app_dir }}/node_modules" }
+ if (Test-Path "{{ tauri_dir }}/node_modules") { Remove-Item -Recurse -Force "{{ tauri_dir }}/node_modules" }
+ if (Test-Path "{{ web_dir }}/node_modules") { Remove-Item -Recurse -Force "{{ web_dir }}/node_modules" }
+ Push-Location "{{ tauri_dir }}/src-tauri"; cargo clean; Pop-Location
+
+# ─── Internal ─────────────────────────────────────────────────────────
+
+# Ensure venv exists (prompt to run setup if not)
+[private, unix]
+_ensure-venv:
+ #!/usr/bin/env bash
+ if [ ! -d "{{ venv }}" ]; then
+ echo "Python venv not found. Run: just setup"
+ exit 1
+ fi
+
+[private, windows]
+_ensure-venv:
+ if (-not (Test-Path "{{ venv }}")) { Write-Host "Python venv not found. Run: just setup"; exit 1 }
+
+# Ensure Tauri dev sidecar placeholder exists
+[private]
+_ensure-sidecar:
+ bun run setup:dev
diff --git a/landing/package.json b/landing/package.json
index 655e57a6..b9c1b554 100644
--- a/landing/package.json
+++ b/landing/package.json
@@ -1,6 +1,6 @@
{
"name": "@voicebox/landing",
- "version": "0.1.12",
+ "version": "0.2.3",
"description": "Landing page for voicebox.sh",
"scripts": {
"dev": "bun --bun next dev --turbo",
@@ -9,11 +9,13 @@
"lint": "next lint"
},
"dependencies": {
+ "@fontsource/space-grotesk": "^5.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"autoprefixer": "^10.4.17",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "framer-motion": "^12.36.0",
"lucide-react": "^0.316.0",
"next": "^16.1.3",
"postcss": "^8.4.33",
@@ -21,7 +23,8 @@
"react-dom": "^18.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^3.4.1",
- "tailwindcss-animate": "^1.0.7"
+ "tailwindcss-animate": "^1.0.7",
+ "wavesurfer.js": "^7.12.2"
},
"devDependencies": {
"@types/node": "^20.11.5",
diff --git a/landing/public/audio/fireship.webm b/landing/public/audio/fireship.webm
new file mode 100644
index 00000000..0561ea18
Binary files /dev/null and b/landing/public/audio/fireship.webm differ
diff --git a/landing/public/audio/jarvis.webm b/landing/public/audio/jarvis.webm
new file mode 100644
index 00000000..4eefdbe8
Binary files /dev/null and b/landing/public/audio/jarvis.webm differ
diff --git a/landing/public/audio/linus.webm b/landing/public/audio/linus.webm
new file mode 100644
index 00000000..63b7ad03
Binary files /dev/null and b/landing/public/audio/linus.webm differ
diff --git a/landing/public/audio/morganfreeman.webm b/landing/public/audio/morganfreeman.webm
new file mode 100644
index 00000000..eb5cddd4
Binary files /dev/null and b/landing/public/audio/morganfreeman.webm differ
diff --git a/landing/public/audio/samaltman.webm b/landing/public/audio/samaltman.webm
new file mode 100644
index 00000000..7400bece
Binary files /dev/null and b/landing/public/audio/samaltman.webm differ
diff --git a/landing/public/audio/samjackson.webm b/landing/public/audio/samjackson.webm
new file mode 100644
index 00000000..02f43a96
Binary files /dev/null and b/landing/public/audio/samjackson.webm differ
diff --git a/landing/public/voicebox-logo-app.webp b/landing/public/voicebox-logo-app.webp
new file mode 100644
index 00000000..9fedd439
Binary files /dev/null and b/landing/public/voicebox-logo-app.webp differ
diff --git a/landing/src/app/api/releases/route.ts b/landing/src/app/api/releases/route.ts
index 60689465..cf10309d 100644
--- a/landing/src/app/api/releases/route.ts
+++ b/landing/src/app/api/releases/route.ts
@@ -2,7 +2,6 @@ import { NextResponse } from 'next/server';
import { getLatestRelease } from '@/lib/releases';
export const dynamic = 'force-dynamic';
-export const revalidate = 600; // Revalidate every 10 minutes
export async function GET() {
try {
diff --git a/landing/src/app/api/stars/route.ts b/landing/src/app/api/stars/route.ts
new file mode 100644
index 00000000..bc8f73c5
--- /dev/null
+++ b/landing/src/app/api/stars/route.ts
@@ -0,0 +1,15 @@
+import { NextResponse } from 'next/server';
+import { getStarCount } from '@/lib/releases';
+
+export const dynamic = 'force-dynamic';
+export const revalidate = 600;
+
+export async function GET() {
+ try {
+ const count = await getStarCount();
+ return NextResponse.json({ count });
+ } catch (error) {
+ console.error('Error fetching star count:', error);
+ return NextResponse.json({ error: 'Failed to fetch star count' }, { status: 500 });
+ }
+}
diff --git a/landing/src/app/download/[platform]/route.ts b/landing/src/app/download/[platform]/route.ts
new file mode 100644
index 00000000..af76c315
--- /dev/null
+++ b/landing/src/app/download/[platform]/route.ts
@@ -0,0 +1,42 @@
+import { type NextRequest, NextResponse } from 'next/server';
+import { getLatestRelease } from '@/lib/releases';
+
+export const dynamic = 'force-dynamic';
+
+const PLATFORM_MAP: Record<
+ string,
+ keyof Awaited>['downloadLinks']
+> = {
+ 'mac-arm': 'macArm',
+ 'mac-intel': 'macIntel',
+ windows: 'windows',
+ linux: 'linux',
+};
+
+export async function GET(
+ _request: NextRequest,
+ { params }: { params: Promise<{ platform: string }> },
+) {
+ const { platform } = await params;
+ const key = PLATFORM_MAP[platform];
+
+ if (!key) {
+ return NextResponse.json(
+ { error: `Unknown platform: ${platform}. Use: ${Object.keys(PLATFORM_MAP).join(', ')}` },
+ { status: 404 },
+ );
+ }
+
+ try {
+ const release = await getLatestRelease();
+ const url = release.downloadLinks[key];
+
+ if (!url) {
+ return NextResponse.json({ error: `No download available for ${platform}` }, { status: 404 });
+ }
+
+ return NextResponse.redirect(url);
+ } catch {
+ return NextResponse.redirect(`https://github.com/jamiepine/voicebox/releases/latest`);
+ }
+}
diff --git a/landing/src/app/globals.css b/landing/src/app/globals.css
index da5b7b99..d83a2060 100644
--- a/landing/src/app/globals.css
+++ b/landing/src/app/globals.css
@@ -16,7 +16,7 @@
--secondary-foreground: 0 0% 0%;
--muted: 0 0% 96%;
--muted-foreground: 0 0% 45%;
- --accent: 0 0% 96%;
+ --accent: 43 50% 50%;
--accent-foreground: 0 0% 0%;
--destructive: 0 0% 0%;
--destructive-foreground: 0 0% 100%;
@@ -27,26 +27,52 @@
}
.dark {
- --background: 0 0% 3%;
- --foreground: 0 0% 98%;
- --card: 0 0% 8% / 0.6;
- --card-foreground: 0 0% 98%;
- --popover: 0 0% 8% / 0.8;
- --popover-foreground: 0 0% 98%;
- --primary: 0 0% 98%;
- --primary-foreground: 0 0% 8%;
- --secondary: 0 0% 12% / 0.5;
- --secondary-foreground: 0 0% 98%;
- --muted: 0 0% 12% / 0.4;
- --muted-foreground: 0 0% 65%;
- --accent: 0 0% 15% / 0.5;
- --accent-foreground: 0 0% 98%;
+ /* Surfaces -- slightly warm-tinted darks */
+ --background: 30 4% 4%;
+ --foreground: 30 10% 94%;
+ --card: 30 4% 7%;
+ --card-foreground: 30 10% 94%;
+ --popover: 30 4% 7%;
+ --popover-foreground: 30 10% 94%;
+ --primary: 30 10% 94%;
+ --primary-foreground: 30 4% 7%;
+ --secondary: 30 4% 10%;
+ --secondary-foreground: 30 10% 94%;
+ --muted: 30 3% 12%;
+ --muted-foreground: 30 5% 55%;
+ --accent: 43 50% 45%;
+ --accent-foreground: 30 10% 94%;
--destructive: 0 62% 50%;
- --destructive-foreground: 0 0% 98%;
- --border: 0 0% 15% / 0.5;
- --input: 0 0% 15% / 0.5;
- --ring: 0 0% 98% / 0.2;
- --radius: 1rem;
+ --destructive-foreground: 30 10% 94%;
+ --border: 30 4% 13%;
+ --input: 30 4% 13%;
+ --ring: 30 10% 94% / 0.2;
+ --radius: 0.75rem;
+
+ /* App-specific surface tokens */
+ --app: 30 4% 4%;
+ --app-box: 30 4% 7%;
+ --app-dark-box: 30 4% 5%;
+ --app-darker-box: 30 4% 3%;
+ --app-light-box: 30 4% 14%;
+ --app-line: 30 4% 13%;
+ --app-button: 30 4% 11%;
+ --app-hover: 30 4% 15%;
+ --app-selected: 30 4% 17%;
+
+ /* Text hierarchy */
+ --ink: 30 10% 94%;
+ --ink-dull: 30 5% 55%;
+ --ink-faint: 30 3% 38%;
+
+ /* Accent shades */
+ --accent-faint: 43 45% 55%;
+ --accent-deep: 43 55% 35%;
+ --accent-glow: 43 60% 50%;
+
+ /* Sidebar */
+ --sidebar: 30 4% 3%;
+ --sidebar-line: 30 4% 10%;
}
}
@@ -60,9 +86,9 @@
body {
@apply bg-background text-foreground antialiased;
overflow-x: hidden;
- background-image:
- radial-gradient(at 0% 0%, rgba(255, 255, 255, 0.03) 0px, transparent 50%),
- radial-gradient(at 100% 100%, rgba(255, 255, 255, 0.02) 0px, transparent 50%);
+ font-family:
+ ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
+ "Helvetica Neue", Arial, sans-serif;
}
}
@@ -71,3 +97,56 @@
text-wrap: balance;
}
}
+
+/* Staggered fade-in animation for hero elements */
+@keyframes fadeUp {
+ from {
+ opacity: 0;
+ transform: translateY(16px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.fade-in {
+ opacity: 0;
+ animation: fadeUp 0.6s ease-out forwards;
+}
+
+.hero-glow-fade {
+ opacity: 0;
+ animation: fadeIn 2s ease-out 0.3s forwards;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+/* Noise texture overlay for hero glow */
+/* .hero-glow::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: 5;
+ pointer-events: none;
+ background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2048' height='2048'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.5' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E") center / 100% 100% no-repeat;
+ opacity: 0.35;
+ mix-blend-mode: overlay;
+ will-change: transform;
+} */
+
+/* Scrollbar hiding */
+::-webkit-scrollbar {
+ display: none;
+}
+
+* {
+ scrollbar-width: none;
+}
diff --git a/landing/src/app/layout.tsx b/landing/src/app/layout.tsx
index fb9e625a..164b7f71 100644
--- a/landing/src/app/layout.tsx
+++ b/landing/src/app/layout.tsx
@@ -1,16 +1,19 @@
import type { Metadata } from 'next';
-import { Inter } from 'next/font/google';
import './globals.css';
-import { Footer } from '@/components/Footer';
-import { Header } from '@/components/Header';
-
-const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
export const metadata: Metadata = {
- title: 'Voicebox - Open Source Voice Cloning Desktop App Powered by Qwen3-TTS',
+ title: 'Voicebox - Open Source Voice Cloning Desktop App',
description:
- 'Near-perfect voice cloning powered by Qwen3-TTS. Desktop app for Mac, Windows, and Linux. Multi-sample support, smart caching, local or remote inference.',
- keywords: ['voice cloning', 'TTS', 'Qwen3', 'desktop app', 'AI voice'],
+ 'Near-perfect voice cloning with multiple TTS engines. Desktop app for Mac, Windows, and Linux. Multi-sample support, smart caching, local or remote inference.',
+ keywords: [
+ 'voice cloning',
+ 'TTS',
+ 'multi-engine',
+ 'desktop app',
+ 'AI voice',
+ 'open source',
+ 'text to speech',
+ ],
icons: {
icon: [
{ url: '/favicon.png', type: 'image/png' },
@@ -19,8 +22,8 @@ export const metadata: Metadata = {
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
},
openGraph: {
- title: 'voicebox',
- description: 'Professional voice cloning with Qwen3-TTS',
+ title: 'Voicebox',
+ description: 'Open source voice cloning. Local-first. Free forever.',
type: 'website',
url: 'https://voicebox.sh',
},
@@ -29,14 +32,16 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
-
-
-
-
- {children}
-
-
-
+
+
+
+
+
+
+ {children}
);
diff --git a/landing/src/app/linux-install/page.tsx b/landing/src/app/linux-install/page.tsx
new file mode 100644
index 00000000..e603c841
--- /dev/null
+++ b/landing/src/app/linux-install/page.tsx
@@ -0,0 +1,169 @@
+import type { Metadata } from 'next';
+import { Footer } from '@/components/Footer';
+import { Navbar } from '@/components/Navbar';
+import { GITHUB_REPO } from '@/lib/constants';
+
+export const metadata: Metadata = {
+ title: 'Linux Install - Voicebox',
+ description: 'Build Voicebox from source on Linux. Clone, setup, and build in three commands.',
+};
+
+export default function LinuxInstall() {
+ return (
+ <>
+
+
+
+
+
Install on Linux
+
+
+ We're currently working through CI issues that prevent us from shipping a reliable
+ pre-built binary for Linux. In the meantime, building from source is straightforward and
+ takes just a few minutes.
+
+
+
+ {/* Prerequisites */}
+
+
+ {/* Steps */}
+
+
+ Build from source
+
+
+
+
# Clone the repo
+
git clone https://github.com/jamiepine/voicebox.git
+
cd voicebox
+
+
+
+
+ # Install all dependencies (Python venv, JS deps, etc.)
+
+
just setup
+
+
+
+
# Build the app
+
just build
+
+
+
+
+ The built app will be in{' '}
+
+ tauri/src-tauri/target/release/bundle/
+
+
+
+
+ {/* Dev mode */}
+
+
+ Or run in dev mode
+
+
+
+ # Start the dev server with hot reload
+
+
just dev
+
+
+
+
+ {/* Links */}
+
+
+
+
+
+ >
+ );
+}
diff --git a/landing/src/app/page.tsx b/landing/src/app/page.tsx
index 4ba9d8c4..e1641d8a 100644
--- a/landing/src/app/page.tsx
+++ b/landing/src/app/page.tsx
@@ -1,304 +1,327 @@
'use client';
-import { Cloud, Code, Cpu, Github, Shield, Zap } from 'lucide-react';
-import Image from 'next/image';
+import { Github, Globe, Languages, MessageSquare, Zap } from 'lucide-react';
import { useEffect, useState } from 'react';
+import { ControlUI } from '@/components/ControlUI';
+import { Features } from '@/components/Features';
+import { Footer } from '@/components/Footer';
+import { Navbar } from '@/components/Navbar';
import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/PlatformIcons';
-import { Button } from '@/components/ui/button';
-import { Section } from '@/components/ui/section';
+import { VoiceCreator } from '@/components/VoiceCreator';
import { DOWNLOAD_LINKS, GITHUB_REPO } from '@/lib/constants';
import type { DownloadLinks } from '@/lib/releases';
-import { FeatureCard } from '../components/ui/feature-card';
export default function Home() {
const [downloadLinks, setDownloadLinks] = useState(DOWNLOAD_LINKS);
+ const [version, setVersion] = useState(null);
+ const [totalDownloads, setTotalDownloads] = useState(null);
useEffect(() => {
- // Fetch latest release info
fetch('/api/releases')
.then((res) => {
- if (!res.ok) {
- throw new Error('Failed to fetch releases');
- }
+ if (!res.ok) throw new Error('Failed to fetch releases');
return res.json();
})
.then((data) => {
- if (data.downloadLinks) {
- setDownloadLinks(data.downloadLinks);
- }
+ if (data.downloadLinks) setDownloadLinks(data.downloadLinks);
+ if (data.version) setVersion(data.version);
+ if (data.totalDownloads != null) setTotalDownloads(data.totalDownloads);
})
.catch((error) => {
console.error('Failed to fetch release info:', error);
- // Keep fallback links (releases page) on error
});
}, []);
- const features = [
- {
- title: 'Near-Perfect Voice Cloning',
- description:
- "Powered by Alibaba's Qwen3-TTS model for exceptional voice quality and accuracy.",
- icon: ,
- },
- {
- title: 'Stories Editor',
- description:
- 'Create multi-voice narratives with a timeline-based editor. Arrange tracks, trim clips, and mix conversations.',
- icon: ,
- },
- {
- title: 'Multi-Sample Support',
- description:
- 'Combine multiple voice samples for higher quality and more natural-sounding results.',
- icon: ,
- },
-
- {
- title: 'Local or Remote',
- description:
- 'Run GPU inference locally or connect to a remote machine. One-click server setup.',
- icon: ,
- },
- {
- title: 'Audio Transcription',
- description:
- 'Powered by Whisper for accurate speech-to-text. Extract reference text from voice samples automatically.',
- icon: ,
- },
- {
- title: 'Cross-Platform',
- description: 'Available for macOS, Windows, and Linux. No Python installation required.',
- icon: ,
- },
- ];
return (
-
- {/* Hero Section */}
-
-
-
- {/* Left side - Content */}
-
-
-
-
-
- Voicebox
-
-
- Open source voice cloning powered by Qwen3-TTS. Create natural-sounding speech from
- text with near-perfect voice replication.
-
+ <>
+
- {/* Mobile: centered screenshot above download buttons */}
-
-
- {/* Download buttons under left content */}
-
-
-
- {/* Desktop: Large screenshot positioned off-screen */}
-
-
+ {/* ── Hero Section ─────────────────────────────────────────────── */}
+
+ {/* Background glow */}
+
-
- {/* Screenshots Section */}
-
-
- {/* Description Section */}
-
-
-
- What is Voicebox?
-
-
-
- Voicebox is a local-first voice cloning studio with DAW-like features
- for professional voice synthesis. Think of it as a{' '}
- local, free and open-source alternative to ElevenLabs — download
- models, clone voices, and generate speech entirely on your machine.
-
-
- Unlike cloud services that lock your voice data behind subscriptions, Voicebox gives
- you complete privacy, professional tools, and native performance. Download a voice
- model, clone any voice from a few seconds of audio, and compose multi-voice projects
- with studio-grade editing tools.
-
-
- Optimized for performance with Metal acceleration on Mac and{' '}
- CUDA acceleration on Windows/Linux for fast, local inference.
-
-
No Python install required.
-
-
-
-
- {/* Demo Video Section */}
-
-
-
- See it in action...
-
-
-
- {/** biome-ignore lint/a11y/useMediaCaption: not generating captions for this, ya damn linter */}
-
-
- Your browser does not support the video tag.
-
-
-
-
-
-
- {/* Features Section */}
-
-
- {features.map((feature) => (
-
+ {/* Logo */}
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
- ))}
+
+
+ {/* Headline */}
+
+
+ Your voice, your machine.
+
+
+
+ {/* Subtitle */}
+
+ Open source voice cloning studio with support for multiple TTS engines. Clone any voice,
+ generate natural speech, and compose multi-voice projects — all running locally.
+
+
+ {/* CTAs */}
+
+
+ {/* Version + downloads */}
+
+ {version ?? ''}
+ {version && totalDownloads != null ? ' \u00b7 ' : ''}
+ {totalDownloads != null ? `${totalDownloads.toLocaleString()} downloads` : ''}
+ {version || totalDownloads != null ? ' \u00b7 ' : ''}
+ macOS, Windows, Linux
+
-
-
+
+ {/* ── ControlUI mockup ─────────────────────────────────────── */}
+
+
+
+
+
+ {/* ── Features ─────────────────────────────────────────────── */}
+
+
+ {/* ── Voice Creator ────────────────────────────────────────── */}
+
+
+ {/* ── Models ─────────────────────────────────────────────────── */}
+
+
+
+
+ Multi-Engine Architecture
+
+
+ Choose the right model for every job. All models run locally on your hardware —
+ download once, use forever.
+
+
+
+
+ {/* Qwen3-TTS */}
+
+
+
+
Qwen3-TTS
+ by Alibaba
+
+
+
+ 1.7B
+
+
+ 0.6B
+
+
+
+
+ High-quality multilingual voice cloning with natural prosody. The only engine with
+ delivery instructions — control tone, pace, and emotion with natural language.
+
+
+
+
+ 10 languages
+
+
+
+ Delivery instructions
+
+
+
+
+ {/* Chatterbox */}
+
+
+
+
Chatterbox
+ by Resemble AI
+
+
+
+ Production-grade voice cloning with the broadest language support. 23 languages with
+ zero-shot cloning and emotion exaggeration control.
+
+
+
+
+ 23 languages
+
+
+
+
+ {/* Chatterbox Turbo */}
+
+
+
+
Chatterbox Turbo
+ by Resemble AI
+
+
+ 350M
+
+
+
+ Lightweight and fast. Supports paralinguistic tags — embed [laugh], [sigh], [gasp]
+ and more directly in your text for expressive, natural speech.
+
+
+
+
+ 350M params
+
+
+
+ [laugh] [sigh] tags
+
+
+
+
+ {/* LuxTTS */}
+
+
+
+
LuxTTS
+ by ZipVoice
+
+
+
+ Ultra-fast, CPU-friendly voice cloning at 48kHz. Exceeds 150x realtime on CPU with
+ ~1GB VRAM. The fastest engine for quick iterations.
+
+
+
+
+ 150x realtime
+
+
+ 48kHz output
+
+
+
+
+
+
+
+ {/* ── Download Section ─────────────────────────────────────── */}
+
+
+
+
+ Download Voicebox
+
+
+ Available for macOS, Windows, and Linux. No dependencies required.
+
+
+
+
+
+ {/* GitHub link */}
+
+
+
+
+ {/* ── Footer ───────────────────────────────────────────────── */}
+
+ >
);
}
diff --git a/landing/src/components/Banner.tsx b/landing/src/components/Banner.tsx
new file mode 100644
index 00000000..f3aee3f2
--- /dev/null
+++ b/landing/src/components/Banner.tsx
@@ -0,0 +1,25 @@
+import { ArrowRight } from 'lucide-react';
+
+export function Banner() {
+ return (
+
+ );
+}
diff --git a/landing/src/components/ControlUI.tsx b/landing/src/components/ControlUI.tsx
new file mode 100644
index 00000000..dcef66ac
--- /dev/null
+++ b/landing/src/components/ControlUI.tsx
@@ -0,0 +1,889 @@
+'use client';
+
+import { motion } from 'framer-motion';
+import {
+ AudioLines,
+ Box,
+ Download,
+ Mic,
+ MoreHorizontal,
+ Pencil,
+ Server,
+ Sparkles,
+ Speaker,
+ Star,
+ Trash2,
+ Volume2,
+ Wand2,
+} from 'lucide-react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { LandingAudioPlayer, unlockAudioContext } from './LandingAudioPlayer';
+
+// ─── Data ───────────────────────────────────────────────────────────────────
+// Edit this section to customise all the content shown in the ControlUI demo.
+
+interface VoiceProfile {
+ name: string;
+ description: string;
+ language: string;
+ hasEffects: boolean;
+}
+
+/** Voice profiles shown in the grid / scroll strip. Index matters — DemoScript references profiles by index. */
+const PROFILES: VoiceProfile[] = [
+ {
+ name: 'Jarvis',
+ description: 'Dry wit, composed British AI assistant',
+ language: 'en',
+ hasEffects: true,
+ },
+ {
+ name: 'Samuel L. Jackson',
+ description: 'Commanding intensity with sharp, punchy delivery',
+ language: 'en',
+ hasEffects: true,
+ },
+ {
+ name: 'Bob Ross',
+ description: 'Gentle, soothing voice full of quiet encouragement',
+ language: 'en',
+ hasEffects: false,
+ },
+ {
+ name: 'Sam Altman',
+ description: 'Measured, thoughtful Silicon Valley cadence',
+ language: 'en',
+ hasEffects: false,
+ },
+ {
+ name: 'Morgan Freeman',
+ description: 'Rich, warm baritone with gravitas and calm authority',
+ language: 'en',
+ hasEffects: false,
+ },
+ {
+ name: 'Linus Tech Tips',
+ description: 'Enthusiastic, fast-paced tech explainer energy',
+ language: 'en',
+ hasEffects: false,
+ },
+ {
+ name: 'Fireship',
+ description: 'Rapid-fire, deadpan tech humor with zero filler',
+ language: 'en',
+ hasEffects: false,
+ },
+ {
+ name: 'Scarlett Johansson',
+ description: 'Smooth, low alto with understated warmth',
+ language: 'en',
+ hasEffects: false,
+ },
+ {
+ name: 'Dario Amodei',
+ description: 'Calm, precise articulation with academic depth',
+ language: 'en',
+ hasEffects: false,
+ },
+ {
+ name: 'David Attenborough',
+ description: 'Warm, reverent narration with wonder and precision',
+ language: 'en',
+ hasEffects: false,
+ },
+ {
+ name: 'Zendaya',
+ description: 'Relaxed, modern delivery with effortless cool',
+ language: 'en',
+ hasEffects: false,
+ },
+ {
+ name: 'Barack Obama',
+ description: 'Measured cadence with rhythmic pauses and gravitas',
+ language: 'en',
+ hasEffects: false,
+ },
+];
+
+/** Each entry is one cycle of the demo animation: select a profile → type text → generate → play audio. */
+interface DemoStep {
+ profileIndex: number;
+ text: string;
+ audioUrl: string;
+ engine: string;
+ duration: string;
+ effect?: string;
+}
+
+const DEMO_SCRIPT: DemoStep[] = [
+ {
+ profileIndex: 0,
+ text: 'Sir, I have completed the analysis. Your code has twelve critical vulnerabilities, your coffee is cold, and frankly your commit messages could use some work.',
+ audioUrl: '/audio/jarvis.webm',
+ engine: 'Qwen 1.7B',
+ duration: '0:10',
+ effect: 'Robot',
+ },
+ {
+ profileIndex: 4,
+ text: "I've narrated penguins, galaxies, and the entire history of mankind. But nothing prepared me for the moment a computer learned to do my job from a five second audio clip.",
+ audioUrl: '/audio/morganfreeman.webm',
+ engine: 'Qwen 1.7B',
+ duration: '0:11',
+ effect: 'Radio',
+ },
+ {
+ profileIndex: 3,
+ text: "Open source? [laugh] What's that?",
+ audioUrl: '/audio/samaltman.webm',
+ engine: 'Chatterbox',
+ duration: '0:03',
+ },
+ {
+ profileIndex: 1,
+ text: "So let me get this straight. You downloaded an app, pressed a button, and now there's two of me? The world was not ready for one",
+ audioUrl: '/audio/samjackson.webm',
+ engine: 'Qwen 1.7B',
+ duration: '0:10',
+ },
+ {
+ profileIndex: 5,
+ text: "So we got this voice cloning software and honestly it's kind of terrifying. Like, my wife could not tell the difference. Voicebox dot s h, link in the description!",
+ audioUrl: '/audio/linus.webm',
+ engine: 'Qwen 1.7B',
+ duration: '0:11',
+ },
+ {
+ profileIndex: 6,
+ text: 'This is Voicebox in one hundred seconds. It clones voices locally, it runs on your GPU, and no, OpenAI cannot hear you. Lets go.',
+ audioUrl: '/audio/fireship.webm',
+ engine: 'Qwen 0.6B',
+ duration: '0:09',
+ },
+];
+
+/** History rows pre-populated on first load. Oldest first visually (array index 0 = top row). */
+interface Generation {
+ id: number;
+ profileName: string;
+ text: string;
+ language: string;
+ engine: string;
+ duration: string;
+ timeAgo: string;
+ favorited: boolean;
+ versions: number;
+}
+
+const INITIAL_GENERATIONS: Generation[] = [
+ {
+ id: 1,
+ profileName: 'Morgan Freeman',
+ text: 'The neural pathways of human speech contain more complexity than any language model can fully capture, yet we keep pushing the boundaries of what is possible.',
+ language: 'en',
+ engine: 'Qwen 1.7B',
+ duration: '0:08',
+ timeAgo: '2 minutes ago',
+ favorited: true,
+ versions: 3,
+ },
+ {
+ id: 2,
+ profileName: 'Samuel L. Jackson',
+ text: 'In a world increasingly shaped by artificial intelligence, the human voice remains our most powerful tool for connection and storytelling.',
+ language: 'en',
+ engine: 'Qwen 1.7B',
+ duration: '0:07',
+ timeAgo: '15 minutes ago',
+ favorited: false,
+ versions: 1,
+ },
+ {
+ id: 3,
+ profileName: 'Jarvis',
+ text: 'The architecture of modern text-to-speech systems reveals an elegant interplay between transformer models and acoustic feature prediction.',
+ language: 'en',
+ engine: 'Qwen 0.6B',
+ duration: '0:09',
+ timeAgo: '1 hour ago',
+ favorited: false,
+ versions: 2,
+ },
+ {
+ id: 4,
+ profileName: 'Bob Ross',
+ text: 'Welcome to the next chapter. Every great story begins with a single voice, and today that voice can be yours.',
+ language: 'en',
+ engine: 'Chatterbox',
+ duration: '0:06',
+ timeAgo: '3 hours ago',
+ favorited: true,
+ versions: 1,
+ },
+ {
+ id: 5,
+ profileName: 'Linus Tech Tips',
+ text: 'Local inference gives you complete control over your voice data. No cloud, no subscriptions, no compromises.',
+ language: 'en',
+ engine: 'Qwen 1.7B',
+ duration: '0:05',
+ timeAgo: '5 hours ago',
+ favorited: false,
+ versions: 1,
+ },
+];
+
+const SIDEBAR_ITEMS = [
+ { icon: Volume2, label: 'Generate' },
+ { icon: AudioLines, label: 'Stories' },
+ { icon: Mic, label: 'Voices' },
+ { icon: Wand2, label: 'Effects' },
+ { icon: Speaker, label: 'Audio' },
+ { icon: Box, label: 'Models' },
+ { icon: Server, label: 'Server' },
+];
+
+// ─── Phase system ───────────────────────────────────────────────────────────
+
+type Phase = 'idle' | 'selecting' | 'typing' | 'generating' | 'complete' | 'playing';
+
+const PHASE_DURATIONS: Record
= {
+ idle: 2500,
+ selecting: 800,
+ typing: 6000,
+ generating: 2800,
+ complete: 1200,
+ playing: 4000,
+};
+
+// ─── Typewriter ─────────────────────────────────────────────────────────────
+
+function TypewriterText({ text, speed }: { text: string; speed?: number }) {
+ // Default: fill the typing phase duration, leaving 500ms buffer at the end
+ const resolvedSpeed =
+ speed ?? Math.max(20, Math.floor((PHASE_DURATIONS.typing - 500) / text.length));
+ const [displayed, setDisplayed] = useState('');
+ const indexRef = useRef(0);
+
+ useEffect(() => {
+ indexRef.current = 0;
+ setDisplayed('');
+ const interval = setInterval(() => {
+ indexRef.current += 1;
+ if (indexRef.current <= text.length) {
+ setDisplayed(text.slice(0, indexRef.current));
+ } else {
+ clearInterval(interval);
+ }
+ }, resolvedSpeed);
+ return () => clearInterval(interval);
+ }, [text, resolvedSpeed]);
+
+ return (
+ <>
+ {displayed}
+
+ >
+ );
+}
+
+// ─── Loading bars (simplified react-loaders replacement) ────────────────────
+
+function LoadingBars({ mode }: { mode: 'idle' | 'generating' | 'playing' }) {
+ const barColor = mode !== 'idle' ? 'bg-accent' : 'bg-muted-foreground/40';
+ return (
+
+ {[0, 1, 2, 3, 4].map((i) => (
+
+ ))}
+
+ );
+}
+
+// ─── Profile Card ───────────────────────────────────────────────────────────
+
+const ProfileCard = ({
+ profile,
+ selected,
+ selecting,
+ cardRef,
+}: {
+ profile: VoiceProfile;
+ selected: boolean;
+ selecting: boolean;
+ cardRef?: React.Ref;
+}) => {
+ return (
+
+ {profile.name}
+
+ {profile.description}
+
+
+
+ {profile.language}
+
+ {profile.hasEffects && }
+
+
+
+ );
+};
+
+// ─── History Row ────────────────────────────────────────────────────────────
+
+function HistoryRow({
+ gen,
+ mode,
+ isNew,
+}: {
+ gen: Generation;
+ mode: 'idle' | 'generating' | 'playing';
+ isNew: boolean;
+}) {
+ return (
+
+
+ {/* Status icon */}
+
+
+
+
+ {/* Meta info */}
+
+
{gen.profileName}
+
+ {gen.language}
+ {gen.engine}
+ {mode !== 'generating' && {gen.duration} }
+
+
+ {mode === 'generating' ? (
+ Generating...
+ ) : (
+ gen.timeAgo
+ )}
+
+
+
+ {/* Transcript */}
+
+
+ {/* Action buttons */}
+
+
+
+
+ {gen.versions > 1 && (
+
+
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+// ─── Floating Generate Box ──────────────────────────────────────────────────
+
+function FloatingGenerateBox({
+ phase,
+ typingText,
+ selectedProfile,
+ engine,
+ effect,
+}: {
+ phase: Phase;
+ typingText: string;
+ selectedProfile: VoiceProfile | null;
+ engine: string;
+ effect?: string;
+}) {
+ const isFocused = phase === 'typing' || phase === 'generating';
+ const isGenerating = phase === 'generating';
+
+ return (
+
+ {/* Text area + generate button */}
+
+
+
+
+ {phase === 'typing' ? (
+
+
+
+ ) : phase === 'generating' ? (
+ {typingText}
+ ) : (
+
+ {selectedProfile
+ ? `Generate speech using ${selectedProfile.name}...`
+ : 'Select a voice profile above...'}
+
+ )}
+
+
+
+
+ {/* Generate button */}
+
+
+
+
+
+ {/* Bottom selectors */}
+
+
+ English
+
+
+ {engine}
+
+
+
+ {effect || 'Effect'}
+
+
+
+ );
+}
+
+// ─── Main ControlUI ─────────────────────────────────────────────────────────
+
+export function ControlUI() {
+ const [phase, setPhase] = useState('idle');
+ const [selectedIndex, setSelectedIndex] = useState(DEMO_SCRIPT[0].profileIndex);
+ const [cycle, setCycle] = useState(0);
+ const [newGenId, setNewGenId] = useState(null);
+ const [generations, setGenerations] = useState([...INITIAL_GENERATIONS]);
+ const [isMuted, setIsMuted] = useState(true);
+ const [isVisible, setIsVisible] = useState(true);
+ const [pageHidden, setPageHidden] = useState(false);
+ const containerRef = useRef(null);
+ const phaseRef = useRef(phase);
+ const mobileCardRefs = useRef>(new Map());
+ const desktopCardRefs = useRef>(new Map());
+ const profileGridRef = useRef(null);
+ const [scrollLeft, setScrollLeft] = useState(0);
+ phaseRef.current = phase;
+
+ const step = DEMO_SCRIPT[cycle % DEMO_SCRIPT.length];
+ const selectedProfile = PROFILES[selectedIndex];
+
+ // Scroll to selected profile card — accounts for generate box overlay on desktop
+ useEffect(() => {
+ const isMobile = window.innerWidth < 768;
+
+ if (isMobile) {
+ const el = mobileCardRefs.current.get(selectedIndex);
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
+ return;
+ }
+
+ // Desktop
+ const el = desktopCardRefs.current.get(selectedIndex);
+ const scrollContainer = profileGridRef.current;
+ if (!el || !scrollContainer) return;
+
+ const containerTop = scrollContainer.getBoundingClientRect().top;
+ const elTop = el.getBoundingClientRect().top;
+ const elRelTop = elTop - containerTop + scrollContainer.scrollTop;
+
+ const rowHeight = 145;
+ const generateBoxHeight = 200;
+ const visibleTop = scrollContainer.scrollTop;
+ const visibleBottom = visibleTop + scrollContainer.clientHeight - generateBoxHeight;
+ const elRelBottom = elRelTop + el.offsetHeight;
+
+ if (elRelTop >= visibleTop && elRelBottom <= visibleBottom) {
+ return;
+ }
+
+ const target = elRelTop - rowHeight;
+ scrollContainer.scrollTo({ top: Math.max(0, target), behavior: 'smooth' });
+ }, [selectedIndex]);
+
+ // Visibility detection
+ useEffect(() => {
+ const observer = new IntersectionObserver(([entry]) => setIsVisible(entry.isIntersecting), {
+ threshold: 0,
+ });
+ if (containerRef.current) observer.observe(containerRef.current);
+
+ const handleVisibility = () => setPageHidden(document.visibilityState !== 'visible');
+ document.addEventListener('visibilitychange', handleVisibility);
+
+ return () => {
+ observer.disconnect();
+ document.removeEventListener('visibilitychange', handleVisibility);
+ };
+ }, []);
+
+ const paused = !isVisible || pageHidden;
+
+ // Phase cycling — `playing` phase is driven by audio finish, not a timeout
+ useEffect(() => {
+ if (paused || phase === 'playing') return;
+
+ const duration = PHASE_DURATIONS[phase];
+ const timer = setTimeout(() => {
+ console.log(
+ '[ControlUI] phase transition',
+ phase,
+ '→ next, cycle:',
+ cycle,
+ 'step profile:',
+ PROFILES[step.profileIndex].name,
+ );
+ switch (phase) {
+ case 'idle': {
+ setSelectedIndex(step.profileIndex);
+ setPhase('selecting');
+ break;
+ }
+ case 'selecting':
+ setPhase('typing');
+ break;
+ case 'typing': {
+ const profile = PROFILES[step.profileIndex];
+ const newGen: Generation = {
+ id: Date.now(),
+ profileName: profile.name,
+ text: step.text,
+ language: profile.language,
+ engine: step.engine,
+ duration: step.duration,
+ timeAgo: 'just now',
+ favorited: false,
+ versions: 1,
+ };
+ setGenerations((prev) => [newGen, ...prev.slice(0, 5)]);
+ setNewGenId(newGen.id);
+ setPhase('generating');
+ break;
+ }
+ case 'generating':
+ setPhase('playing');
+ break;
+ }
+ }, duration);
+
+ return () => clearTimeout(timer);
+ }, [phase, paused, step, cycle]);
+
+ const handleAudioFinish = useCallback(() => {
+ if (phaseRef.current !== 'playing') return;
+ setPhase('idle');
+ setCycle((c) => c + 1);
+ setNewGenId(null);
+ }, []);
+
+ const isGenerating = phase === 'generating';
+
+ return (
+
+ {/* Unmute button with handwritten hint */}
+
+
+ {/* Handwritten hint — absolutely positioned above the button */}
+ {isMuted && (
+
+
+ try me!
+
+ {/* Curved arrow from text down-right toward the button */}
+
+ Arrow
+
+
+
+
+ )}
+
{
+ unlockAudioContext();
+ setIsMuted(!isMuted);
+ }}
+ className="flex items-center gap-2 px-3 py-1.5 rounded-full border border-border bg-card/50 backdrop-blur text-xs text-muted-foreground hover:text-foreground transition-colors"
+ >
+ {isMuted ? (
+ <>
+
+ Unmute
+ >
+ ) : (
+ <>
+
+ Mute
+ >
+ )}
+
+
+
+
+
+
+ {/* ── Sidebar (hidden on mobile) ─────────────────────────── */}
+
+ {/* Logo */}
+
+
+
+
+
+
+ {/* Nav items */}
+
+ {SIDEBAR_ITEMS.map((item, i) => {
+ const Icon = item.icon;
+ const active = i === 0;
+ return (
+
+
+
+ );
+ })}
+
+
+ {/* Version */}
+
v0.2.0
+
+
+ {/* ── Main content ──────────────────────────────────────── */}
+
+ {/* Left: Profiles + Generate box */}
+
+ {/* Gradient fade overlay — sits between header and scroll content */}
+
+
+ {/* Header — floats above everything */}
+
+
Voicebox
+
+
+ Import Voice
+
+
+ Create Voice
+
+
+
+
+ {/* Scrollable profile cards — scrolls behind header + gradient */}
+
+
+ {/* Mobile: horizontal scroll strip with edge fade */}
+
+ {scrollLeft > 0 && (
+
+ )}
+
+
setScrollLeft(e.currentTarget.scrollLeft)}
+ >
+ {PROFILES.map((profile, i) => (
+
{
+ if (el) mobileCardRefs.current.set(i, el);
+ }}
+ >
+
+
+ ))}
+
+
+
+ {/* Desktop: 3-col grid */}
+
+ {PROFILES.map((profile, i) => (
+
{
+ if (el) desktopCardRefs.current.set(i, el);
+ }}
+ />
+ ))}
+
+
+
+
+ {/* Floating generate box — desktop: absolute overlay, mobile: inline */}
+
+
+
+
+
+ {/* Right/Below: History */}
+
+
+
+ {generations.map((gen) => {
+ const isThisNew = gen.id === newGenId;
+ const rowMode: 'idle' | 'generating' | 'playing' =
+ isThisNew && isGenerating
+ ? 'generating'
+ : isThisNew && phase === 'playing'
+ ? 'playing'
+ : 'idle';
+ return ;
+ })}
+
+
+
+
+ {/* Audio player */}
+
{}}
+ />
+
+
+
+
+ );
+}
diff --git a/landing/src/components/Features.tsx b/landing/src/components/Features.tsx
new file mode 100644
index 00000000..ad77aea2
--- /dev/null
+++ b/landing/src/components/Features.tsx
@@ -0,0 +1,855 @@
+'use client';
+
+import { motion } from 'framer-motion';
+import { AudioLines, Cloud, MessageSquareText, Mic, Sparkles, TextCursorInput } from 'lucide-react';
+import { useEffect, useMemo, useRef, useState } from 'react';
+
+// ─── Lazy load wrapper ──────────────────────────────────────────────────────
+
+function LazyLoad({
+ children,
+ className,
+ rootMargin = '200px',
+}: {
+ children: React.ReactNode;
+ className?: string;
+ rootMargin?: string;
+}) {
+ const ref = useRef(null);
+ const [visible, setVisible] = useState(false);
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting) {
+ setVisible(true);
+ observer.disconnect();
+ }
+ },
+ { rootMargin },
+ );
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [rootMargin]);
+
+ return (
+
+ {visible ? children : null}
+
+ );
+}
+
+// ─── Animation: Voice Cloning ───────────────────────────────────────────────
+
+function VoiceCloningAnimation() {
+ const [phase, setPhase] = useState(0);
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setPhase((p) => (p + 1) % 3);
+ }, 2400);
+ return () => clearInterval(interval);
+ }, []);
+
+ const samples = ['Sample 1', 'Sample 2', 'Sample 3'];
+ const bars = [0.4, 0.7, 0.5, 0.9, 0.3, 0.6, 0.8, 0.4, 0.7, 0.5, 0.3, 0.6];
+
+ return (
+
+
+ {/* Sample pills */}
+
+ {samples.map((s, i) => (
+
+ {s}
+
+ ))}
+
+
+ {/* Waveform visualization */}
+
+ {bars.map((h, i) => (
+
+ ))}
+
+
+ {/* Result label */}
+
+ voice profile ready
+
+
+
+ );
+}
+
+// ─── Mini waveform for clips ────────────────────────────────────────────────
+// Fixed-width dense waveform that overflows — the clip container clips it.
+// This way resizing a clip just reveals/hides bars instead of re-rendering.
+
+const WAVEFORM_BAR_COUNT = 60;
+
+function MiniWaveform({ seed, color }: { seed: number; color: string }) {
+ // Deterministic pseudo-random waveform that looks like real speech audio.
+ // Uses layered noise at different frequencies for natural envelope + detail.
+ const bars = useMemo(() => {
+ // Seeded pseudo-random number generator (deterministic per seed)
+ let s = seed * 9301 + 49297;
+ const rand = () => {
+ s = (s * 16807 + 0) % 2147483647;
+ return s / 2147483647;
+ };
+
+ // Pre-generate random values
+ const r = Array.from({ length: WAVEFORM_BAR_COUNT }, () => rand());
+
+ return Array.from({ length: WAVEFORM_BAR_COUNT }, (_, i) => {
+ const t = i / WAVEFORM_BAR_COUNT;
+
+ // Slow envelope — broad amplitude shape (words / phrases)
+ const envelope =
+ 0.3 +
+ 0.35 *
+ Math.sin(t * Math.PI * (2 + (seed % 3))) *
+ Math.sin(t * Math.PI * (1.3 + seed * 0.7)) +
+ 0.2 * Math.sin(t * Math.PI * (4.7 + seed * 1.3));
+
+ // Medium variation — syllable-level bumps
+ const mid = 0.15 * Math.sin(i * 0.8 + seed * 3.1) * Math.cos(i * 1.3 + seed);
+
+ // High-frequency noise — individual sample jitter
+ const noise = (r[i] - 0.5) * 0.25;
+
+ // Combine and clamp
+ const raw = envelope + mid + noise;
+ return Math.max(0.06, Math.min(1, raw));
+ });
+ }, [seed]);
+
+ return (
+
+ {bars.map((h, i) => (
+
+ ))}
+
+ );
+}
+
+// ─── Animation: Stories Editor ───────────────────────────────────────────────
+
+// Clip shape: id, profile, track, left (px out of 220), width (px), waveform seed
+type DemoClip = { id: string; profile: string; track: number; x: number; w: number; seed: number };
+
+const INITIAL_CLIPS: DemoClip[] = [
+ { id: 'n1', profile: 'Morgan', track: 0, x: 4, w: 70, seed: 1 },
+ { id: 'n2', profile: 'Morgan', track: 0, x: 135, w: 35, seed: 2 },
+ { id: 'a1', profile: 'Scarlett', track: 1, x: 25, w: 40, seed: 3 },
+ { id: 'a2', profile: 'Scarlett', track: 1, x: 120, w: 35, seed: 4 },
+ { id: 'b1', profile: 'Jarvis', track: 2, x: 70, w: 45, seed: 5 },
+];
+
+// Timeline width the clips live inside
+const TL_W = 220;
+// Each action returns a new clips array (or modifies in place)
+type Action = { label: string; apply: (clips: DemoClip[]) => DemoClip[] };
+
+const ACTIONS: Action[] = [
+ // 0 — move Jarvis clip earlier
+ { label: 'Move clip', apply: (c) => c.map((cl) => (cl.id === 'b1' ? { ...cl, x: 55 } : cl)) },
+ // 1 — split Morgan's first clip into two with visible gap
+ {
+ label: 'Split clip',
+ apply: (c) => {
+ // Idempotent: if n1b already exists, the split already happened
+ if (c.some((cl) => cl.id === 'n1b')) return c;
+ const clip = c.find((cl) => cl.id === 'n1');
+ if (!clip) return c;
+ const leftW = 25;
+ const gap = 8;
+ const rightW = clip.w - leftW - gap;
+ return [
+ ...c.filter((cl) => cl.id !== 'n1'),
+ { ...clip, w: leftW, id: 'n1' },
+ {
+ id: 'n1b',
+ profile: clip.profile,
+ track: clip.track,
+ x: clip.x + leftW + gap,
+ w: rightW,
+ seed: 6,
+ },
+ ];
+ },
+ },
+ // 2 — trim Scarlett's second clip shorter
+ { label: 'Trim clip', apply: (c) => c.map((cl) => (cl.id === 'a2' ? { ...cl, w: 25 } : cl)) },
+ // 3 — duplicate Jarvis to track 0
+ {
+ label: 'Duplicate',
+ apply: (c) => {
+ // Idempotent: if b1d already exists, the duplicate already happened
+ if (c.some((cl) => cl.id === 'b1d')) return c;
+ const clip = c.find((cl) => cl.id === 'b1');
+ if (!clip) return c;
+ return [...c, { ...clip, id: 'b1d', track: 0, x: 180, w: 35, seed: 7 }];
+ },
+ },
+ // 4 — reset
+ { label: '', apply: () => INITIAL_CLIPS },
+];
+
+function StoriesAnimation() {
+ const [clips, setClips] = useState(INITIAL_CLIPS);
+ const [actionIndex, setActionIndex] = useState(-1);
+ const [playheadX, setPlayheadX] = useState(0);
+ const [selectedId, setSelectedId] = useState(null);
+ const playheadRef = useRef>(0);
+
+ // Animate the playhead continuously
+ useEffect(() => {
+ let start: number | null = null;
+ const speed = 12; // px per second
+ const animate = (ts: number) => {
+ if (start === null) start = ts;
+ const elapsed = (ts - start) / 1000;
+ setPlayheadX((elapsed * speed) % TL_W);
+ playheadRef.current = requestAnimationFrame(animate);
+ };
+ playheadRef.current = requestAnimationFrame(animate);
+ return () => cancelAnimationFrame(playheadRef.current);
+ }, []);
+
+ // Step through actions
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setActionIndex((prev) => {
+ const next = (prev + 1) % ACTIONS.length;
+ setClips((current) => ACTIONS[next].apply(current));
+ // Highlight the clip being acted on
+ if (next === 0) setSelectedId('b1');
+ else if (next === 1) setSelectedId('n1');
+ else if (next === 2) setSelectedId('a2');
+ else if (next === 3) setSelectedId('b1');
+ else setSelectedId(null);
+ return next;
+ });
+ }, 2600);
+ return () => clearInterval(interval);
+ }, []);
+
+ const trackLabels = ['1', '0', '-1'];
+ const timeMarkers = [0, 2, 4, 6, 8];
+ const accentColor = 'hsl(43 50% 45%)';
+ const accentFg = 'hsl(30 10% 94%)';
+
+ return (
+
+ {/* Toolbar */}
+
+
+
+
0:03 / 0:10
+
+ {actionIndex >= 0 && actionIndex < ACTIONS.length - 1 && (
+
+ {ACTIONS[actionIndex].label}
+
+ )}
+
+
+
+ {/* Timeline */}
+
+ {/* Track labels sidebar */}
+
+
+ {trackLabels.map((label) => (
+
+ {label}
+
+ ))}
+
+
+ {/* Tracks area */}
+
+ {/* Time ruler */}
+
+ {timeMarkers.map((t) => (
+
+ ))}
+
+
+ {/* Track rows + clips — same parent so percentages match */}
+
+ {/* Track rows background */}
+ {trackLabels.map((label, i) => (
+
+ ))}
+
+ {/* Clips */}
+ {clips.map((clip) => {
+ const trackIdx = clip.track;
+ const isSelected = clip.id === selectedId;
+ const clipTop = `calc(${(trackIdx * 100) / 3}% + 2px)`;
+ const clipHeight = `calc(${100 / 3}% - 4px)`;
+ return (
+
+
+ {/* Profile label — scaled to bypass browser min font size */}
+
+
+ {clip.profile}
+
+
+ {/* Waveform — absolutely positioned so it never affects clip width */}
+
+
+
+
+ {/* Trim handles on selected */}
+ {isSelected && (
+ <>
+
+
+ >
+ )}
+
+ );
+ })}
+
+ {/* Playhead */}
+
+
+
+
+
+
+
+ );
+}
+
+// ─── Animation: Effects Pipeline ────────────────────────────────────────────
+
+function EffectsAnimation() {
+ const [activeEffect, setActiveEffect] = useState(0);
+ const effects = [
+ { name: 'Pitch Shift', param: '-3 semitones', color: '#3b82f6' },
+ { name: 'Reverb', param: 'Room 0.7', color: '#8b5cf6' },
+ { name: 'Compressor', param: '-15 dB', color: '#ec4899' },
+ { name: 'Low-Pass', param: '6000 Hz', color: '#14b8a6' },
+ ];
+
+ // Waveform bars — original shape
+ const rawBars = [0.3, 0.6, 0.8, 0.5, 0.9, 0.4, 0.7, 0.3, 0.6, 0.5, 0.8, 0.4, 0.7, 0.9, 0.3];
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setActiveEffect((p) => (p + 1) % effects.length);
+ }, 2200);
+ return () => clearInterval(interval);
+ }, [effects.length]);
+
+ return (
+
+ {/* Effects chain */}
+
+ {effects.map((fx, i) => (
+
+
+ {fx.name}
+
+ {i < effects.length - 1 && (
+
+ →
+
+ )}
+
+ ))}
+
+
+ {/* Waveform that morphs as effects are applied */}
+
+ {rawBars.map((h, i) => {
+ // Each effect stage progressively transforms the shape
+ const shifted = activeEffect >= 0 ? h * (0.7 + 0.3 * Math.sin(i * 0.8)) : h;
+ const dampened = activeEffect >= 1 ? shifted * (0.6 + 0.4 * Math.cos(i * 0.3)) : shifted;
+ const compressed = activeEffect >= 2 ? 0.3 + dampened * 0.5 : dampened;
+ const filtered = activeEffect >= 3 ? compressed * (1 - i * 0.03) : compressed;
+ const finalH = Math.max(0.08, Math.min(1, filtered));
+
+ return (
+
+ );
+ })}
+
+
+ {/* Active effect detail */}
+
+ {effects[activeEffect].name}: {effects[activeEffect].param}
+
+
+ );
+}
+
+// ─── Animation: Local or Remote ─────────────────────────────────────────────
+
+function LocalRemoteAnimation() {
+ const [mode, setMode] = useState(0);
+ const modes = ['Local GPU', 'Remote Server'];
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setMode((p) => (p + 1) % 2);
+ }, 2800);
+ return () => clearInterval(interval);
+ }, []);
+
+ return (
+
+
+ {/* Toggle */}
+
+ {modes.map((m, i) => (
+
+ {m}
+
+ ))}
+
+
+ {/* Status */}
+
+
+
+ {mode === 0 ? 'Metal acceleration active' : 'Connected to 192.168.1.50'}
+
+
+ {mode === 0 ? 'VRAM: 8.2 / 16.0 GB' : 'Latency: 12ms | CUDA'}
+
+
+
+
+ );
+}
+
+// ─── Animation: Transcription ───────────────────────────────────────────────
+
+function TranscriptionAnimation() {
+ const [charIndex, setCharIndex] = useState(0);
+ const text = 'The quick brown fox jumps over the lazy dog near the riverbank.';
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setCharIndex((p) => {
+ if (p >= text.length) return 0;
+ return p + 1;
+ });
+ }, 80);
+ return () => clearInterval(interval);
+ }, [text.length]);
+
+ return (
+
+ {/* Fake waveform */}
+
+ {Array.from({ length: 30 }, (_, i) => {
+ const h = 0.2 + 0.8 * Math.abs(Math.sin(i * 0.5 + charIndex * 0.1));
+ const active = i < (charIndex / text.length) * 30;
+ return (
+
+ );
+ })}
+
+
+ {/* Transcribed text */}
+
+ {text.slice(0, charIndex)}
+ {charIndex < text.length && (
+
+ )}
+
+
+ );
+}
+
+// ─── Animation: Unlimited Length ─────────────────────────────────────────────
+
+function UnlimitedLengthAnimation() {
+ const [phase, setPhase] = useState(0);
+
+ const chunks = [
+ 'The morning sun crept over the mountains, casting long shadows across the valley below.',
+ 'Birds stirred in the canopy, their songs weaving through the cool air like threads of gold.',
+ 'Far below, a river wound its way through ancient stones, carrying whispers of the night.',
+ ];
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setPhase((p) => (p + 1) % 4); // 0-2 = processing chunks, 3 = crossfade/done
+ }, 2000);
+ return () => clearInterval(interval);
+ }, []);
+
+ return (
+
+ {/* Chunk pills */}
+
+ {chunks.map((chunk, i) => (
+
+ {/* Status indicator */}
+
+
+ {chunk}
+
+
+ ))}
+
+
+ {/* Crossfade / result bar */}
+
+ {chunks.map((_, i) => (
+
+ ))}
+
+
+ {/* Status text */}
+
+
+ {phase < 3
+ ? `generating chunk ${phase + 1} of ${chunks.length}...`
+ : 'crossfaded & ready'}
+
+
+
+ );
+}
+
+// ─── Feature data ───────────────────────────────────────────────────────────
+
+const FEATURES = [
+ {
+ title: 'Near-Perfect Voice Cloning',
+ description:
+ 'Multiple TTS engines for exceptional voice quality. Clone any voice from a few seconds of audio with natural intonation and emotion.',
+ icon: Mic,
+ animation: VoiceCloningAnimation,
+ },
+ {
+ title: 'Stories Editor',
+ description:
+ 'Create multi-voice narratives with a timeline-based editor. Arrange tracks, trim clips, and mix conversations between characters.',
+ icon: AudioLines,
+ animation: StoriesAnimation,
+ },
+ {
+ title: 'Audio Effects Pipeline',
+ description:
+ 'Apply pitch shift, reverb, delay, compression, and more — then save as presets. Preview effects live and set defaults per voice profile.',
+ icon: Sparkles,
+ animation: EffectsAnimation,
+ },
+ {
+ title: 'Local or Remote',
+ description:
+ 'Run GPU inference locally with Metal, CUDA, ROCm, Intel Arc, or DirectML — or connect to a remote machine. One-click server setup with automatic discovery.',
+ icon: Cloud,
+ animation: LocalRemoteAnimation,
+ },
+ {
+ title: 'Audio Transcription',
+ description:
+ 'Powered by Whisper for accurate speech-to-text. Automatically extract reference text from voice samples.',
+ icon: MessageSquareText,
+ animation: TranscriptionAnimation,
+ },
+ {
+ title: 'Unlimited Generation Length',
+ description:
+ 'Generate up to 50,000 characters in one go. Text is auto-split at sentence boundaries, generated per-chunk, and crossfaded seamlessly.',
+ icon: TextCursorInput,
+ animation: UnlimitedLengthAnimation,
+ },
+];
+
+// ─── Feature Card ───────────────────────────────────────────────────────────
+
+function FeatureCard({ feature }: { feature: (typeof FEATURES)[number] }) {
+ const Icon = feature.icon;
+ const Animation = feature.animation;
+
+ return (
+
+
+
+
+
+
+
+
{feature.title}
+
+
{feature.description}
+
+
+ );
+}
+
+// ─── Features Section ───────────────────────────────────────────────────────
+
+export function Features() {
+ return (
+
+
+
+
+ Professional voice tools, zero compromise
+
+
+ Everything you need to clone voices, generate speech, and produce multi-voice content —
+ running entirely on your machine.
+
+
+
+ {FEATURES.map((feature) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/landing/src/components/Footer.tsx b/landing/src/components/Footer.tsx
index 9370ac86..9e435a82 100644
--- a/landing/src/components/Footer.tsx
+++ b/landing/src/components/Footer.tsx
@@ -1,22 +1,33 @@
+import Image from 'next/image';
import Link from 'next/link';
-import { Separator } from '@/components/ui/separator';
import { GITHUB_REPO } from '@/lib/constants';
export function Footer() {
return (
-
-
-
-
-
voicebox
-
- Professional voice cloning powered by Qwen3-TTS. Desktop app for Mac, Windows, and
- Linux.
+
+
+
+ {/* Brand */}
+
+
+
+ Voicebox
+
+
+ Open source voice cloning studio. Local-first, free forever.
+
+ {/* Product */}
+
+ {/* Resources */}
+
+ {/* Also by */}
+
-
-
-
© 2026 voicebox. All rights reserved.
+
+
+
+ © {new Date().getFullYear()} Voicebox. Open source under MIT license.
+
diff --git a/landing/src/components/LandingAudioPlayer.tsx b/landing/src/components/LandingAudioPlayer.tsx
new file mode 100644
index 00000000..d240fc4a
--- /dev/null
+++ b/landing/src/components/LandingAudioPlayer.tsx
@@ -0,0 +1,298 @@
+'use client';
+
+import { Pause, Play, Repeat, Volume2, VolumeX } from 'lucide-react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import WaveSurfer from 'wavesurfer.js';
+
+function formatDuration(seconds: number): string {
+ const m = Math.floor(seconds / 60);
+ const s = Math.floor(seconds % 60);
+ return `${m}:${s.toString().padStart(2, '0')}`;
+}
+
+// Shared ref so the unmute button can unlock WaveSurfer's audio on iOS Safari
+// Must call .play() on WaveSurfer's actual media element during a user gesture
+let sharedWaveSurfer: WaveSurfer | null = null;
+let audioUnlocked = false;
+
+export function unlockAudioContext() {
+ if (audioUnlocked) return;
+ audioUnlocked = true;
+
+ // Unlock WaveSurfer's internal audio element
+ // Skip if already playing — the context is already unlocked and the
+ // play/pause/reset dance would destroy the active playback.
+ if (sharedWaveSurfer && !sharedWaveSurfer.isPlaying()) {
+ const media = sharedWaveSurfer.getMediaElement();
+ if (media) {
+ media.muted = true;
+ media
+ .play()
+ .then(() => {
+ media.pause();
+ media.muted = false;
+ media.currentTime = 0;
+ })
+ .catch(() => {});
+ }
+ }
+
+ // Also unlock a standalone AudioContext as fallback
+ try {
+ const ctx = new (
+ window.AudioContext ||
+ (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
+ )();
+ const buffer = ctx.createBuffer(1, 1, 22050);
+ const source = ctx.createBufferSource();
+ source.buffer = buffer;
+ source.connect(ctx.destination);
+ source.start(0);
+ } catch {
+ // Silently fail
+ }
+}
+
+interface LandingAudioPlayerProps {
+ audioUrl: string;
+ title: string;
+ playing: boolean;
+ muted: boolean;
+ onFinish: () => void;
+ onClose: () => void;
+}
+
+export function LandingAudioPlayer({
+ audioUrl,
+ title,
+ playing,
+ muted,
+ onFinish,
+ onClose,
+}: LandingAudioPlayerProps) {
+ const waveformRef = useRef
(null);
+ const wavesurferRef = useRef(null);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const [volume, setVolume] = useState(0.75);
+ const [isLooping, setIsLooping] = useState(false);
+ const [isReady, setIsReady] = useState(false);
+ const onFinishRef = useRef(onFinish);
+ onFinishRef.current = onFinish;
+ const playingRef = useRef(playing);
+ playingRef.current = playing;
+ const mutedRef = useRef(muted);
+ mutedRef.current = muted;
+
+ // Initialize WaveSurfer
+ useEffect(() => {
+ const initWaveSurfer = () => {
+ const container = waveformRef.current;
+ if (!container) {
+ setTimeout(initWaveSurfer, 50);
+ return;
+ }
+
+ const rect = container.getBoundingClientRect();
+ if (rect.width === 0 || rect.height === 0) {
+ setTimeout(initWaveSurfer, 50);
+ return;
+ }
+
+ // Clean up existing instance
+ if (wavesurferRef.current) {
+ wavesurferRef.current.destroy();
+ wavesurferRef.current = null;
+ }
+
+ const root = document.documentElement;
+ const getCSSVar = (varName: string) => {
+ const value = getComputedStyle(root).getPropertyValue(varName).trim();
+ return value ? `hsl(${value})` : '';
+ };
+
+ const ws = WaveSurfer.create({
+ container,
+ waveColor: getCSSVar('--muted'),
+ progressColor: getCSSVar('--accent'),
+ cursorColor: getCSSVar('--accent'),
+ barWidth: 2,
+ barRadius: 2,
+ height: 80,
+ normalize: true,
+ interact: true,
+ mediaControls: false,
+ });
+
+ ws.on('ready', () => {
+ setDuration(ws.getDuration());
+ ws.setVolume(mutedRef.current ? 0 : volume);
+ setIsReady(true);
+ });
+
+ ws.on('play', () => {
+ console.log('[Player] play event');
+ setIsPlaying(true);
+ });
+ ws.on('pause', () => {
+ console.log('[Player] pause event');
+ setIsPlaying(false);
+ });
+
+ ws.on('timeupdate', (time: number) => {
+ setCurrentTime(Math.min(time, ws.getDuration()));
+ });
+
+ let didFinish = false;
+ ws.on('finish', () => {
+ if (didFinish) return;
+ didFinish = true;
+ console.log(
+ '[Player] finish event, currentTime:',
+ ws.getCurrentTime(),
+ 'duration:',
+ ws.getDuration(),
+ );
+ setIsPlaying(false);
+ onFinishRef.current();
+ });
+
+ ws.load(audioUrl);
+ wavesurferRef.current = ws;
+ sharedWaveSurfer = ws;
+ };
+
+ setIsReady(false);
+ setCurrentTime(0);
+ setDuration(0);
+
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ setTimeout(initWaveSurfer, 10);
+ });
+ });
+
+ return () => {
+ if (wavesurferRef.current) {
+ wavesurferRef.current.destroy();
+ wavesurferRef.current = null;
+ }
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [audioUrl]);
+
+ // Respond to external play/stop signals
+ useEffect(() => {
+ const ws = wavesurferRef.current;
+ console.log('[Player] effect', { playing, isReady, hasWs: !!ws });
+ if (!ws || !isReady) return;
+
+ if (playing) {
+ // Resume the AudioContext first (required for iOS Safari after unlock)
+ const backend = ws.getMediaElement();
+ if (backend && 'context' in backend) {
+ const ctx = (backend as unknown as { context: AudioContext }).context;
+ if (ctx?.state === 'suspended') ctx.resume();
+ }
+ ws.play()
+ .then(() => {
+ console.log('[Player] play succeeded');
+ })
+ .catch((e: Error) => {
+ if (e.name === 'NotAllowedError') {
+ console.warn('[Player] Autoplay blocked by browser — waiting for user gesture');
+ } else {
+ console.error('[Player] play failed', e);
+ }
+ });
+ } else {
+ ws.pause();
+ }
+ }, [playing, isReady]);
+
+ // Sync volume and muted state
+ useEffect(() => {
+ if (wavesurferRef.current) {
+ wavesurferRef.current.setVolume(muted ? 0 : volume);
+ }
+ }, [volume, muted]);
+
+ const handlePlayPause = useCallback(() => {
+ if (!wavesurferRef.current) return;
+ wavesurferRef.current.playPause();
+ }, []);
+
+ return (
+
+
+ {/* Waveform — full width row on mobile, inline on desktop */}
+
+
+ {/* Controls row */}
+
+ {/* Play/Pause */}
+
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Time */}
+
+ {formatDuration(currentTime)}
+ /
+ {formatDuration(duration)}
+
+
+ {/* Title */}
+ {title && (
+
+ {title}
+
+ )}
+
+ {/* Loop */}
+
setIsLooping(!isLooping)}
+ className={`h-8 w-8 flex items-center justify-center rounded-sm shrink-0 hover:bg-muted md:order-5 ${
+ isLooping ? 'text-foreground' : 'text-muted-foreground'
+ }`}
+ >
+
+
+
+ {/* Volume */}
+
+ setVolume(volume > 0 ? 0 : 0.75)}
+ className="h-8 w-8 flex items-center justify-center hover:bg-muted rounded-sm"
+ >
+ {volume > 0 ? (
+
+ ) : (
+
+ )}
+
+ setVolume(Number(e.target.value) / 100)}
+ className="flex-1 h-1 appearance-none bg-muted rounded-full accent-foreground cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-foreground"
+ />
+
+
+
+
+ );
+}
diff --git a/landing/src/components/Navbar.tsx b/landing/src/components/Navbar.tsx
new file mode 100644
index 00000000..c0b0b186
--- /dev/null
+++ b/landing/src/components/Navbar.tsx
@@ -0,0 +1,88 @@
+'use client';
+
+import { Github } from 'lucide-react';
+import Image from 'next/image';
+import { useEffect, useState } from 'react';
+import { GITHUB_REPO } from '@/lib/constants';
+
+function formatStarCount(count: number): string {
+ if (count >= 1000) {
+ const k = count / 1000;
+ return k % 1 === 0 ? `${k}k` : `${k.toFixed(1)}k`;
+ }
+ return count.toString();
+}
+
+export function Navbar() {
+ const [starCount, setStarCount] = useState(null);
+
+ useEffect(() => {
+ fetch('/api/stars')
+ .then((res) => {
+ if (!res.ok) throw new Error('Failed to fetch stars');
+ return res.json();
+ })
+ .then((data) => {
+ if (typeof data.count === 'number') setStarCount(data.count);
+ })
+ .catch((error) => {
+ console.error('Failed to fetch star count:', error);
+ });
+ }, []);
+
+ return (
+
+
+
+ );
+}
diff --git a/landing/src/components/VoiceCreator.tsx b/landing/src/components/VoiceCreator.tsx
new file mode 100644
index 00000000..86855967
--- /dev/null
+++ b/landing/src/components/VoiceCreator.tsx
@@ -0,0 +1,479 @@
+'use client';
+
+import { AnimatePresence, motion } from 'framer-motion';
+import { Mic, Monitor, Upload } from 'lucide-react';
+import { useEffect, useMemo, useState } from 'react';
+
+// ─── Waveform bars generator ────────────────────────────────────────────────
+
+function generateWaveformBars(count: number, seed: number): number[] {
+ const bars: number[] = [];
+ for (let i = 0; i < count; i++) {
+ const x = i / count;
+ // Speech-like envelope: ramp up, sustain, taper
+ const envelope = Math.sin(x * Math.PI) * 0.8 + 0.2;
+ // Layered pseudo-random noise
+ const n1 = Math.sin(seed * 127.1 + i * 43.7) * 0.5 + 0.5;
+ const n2 = Math.sin(seed * 269.5 + i * 17.3) * 0.3 + 0.5;
+ const n3 = Math.sin(seed * 53.9 + i * 97.1) * 0.2 + 0.5;
+ const noise = (n1 + n2 + n3) / 3;
+ bars.push(envelope * noise);
+ }
+ return bars;
+}
+
+// ─── Animated waveform background ───────────────────────────────────────────
+
+function WaveformBackground({ active }: { active: boolean }) {
+ const bars = useMemo(() => generateWaveformBars(60, 42), []);
+
+ return (
+
+
+ {bars.map((h, i) => {
+ const maxH = 120; // max bar height in px
+ const baseH = 4;
+ const activeH = baseH + h * maxH;
+ const idleH = baseH + h * maxH * 0.25;
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+// ─── Tab content panels ─────────────────────────────────────────────────────
+
+function UploadPanel() {
+ const [hasFile, setHasFile] = useState(false);
+
+ useEffect(() => {
+ // Simulate file drop after 2s
+ const t1 = setTimeout(() => setHasFile(true), 2000);
+ const t2 = setTimeout(() => setHasFile(false), 5000);
+ return () => {
+ clearTimeout(t1);
+ clearTimeout(t2);
+ };
+ }, []);
+
+ return (
+
+
+ {!hasFile ? (
+
+
+
+ Choose File
+
+
+ Drag and drop an audio file, or click to browse.
+
+ Maximum duration: 30 seconds.
+
+
+ ) : (
+
+
+
+ sample-voice-clip.wav
+
+
+
+ 0:04
+
+
+
+ Transcribe
+
+
+
+ )}
+
+
+ );
+}
+
+function RecordPanel() {
+ const [state, setState] = useState<'idle' | 'recording' | 'done'>('idle');
+ const [elapsed, setElapsed] = useState(0);
+
+ useEffect(() => {
+ const t1 = setTimeout(() => setState('recording'), 1500);
+ const t2 = setTimeout(() => setState('done'), 5500);
+ const t3 = setTimeout(() => {
+ setState('idle');
+ setElapsed(0);
+ }, 8000);
+ return () => {
+ clearTimeout(t1);
+ clearTimeout(t2);
+ clearTimeout(t3);
+ };
+ }, []);
+
+ // Timer
+ useEffect(() => {
+ if (state !== 'recording') return;
+ setElapsed(0);
+ const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
+ return () => clearInterval(interval);
+ }, [state]);
+
+ const formatTime = (s: number) => `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
+
+ return (
+
+
+
+
+ {state === 'idle' && (
+
+
+
+ Start Recording
+
+
+ Click to record from your microphone.
+
+ Maximum duration: 30 seconds.
+
+
+ )}
+
+ {state === 'recording' && (
+
+
+
+
{formatTime(elapsed)}
+
+
+ {formatTime(30 - elapsed)} remaining
+
+ )}
+
+ {state === 'done' && (
+
+
+
+ Recording complete
+
+
+
+ 0:04
+
+
+
+ Transcribe
+
+
+
+ )}
+
+
+ );
+}
+
+function SystemPanel() {
+ const [state, setState] = useState<'idle' | 'capturing' | 'done'>('idle');
+ const [elapsed, setElapsed] = useState(0);
+
+ useEffect(() => {
+ const t1 = setTimeout(() => setState('capturing'), 1500);
+ const t2 = setTimeout(() => setState('done'), 5500);
+ const t3 = setTimeout(() => {
+ setState('idle');
+ setElapsed(0);
+ }, 8000);
+ return () => {
+ clearTimeout(t1);
+ clearTimeout(t2);
+ clearTimeout(t3);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (state !== 'capturing') return;
+ setElapsed(0);
+ const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
+ return () => clearInterval(interval);
+ }, [state]);
+
+ const formatTime = (s: number) => `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
+
+ return (
+
+
+
+
+ {state === 'idle' && (
+
+
+
+ Start Capture
+
+
+ Capture audio playing on your system.
+
+ Maximum duration: 30 seconds.
+
+
+ )}
+
+ {state === 'capturing' && (
+
+
+
+
{formatTime(elapsed)}
+
+
+ {formatTime(30 - elapsed)} remaining
+
+ )}
+
+ {state === 'done' && (
+
+
+
+ Capture complete
+
+
+
+ 0:04
+
+
+
+ Transcribe
+
+
+
+ )}
+
+
+ );
+}
+
+// ─── Tab selector ───────────────────────────────────────────────────────────
+
+const TABS = [
+ { id: 'upload' as const, label: 'Upload', icon: Upload },
+ { id: 'record' as const, label: 'Microphone', icon: Mic },
+ { id: 'system' as const, label: 'System Audio', icon: Monitor },
+];
+
+type TabId = (typeof TABS)[number]['id'];
+
+// ─── Main section ───────────────────────────────────────────────────────────
+
+export function VoiceCreator() {
+ const [activeTab, setActiveTab] = useState('record');
+ const [cycleKey, setCycleKey] = useState(0);
+
+ // Auto-cycle tabs
+ useEffect(() => {
+ const tabOrder: TabId[] = ['record', 'upload', 'system'];
+ let idx = tabOrder.indexOf(activeTab);
+
+ const interval = setInterval(() => {
+ idx = (idx + 1) % tabOrder.length;
+ setActiveTab(tabOrder[idx]);
+ setCycleKey((k) => k + 1);
+ }, 9000);
+
+ return () => clearInterval(interval);
+ }, [activeTab]);
+
+ return (
+
+
+
+ {/* Left: Copy */}
+
+
+ Clone any voice in seconds
+
+
+ Three ways to capture a voice sample. Upload a clip, record from your microphone, or
+ capture audio playing on your system. Voicebox clones the voice from as little as 3
+ seconds of audio.
+
+
+
+
+
+
+
+
Upload a clip
+
+ Drag and drop any audio file — WAV, MP3, FLAC, or WebM.
+
+
+
+
+
+
+
+
+
Record from microphone
+
+ Live waveform preview while you record. Up to 30 seconds.
+
+
+
+
+
+
+
+
+
System audio capture
+
+ Clone a voice from a YouTube video, podcast, or any app playing audio.
+
+
+
+
+
+
+ {/* Right: Animated UI mock */}
+
+
+ {/* Tab bar */}
+
+ {TABS.map((tab) => {
+ const Icon = tab.icon;
+ const isActive = activeTab === tab.id;
+ return (
+ {
+ setActiveTab(tab.id);
+ setCycleKey((k) => k + 1);
+ }}
+ className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
+ isActive
+ ? 'bg-background text-foreground shadow-sm'
+ : 'text-muted-foreground hover:text-foreground'
+ }`}
+ >
+
+ {tab.label}
+
+ );
+ })}
+
+
+ {/* Panel */}
+
+
+ {activeTab === 'upload' && }
+ {activeTab === 'record' && }
+ {activeTab === 'system' && }
+
+
+
+
+
+
+
+ );
+}
diff --git a/landing/src/lib/releases.ts b/landing/src/lib/releases.ts
index ad19c548..18c77c47 100644
--- a/landing/src/lib/releases.ts
+++ b/landing/src/lib/releases.ts
@@ -9,6 +9,7 @@ export interface DownloadLinks {
export interface ReleaseInfo {
version: string;
downloadLinks: DownloadLinks;
+ totalDownloads: number;
}
const GITHUB_REPO = 'jamiepine/voicebox';
@@ -17,7 +18,11 @@ const GITHUB_API_BASE = 'https://api.github.com';
// Cache for release info (in-memory cache, resets on server restart)
let cachedReleaseInfo: ReleaseInfo | null = null;
let cacheTimestamp: number = 0;
-const CACHE_DURATION = 1000 * 60 * 10; // 10 minutes
+const CACHE_DURATION = 1000 * 60 * 5; // 5 minutes
+
+// Cache for star count
+let cachedStarCount: number | null = null;
+let starCacheTimestamp: number = 0;
/**
* Fetches the latest release from GitHub and extracts download links
@@ -31,7 +36,7 @@ export async function getLatestRelease(): Promise {
try {
const response = await fetch(`${GITHUB_API_BASE}/repos/${GITHUB_REPO}/releases/latest`, {
- next: { revalidate: 600 }, // Revalidate every 10 minutes
+ cache: 'no-store',
headers: {
Accept: 'application/vnd.github.v3+json',
},
@@ -57,9 +62,9 @@ export async function getLatestRelease(): Promise {
continue;
}
- if ((name.includes('aarch64') || name.includes('arm64')) && name.endsWith('.app.tar.gz')) {
+ if ((name.includes('aarch64') || name.includes('arm64')) && name.endsWith('.dmg')) {
downloadLinks.macArm = url;
- } else if (name.includes('x64') && name.endsWith('.app.tar.gz')) {
+ } else if (name.includes('x64') && name.endsWith('.dmg')) {
downloadLinks.macIntel = url;
} else if (name.endsWith('.msi')) {
downloadLinks.windows = url;
@@ -68,14 +73,20 @@ export async function getLatestRelease(): Promise {
}
}
+ // Fetch total downloads across ALL releases
+ const totalDownloads = await getTotalDownloads();
+
// Fallback: construct URLs if not found in assets
const baseUrl = `https://github.com/${GITHUB_REPO}/releases/download/${version}`;
const releaseInfo: ReleaseInfo = {
version,
+ totalDownloads,
downloadLinks: {
- macArm: downloadLinks.macArm || `${baseUrl}/voicebox_aarch64.app.tar.gz`,
- macIntel: downloadLinks.macIntel || `${baseUrl}/voicebox_x64.app.tar.gz`,
+ macArm:
+ downloadLinks.macArm || `${baseUrl}/Voicebox_${version.replace('v', '')}_aarch64.dmg`,
+ macIntel:
+ downloadLinks.macIntel || `${baseUrl}/Voicebox_${version.replace('v', '')}_x64.dmg`,
windows:
downloadLinks.windows || `${baseUrl}/voicebox_${version.replace('v', '')}_x64_en-US.msi`,
linux: downloadLinks.linux || `${baseUrl}/voicebox_x86_64-unknown-linux-gnu.AppImage`,
@@ -92,3 +103,89 @@ export async function getLatestRelease(): Promise {
throw error;
}
}
+
+// Cache for total download count
+let cachedTotalDownloads: number | null = null;
+let downloadsCacheTimestamp: number = 0;
+
+/**
+ * Fetches download counts across ALL releases (paginated)
+ */
+async function getTotalDownloads(): Promise {
+ const now = Date.now();
+ if (cachedTotalDownloads !== null && now - downloadsCacheTimestamp < CACHE_DURATION) {
+ return cachedTotalDownloads;
+ }
+
+ let total = 0;
+ let page = 1;
+
+ try {
+ while (true) {
+ const response = await fetch(
+ `${GITHUB_API_BASE}/repos/${GITHUB_REPO}/releases?per_page=100&page=${page}`,
+ {
+ cache: 'no-store',
+ headers: { Accept: 'application/vnd.github.v3+json' },
+ },
+ );
+
+ if (!response.ok) break;
+
+ const releases = await response.json();
+ if (!Array.isArray(releases) || releases.length === 0) break;
+
+ for (const release of releases) {
+ for (const asset of release.assets || []) {
+ total += asset.download_count || 0;
+ }
+ }
+
+ if (releases.length < 100) break;
+ page++;
+ }
+
+ cachedTotalDownloads = total;
+ downloadsCacheTimestamp = now;
+ } catch (error) {
+ console.error('Failed to fetch total downloads:', error);
+ if (cachedTotalDownloads !== null) return cachedTotalDownloads;
+ }
+
+ return total;
+}
+
+/**
+ * Fetches the star count for the repo from GitHub
+ */
+export async function getStarCount(): Promise {
+ const now = Date.now();
+ if (cachedStarCount !== null && now - starCacheTimestamp < CACHE_DURATION) {
+ return cachedStarCount;
+ }
+
+ try {
+ const response = await fetch(`${GITHUB_API_BASE}/repos/${GITHUB_REPO}`, {
+ next: { revalidate: 600 },
+ headers: {
+ Accept: 'application/vnd.github.v3+json',
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`GitHub API error: ${response.status}`);
+ }
+
+ const repo = await response.json();
+ const count = repo.stargazers_count ?? 0;
+
+ cachedStarCount = count;
+ starCacheTimestamp = now;
+
+ return count;
+ } catch (error) {
+ console.error('Failed to fetch star count:', error);
+ if (cachedStarCount !== null) return cachedStarCount;
+ throw error;
+ }
+}
diff --git a/landing/tailwind.config.js b/landing/tailwind.config.js
index 0a06b883..274291d1 100644
--- a/landing/tailwind.config.js
+++ b/landing/tailwind.config.js
@@ -8,6 +8,9 @@ module.exports = {
],
theme: {
extend: {
+ fontFamily: {
+ sans: ['var(--font-sans)', 'system-ui', 'sans-serif'],
+ },
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
@@ -33,6 +36,9 @@ module.exports = {
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
+ faint: 'hsl(var(--accent-faint))',
+ deep: 'hsl(var(--accent-deep))',
+ glow: 'hsl(var(--accent-glow))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
@@ -42,6 +48,27 @@ module.exports = {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
+ // App surface tokens
+ app: {
+ DEFAULT: 'hsl(var(--app))',
+ box: 'hsl(var(--app-box))',
+ darkBox: 'hsl(var(--app-dark-box))',
+ darkerBox: 'hsl(var(--app-darker-box))',
+ lightBox: 'hsl(var(--app-light-box))',
+ line: 'hsl(var(--app-line))',
+ button: 'hsl(var(--app-button))',
+ hover: 'hsl(var(--app-hover))',
+ selected: 'hsl(var(--app-selected))',
+ },
+ ink: {
+ DEFAULT: 'hsl(var(--ink))',
+ dull: 'hsl(var(--ink-dull))',
+ faint: 'hsl(var(--ink-faint))',
+ },
+ sidebar: {
+ DEFAULT: 'hsl(var(--sidebar))',
+ line: 'hsl(var(--sidebar-line))',
+ },
},
borderRadius: {
lg: 'var(--radius)',
diff --git a/package.json b/package.json
index c0f3c21e..71f14f51 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "voicebox",
- "version": "0.1.12",
+ "version": "0.2.3",
"private": true,
"workspaces": [
"app",
@@ -40,5 +40,9 @@
"engines": {
"bun": ">=1.0.0"
},
- "packageManager": "bun@1.3.8"
+ "packageManager": "bun@1.3.8",
+ "dependencies": {
+ "loaders.css": "^0.1.2",
+ "react-loaders": "^3.0.1"
+ }
}
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 00000000..ea444b9c
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,9 @@
+uvicorn
+fastapi
+sqlalchemy
+torch
+torchvision
+soundfile
+librosa
+python-multipart
+huggingface_hub
diff --git a/scripts/build-server.sh b/scripts/build-server.sh
index a8458418..d9eb72c5 100755
--- a/scripts/build-server.sh
+++ b/scripts/build-server.sh
@@ -9,12 +9,14 @@ PLATFORM=$(rustc --print host-tuple 2>/dev/null || echo "unknown")
echo "Building voicebox-server for platform: $PLATFORM"
# Build Python binary
+# Resolve PATH to absolute paths before changing directory
+export PATH="$(cd "$(dirname "$0")/.." && pwd)/backend/venv/bin:$PATH"
cd backend
# Check if PyInstaller is installed
if ! python -c "import PyInstaller" 2>/dev/null; then
echo "Installing PyInstaller..."
- pip install pyinstaller
+ python -m pip install pyinstaller
fi
# Build binary
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/package.json b/tauri/package.json
index 163f56c8..32c9425c 100644
--- a/tauri/package.json
+++ b/tauri/package.json
@@ -1,7 +1,7 @@
{
"name": "@voicebox/tauri",
"private": true,
- "version": "0.1.12",
+ "version": "0.2.3",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/tauri/src-tauri/Cargo.lock b/tauri/src-tauri/Cargo.lock
index 4528097c..b133dfc8 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.11"
+version = "0.2.3"
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 739dd34d..68cf2964 100644
--- a/tauri/src-tauri/Cargo.toml
+++ b/tauri/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "voicebox"
-version = "0.1.12"
+version = "0.2.3"
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
authors = ["you"]
license = ""
@@ -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
deleted file mode 100644
index de0e9a08..00000000
Binary files a/tauri/src-tauri/gen/Assets.car and /dev/null 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..21c44190 100644
--- a/tauri/src-tauri/src/main.rs
+++ b/tauri/src-tauri/src/main.rs
@@ -12,10 +12,52 @@ use tokio::sync::mpsc;
const LEGACY_PORT: u16 = 8000;
const SERVER_PORT: u16 = 17493;
+/// Find a voicebox-server process listening on a given port (Windows only).
+///
+/// Uses PowerShell `Get-NetTCPConnection` to look up the PID owning the port,
+/// then verifies via `tasklist` that it's a voicebox process. The caller is
+/// responsible for checking port occupancy first (e.g. `TcpStream::connect_timeout`).
+/// Replaces the previous `netstat -ano` approach which failed on systems with
+/// corrupted system DLLs (see #277).
+#[cfg(windows)]
+fn find_voicebox_pid_on_port(port: u16) -> Option {
+ use std::process::Command;
+
+ // Use PowerShell's Get-NetTCPConnection to find the PID listening on the port.
+ // This is a built-in cmdlet that doesn't depend on netstat.exe.
+ let ps_script = format!(
+ "Get-NetTCPConnection -LocalPort {} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess",
+ port
+ );
+ if let Ok(output) = Command::new("powershell")
+ .args(["-NoProfile", "-Command", &ps_script])
+ .output()
+ {
+ let output_str = String::from_utf8_lossy(&output.stdout);
+ for line in output_str.lines() {
+ if let Ok(pid) = line.trim().parse::() {
+ // Verify this PID is a voicebox process
+ if let Ok(tasklist_output) = Command::new("tasklist")
+ .args(["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"])
+ .output()
+ {
+ let tasklist_str = String::from_utf8_lossy(&tasklist_output.stdout);
+ if tasklist_str.to_lowercase().contains("voicebox") {
+ return Some(pid);
+ }
+ }
+ }
+ }
+ }
+
+ None
+}
+
struct ServerState {
child: Mutex>,
server_pid: Mutex >,
keep_running_on_close: Mutex,
+ models_dir: Mutex>,
}
#[command]
@@ -23,7 +65,16 @@ async fn start_server(
app: tauri::AppHandle,
state: State<'_, ServerState>,
remote: Option,
+ models_dir: Option,
) -> Result {
+ // Store models_dir for use on restart (empty string means reset to default)
+ if let Some(ref dir) = models_dir {
+ if dir.is_empty() {
+ *state.models_dir.lock().unwrap() = None;
+ } else {
+ *state.models_dir.lock().unwrap() = Some(dir.clone());
+ }
+ }
// Check if server is already running (managed by this app instance)
if state.child.lock().unwrap().is_some() {
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
@@ -58,31 +109,22 @@ async fn start_server(
#[cfg(windows)]
{
- use std::process::Command;
- if let Ok(output) = Command::new("netstat")
- .args(["-ano"])
- .output()
- {
- let output_str = String::from_utf8_lossy(&output.stdout);
- for line in output_str.lines() {
- if line.contains(&format!(":{}", SERVER_PORT)) && line.contains("LISTENING") {
- if let Some(pid_str) = line.split_whitespace().last() {
- if let Ok(pid) = pid_str.parse::() {
- if let Ok(tasklist_output) = Command::new("tasklist")
- .args(["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"])
- .output()
- {
- let tasklist_str = String::from_utf8_lossy(&tasklist_output.stdout);
- if tasklist_str.to_lowercase().contains("voicebox") {
- println!("Found existing voicebox-server on port {} (PID: {}), reusing it", SERVER_PORT, pid);
- // Store the PID so we can kill it on exit if needed
- *state.server_pid.lock().unwrap() = Some(pid);
- return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
- }
- }
- }
- }
- }
+ use std::net::TcpStream;
+ if TcpStream::connect_timeout(
+ &format!("127.0.0.1:{}", SERVER_PORT).parse().unwrap(),
+ std::time::Duration::from_secs(1),
+ ).is_ok() {
+ // Port is in use — check if it's a voicebox process
+ if let Some(pid) = find_voicebox_pid_on_port(SERVER_PORT) {
+ println!("Found existing voicebox-server on port {} (PID: {}), reusing it", SERVER_PORT, pid);
+ *state.server_pid.lock().unwrap() = Some(pid);
+ return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
+ } else {
+ return Err(format!(
+ "Port {} is already in use by another application. \
+ Close the other application or change the Voicebox port.",
+ SERVER_PORT
+ ));
}
}
}
@@ -92,24 +134,20 @@ async fn start_server(
#[cfg(unix)]
{
use std::process::Command;
- // Find processes listening on legacy port 8000 with their command names
if let Ok(output) = Command::new("lsof")
.args(["-i", &format!(":{}", LEGACY_PORT), "-sTCP:LISTEN"])
.output()
{
let output_str = String::from_utf8_lossy(&output.stdout);
- for line in output_str.lines().skip(1) { // Skip header line
- // lsof output format: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
+ for line in output_str.lines().skip(1) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
let command = parts[0];
let pid_str = parts[1];
- // Only kill if it's a voicebox-server process
if command.contains("voicebox") {
if let Ok(pid) = pid_str.parse::() {
println!("Found orphaned voicebox-server on legacy port {} (PID: {}, CMD: {}), killing it...", LEGACY_PORT, pid, command);
- // Kill the process group
let _ = Command::new("kill")
.args(["-9", "--", &format!("-{}", pid)])
.output();
@@ -127,35 +165,16 @@ async fn start_server(
#[cfg(windows)]
{
- use std::process::Command;
- // On Windows, find PIDs on legacy port 8000, then check their names
- if let Ok(output) = Command::new("netstat")
- .args(["-ano"])
- .output()
- {
- let output_str = String::from_utf8_lossy(&output.stdout);
- for line in output_str.lines() {
- if line.contains(&format!(":{}", LEGACY_PORT)) && line.contains("LISTENING") {
- if let Some(pid_str) = line.split_whitespace().last() {
- if let Ok(pid) = pid_str.parse::() {
- // Get process name for this PID
- if let Ok(tasklist_output) = Command::new("tasklist")
- .args(["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"])
- .output()
- {
- let tasklist_str = String::from_utf8_lossy(&tasklist_output.stdout);
- if tasklist_str.to_lowercase().contains("voicebox") {
- println!("Found orphaned voicebox-server on legacy port {} (PID: {}), killing it...", LEGACY_PORT, pid);
- let _ = Command::new("taskkill")
- .args(["/PID", &pid.to_string(), "/T", "/F"])
- .output();
- } else {
- println!("Legacy port {} is in use by non-voicebox process (PID: {}), not killing", LEGACY_PORT, pid);
- }
- }
- }
- }
- }
+ use std::net::TcpStream;
+ if TcpStream::connect_timeout(
+ &format!("127.0.0.1:{}", LEGACY_PORT).parse().unwrap(),
+ std::time::Duration::from_secs(1),
+ ).is_ok() {
+ if let Some(pid) = find_voicebox_pid_on_port(LEGACY_PORT) {
+ println!("Found orphaned voicebox-server on legacy port {} (PID: {}), killing it...", LEGACY_PORT, pid);
+ let _ = std::process::Command::new("taskkill")
+ .args(["/PID", &pid.to_string(), "/T", "/F"])
+ .output();
}
}
}
@@ -178,6 +197,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 +285,45 @@ 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 parent_pid_str = std::process::id().to_string();
+ let is_remote = remote.unwrap_or(false);
- if remote.unwrap_or(false) {
- sidecar = sidecar.args(["--host", "0.0.0.0"]);
+ // Resolve the custom models directory from the parameter or stored state
+ let effective_models_dir = models_dir.or_else(|| state.models_dir.lock().unwrap().clone());
+ if let Some(ref dir) = effective_models_dir {
+ println!("Custom models directory: {}", dir);
}
- 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, "--parent-pid", &parent_pid_str]);
+ if is_remote {
+ cmd = cmd.args(["--host", "0.0.0.0"]);
+ }
+ if let Some(ref dir) = effective_models_dir {
+ cmd = cmd.env("VOICEBOX_MODELS_DIR", dir);
+ }
+ cmd.spawn()
+ } else {
+ // Use the bundled CPU sidecar
+ sidecar = sidecar.args(["--data-dir", &data_dir_str, "--port", &port_str, "--parent-pid", &parent_pid_str]);
+ if is_remote {
+ sidecar = sidecar.args(["--host", "0.0.0.0"]);
+ }
+ if let Some(ref dir) = effective_models_dir {
+ sidecar = sidecar.env("VOICEBOX_MODELS_DIR", dir);
+ }
+ println!("Spawning server process...");
+ sidecar.spawn()
+ };
let (mut rx, child) = match spawn_result {
Ok(result) => result,
@@ -408,67 +500,13 @@ async fn start_server(
Ok(format!("http://127.0.0.1:{}", SERVER_PORT))
}
-/// Check if a Windows process is still running
-#[cfg(windows)]
-fn is_process_running(pid: u32) -> bool {
- use std::process::Command;
- if let Ok(output) = Command::new("tasklist")
- .args(["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"])
- .output()
- {
- // If process exists, tasklist returns it in output
- let output_str = String::from_utf8_lossy(&output.stdout);
- return !output_str.trim().is_empty() && output_str.contains(&pid.to_string());
- }
- false
-}
-
-/// Kill entire Windows process tree by enumerating children
-#[cfg(windows)]
-fn kill_windows_process_tree(parent_pid: u32) -> Result<(), String> {
- use std::process::Command;
-
- // Find all child processes using WMIC
- let output = Command::new("wmic")
- .args([
- "process",
- "where",
- &format!("ParentProcessId={}", parent_pid),
- "get",
- "ProcessId"
- ])
- .output();
-
- if let Ok(output) = output {
- let output_str = String::from_utf8_lossy(&output.stdout);
- for line in output_str.lines().skip(1) { // Skip header
- if let Ok(child_pid) = line.trim().parse::() {
- println!("Found child process: {}", child_pid);
- // Recursively kill child's children
- let _ = kill_windows_process_tree(child_pid);
- // Kill the child
- let _ = Command::new("taskkill")
- .args(["/PID", &child_pid.to_string(), "/F"])
- .output();
- }
- }
- }
-
- // Kill the parent process
- let _ = Command::new("taskkill")
- .args(["/PID", &parent_pid.to_string(), "/F"])
- .output();
-
- Ok(())
-}
-
#[command]
async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
let pid = state.server_pid.lock().unwrap().take();
let _child = state.child.lock().unwrap().take();
if let Some(pid) = pid {
- println!("stop_server: Killing server process group with PID: {}", pid);
+ println!("stop_server: Stopping server with PID: {}", pid);
#[cfg(unix)]
{
@@ -487,70 +525,63 @@ async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
let _ = Command::new("kill")
.args(["-9", &pid.to_string()])
.output();
+
+ println!("stop_server: Process group kill completed");
}
#[cfg(windows)]
{
- // Layer 1: Try graceful HTTP shutdown first
- println!("Attempting graceful shutdown via HTTP...");
+ // Send graceful shutdown via HTTP — the server's parent-pid watchdog
+ // will also handle cleanup if this app process exits.
+ println!("Sending graceful shutdown via HTTP...");
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
.unwrap();
- let shutdown_result = client
+ let _ = client
.post(&format!("http://127.0.0.1:{}/shutdown", SERVER_PORT))
.send();
- if shutdown_result.is_ok() {
- println!("HTTP shutdown sent, waiting for graceful exit...");
- // Wait up to 3 seconds for graceful shutdown
- for i in 0..30 {
- std::thread::sleep(std::time::Duration::from_millis(100));
- if !is_process_running(pid) {
- println!("Process exited gracefully after {}ms", i * 100);
- return Ok(());
- }
- }
- println!("Graceful shutdown timed out, forcing kill...");
- } else {
- println!("HTTP shutdown failed, forcing kill...");
- }
-
- // Layer 2: Kill process tree with enumeration
- println!("Killing process tree for wrapper PID {}...", pid);
- kill_windows_process_tree(pid)?;
-
- // Layer 3: Verify and kill by name if still running
- std::thread::sleep(std::time::Duration::from_millis(200));
- if is_process_running(pid) {
- println!("Process tree kill failed, killing by name...");
- use std::process::Command;
- let _ = Command::new("taskkill")
- .args(["/IM", "voicebox-server.exe", "/T", "/F"])
- .output();
- }
-
- // Layer 4: Final verification
- std::thread::sleep(std::time::Duration::from_millis(200));
- if is_process_running(pid) {
- eprintln!("WARNING: Failed to kill server after all attempts");
- } else {
- println!("Server killed successfully");
- }
- }
-
- #[cfg(unix)]
- {
- println!("stop_server: Process group kill completed");
+ println!("Shutdown request sent (server watchdog will handle cleanup)");
}
}
Ok(())
}
+#[command]
+async fn restart_server(
+ app: tauri::AppHandle,
+ state: State<'_, ServerState>,
+ models_dir: Option,
+) -> Result {
+ println!("restart_server: stopping current server...");
+
+ // Update stored models_dir: empty string means reset to default, non-empty means set
+ if let Some(ref dir) = models_dir {
+ if dir.is_empty() {
+ *state.models_dir.lock().unwrap() = None;
+ } else {
+ *state.models_dir.lock().unwrap() = Some(dir.clone());
+ }
+ }
+
+ // 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 and use stored models_dir)
+ println!("restart_server: starting server...");
+ start_server(app, state, None, None).await
+}
+
#[command]
fn set_keep_server_running(state: State<'_, ServerState>, keep_running: bool) {
+ println!("set_keep_server_running called with: {}", keep_running);
*state.keep_running_on_close.lock().unwrap() = keep_running;
}
@@ -607,6 +638,7 @@ pub fn run() {
child: Mutex::new(None),
server_pid: Mutex::new(None),
keep_running_on_close: Mutex::new(false),
+ models_dir: Mutex::new(None),
})
.manage(audio_capture::AudioCaptureState::new())
.manage(audio_output::AudioOutputState::new())
@@ -635,11 +667,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,
@@ -648,9 +718,17 @@ pub fn run() {
play_audio_to_devices,
stop_audio_playback
])
- .on_window_event(|window, event| {
+ .on_window_event({
+ let closing = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
+ move |window, event| {
if let WindowEvent::CloseRequested { api, .. } = event {
- // Prevent automatic close
+ // If we're already in the close flow, let it proceed
+ if closing.load(std::sync::atomic::Ordering::SeqCst) {
+ return;
+ }
+ closing.store(true, std::sync::atomic::Ordering::SeqCst);
+
+ // Prevent automatic close so frontend can clean up
api.prevent_close();
// Emit event to frontend to check setting and stop server if needed
@@ -658,160 +736,83 @@ pub fn run() {
if let Err(e) = app_handle.emit("window-close-requested", ()) {
eprintln!("Failed to emit window-close-requested event: {}", e);
- // If event emission fails, allow close anyway
window.close().ok();
return;
}
// Set up listener for frontend response
let window_for_close = window.clone();
+ let closing_for_timeout = closing.clone();
let (tx, mut rx) = mpsc::unbounded_channel::<()>();
- // Listen for response from frontend using window's listen method
let listener_id = window.listen("window-close-allowed", move |_| {
- // Frontend has checked setting and stopped server if needed
- // Signal that we can close
let _ = tx.send(());
});
- // Wait for frontend response or timeout
- tokio::spawn(async move {
+ tauri::async_runtime::spawn(async move {
tokio::select! {
_ = rx.recv() => {
- // Frontend responded, close window
window_for_close.close().ok();
}
_ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => {
- // Timeout - close anyway
eprintln!("Window close timeout, closing anyway");
window_for_close.close().ok();
}
}
- // Clean up listener
window_for_close.unlisten(listener_id);
+ closing_for_timeout.store(false, std::sync::atomic::Ordering::SeqCst);
});
}
- })
+ }})
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app, event| {
+ let _ = &app; // used on unix
match &event {
RunEvent::Exit => {
- println!("=================================================================");
- println!("RunEvent::Exit received - checking server cleanup");
let state = app.state::();
let keep_running = *state.keep_running_on_close.lock().unwrap();
- println!("keep_running_on_close = {}", keep_running);
-
- if !keep_running {
- // Get the stored PID for process group killing
- let pid = state.server_pid.lock().unwrap().take();
- // Also take the child to clean up
- let _child = state.child.lock().unwrap().take();
-
- if let Some(pid) = pid {
- println!("Killing server process group with PID: {}", pid);
-
- // Kill the entire process group on Unix systems
- // Using negative PID sends signal to all processes in the group
- #[cfg(unix)]
- {
+ let has_pid = state.server_pid.lock().unwrap().is_some();
+ println!("RunEvent::Exit — keep_running={}, has_pid={}", keep_running, has_pid);
+
+ if keep_running {
+ // Tell the server to disable its watchdog so it survives
+ // after this process exits.
+ println!("Keep server running: disabling watchdog...");
+ let client = reqwest::blocking::Client::builder()
+ .timeout(std::time::Duration::from_secs(2))
+ .build()
+ .unwrap();
+ match client
+ .post(&format!("http://127.0.0.1:{}/watchdog/disable", SERVER_PORT))
+ .send()
+ {
+ Ok(resp) => println!("Watchdog disable response: {}", resp.status()),
+ Err(e) => eprintln!("Failed to disable watchdog: {}", e),
+ }
+ } else {
+ // Server will self-terminate via parent-pid watchdog when
+ // this process exits. On Unix, also send SIGTERM for
+ // immediate cleanup.
+ println!("RunEvent::Exit - server will self-terminate via watchdog");
+
+ #[cfg(unix)]
+ {
+ if let Some(pid) = state.server_pid.lock().unwrap().take() {
use std::process::Command;
- // First try SIGTERM to the process group
- let pgid_kill = Command::new("kill")
+ let _ = Command::new("kill")
.args(["-TERM", "--", &format!("-{}", pid)])
.output();
-
- match pgid_kill {
- Ok(output) => {
- if output.status.success() {
- println!("SIGTERM sent to process group -{}", pid);
- } else {
- // Process group kill failed, try direct kill
- println!("Process group kill failed, trying direct kill");
- let _ = Command::new("kill")
- .args(["-TERM", &pid.to_string()])
- .output();
- }
- }
- Err(e) => {
- eprintln!("Failed to execute kill command: {}", e);
- }
- }
-
- // Give it a moment, then force kill if needed
std::thread::sleep(std::time::Duration::from_millis(100));
-
- // Force kill with SIGKILL
let _ = Command::new("kill")
.args(["-9", "--", &format!("-{}", pid)])
.output();
let _ = Command::new("kill")
.args(["-9", &pid.to_string()])
.output();
-
- println!("Server process group kill completed");
}
-
- #[cfg(windows)]
- {
- // Layer 1: Try graceful HTTP shutdown first
- println!("Attempting graceful shutdown via HTTP...");
- let client = reqwest::blocking::Client::builder()
- .timeout(std::time::Duration::from_secs(2))
- .build()
- .unwrap();
-
- let shutdown_result = client
- .post(&format!("http://127.0.0.1:{}/shutdown", SERVER_PORT))
- .send();
-
- if shutdown_result.is_ok() {
- println!("HTTP shutdown sent, waiting for graceful exit...");
- // Wait up to 3 seconds for graceful shutdown
- for i in 0..30 {
- std::thread::sleep(std::time::Duration::from_millis(100));
- if !is_process_running(pid) {
- println!("Process exited gracefully after {}ms", i * 100);
- println!("Server process tree kill completed");
- return;
- }
- }
- println!("Graceful shutdown timed out, forcing kill...");
- } else {
- println!("HTTP shutdown failed, forcing kill...");
- }
-
- // Layer 2: Kill process tree with enumeration
- println!("Killing process tree for wrapper PID {}...", pid);
- let _ = kill_windows_process_tree(pid);
-
- // Layer 3: Verify and kill by name if still running
- std::thread::sleep(std::time::Duration::from_millis(200));
- if is_process_running(pid) {
- println!("Process tree kill failed, killing by name...");
- use std::process::Command;
- let _ = Command::new("taskkill")
- .args(["/IM", "voicebox-server.exe", "/T", "/F"])
- .output();
- }
-
- // Layer 4: Final verification
- std::thread::sleep(std::time::Duration::from_millis(200));
- if is_process_running(pid) {
- eprintln!("WARNING: Failed to kill server after all attempts");
- } else {
- println!("Server killed successfully");
- }
- println!("Server process tree kill completed");
- }
- } else {
- println!("No server PID found (already stopped or never started)");
}
- } else {
- println!("Keeping server running per user setting");
}
- println!("=================================================================");
}
RunEvent::ExitRequested { api, .. } => {
println!("RunEvent::ExitRequested received");
diff --git a/tauri/src-tauri/tauri.conf.json b/tauri/src-tauri/tauri.conf.json
index 53b95d18..53d33d71 100644
--- a/tauri/src-tauri/tauri.conf.json
+++ b/tauri/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Voicebox",
- "version": "0.1.12",
+ "version": "0.2.3",
"identifier": "sh.voicebox.app",
"build": {
"beforeDevCommand": "bun run dev",
@@ -12,7 +12,7 @@
"bundle": {
"active": true,
"targets": "all",
- "createUpdaterArtifacts": true,
+ "createUpdaterArtifacts": "v1Compatible",
"externalBin": ["binaries/voicebox-server"],
"icon": [
"icons/32x32.png",
@@ -56,7 +56,7 @@
},
"plugins": {
"shell": {
- "open": true
+ "open": ".*"
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEUxRENBQkRBQjdBNTM1OTIKUldTU05hVzMycXZjNGJGcUxmcVVocll2QjdSaTJNdlFxR2M3VDJsMnVvbDdyZGRPMmRlOW9aWTcK",
diff --git a/tauri/src/platform/filesystem.ts b/tauri/src/platform/filesystem.ts
index 2292a99a..1d8ded39 100644
--- a/tauri/src/platform/filesystem.ts
+++ b/tauri/src/platform/filesystem.ts
@@ -1,41 +1,38 @@
-import type { PlatformFilesystem, FileFilter } from '@/platform/types';
+import type { FileFilter, PlatformFilesystem } from '@/platform/types';
export const tauriFilesystem: PlatformFilesystem = {
async saveFile(filename: string, blob: Blob, filters?: FileFilter[]) {
- try {
- const { save } = await import('@tauri-apps/plugin-dialog');
- const filePath = await save({
- defaultPath: filename,
- filters: filters || [],
- });
+ const { save } = await import('@tauri-apps/plugin-dialog');
+ const { writeFile } = await import('@tauri-apps/plugin-fs');
- if (filePath) {
- let resolvedPath = '';
- if (typeof filePath === 'string') {
- resolvedPath = filePath;
- } else if (filePath && typeof filePath === 'object' && 'path' in filePath) {
- resolvedPath = (filePath as { path: string }).path;
- }
+ const filePath = await save({
+ defaultPath: filename,
+ filters: filters || [],
+ });
- if (!resolvedPath) {
- throw new Error('Failed to resolve save path');
- }
+ if (!filePath) return; // User cancelled the dialog
- const { writeFile } = await import('@tauri-apps/plugin-fs');
- const arrayBuffer = await blob.arrayBuffer();
- await writeFile(resolvedPath, new Uint8Array(arrayBuffer));
- }
- } catch (error) {
- console.error('Failed to use Tauri dialog, falling back to browser download:', error);
- // Fall back to browser download if Tauri dialog fails
- const url = window.URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- window.URL.revokeObjectURL(url);
- document.body.removeChild(a);
+ const resolvedPath =
+ typeof filePath === 'string' ? filePath : (filePath as { path: string }).path;
+
+ if (!resolvedPath) {
+ throw new Error('Failed to resolve save path from dialog');
}
+
+ const arrayBuffer = await blob.arrayBuffer();
+ await writeFile(resolvedPath, new Uint8Array(arrayBuffer));
+ },
+
+ async openPath(path: string) {
+ const { open } = await import('@tauri-apps/plugin-shell');
+ await open(path);
+ },
+
+ async pickDirectory(title: string) {
+ const { open } = await import('@tauri-apps/plugin-dialog');
+ const selected = await open({ directory: true, title });
+ if (!selected) return null;
+ const dir = typeof selected === 'string' ? selected : (selected as { path: string }).path;
+ return dir || null;
},
};
diff --git a/tauri/src/platform/lifecycle.ts b/tauri/src/platform/lifecycle.ts
index 562c75aa..357f48d3 100644
--- a/tauri/src/platform/lifecycle.ts
+++ b/tauri/src/platform/lifecycle.ts
@@ -1,13 +1,16 @@
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 {
onServerReady?: () => void;
- async startServer(remote = false): Promise {
+ async startServer(remote = false, modelsDir?: string | null): Promise {
try {
- const result = await invoke('start_server', { remote });
+ const result = await invoke('start_server', {
+ remote,
+ modelsDir: modelsDir ?? undefined,
+ });
console.log('Server started:', result);
this.onServerReady?.();
return result;
@@ -27,6 +30,20 @@ class TauriLifecycle implements PlatformLifecycle {
}
}
+ async restartServer(modelsDir?: string | null): Promise {
+ try {
+ const result = await invoke('restart_server', {
+ modelsDir: modelsDir ?? undefined,
+ });
+ 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 });
@@ -47,6 +64,12 @@ class TauriLifecycle implements PlatformLifecycle {
// @ts-expect-error - accessing module-level variable from another module
const serverStartedByApp = window.__voiceboxServerStartedByApp ?? false;
+ console.log(
+ '[lifecycle] window-close-requested: keepRunning=%s, serverStartedByApp=%s',
+ keepRunning,
+ serverStartedByApp,
+ );
+
if (!keepRunning && serverStartedByApp) {
// Stop server before closing (only if we started it)
try {
diff --git a/tauri/src/platform/updater.ts b/tauri/src/platform/updater.ts
index 24a1b6b8..72fffe27 100644
--- a/tauri/src/platform/updater.ts
+++ b/tauri/src/platform/updater.ts
@@ -64,13 +64,17 @@ class TauriUpdater implements PlatformUpdater {
}
this.notifySubscribers();
} catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ // Tauri updater throws on 404 / no published release / network errors.
+ // Treat "no update available" style errors as up-to-date, not failures.
+ const isNoUpdate = /404|not found|no update|up.to.date/i.test(message);
this.status = {
checking: false,
available: false,
downloading: false,
installing: false,
readyToInstall: false,
- error: error instanceof Error ? error.message : 'Failed to check for updates',
+ error: isNoUpdate ? undefined : message,
};
this.notifySubscribers();
}
diff --git a/web/package.json b/web/package.json
index 99d82c56..e06e8be9 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,7 +1,7 @@
{
"name": "@voicebox/web",
"private": true,
- "version": "0.1.12",
+ "version": "0.2.3",
"type": "module",
"scripts": {
"dev": "vite",
@@ -21,6 +21,7 @@
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
+ "@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-react": "^4.3.0",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
diff --git a/web/src/platform/filesystem.ts b/web/src/platform/filesystem.ts
index 1f45a49c..3a871ad2 100644
--- a/web/src/platform/filesystem.ts
+++ b/web/src/platform/filesystem.ts
@@ -1,4 +1,4 @@
-import type { PlatformFilesystem, FileFilter } from '@/platform/types';
+import type { FileFilter, PlatformFilesystem } from '@/platform/types';
export const webFilesystem: PlatformFilesystem = {
async saveFile(filename: string, blob: Blob, _filters?: FileFilter[]) {
@@ -12,4 +12,12 @@ export const webFilesystem: PlatformFilesystem = {
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
},
+
+ async openPath(_path: string) {
+ // No filesystem access in browser
+ },
+
+ async pickDirectory(_title: string) {
+ return null;
+ },
};
diff --git a/web/src/platform/lifecycle.ts b/web/src/platform/lifecycle.ts
index c5e9ea6e..9a6d825a 100644
--- a/web/src/platform/lifecycle.ts
+++ b/web/src/platform/lifecycle.ts
@@ -3,7 +3,7 @@ import type { PlatformLifecycle } from '@/platform/types';
class WebLifecycle implements PlatformLifecycle {
onServerReady?: () => void;
- async startServer(_remote = false): Promise {
+ async startServer(_remote = false, _modelsDir?: string | null): Promise {
// Web assumes server is running externally
// Return a default URL - this should be configured via env vars
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:17493';
@@ -15,6 +15,11 @@ class WebLifecycle implements PlatformLifecycle {
// No-op for web - server is managed externally
}
+ async restartServer(_modelsDir?: string | null): 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
}
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 27a79a73..e3aa4013 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -1,9 +1,10 @@
import path from 'node:path';
import react from '@vitejs/plugin-react';
+import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
- plugins: [react()],
+ plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, '../app/src'),