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 { apiClient } from '@/lib/api/client';
import { formatAudioDuration } from '@/lib/utils/audio'; import { formatAudioDuration } from '@/lib/utils/audio';
import { debug } from '@/lib/utils/debug'; import { debug } from '@/lib/utils/debug';
import { usePlayerStore } from '@/stores/playerStore';
import { usePlatform } from '@/platform/PlatformContext'; import { usePlatform } from '@/platform/PlatformContext';
import { usePlayerStore } from '@/stores/playerStore';
export function AudioPlayer() { export function AudioPlayer() {
const platform = usePlatform(); const platform = usePlatform();
@@ -360,7 +360,7 @@ export function AudioPlayer() {
if (shouldAutoPlayNow) { if (shouldAutoPlayNow) {
// Clear the flag first // Clear the flag first
usePlayerStore.getState().clearAutoPlayFlag(); usePlayerStore.getState().clearAutoPlayFlag();
// Use a small delay to ensure audio element is fully ready // Use a small delay to ensure audio element is fully ready
setTimeout(() => { setTimeout(() => {
wavesurfer.play().catch((error) => { wavesurfer.play().catch((error) => {
@@ -665,7 +665,7 @@ export function AudioPlayer() {
// Handle shouldAutoPlay flag - for story mode auto-advance // Handle shouldAutoPlay flag - for story mode auto-advance
const shouldAutoPlay = usePlayerStore((state) => state.shouldAutoPlay); const shouldAutoPlay = usePlayerStore((state) => state.shouldAutoPlay);
const clearAutoPlayFlag = usePlayerStore((state) => state.clearAutoPlayFlag); const clearAutoPlayFlag = usePlayerStore((state) => state.clearAutoPlayFlag);
useEffect(() => { useEffect(() => {
const wavesurfer = wavesurferRef.current; const wavesurfer = wavesurferRef.current;
if (!wavesurfer || !shouldAutoPlay || duration === 0) { if (!wavesurfer || !shouldAutoPlay || duration === 0) {
@@ -833,11 +833,7 @@ export function AudioPlayer() {
className="shrink-0" className="shrink-0"
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''} title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
aria-label={ aria-label={
duration === 0 && !isLoading duration === 0 && !isLoading ? 'Audio not loaded' : isPlaying ? 'Pause' : 'Play'
? 'Audio not loaded'
: isPlaying
? 'Pause'
: 'Play'
} }
> >
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />} {isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
@@ -872,7 +868,9 @@ export function AudioPlayer() {
{/* Title */} {/* Title */}
{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 */} {/* Loop Button */}
@@ -888,7 +886,11 @@ export function AudioPlayer() {
</Button> </Button>
{/* Volume Control */} {/* 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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -12,12 +12,12 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages'; import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
import { useGenerationForm } from '@/lib/hooks/useGenerationForm'; import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles'; 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 { cn } from '@/lib/utils/cn';
import { useGenerationStore } from '@/stores/generationStore';
import { useStoryStore } from '@/stores/storyStore'; import { useStoryStore } from '@/stores/storyStore';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
import { ParalinguisticInput } from './ParalinguisticInput'; import { ParalinguisticInput } from './ParalinguisticInput';
@@ -44,8 +44,7 @@ export function FloatingGenerateBox({
const selectedStoryId = useStoryStore((state) => state.selectedStoryId); const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight); const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
const { data: currentStory } = useStory(selectedStoryId); const { data: currentStory } = useStory(selectedStoryId);
const addStoryItem = useAddStoryItem(); const addPendingStoryAdd = useGenerationStore((s) => s.addPendingStoryAdd);
const { toast } = useToast();
// Calculate if track editor is visible (on stories route with items) // Calculate if track editor is visible (on stories route with items)
const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0; const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0;
@@ -53,25 +52,9 @@ export function FloatingGenerateBox({
const { form, handleSubmit, isPending } = useGenerationForm({ const { form, handleSubmit, isPending } = useGenerationForm({
onSuccess: async (generationId) => { onSuccess: async (generationId) => {
setIsExpanded(false); 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) { if (isStoriesRoute && selectedStoryId && generationId) {
try { addPendingStoryAdd(generationId, selectedStoryId);
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',
});
}
} }
}, },
}); });
@@ -182,7 +165,7 @@ export function FloatingGenerateBox({
isStoriesRoute isStoriesRoute
? // Position aligned with story list: after sidebar + padding, width 360px ? // Position aligned with story list: after sidebar + padding, width 360px
'left-[calc(5rem+2rem)] w-[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={{ style={{
// On stories route: offset by track editor height when visible // 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 { import {
AudioWaveform,
Download, Download,
FileArchive, FileArchive,
Loader2, Loader2,
MoreHorizontal, MoreHorizontal,
Play, Play,
RotateCcw,
Trash2, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import Loader from 'react-loaders';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
@@ -36,7 +38,8 @@ import {
useImportGeneration, useImportGeneration,
} from '@/lib/hooks/useHistory'; } from '@/lib/hooks/useHistory';
import { cn } from '@/lib/utils/cn'; 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'; import { usePlayerStore } from '@/stores/playerStore';
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history) // OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
@@ -54,9 +57,12 @@ export function HistoryTable() {
const [importDialogOpen, setImportDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); 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 limit = 20;
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient();
const { const {
data: historyData, data: historyData,
@@ -71,6 +77,7 @@ export function HistoryTable() {
const exportGeneration = useExportGeneration(); const exportGeneration = useExportGeneration();
const exportGenerationAudio = useExportGenerationAudio(); const exportGenerationAudio = useExportGenerationAudio();
const importGeneration = useImportGeneration(); const importGeneration = useImportGeneration();
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay); const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio); const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
const currentAudioId = usePlayerStore((state) => state.audioId); 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 = () => { const handleImportConfirm = () => {
if (selectedFile) { if (selectedFile) {
importGeneration.mutate(selectedFile, { importGeneration.mutate(selectedFile, {
@@ -250,22 +271,30 @@ export function HistoryTable() {
> >
{history.map((gen) => { {history.map((gen) => {
const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying; const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying;
const isGenerating = gen.status === 'generating';
const isFailed = gen.status === 'failed';
const isPlayable = !isGenerating && !isFailed;
return ( return (
<div <div
key={gen.id} key={gen.id}
role="button" role={isPlayable ? 'button' : undefined}
tabIndex={0} tabIndex={isPlayable ? 0 : undefined}
className={cn( 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', isCurrentlyPlaying && 'bg-muted/70',
)} )}
aria-label={ aria-label={
isCurrentlyPlaying isGenerating
? `Sample from ${gen.profile_name}, ${formatDuration(gen.duration)}, ${formatDate(gen.created_at)}. Playing. Press Enter to restart.` ? `Generating speech for ${gen.profile_name}...`
: `Sample from ${gen.profile_name}, ${formatDuration(gen.duration)}, ${formatDate(gen.created_at)}. Press Enter to play.` : 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) => { onMouseDown={(e) => {
// Don't trigger play if clicking on textarea or if text is selected if (!isPlayable) return;
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.closest('textarea') || window.getSelection()?.toString()) { if (target.closest('textarea') || window.getSelection()?.toString()) {
return; return;
@@ -273,6 +302,7 @@ export function HistoryTable() {
handlePlay(gen.id, gen.text, gen.profile_id); handlePlay(gen.id, gen.text, gen.profile_id);
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (!isPlayable) return;
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.closest('textarea') || target.closest('button')) return; if (target.closest('textarea') || target.closest('button')) return;
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -281,9 +311,14 @@ export function HistoryTable() {
} }
}} }}
> >
{/* Waveform icon */} {/* Status icon */}
<div className="flex items-center shrink-0"> <div className="flex items-center shrink-0 w-10 justify-center overflow-hidden">
<AudioWaveform className="h-5 w-5 text-muted-foreground" /> <div className="scale-50">
<Loader
type={isGenerating ? 'line-scale' : 'line-scale-pulse-out-rapid'}
active={isGenerating || isCurrentlyPlaying}
/>
</div>
</div> </div>
{/* Left side - Meta information */} {/* Left side - Meta information */}
@@ -294,11 +329,22 @@ export function HistoryTable() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{gen.language}</span> <span className="text-xs text-muted-foreground">{gen.language}</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{formatDuration(gen.duration)} {formatEngineName(gen.engine, gen.model_size)}
</span> </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>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{formatDate(gen.created_at)} {isGenerating ? (
<span className="text-accent">Generating...</span>
) : (
formatDate(gen.created_at)
)}
</div> </div>
</div> </div>
@@ -308,58 +354,70 @@ export function HistoryTable() {
value={gen.text} value={gen.text}
className="flex-1 resize-none text-sm text-muted-foreground select-text" className="flex-1 resize-none text-sm text-muted-foreground select-text"
readOnly 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> </div>
{/* Far right - Ellipsis actions */} {/* Far right - Actions */}
<div <div
className="w-10 shrink-0 flex justify-end" className="w-10 shrink-0 flex justify-end items-center"
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<DropdownMenu> {isFailed ? (
<DropdownMenuTrigger asChild> <Button
<Button variant="ghost"
variant="ghost" size="icon"
size="icon" className="h-8 w-8"
className="h-8 w-8" aria-label="Retry generation"
aria-label="Actions" onClick={() => handleRetry(gen.id)}
> >
<MoreHorizontal className="h-4 w-4" /> <RotateCcw className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> ) : isPlayable ? (
<DropdownMenuContent align="end"> <DropdownMenu>
<DropdownMenuItem <DropdownMenuTrigger asChild>
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)} <Button
> variant="ghost"
<Play className="mr-2 h-4 w-4" /> size="icon"
Play className="h-8 w-8"
</DropdownMenuItem> aria-label="Actions"
<DropdownMenuItem >
onClick={() => handleDownloadAudio(gen.id, gen.text)} <MoreHorizontal className="h-4 w-4" />
disabled={exportGenerationAudio.isPending} </Button>
> </DropdownMenuTrigger>
<Download className="mr-2 h-4 w-4" /> <DropdownMenuContent align="end">
Export Audio <DropdownMenuItem
</DropdownMenuItem> onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
<DropdownMenuItem >
onClick={() => handleExportPackage(gen.id, gen.text)} <Play className="mr-2 h-4 w-4" />
disabled={exportGeneration.isPending} Play
> </DropdownMenuItem>
<FileArchive className="mr-2 h-4 w-4" /> <DropdownMenuItem
Export Package onClick={() => handleDownloadAudio(gen.id, gen.text)}
</DropdownMenuItem> disabled={exportGenerationAudio.isPending}
<DropdownMenuItem >
onClick={() => handleDeleteClick(gen.id, gen.profile_name)} <Download className="mr-2 h-4 w-4" />
disabled={deleteGeneration.isPending} Export Audio
className="text-destructive focus:text-destructive" </DropdownMenuItem>
> <DropdownMenuItem
<Trash2 className="mr-2 h-4 w-4" /> onClick={() => handleExportPackage(gen.id, gen.text)}
Delete disabled={exportGeneration.isPending}
</DropdownMenuItem> >
</DropdownMenuContent> <FileArchive className="mr-2 h-4 w-4" />
</DropdownMenu> 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>
</div> </div>
); );
@@ -387,7 +445,8 @@ export function HistoryTable() {
<DialogHeader> <DialogHeader>
<DialogTitle>Delete Generation</DialogTitle> <DialogTitle>Delete Generation</DialogTitle>
<DialogDescription> <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> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
+7 -7
View File
@@ -13,7 +13,7 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { ProfileList } from '@/components/VoiceProfiles/ProfileList'; import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { useImportProfile } from '@/lib/hooks/useProfiles'; import { useImportProfile } from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
@@ -77,9 +77,9 @@ export function MainEditor() {
return ( return (
// Main view: Profiles top left, Generator bottom left, History right // 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 */} {/* 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 */} {/* 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" /> <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 */} {/* Scrollable Content */}
<div <div
ref={scrollRef} ref={scrollRef}
className={cn( className={cn('flex-1 min-h-0 overflow-y-auto pt-14 pb-4', isPlayerVisible && 'lg:pb-32')}
'flex-1 min-h-0 overflow-y-auto pt-14',
isPlayerVisible ? BOTTOM_SAFE_AREA_PADDING : 'pb-4',
)}
> >
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="shrink-0 flex flex-col"> <div className="shrink-0 flex flex-col">
@@ -123,6 +120,9 @@ export function MainEditor() {
</div> </div>
</div> </div>
{/* Divider - single column only */}
{/* <div className="border-t border-border -my-3 lg:hidden" /> */}
{/* Right Column - History */} {/* Right Column - History */}
<div className="flex flex-col min-h-0 overflow-hidden"> <div className="flex flex-col min-h-0 overflow-hidden">
<HistoryTable /> <HistoryTable />
@@ -124,6 +124,7 @@ export function ConnectionForm() {
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<Checkbox <Checkbox
id="keepServerRunning" id="keepServerRunning"
className="mt-[6px]"
checked={keepServerRunningOnClose} checked={keepServerRunningOnClose}
onCheckedChange={(checked: boolean) => { onCheckedChange={(checked: boolean) => {
setKeepServerRunningOnClose(checked); setKeepServerRunningOnClose(checked);
@@ -158,6 +159,7 @@ export function ConnectionForm() {
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<Checkbox <Checkbox
id="allowNetworkAccess" id="allowNetworkAccess"
className="mt-[6px]"
checked={mode === 'remote'} checked={mode === 'remote'}
onCheckedChange={(checked: boolean) => { onCheckedChange={(checked: boolean) => {
setMode(checked ? 'remote' : 'local'); setMode(checked ? 'remote' : 'local');
@@ -10,6 +10,8 @@ export function GenerationSettings() {
const setCrossfadeMs = useServerStore((state) => state.setCrossfadeMs); const setCrossfadeMs = useServerStore((state) => state.setCrossfadeMs);
const normalizeAudio = useServerStore((state) => state.normalizeAudio); const normalizeAudio = useServerStore((state) => state.normalizeAudio);
const setNormalizeAudio = useServerStore((state) => state.setNormalizeAudio); const setNormalizeAudio = useServerStore((state) => state.setNormalizeAudio);
const autoplayOnGenerate = useServerStore((state) => state.autoplayOnGenerate);
const setAutoplayOnGenerate = useServerStore((state) => state.setAutoplayOnGenerate);
return ( return (
<Card role="region" aria-label="Generation Settings" tabIndex={0}> <Card role="region" aria-label="Generation Settings" tabIndex={0}>
@@ -35,7 +37,7 @@ export function GenerationSettings() {
value={[maxChunkChars]} value={[maxChunkChars]}
onValueChange={([value]) => setMaxChunkChars(value)} onValueChange={([value]) => setMaxChunkChars(value)}
min={100} min={100}
max={2000} max={5000}
step={50} step={50}
aria-label="Auto-chunking character limit" aria-label="Auto-chunking character limit"
/> />
@@ -73,6 +75,7 @@ export function GenerationSettings() {
id="normalizeAudio" id="normalizeAudio"
checked={normalizeAudio} checked={normalizeAudio}
onCheckedChange={setNormalizeAudio} onCheckedChange={setNormalizeAudio}
className="mt-[6px]"
/> />
<div className="space-y-1"> <div className="space-y-1">
<label <label
@@ -86,6 +89,26 @@ export function GenerationSettings() {
</p> </p>
</div> </div>
</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> </div>
</CardContent> </CardContent>
</Card> </Card>
+11 -22
View File
@@ -1,9 +1,9 @@
import { Link, useMatchRoute } from '@tanstack/react-router'; 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 voiceboxLogo from '@/assets/voicebox-logo.png';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { version } from '../../package.json';
interface SidebarProps { interface SidebarProps {
isMacOS?: boolean; isMacOS?: boolean;
@@ -19,10 +19,8 @@ const tabs = [
]; ];
export function Sidebar({ isMacOS }: SidebarProps) { export function Sidebar({ isMacOS }: SidebarProps) {
const isGenerating = useGenerationStore((state) => state.isGenerating);
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
const matchRoute = useMatchRoute(); const matchRoute = useMatchRoute();
const isPlayerOpen = !!usePlayerStore((s) => s.audioUrl);
return ( return (
<div <div
@@ -42,9 +40,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
const Icon = tab.icon; const Icon = tab.icon;
// For index route, use exact match; for others, use default matching // For index route, use exact match; for others, use default matching
const isActive = const isActive =
tab.path === '/' tab.path === '/' ? matchRoute({ to: '/', exact: true }) : matchRoute({ to: tab.path });
? matchRoute({ to: '/', exact: true })
: matchRoute({ to: tab.path });
return ( return (
<Link <Link
@@ -64,20 +60,13 @@ export function Sidebar({ isMacOS }: SidebarProps) {
})} })}
</div> </div>
{/* Spacer to push loader to bottom */} {/* Version */}
<div className="flex-1" /> <div
className="mt-auto text-[10px] text-muted-foreground/50 transition-all duration-300"
{/* Generation Loader */} style={{ paddingBottom: isPlayerOpen ? '7rem' : undefined }}
{isGenerating && ( >
<div v{version}
className={cn( </div>
'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>
)}
</div> </div>
); );
} }
+33 -6
View File
@@ -13,8 +13,11 @@ import {
sortableKeyboardCoordinates, sortableKeyboardCoordinates,
verticalListSortingStrategy, verticalListSortingStrategy,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { Link } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
import { Download, Plus } from 'lucide-react'; import { Download, Plus } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import Loader from 'react-loaders';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
@@ -28,6 +31,7 @@ import {
useStory, useStory,
} from '@/lib/hooks/useStories'; } from '@/lib/hooks/useStories';
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback'; import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
import { useGenerationStore } from '@/stores/generationStore';
import { useStoryStore } from '@/stores/storyStore'; import { useStoryStore } from '@/stores/storyStore';
import { SortableStoryChatItem } from './StoryChatItem'; import { SortableStoryChatItem } from './StoryChatItem';
@@ -40,6 +44,7 @@ export function StoryContent() {
const addStoryItem = useAddStoryItem(); const addStoryItem = useAddStoryItem();
const { toast } = useToast(); const { toast } = useToast();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
// Add generation popover state // Add generation popover state
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -53,9 +58,9 @@ export function StoryContent() {
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
return historyData.items.filter( return historyData.items.filter(
(gen) => (gen) =>
gen.status === 'completed' &&
!storyGenerationIds.has(gen.id) && !storyGenerationIds.has(gen.id) &&
(gen.text.toLowerCase().includes(query) || (gen.text.toLowerCase().includes(query) || gen.profile_name.toLowerCase().includes(query)),
gen.profile_name.toLowerCase().includes(query)),
); );
}, [historyData, story, searchQuery]); }, [historyData, story, searchQuery]);
@@ -267,7 +272,31 @@ export function StoryContent() {
<p className="text-sm text-muted-foreground mt-1">{story.description}</p> <p className="text-sm text-muted-foreground mt-1">{story.description}</p>
)} )}
</div> </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}> <Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
@@ -287,9 +316,7 @@ export function StoryContent() {
<div className="max-h-60 overflow-y-auto"> <div className="max-h-60 overflow-y-auto">
{availableGenerations.length === 0 ? ( {availableGenerations.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground"> <div className="p-4 text-center text-sm text-muted-foreground">
{searchQuery {searchQuery ? 'No matching generations found' : 'No available generations'}
? 'No matching generations found'
: 'No available generations'}
</div> </div>
) : ( ) : (
availableGenerations.map((gen) => ( availableGenerations.map((gen) => (
@@ -78,7 +78,7 @@ export function ProfileCard({ profile }: ProfileCardProps) {
<> <>
<Card <Card
className={cn( 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', isSelected && 'ring-2 ring-primary shadow-md',
)} )}
onClick={handleSelect} onClick={handleSelect}
@@ -41,9 +41,11 @@ export function ProfileList() {
</CardContent> </CardContent>
</Card> </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) => ( {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> </div>
)} )}
+1 -1
View File
@@ -1,5 +1,5 @@
import * as React from 'react';
import { Check } from 'lucide-react'; import { Check } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
export interface CheckboxProps { export interface CheckboxProps {
+16
View File
@@ -1,4 +1,5 @@
@import "tailwindcss" source("."); @import "tailwindcss" source(".");
@import "loaders.css/loaders.min.css";
@theme { @theme {
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
@@ -155,3 +156,18 @@
animation: fadeIn 0.5s ease-out 0.15s forwards; animation: fadeIn 0.5s ease-out 0.15s forwards;
opacity: 0; 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 // History
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> { async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -278,6 +284,11 @@ class ApiClient {
return response.json(); return response.json();
} }
// Generation status SSE
getGenerationStatusUrl(generationId: string): string {
return `${this.getBaseUrl()}/generate/${generationId}/status`;
}
// Audio // Audio
getAudioUrl(audioId: string): string { getAudioUrl(audioId: string): string {
return `${this.getBaseUrl()}/audio/${audioId}`; return `${this.getBaseUrl()}/audio/${audioId}`;
+7 -2
View File
@@ -46,9 +46,14 @@ export interface GenerationResponse {
profile_id: string; profile_id: string;
text: string; text: string;
language: string; language: string;
audio_path: string; audio_path?: string;
duration: number; duration?: number;
seed?: number; seed?: number;
instruct?: string;
engine?: string;
model_size?: string;
status: 'generating' | 'completed' | 'failed';
error?: string;
created_at: 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 { useGeneration } from '@/lib/hooks/useGeneration';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast'; import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { useGenerationStore } from '@/stores/generationStore'; import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useServerStore } from '@/stores/serverStore'; import { useServerStore } from '@/stores/serverStore';
const generationSchema = z.object({ const generationSchema = z.object({
@@ -30,8 +29,7 @@ interface UseGenerationFormOptions {
export function useGenerationForm(options: UseGenerationFormOptions = {}) { export function useGenerationForm(options: UseGenerationFormOptions = {}) {
const { toast } = useToast(); const { toast } = useToast();
const generation = useGeneration(); const generation = useGeneration();
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay); const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
const maxChunkChars = useServerStore((state) => state.maxChunkChars); const maxChunkChars = useServerStore((state) => state.maxChunkChars);
const crossfadeMs = useServerStore((state) => state.crossfadeMs); const crossfadeMs = useServerStore((state) => state.crossfadeMs);
const normalizeAudio = useServerStore((state) => state.normalizeAudio); const normalizeAudio = useServerStore((state) => state.normalizeAudio);
@@ -71,8 +69,6 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
} }
try { try {
setIsGenerating(true);
const engine = data.engine || 'qwen'; const engine = data.engine || 'qwen';
const modelName = const modelName =
engine === 'luxtts' engine === 'luxtts'
@@ -93,6 +89,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
? 'Qwen TTS 1.7B' ? 'Qwen TTS 1.7B'
: 'Qwen TTS 0.6B'; : 'Qwen TTS 0.6B';
// Check if model needs downloading
try { try {
const modelStatus = await apiClient.getModelStatus(); const modelStatus = await apiClient.getModelStatus();
const model = modelStatus.models.find((m) => m.model_name === modelName); const model = modelStatus.models.find((m) => m.model_name === modelName);
@@ -106,6 +103,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
} }
const isQwen = engine === 'qwen'; const isQwen = engine === 'qwen';
// This now returns immediately with status="generating"
const result = await generation.mutateAsync({ const result = await generation.mutateAsync({
profile_id: selectedProfileId, profile_id: selectedProfileId,
text: data.text, text: data.text,
@@ -119,14 +117,10 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
normalize: normalizeAudio, normalize: normalizeAudio,
}); });
toast({ // Track this generation for SSE status updates
title: 'Generation complete!', addPendingGeneration(result.id);
description: `Audio generated (${result.duration.toFixed(2)}s)`,
});
const audioUrl = apiClient.getAudioUrl(result.id);
setAudioWithAutoPlay(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50));
// Reset form immediately — user can start typing again
form.reset({ form.reset({
text: '', text: '',
language: data.language, language: data.language,
@@ -143,7 +137,6 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) {
variant: 'destructive', variant: 'destructive',
}); });
} finally { } finally {
setIsGenerating(false);
setDownloadingModelName(null); setDownloadingModelName(null);
setDownloadingDisplayName(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() { export function useRestoreActiveTasks() {
const [activeDownloads, setActiveDownloads] = useState<ActiveDownloadTask[]>([]); const [activeDownloads, setActiveDownloads] = useState<ActiveDownloadTask[]>([]);
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
const setActiveGenerationId = useGenerationStore((state) => state.setActiveGenerationId); const setActiveGenerationId = useGenerationStore((state) => state.setActiveGenerationId);
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
// Track which downloads we've seen to detect new ones // Track which downloads we've seen to detect new ones
const seenDownloadsRef = useRef<Set<string>>(new Set()); const seenDownloadsRef = useRef<Set<string>>(new Set());
@@ -25,15 +25,15 @@ export function useRestoreActiveTasks() {
try { try {
const tasks = await apiClient.getActiveTasks(); const tasks = await apiClient.getActiveTasks();
// Update generation state // Restore pending generations (e.g., after page refresh)
if (tasks.generations.length > 0) { if (tasks.generations.length > 0) {
setIsGenerating(true);
setActiveGenerationId(tasks.generations[0].task_id); setActiveGenerationId(tasks.generations[0].task_id);
for (const gen of tasks.generations) {
addPendingGeneration(gen.task_id);
}
} else { } else {
// Only clear if we were tracking a generation
const currentId = useGenerationStore.getState().activeGenerationId; const currentId = useGenerationStore.getState().activeGenerationId;
if (currentId) { if (currentId) {
setIsGenerating(false);
setActiveGenerationId(null); setActiveGenerationId(null);
} }
} }
@@ -59,7 +59,7 @@ export function useRestoreActiveTasks() {
// Silently fail - server might be temporarily unavailable // Silently fail - server might be temporarily unavailable
console.debug('Failed to fetch active tasks:', error); console.debug('Failed to fetch active tasks:', error);
} }
}, [setIsGenerating, setActiveGenerationId]); }, [setActiveGenerationId, addPendingGeneration]);
useEffect(() => { useEffect(() => {
// Fetch immediately on mount // Fetch immediately on mount
+16 -1
View File
@@ -21,10 +21,25 @@ export function formatDate(date: string | Date): string {
} else { } else {
dateObj = date; dateObj = date;
} }
return formatDistance(dateObj, new Date(), { addSuffix: true }).replace(/^about /i, ''); 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 { export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes'; if (bytes === 0) return '0 Bytes';
const k = 1024; const k = 1024;
+5
View File
@@ -8,8 +8,10 @@ import { Sidebar } from '@/components/Sidebar';
import { StoriesTab } from '@/components/StoriesTab/StoriesTab'; import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
import { Toaster } from '@/components/ui/toaster'; import { Toaster } from '@/components/ui/toaster';
import { VoicesTab } from '@/components/VoicesTab/VoicesTab'; import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
import { useGenerationProgress } from '@/lib/hooks/useGenerationProgress';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast'; import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks'; import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
// Simple platform check that works in both web and Tauri // Simple platform check that works in both web and Tauri
const isMacOS = () => navigator.platform.toLowerCase().includes('mac'); const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
@@ -18,6 +20,9 @@ function RootLayout() {
// Monitor active downloads/generations and show toasts for them // Monitor active downloads/generations and show toasts for them
const activeDownloads = useRestoreActiveTasks(); const activeDownloads = useRestoreActiveTasks();
// Subscribe to SSE for pending generations — handles completion, auto-play, and history refresh
useGenerationProgress();
return ( return (
<AppFrame> <AppFrame>
<div className="flex flex-1 min-h-0 overflow-hidden"> <div className="flex flex-1 min-h-0 overflow-hidden">
+47 -4
View File
@@ -1,15 +1,58 @@
import { create } from 'zustand'; import { create } from 'zustand';
interface GenerationState { interface GenerationState {
/** IDs of generations currently in progress */
pendingGenerationIds: Set<string>;
/** Whether any generation is in progress (derived from pendingGenerationIds) */
isGenerating: boolean; isGenerating: boolean;
activeGenerationId: string | null; /** Map of generationId → storyId for deferred story additions */
setIsGenerating: (generating: boolean) => void; 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; 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, isGenerating: false,
activeGenerationId: null, 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 }), setActiveGenerationId: (id) => set({ activeGenerationId: id }),
})); }));
+6
View File
@@ -23,6 +23,9 @@ interface ServerStore {
normalizeAudio: boolean; normalizeAudio: boolean;
setNormalizeAudio: (value: boolean) => void; setNormalizeAudio: (value: boolean) => void;
autoplayOnGenerate: boolean;
setAutoplayOnGenerate: (value: boolean) => void;
customModelsDir: string | null; customModelsDir: string | null;
setCustomModelsDir: (dir: string | null) => void; setCustomModelsDir: (dir: string | null) => void;
} }
@@ -51,6 +54,9 @@ export const useServerStore = create<ServerStore>()(
normalizeAudio: true, normalizeAudio: true,
setNormalizeAudio: (value) => set({ normalizeAudio: value }), setNormalizeAudio: (value) => set({ normalizeAudio: value }),
autoplayOnGenerate: true,
setAutoplayOnGenerate: (value) => set({ autoplayOnGenerate: value }),
customModelsDir: null, customModelsDir: null,
setCustomModelsDir: (dir) => set({ customModelsDir: dir }), 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) profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
text = Column(Text, nullable=False) text = Column(Text, nullable=False)
language = Column(String, default="en") language = Column(String, default="en")
audio_path = Column(String, nullable=False) audio_path = Column(String, nullable=True)
duration = Column(Float, nullable=False) duration = Column(Float, nullable=True)
seed = Column(Integer) seed = Column(Integer)
instruct = Column(Text) 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) created_at = Column(DateTime, default=datetime.utcnow)
@@ -288,6 +292,36 @@ def _run_migrations(engine):
conn.commit() conn.commit()
print("Added avatar_path column to profiles") 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(): def get_db():
"""Get database session (generator for dependency injection).""" """Get database session (generator for dependency injection)."""
+42 -1
View File
@@ -29,6 +29,10 @@ async def create_generation(
seed: Optional[int], seed: Optional[int],
db: Session, db: Session,
instruct: Optional[str] = None, instruct: Optional[str] = None,
generation_id: Optional[str] = None,
status: str = "completed",
engine: Optional[str] = "qwen",
model_size: Optional[str] = None,
) -> GenerationResponse: ) -> GenerationResponse:
""" """
Create a new generation history entry. Create a new generation history entry.
@@ -42,12 +46,16 @@ async def create_generation(
seed: Random seed used (if any) seed: Random seed used (if any)
db: Database session db: Database session
instruct: Natural language instruction used (if any) 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: Returns:
Created generation entry Created generation entry
""" """
db_generation = DBGeneration( db_generation = DBGeneration(
id=str(uuid.uuid4()), id=generation_id or str(uuid.uuid4()),
profile_id=profile_id, profile_id=profile_id,
text=text, text=text,
language=language, language=language,
@@ -55,6 +63,9 @@ async def create_generation(
duration=duration, duration=duration,
seed=seed, seed=seed,
instruct=instruct, instruct=instruct,
engine=engine,
model_size=model_size,
status=status,
created_at=datetime.utcnow(), created_at=datetime.utcnow(),
) )
@@ -65,6 +76,32 @@ async def create_generation(
return GenerationResponse.model_validate(db_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( async def get_generation(
generation_id: str, generation_id: str,
db: Session, db: Session,
@@ -143,6 +180,10 @@ async def list_generations(
duration=generation.duration, duration=generation.duration,
seed=generation.seed, seed=generation.seed,
instruct=generation.instruct, 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, 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 # Keep references to fire-and-forget background tasks to prevent GC
_background_tasks: set = set() _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: def _create_background_task(coro) -> asyncio.Task:
"""Create a background task and prevent it from being garbage collected.""" """Create a background task and prevent it from being garbage collected."""
@@ -71,6 +74,24 @@ def _create_background_task(coro) -> asyncio.Task:
return 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( app = FastAPI(
title="voicebox API", title="voicebox API",
description="Production-quality Qwen3-TTS voice cloning API", description="Production-quality Qwen3-TTS voice cloning API",
@@ -695,214 +716,255 @@ async def generate_speech(
data: models.GenerationRequest, data: models.GenerationRequest,
db: Session = Depends(get_db), 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() task_manager = get_task_manager()
generation_id = str(uuid.uuid4()) 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" # Validate profile exists before creating the record
tts_model = get_tts_backend_for_engine(engine) 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) from .backends import get_tts_backend_for_engine
model_size = data.model_size or "1.7B" 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 # Create the history entry immediately with status="generating"
if engine == "qwen": generation = await history.create_generation(
if not tts_model._is_model_cached(model_size): profile_id=data.profile_id,
model_name = f"qwen-tts-{model_size}" 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(): # Track in task manager
try: task_manager.start_generation(
await tts_model.load_model_async(model_size) task_id=generation_id,
except Exception as e: profile_id=data.profile_id,
task_manager.error_download(model_name, str(e)) text=data.text,
)
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
# Kick off TTS in background
async def _run_generation():
bg_db = next(get_db())
try: try:
save_audio(audio, str(audio_path), sample_rate) # Load model
except BrokenPipeError: if engine == "qwen":
raise HTTPException( await tts_model.load_model_async(model_size)
status_code=500,
detail="Audio save failed: broken pipe (the output stream was closed unexpectedly)",
)
except OSError as save_err:
err_no = getattr(save_err, "errno", None) or (
getattr(save_err.__cause__, "errno", None)
if save_err.__cause__
else None
)
if err_no == errno.ENOENT:
msg = f"Audio save failed: directory not found — {audio_path.parent}"
elif err_no == errno.EACCES:
msg = f"Audio save failed: permission denied — {audio_path.parent}"
elif err_no == errno.ENOSPC:
msg = "Audio save failed: no disk space remaining"
else: else:
msg = f"Audio save failed: {save_err}" await tts_model.load_model()
raise HTTPException(status_code=500, detail=msg)
# Create history entry # Create voice prompt
generation = await history.create_generation( voice_prompt = await profiles.create_voice_prompt_for_profile(
profile_id=data.profile_id, data.profile_id,
text=data.text, bg_db,
language=data.language, use_cache=True,
audio_path=str(audio_path), engine=engine,
duration=duration, )
seed=data.seed,
db=db, from .utils.chunked_tts import generate_chunked
instruct=data.instruct,
) trim_fn = None
if engine in ("chatterbox", "chatterbox_turbo"):
# Mark generation as complete from .utils.audio import trim_tts_output
task_manager.complete_generation(generation_id) trim_fn = trim_tts_output
return generation audio, sample_rate = await generate_chunked(
tts_model,
except ValueError as e: data.text,
task_manager.complete_generation(generation_id) voice_prompt,
raise HTTPException(status_code=400, detail=str(e)) language=data.language,
except Exception as e: seed=data.seed,
task_manager.complete_generation(generation_id) instruct=data.instruct,
raise HTTPException(status_code=500, detail=str(e)) 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") @app.post("/generate/stream")
@@ -2480,9 +2542,29 @@ def _get_gpu_status() -> str:
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
"""Run on application startup.""" """Run on application startup."""
global _generation_queue
print("voicebox API starting up...") print("voicebox API starting up...")
database.init_db() database.init_db()
print(f"Database initialized at {database._db_path}") 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() backend_type = get_backend_type()
print(f"Backend: {backend_type.upper()}") print(f"Backend: {backend_type.upper()}")
print(f"GPU available: {_get_gpu_status()}") print(f"GPU available: {_get_gpu_status()}")
+16 -8
View File
@@ -69,10 +69,14 @@ class GenerationResponse(BaseModel):
profile_id: str profile_id: str
text: str text: str
language: str language: str
audio_path: str audio_path: Optional[str] = None
duration: float duration: Optional[float] = None
seed: Optional[int] seed: Optional[int] = None
instruct: Optional[str] instruct: Optional[str] = None
engine: Optional[str] = "qwen"
model_size: Optional[str] = None
status: str = "completed"
error: Optional[str] = None
created_at: datetime created_at: datetime
class Config: class Config:
@@ -94,10 +98,14 @@ class HistoryResponse(BaseModel):
profile_name: str profile_name: str
text: str text: str
language: str language: str
audio_path: str audio_path: Optional[str] = None
duration: float duration: Optional[float] = None
seed: Optional[int] seed: Optional[int] = None
instruct: Optional[str] instruct: Optional[str] = None
engine: Optional[str] = "qwen"
model_size: Optional[str] = None
status: str = "completed"
error: Optional[str] = None
created_at: datetime created_at: datetime
class Config: class Config:
+6 -6
View File
@@ -270,11 +270,14 @@ async def add_item_to_story(
generation_created_at=generation.created_at, 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 # Calculate start_time_ms if not provided
if data.start_time_ms is not None: if data.start_time_ms is not None:
start_time_ms = data.start_time_ms start_time_ms = data.start_time_ms
else: 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( existing_items = db.query(
DBStoryItem, DBStoryItem,
DBGeneration DBGeneration
@@ -282,11 +285,11 @@ async def add_item_to_story(
DBGeneration, DBGeneration,
DBStoryItem.generation_id == DBGeneration.id DBStoryItem.generation_id == DBGeneration.id
).filter( ).filter(
DBStoryItem.story_id == story_id DBStoryItem.story_id == story_id,
DBStoryItem.track == track,
).all() ).all()
if not existing_items: if not existing_items:
# First item starts at 0
start_time_ms = 0 start_time_ms = 0
else: else:
max_end_time_ms = 0 max_end_time_ms = 0
@@ -297,9 +300,6 @@ async def add_item_to_story(
# Add 200ms gap after the last item # Add 200ms gap after the last item
start_time_ms = max_end_time_ms + 200 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 # Create item
item = DBStoryItem( item = DBStoryItem(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
+14
View File
@@ -4,6 +4,10 @@
"workspaces": { "workspaces": {
"": { "": {
"name": "voicebox", "name": "voicebox",
"dependencies": {
"loaders.css": "^0.1.2",
"react-loaders": "^3.0.1",
},
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.3.12", "@biomejs/biome": "2.3.12",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
@@ -678,6 +682,8 @@
"class-variance-authority": ["[email protected]", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "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=="], "client-only": ["[email protected]", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -874,6 +880,8 @@
"lines-and-columns": ["[email protected]", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "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=="], "locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.merge": ["[email protected]", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "lodash.merge": ["[email protected]", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
@@ -960,6 +968,8 @@
"prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "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=="], "punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"queue-microtask": ["[email protected]", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "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-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-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=="], "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": { "engines": {
"bun": ">=1.0.0" "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.