Merge pull request #269 from jamiepine/feat/async-generation-queue

feat: async generation queue
This commit is contained in:
Jamie Pine
2026-03-13 10:58:18 -07:00
committed by GitHub
29 changed files with 894 additions and 376 deletions
+12 -10
View File
@@ -7,8 +7,8 @@ import { Slider } from '@/components/ui/slider';
import { apiClient } from '@/lib/api/client';
import { formatAudioDuration } from '@/lib/utils/audio';
import { debug } from '@/lib/utils/debug';
import { usePlayerStore } from '@/stores/playerStore';
import { usePlatform } from '@/platform/PlatformContext';
import { usePlayerStore } from '@/stores/playerStore';
export function AudioPlayer() {
const platform = usePlatform();
@@ -360,7 +360,7 @@ export function AudioPlayer() {
if (shouldAutoPlayNow) {
// Clear the flag first
usePlayerStore.getState().clearAutoPlayFlag();
// Use a small delay to ensure audio element is fully ready
setTimeout(() => {
wavesurfer.play().catch((error) => {
@@ -665,7 +665,7 @@ export function AudioPlayer() {
// Handle shouldAutoPlay flag - for story mode auto-advance
const shouldAutoPlay = usePlayerStore((state) => state.shouldAutoPlay);
const clearAutoPlayFlag = usePlayerStore((state) => state.clearAutoPlayFlag);
useEffect(() => {
const wavesurfer = wavesurferRef.current;
if (!wavesurfer || !shouldAutoPlay || duration === 0) {
@@ -833,11 +833,7 @@ export function AudioPlayer() {
className="shrink-0"
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
aria-label={
duration === 0 && !isLoading
? 'Audio not loaded'
: isPlaying
? 'Pause'
: 'Play'
duration === 0 && !isLoading ? 'Audio not loaded' : isPlaying ? 'Pause' : 'Play'
}
>
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
@@ -872,7 +868,9 @@ export function AudioPlayer() {
{/* Title */}
{title && (
<div className="text-sm font-medium truncate max-w-[200px] shrink-0">{title}</div>
<div className="text-sm font-medium truncate max-w-[200px] shrink-0 hidden lg:block">
{title}
</div>
)}
{/* Loop Button */}
@@ -888,7 +886,11 @@ export function AudioPlayer() {
</Button>
{/* Volume Control */}
<div className="flex items-center gap-2 shrink-0 w-[120px]" role="group" aria-label="Volume">
<div
className="flex items-center gap-2 shrink-0 w-[120px]"
role="group"
aria-label="Volume"
>
<Button
variant="ghost"
size="icon"
@@ -12,12 +12,12 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
import { useAddStoryItem, useStory } from '@/lib/hooks/useStories';
import { useStory } from '@/lib/hooks/useStories';
import { cn } from '@/lib/utils/cn';
import { useGenerationStore } from '@/stores/generationStore';
import { useStoryStore } from '@/stores/storyStore';
import { useUIStore } from '@/stores/uiStore';
import { ParalinguisticInput } from './ParalinguisticInput';
@@ -44,8 +44,7 @@ export function FloatingGenerateBox({
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
const { data: currentStory } = useStory(selectedStoryId);
const addStoryItem = useAddStoryItem();
const { toast } = useToast();
const addPendingStoryAdd = useGenerationStore((s) => s.addPendingStoryAdd);
// Calculate if track editor is visible (on stories route with items)
const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0;
@@ -53,25 +52,9 @@ export function FloatingGenerateBox({
const { form, handleSubmit, isPending } = useGenerationForm({
onSuccess: async (generationId) => {
setIsExpanded(false);
// If on stories route and a story is selected, add generation to story
// Defer the story add until TTS completes — useGenerationProgress handles it
if (isStoriesRoute && selectedStoryId && generationId) {
try {
await addStoryItem.mutateAsync({
storyId: selectedStoryId,
data: { generation_id: generationId },
});
toast({
title: 'Added to story',
description: `Generation added to "${currentStory?.name || 'story'}"`,
});
} catch (error) {
toast({
title: 'Failed to add to story',
description:
error instanceof Error ? error.message : 'Could not add generation to story',
variant: 'destructive',
});
}
addPendingStoryAdd(generationId, selectedStoryId);
}
},
});
@@ -182,7 +165,7 @@ export function FloatingGenerateBox({
isStoriesRoute
? // Position aligned with story list: after sidebar + padding, width 360px
'left-[calc(5rem+2rem)] w-[360px]'
: 'left-[calc(5rem+2rem)] w-[calc((100%-5rem-4rem)/2-1rem)]',
: 'left-[calc(5rem+2rem)] right-8 lg:right-auto lg:w-[calc((100%-5rem-4rem)/2-1rem)]',
)}
style={{
// On stories route: offset by track editor height when visible
+120 -61
View File
@@ -1,13 +1,15 @@
import { useQueryClient } from '@tanstack/react-query';
import {
AudioWaveform,
Download,
FileArchive,
Loader2,
MoreHorizontal,
Play,
RotateCcw,
Trash2,
} from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import Loader from 'react-loaders';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -36,7 +38,8 @@ import {
useImportGeneration,
} from '@/lib/hooks/useHistory';
import { cn } from '@/lib/utils/cn';
import { formatDate, formatDuration } from '@/lib/utils/format';
import { formatDate, formatDuration, formatEngineName } from '@/lib/utils/format';
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
@@ -54,9 +57,12 @@ export function HistoryTable() {
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(null);
const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(
null,
);
const limit = 20;
const { toast } = useToast();
const queryClient = useQueryClient();
const {
data: historyData,
@@ -71,6 +77,7 @@ export function HistoryTable() {
const exportGeneration = useExportGeneration();
const exportGenerationAudio = useExportGenerationAudio();
const importGeneration = useImportGeneration();
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
const currentAudioId = usePlayerStore((state) => state.audioId);
@@ -194,6 +201,20 @@ export function HistoryTable() {
}
};
const handleRetry = async (generationId: string) => {
try {
const result = await apiClient.retryGeneration(generationId);
addPendingGeneration(result.id);
queryClient.invalidateQueries({ queryKey: ['history'] });
} catch (error) {
toast({
title: 'Retry failed',
description: error instanceof Error ? error.message : 'Could not retry generation',
variant: 'destructive',
});
}
};
const handleImportConfirm = () => {
if (selectedFile) {
importGeneration.mutate(selectedFile, {
@@ -250,22 +271,30 @@ export function HistoryTable() {
>
{history.map((gen) => {
const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying;
const isGenerating = gen.status === 'generating';
const isFailed = gen.status === 'failed';
const isPlayable = !isGenerating && !isFailed;
return (
<div
key={gen.id}
role="button"
tabIndex={0}
role={isPlayable ? 'button' : undefined}
tabIndex={isPlayable ? 0 : undefined}
className={cn(
'flex items-stretch gap-4 h-26 border rounded-md p-3 bg-card hover:bg-muted/70 transition-colors text-left w-full',
'flex items-stretch gap-4 h-26 border rounded-md p-3 bg-card transition-colors text-left w-full',
isPlayable && 'hover:bg-muted/70 cursor-pointer',
isCurrentlyPlaying && 'bg-muted/70',
)}
aria-label={
isCurrentlyPlaying
? `Sample from ${gen.profile_name}, ${formatDuration(gen.duration)}, ${formatDate(gen.created_at)}. Playing. Press Enter to restart.`
: `Sample from ${gen.profile_name}, ${formatDuration(gen.duration)}, ${formatDate(gen.created_at)}. Press Enter to play.`
isGenerating
? `Generating speech for ${gen.profile_name}...`
: isFailed
? `Generation failed for ${gen.profile_name}`
: isCurrentlyPlaying
? `Sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}, ${formatDate(gen.created_at)}. Playing. Press Enter to restart.`
: `Sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}, ${formatDate(gen.created_at)}. Press Enter to play.`
}
onMouseDown={(e) => {
// Don't trigger play if clicking on textarea or if text is selected
if (!isPlayable) return;
const target = e.target as HTMLElement;
if (target.closest('textarea') || window.getSelection()?.toString()) {
return;
@@ -273,6 +302,7 @@ export function HistoryTable() {
handlePlay(gen.id, gen.text, gen.profile_id);
}}
onKeyDown={(e) => {
if (!isPlayable) return;
const target = e.target as HTMLElement;
if (target.closest('textarea') || target.closest('button')) return;
if (e.key === 'Enter' || e.key === ' ') {
@@ -281,9 +311,14 @@ export function HistoryTable() {
}
}}
>
{/* Waveform icon */}
<div className="flex items-center shrink-0">
<AudioWaveform className="h-5 w-5 text-muted-foreground" />
{/* Status icon */}
<div className="flex items-center shrink-0 w-10 justify-center overflow-hidden">
<div className="scale-50">
<Loader
type={isGenerating ? 'line-scale' : 'line-scale-pulse-out-rapid'}
active={isGenerating || isCurrentlyPlaying}
/>
</div>
</div>
{/* Left side - Meta information */}
@@ -294,11 +329,22 @@ export function HistoryTable() {
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{gen.language}</span>
<span className="text-xs text-muted-foreground">
{formatDuration(gen.duration)}
{formatEngineName(gen.engine, gen.model_size)}
</span>
{isFailed ? (
<span className="text-xs text-destructive">Failed</span>
) : !isGenerating ? (
<span className="text-xs text-muted-foreground">
{formatDuration(gen.duration ?? 0)}
</span>
) : null}
</div>
<div className="text-xs text-muted-foreground">
{formatDate(gen.created_at)}
{isGenerating ? (
<span className="text-accent">Generating...</span>
) : (
formatDate(gen.created_at)
)}
</div>
</div>
@@ -308,58 +354,70 @@ export function HistoryTable() {
value={gen.text}
className="flex-1 resize-none text-sm text-muted-foreground select-text"
readOnly
aria-label={`Transcript for sample from ${gen.profile_name}, ${formatDuration(gen.duration)}`}
aria-label={`Transcript for sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}`}
/>
</div>
{/* Far right - Ellipsis actions */}
{/* Far right - Actions */}
<div
className="w-10 shrink-0 flex justify-end"
className="w-10 shrink-0 flex justify-end items-center"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label="Actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
>
<Play className="mr-2 h-4 w-4" />
Play
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDownloadAudio(gen.id, gen.text)}
disabled={exportGenerationAudio.isPending}
>
<Download className="mr-2 h-4 w-4" />
Export Audio
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExportPackage(gen.id, gen.text)}
disabled={exportGeneration.isPending}
>
<FileArchive className="mr-2 h-4 w-4" />
Export Package
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
disabled={deleteGeneration.isPending}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isFailed ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label="Retry generation"
onClick={() => handleRetry(gen.id)}
>
<RotateCcw className="h-4 w-4" />
</Button>
) : isPlayable ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label="Actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
>
<Play className="mr-2 h-4 w-4" />
Play
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDownloadAudio(gen.id, gen.text)}
disabled={exportGenerationAudio.isPending}
>
<Download className="mr-2 h-4 w-4" />
Export Audio
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExportPackage(gen.id, gen.text)}
disabled={exportGeneration.isPending}
>
<FileArchive className="mr-2 h-4 w-4" />
Export Package
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
disabled={deleteGeneration.isPending}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
);
@@ -387,7 +445,8 @@ export function HistoryTable() {
<DialogHeader>
<DialogTitle>Delete Generation</DialogTitle>
<DialogDescription>
Are you sure you want to delete this generation from "{generationToDelete?.name}"? This action cannot be undone.
Are you sure you want to delete this generation from "{generationToDelete?.name}"?
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
+7 -7
View File
@@ -13,7 +13,7 @@ import {
} from '@/components/ui/dialog';
import { useToast } from '@/components/ui/use-toast';
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { useImportProfile } from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore';
@@ -77,9 +77,9 @@ export function MainEditor() {
return (
// Main view: Profiles top left, Generator bottom left, History right
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0 overflow-hidden relative">
<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-6 h-full min-h-0 overflow-hidden relative">
{/* Left Column */}
<div className="flex flex-col min-h-0 overflow-hidden relative">
<div className="flex flex-col min-h-0 overflow-hidden relative lg:overflow-hidden">
{/* Scroll Mask - Always visible, behind content */}
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />
@@ -110,10 +110,7 @@ export function MainEditor() {
{/* Scrollable Content */}
<div
ref={scrollRef}
className={cn(
'flex-1 min-h-0 overflow-y-auto pt-14',
isPlayerVisible ? BOTTOM_SAFE_AREA_PADDING : 'pb-4',
)}
className={cn('flex-1 min-h-0 overflow-y-auto pt-14 pb-4', isPlayerVisible && 'lg:pb-32')}
>
<div className="flex flex-col gap-6">
<div className="shrink-0 flex flex-col">
@@ -123,6 +120,9 @@ export function MainEditor() {
</div>
</div>
{/* Divider - single column only */}
{/* <div className="border-t border-border -my-3 lg:hidden" /> */}
{/* Right Column - History */}
<div className="flex flex-col min-h-0 overflow-hidden">
<HistoryTable />
@@ -124,6 +124,7 @@ export function ConnectionForm() {
<div className="flex items-start space-x-3">
<Checkbox
id="keepServerRunning"
className="mt-[6px]"
checked={keepServerRunningOnClose}
onCheckedChange={(checked: boolean) => {
setKeepServerRunningOnClose(checked);
@@ -158,6 +159,7 @@ export function ConnectionForm() {
<div className="flex items-start space-x-3">
<Checkbox
id="allowNetworkAccess"
className="mt-[6px]"
checked={mode === 'remote'}
onCheckedChange={(checked: boolean) => {
setMode(checked ? 'remote' : 'local');
@@ -10,6 +10,8 @@ export function GenerationSettings() {
const setCrossfadeMs = useServerStore((state) => state.setCrossfadeMs);
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
const setNormalizeAudio = useServerStore((state) => state.setNormalizeAudio);
const autoplayOnGenerate = useServerStore((state) => state.autoplayOnGenerate);
const setAutoplayOnGenerate = useServerStore((state) => state.setAutoplayOnGenerate);
return (
<Card role="region" aria-label="Generation Settings" tabIndex={0}>
@@ -35,7 +37,7 @@ export function GenerationSettings() {
value={[maxChunkChars]}
onValueChange={([value]) => setMaxChunkChars(value)}
min={100}
max={2000}
max={5000}
step={50}
aria-label="Auto-chunking character limit"
/>
@@ -73,6 +75,7 @@ export function GenerationSettings() {
id="normalizeAudio"
checked={normalizeAudio}
onCheckedChange={setNormalizeAudio}
className="mt-[6px]"
/>
<div className="space-y-1">
<label
@@ -86,6 +89,26 @@ export function GenerationSettings() {
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="autoplayOnGenerate"
checked={autoplayOnGenerate}
onCheckedChange={setAutoplayOnGenerate}
className="mt-[6px]"
/>
<div className="space-y-1">
<label
htmlFor="autoplayOnGenerate"
className="text-sm font-medium leading-none cursor-pointer"
>
Autoplay on generate
</label>
<p className="text-sm text-muted-foreground">
Automatically play audio when a generation completes.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
+11 -22
View File
@@ -1,9 +1,9 @@
import { Link, useMatchRoute } from '@tanstack/react-router';
import { Box, BookOpen, Loader2, Mic, Server, Speaker, Volume2 } from 'lucide-react';
import { BookOpen, Box, Mic, Server, Speaker, Volume2 } from 'lucide-react';
import voiceboxLogo from '@/assets/voicebox-logo.png';
import { cn } from '@/lib/utils/cn';
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
import { version } from '../../package.json';
interface SidebarProps {
isMacOS?: boolean;
@@ -19,10 +19,8 @@ const tabs = [
];
export function Sidebar({ isMacOS }: SidebarProps) {
const isGenerating = useGenerationStore((state) => state.isGenerating);
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
const matchRoute = useMatchRoute();
const isPlayerOpen = !!usePlayerStore((s) => s.audioUrl);
return (
<div
@@ -42,9 +40,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
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 });
return (
<Link
@@ -64,20 +60,13 @@ export function Sidebar({ isMacOS }: SidebarProps) {
})}
</div>
{/* Spacer to push loader to bottom */}
<div className="flex-1" />
{/* Generation Loader */}
{isGenerating && (
<div
className={cn(
'w-full flex items-center justify-center transition-all duration-200',
isPlayerVisible ? 'mb-[120px]' : 'mb-0',
)}
>
<Loader2 className="h-6 w-6 text-accent animate-spin" />
</div>
)}
{/* Version */}
<div
className="mt-auto text-[10px] text-muted-foreground/50 transition-all duration-300"
style={{ paddingBottom: isPlayerOpen ? '7rem' : undefined }}
>
v{version}
</div>
</div>
);
}
+33 -6
View File
@@ -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<HTMLDivElement>(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() {
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
)}
</div>
<div className="flex gap-2">
<div className="flex gap-2 items-center">
<AnimatePresence>
{pendingCount > 0 && (
<motion.div
initial={{ opacity: 0, scale: 0.9, width: 0 }}
animate={{ opacity: 1, scale: 1, width: 'auto' }}
exit={{ opacity: 0, scale: 0.9, width: 0 }}
transition={{ duration: 0.2 }}
>
<Link
to="/"
className="flex items-center gap-2 h-8 pl-1.5 pr-3 rounded-full bg-card border border-border hover:bg-muted/50 transition-all duration-200 cursor-pointer"
>
<div className="shrink-0 w-10 h-5 overflow-hidden flex items-center justify-center">
<div className="scale-[0.45]">
<Loader type="line-scale" active />
</div>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
Generating {pendingCount} {pendingCount === 1 ? 'audio' : 'audios'}
</span>
</Link>
</motion.div>
)}
</AnimatePresence>
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
@@ -287,9 +316,7 @@ export function StoryContent() {
<div className="max-h-60 overflow-y-auto">
{availableGenerations.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{searchQuery
? 'No matching generations found'
: 'No available generations'}
{searchQuery ? 'No matching generations found' : 'No available generations'}
</div>
) : (
availableGenerations.map((gen) => (
@@ -78,7 +78,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
<>
<Card
className={cn(
'cursor-pointer hover:shadow-md transition-all flex flex-col',
'cursor-pointer hover:shadow-md transition-all flex flex-col h-[162px]',
isSelected && 'ring-2 ring-primary shadow-md',
)}
onClick={handleSelect}
@@ -41,9 +41,11 @@ export function ProfileList() {
</CardContent>
</Card>
) : (
<div className="grid gap-4 grid-cols-3 auto-rows-auto p-1 pb-[150px]">
<div className="flex gap-4 overflow-x-auto p-1 pb-1 lg:grid lg:grid-cols-3 lg:auto-rows-auto lg:overflow-x-visible lg:pb-[150px]">
{allProfiles.map((profile) => (
<ProfileCard key={profile.id} profile={profile} />
<div key={profile.id} className="shrink-0 w-[200px] lg:w-auto lg:shrink">
<ProfileCard profile={profile} />
</div>
))}
</div>
)}
+1 -1
View File
@@ -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 {
+16
View File
@@ -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;
}
+11
View File
@@ -200,6 +200,12 @@ class ApiClient {
});
}
async retryGeneration(generationId: string): Promise<GenerationResponse> {
return this.request<GenerationResponse>(`/generate/${generationId}/retry`, {
method: 'POST',
});
}
// History
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
const params = new URLSearchParams();
@@ -278,6 +284,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}`;
+7 -2
View File
@@ -46,9 +46,14 @@ 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: 'generating' | 'completed' | 'failed';
error?: string;
created_at: string;
}
+6 -13
View File
@@ -8,7 +8,6 @@ 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({
@@ -30,8 +29,7 @@ interface UseGenerationFormOptions {
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);
@@ -71,8 +69,6 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
}
try {
setIsGenerating(true);
const engine = data.engine || 'qwen';
const modelName =
engine === 'luxtts'
@@ -93,6 +89,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
? '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);
@@ -106,6 +103,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
}
const isQwen = engine === 'qwen';
// This now returns immediately with status="generating"
const result = await generation.mutateAsync({
profile_id: selectedProfileId,
text: data.text,
@@ -119,14 +117,10 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
normalize: normalizeAudio,
});
toast({
title: 'Generation complete!',
description: `Audio generated (${result.duration.toFixed(2)}s)`,
});
const audioUrl = apiClient.getAudioUrl(result.id);
setAudioWithAutoPlay(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
// Track this generation for SSE status updates
addPendingGeneration(result.id);
// Reset form immediately — user can start typing again
form.reset({
text: '',
language: data.language,
@@ -143,7 +137,6 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
variant: 'destructive',
});
} finally {
setIsGenerating(false);
setDownloadingModelName(null);
setDownloadingDisplayName(null);
}
+154
View File
@@ -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: '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<Map<string, EventSource>>(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,
]);
}
+6 -6
View File
@@ -15,8 +15,8 @@ const POLL_INTERVAL = 30000;
*/
export function useRestoreActiveTasks() {
const [activeDownloads, setActiveDownloads] = useState<ActiveDownloadTask[]>([]);
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<Set<string>>(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);
}
}
@@ -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
+16 -1
View File
@@ -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<string, string> = {
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;
+5
View File
@@ -8,8 +8,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 +20,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 (
<AppFrame>
<div className="flex flex-1 min-h-0 overflow-hidden">
+47 -4
View File
@@ -1,15 +1,58 @@
import { create } from 'zustand';
interface GenerationState {
/** IDs of generations currently in progress */
pendingGenerationIds: Set<string>;
/** 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<string, string>;
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<GenerationState>((set) => ({
export const useGenerationStore = create<GenerationState>((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 }),
}));
+6
View File
@@ -23,6 +23,9 @@ interface ServerStore {
normalizeAudio: boolean;
setNormalizeAudio: (value: boolean) => void;
autoplayOnGenerate: boolean;
setAutoplayOnGenerate: (value: boolean) => void;
customModelsDir: string | null;
setCustomModelsDir: (dir: string | null) => void;
}
@@ -51,6 +54,9 @@ export const useServerStore = create<ServerStore>()(
normalizeAudio: true,
setNormalizeAudio: (value) => set({ normalizeAudio: value }),
autoplayOnGenerate: true,
setAutoplayOnGenerate: (value) => set({ autoplayOnGenerate: value }),
customModelsDir: null,
setCustomModelsDir: (dir) => set({ customModelsDir: dir }),
}),
+36 -2
View File
@@ -45,10 +45,14 @@ class Generation(Base):
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)
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") # generating, completed, failed
error = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
@@ -288,6 +292,36 @@ def _run_migrations(engine):
conn.commit()
print("Added avatar_path column to profiles")
# Migration: Add status and error columns to generations table
if 'generations' in inspector.get_table_names():
columns = {col['name'] for col in inspector.get_columns('generations')}
if 'status' not in columns:
print("Migrating generations: adding status column")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE generations ADD COLUMN status VARCHAR DEFAULT 'completed'"))
conn.commit()
print("Added status column to generations")
if 'error' not in columns:
print("Migrating generations: adding error column")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE generations ADD COLUMN error TEXT"))
conn.commit()
print("Added error column to generations")
if 'engine' not in columns:
print("Migrating generations: adding engine column")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE generations ADD COLUMN engine VARCHAR DEFAULT 'qwen'"))
conn.commit()
print("Added engine column to generations")
# Re-read columns after engine migration (variable name shadows outer `engine`)
columns = {col['name'] for col in inspector.get_columns('generations')}
if 'model_size' not in columns:
print("Migrating generations: adding model_size column")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE generations ADD COLUMN model_size VARCHAR"))
conn.commit()
print("Added model_size column to generations")
def get_db():
"""Get database session (generator for dependency injection)."""
+42 -1
View File
@@ -29,6 +29,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 +46,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 +63,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 +76,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,
@@ -143,6 +180,10 @@ 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,
created_at=generation.created_at,
))
+280 -198
View File
@@ -62,6 +62,9 @@ from .platform_detect import get_backend_type
# 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."""
@@ -71,6 +74,24 @@ def _create_background_task(coro) -> asyncio.Task:
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:
import traceback
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)
app = FastAPI(
title="voicebox API",
description="Production-quality Qwen3-TTS voice cloning API",
@@ -695,214 +716,255 @@ async def generate_speech(
data: models.GenerationRequest,
db: Session = Depends(get_db),
):
"""Generate speech from text using a voice profile."""
"""Generate speech from text using a voice profile.
Creates a history entry immediately with status='generating' and kicks off
TTS in the background. The frontend can poll or use SSE to detect completion.
"""
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")
# Generate audio
from .backends import get_tts_backend_for_engine
engine = data.engine or "qwen"
tts_model = get_tts_backend_for_engine(engine)
# Validate profile exists before creating the record
profile = await profiles.get_profile(data.profile_id, db)
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
# Resolve model size (only relevant for Qwen engine)
model_size = data.model_size or "1.7B"
from .backends import get_tts_backend_for_engine
engine = data.engine or "qwen"
tts_model = get_tts_backend_for_engine(engine)
model_size = data.model_size or "1.7B"
# Check if model needs to be downloaded first
if engine == "qwen":
if not tts_model._is_model_cached(model_size):
model_name = f"qwen-tts-{model_size}"
# Create the history entry immediately with status="generating"
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 == "qwen" else None,
)
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)
_create_background_task(download_model_background())
raise HTTPException(
status_code=202,
detail={
"message": f"Model {model_size} is being downloaded. Please wait and try again.",
"model_name": model_name,
"downloading": True,
},
)
# Load (or switch to) the requested model
await tts_model.load_model_async(model_size)
elif engine == "luxtts":
if not tts_model._is_model_cached():
model_name = "luxtts"
async def download_luxtts_background():
try:
await tts_model.load_model()
except Exception as e:
task_manager.error_download(model_name, str(e))
task_manager.start_download(model_name)
_create_background_task(download_luxtts_background())
raise HTTPException(
status_code=202,
detail={
"message": "LuxTTS model is being downloaded. Please wait and try again.",
"model_name": model_name,
"downloading": True,
},
)
await tts_model.load_model()
elif engine == "chatterbox":
if not tts_model._is_model_cached():
model_name = "chatterbox-tts"
async def download_chatterbox_background():
try:
await tts_model.load_model()
except Exception as e:
task_manager.error_download(model_name, str(e))
task_manager.start_download(model_name)
asyncio.create_task(download_chatterbox_background())
raise HTTPException(
status_code=202,
detail={
"message": "Chatterbox model is being downloaded. Please wait and try again.",
"model_name": model_name,
"downloading": True,
},
)
await tts_model.load_model()
elif engine == "chatterbox_turbo":
if not tts_model._is_model_cached():
model_name = "chatterbox-turbo"
async def download_chatterbox_turbo_background():
try:
await tts_model.load_model()
except Exception as e:
task_manager.error_download(model_name, str(e))
task_manager.start_download(model_name)
asyncio.create_task(download_chatterbox_turbo_background())
raise HTTPException(
status_code=202,
detail={
"message": "Chatterbox Turbo model is being downloaded. Please wait and try again.",
"model_name": model_name,
"downloading": True,
},
)
await tts_model.load_model()
# Create voice prompt from profile
voice_prompt = await profiles.create_voice_prompt_for_profile(
data.profile_id,
db,
use_cache=True,
engine=engine,
)
from .utils.chunked_tts import generate_chunked
# Resolve per-chunk trim function for engines that need it
trim_fn = None
if engine in ("chatterbox", "chatterbox_turbo"):
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)
# 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
import errno
# Track in task manager
task_manager.start_generation(
task_id=generation_id,
profile_id=data.profile_id,
text=data.text,
)
# Kick off TTS in background
async def _run_generation():
bg_db = next(get_db())
try:
save_audio(audio, str(audio_path), sample_rate)
except BrokenPipeError:
raise HTTPException(
status_code=500,
detail="Audio save failed: broken pipe (the output stream was closed unexpectedly)",
)
except OSError as save_err:
err_no = getattr(save_err, "errno", None) or (
getattr(save_err.__cause__, "errno", None)
if save_err.__cause__
else None
)
if err_no == errno.ENOENT:
msg = f"Audio save failed: directory not found — {audio_path.parent}"
elif err_no == errno.EACCES:
msg = f"Audio save failed: permission denied — {audio_path.parent}"
elif err_no == errno.ENOSPC:
msg = "Audio save failed: no disk space remaining"
# Load model
if engine == "qwen":
await tts_model.load_model_async(model_size)
else:
msg = f"Audio save failed: {save_err}"
raise HTTPException(status_code=500, detail=msg)
await tts_model.load_model()
# 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))
# Create voice prompt
voice_prompt = await profiles.create_voice_prompt_for_profile(
data.profile_id,
bg_db,
use_cache=True,
engine=engine,
)
from .utils.chunked_tts import generate_chunked
trim_fn = None
if engine in ("chatterbox", "chatterbox_turbo"):
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)
duration = len(audio) / sample_rate
audio_path = config.get_generations_dir() / f"{generation_id}.wav"
from .utils.audio import save_audio
save_audio(audio, str(audio_path), sample_rate)
# Update the record to completed
await history.update_generation_status(
generation_id=generation_id,
status="completed",
db=bg_db,
audio_path=str(audio_path),
duration=duration,
)
except Exception as e:
import traceback
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()
_enqueue_generation(_run_generation())
return generation
@app.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")
# Reset the record to generating
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,
)
# Resolve engine/model from stored values
retry_engine = gen.engine or "qwen"
retry_model_size = gen.model_size or "1.7B"
from .backends import get_tts_backend_for_engine
tts_model = get_tts_backend_for_engine(retry_engine)
async def _run_retry():
bg_db = next(get_db())
try:
if retry_engine == "qwen":
await tts_model.load_model_async(retry_model_size)
else:
await tts_model.load_model()
voice_prompt = await profiles.create_voice_prompt_for_profile(
gen.profile_id,
bg_db,
use_cache=True,
engine=retry_engine,
)
from .utils.chunked_tts import generate_chunked
trim_fn = None
if retry_engine in ("chatterbox", "chatterbox_turbo"):
from .utils.audio import trim_tts_output
trim_fn = trim_tts_output
audio, sample_rate = await generate_chunked(
tts_model,
gen.text,
voice_prompt,
language=gen.language,
seed=gen.seed,
instruct=gen.instruct,
trim_fn=trim_fn,
)
duration = len(audio) / sample_rate
audio_path = config.get_generations_dir() / f"{generation_id}.wav"
from .utils.audio import save_audio
save_audio(audio, str(audio_path), sample_rate)
await history.update_generation_status(
generation_id=generation_id,
status="completed",
db=bg_db,
audio_path=str(audio_path),
duration=duration,
)
except Exception as e:
import traceback
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()
_enqueue_generation(_run_retry())
return models.GenerationResponse.model_validate(gen)
@app.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.
Polls the DB every second and yields the current status. Closes when
the generation reaches 'completed' or 'failed'.
"""
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",
},
)
@app.post("/generate/stream")
@@ -2480,9 +2542,29 @@ def _get_gpu_status() -> str:
@app.on_event("startup")
async def startup_event():
"""Run on application startup."""
global _generation_queue
print("voicebox API starting up...")
database.init_db()
print(f"Database initialized at {database._db_path}")
# Start the serial generation worker
_generation_queue = asyncio.Queue()
_create_background_task(_generation_worker())
# Mark any stale "generating" records as failed — these are leftovers
# from a previous process that was killed mid-generation
try:
from sqlalchemy import text as sa_text
db = next(get_db())
result = db.execute(
sa_text("UPDATE generations SET status = 'failed', error = 'Server was shut down during generation' WHERE status = 'generating'")
)
if result.rowcount > 0:
print(f"Marked {result.rowcount} stale generation(s) as failed")
db.commit()
db.close()
except Exception as e:
print(f"Warning: Could not clean up stale generations: {e}")
backend_type = get_backend_type()
print(f"Backend: {backend_type.upper()}")
print(f"GPU available: {_get_gpu_status()}")
+16 -8
View File
@@ -69,10 +69,14 @@ class GenerationResponse(BaseModel):
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
created_at: datetime
class Config:
@@ -94,10 +98,14 @@ class HistoryResponse(BaseModel):
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
created_at: datetime
class Config:
+6 -6
View File
@@ -270,11 +270,14 @@ async def add_item_to_story(
generation_created_at=generation.created_at,
)
# 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
# Find the maximum end time on the target track only
existing_items = db.query(
DBStoryItem,
DBGeneration
@@ -282,11 +285,11 @@ async def add_item_to_story(
DBGeneration,
DBStoryItem.generation_id == DBGeneration.id
).filter(
DBStoryItem.story_id == story_id
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
@@ -297,9 +300,6 @@ async def add_item_to_story(
# 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()),
+14
View File
@@ -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",
@@ -678,6 +682,8 @@
"class-variance-authority": ["[email protected]", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"classnames": ["[email protected]", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
"client-only": ["[email protected]", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -874,6 +880,8 @@
"lines-and-columns": ["[email protected]", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"loaders.css": ["[email protected]", "", {}, "sha512-Rhowlq24ey1VOeor+3wYOt9+MjaxBOJm1u4KlQgNC3+0xJ0LS4wq4iG57D/BPzvuD/7HHDGQOWJ+81oR2EI9bQ=="],
"locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.merge": ["[email protected]", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
@@ -960,6 +968,8 @@
"prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prop-types": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"queue-microtask": ["[email protected]", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
@@ -970,6 +980,10 @@
"react-hook-form": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w=="],
"react-is": ["[email protected]", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-loaders": ["[email protected]", "", { "dependencies": { "classnames": "^2.2.3" }, "peerDependencies": { "prop-types": ">=15.6.0", "react": ">=15" } }, "sha512-4igMNqs9Fb3d4Z+0UHIGQNJsw/37gX0nUO8QxupnEKRn1dtyYC1LGwk5GuaoDciMQCQc/MmPwb4Fn6ZfdoX1FQ=="],
"react-refresh": ["[email protected]", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-remove-scroll": ["[email protected]", "", { "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=="],
+5 -1
View File
@@ -40,5 +40,9 @@
"engines": {
"bun": ">=1.0.0"
},
"packageManager": "[email protected]"
"packageManager": "[email protected]",
"dependencies": {
"loaders.css": "^0.1.2",
"react-loaders": "^3.0.1"
}
}
Binary file not shown.