From 655a60ca8173614dbf49995b16ae3ed7330433b6 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Fri, 13 Mar 2026 10:02:41 -0700 Subject: [PATCH 1/4] feat: async generation queue with serial execution Generations now return immediately with a 'generating' status and appear in history right away. TTS runs in a serial background queue to avoid GPU contention. Users can kick off multiple generations without blocking. - Async POST /generate creates DB record immediately, queues TTS work - Serial generation queue prevents concurrent GPU access (Metal/CUDA/CPU) - SSE endpoint GET /generate/{id}/status for real-time completion tracking - Retry endpoint POST /generate/{id}/retry for failed generations - Store engine and model_size on generation records for retry support - History cards show animated loader (react-loaders) for generating/playing - Failed generations show retry button instead of actions menu - Model downloads happen inline in the queue instead of rejecting with 202 - Stale 'generating' records marked as failed on server startup - Autoplay on generate setting (default: on) - Show engine name on generation cards - Remove sidebar generation spinner - Checkbox alignment fix in settings --- app/src/components/History/HistoryTable.tsx | 181 ++++--- .../ServerSettings/ConnectionForm.tsx | 2 + .../ServerSettings/GenerationSettings.tsx | 25 +- app/src/components/Sidebar.tsx | 26 +- app/src/components/ui/checkbox.tsx | 2 +- app/src/index.css | 16 + app/src/lib/api/client.ts | 11 + app/src/lib/api/types.ts | 9 +- app/src/lib/hooks/useGenerationForm.ts | 19 +- app/src/lib/hooks/useGenerationProgress.ts | 118 +++++ app/src/lib/hooks/useRestoreActiveTasks.tsx | 6 +- app/src/lib/utils/format.ts | 17 +- app/src/router.tsx | 5 + app/src/stores/generationStore.ts | 24 +- app/src/stores/serverStore.ts | 6 + backend/database.py | 38 +- backend/history.py | 43 +- backend/main.py | 450 +++++++++++------- backend/models.py | 24 +- bun.lock | 14 + package.json | 6 +- tauri/src-tauri/gen/Assets.car | Bin 3847048 -> 3847048 bytes 22 files changed, 741 insertions(+), 301 deletions(-) create mode 100644 app/src/lib/hooks/useGenerationProgress.ts diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index 74f722b7..7ca8d65e 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -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(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 (
{ - // 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 */} -
- + {/* Status icon */} +
+
+ +
{/* Left side - Meta information */} @@ -294,11 +329,22 @@ export function HistoryTable() {
{gen.language} - {formatDuration(gen.duration)} + {formatEngineName(gen.engine, gen.model_size)} + {isFailed ? ( + Failed + ) : !isGenerating ? ( + + {formatDuration(gen.duration ?? 0)} + + ) : null}
- {formatDate(gen.created_at)} + {isGenerating ? ( + Generating... + ) : ( + formatDate(gen.created_at) + )}
@@ -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)}`} />
- {/* Far right - Ellipsis actions */} + {/* Far right - Actions */}
e.stopPropagation()} onClick={(e) => e.stopPropagation()} > - - - - - - handlePlay(gen.id, gen.text, gen.profile_id)} - > - - Play - - handleDownloadAudio(gen.id, gen.text)} - disabled={exportGenerationAudio.isPending} - > - - Export Audio - - handleExportPackage(gen.id, gen.text)} - disabled={exportGeneration.isPending} - > - - Export Package - - handleDeleteClick(gen.id, gen.profile_name)} - disabled={deleteGeneration.isPending} - className="text-destructive focus:text-destructive" - > - - Delete - - - + {isFailed ? ( + + ) : isPlayable ? ( + + + + + + handlePlay(gen.id, gen.text, gen.profile_id)} + > + + Play + + handleDownloadAudio(gen.id, gen.text)} + disabled={exportGenerationAudio.isPending} + > + + Export Audio + + handleExportPackage(gen.id, gen.text)} + disabled={exportGeneration.isPending} + > + + Export Package + + handleDeleteClick(gen.id, gen.profile_name)} + disabled={deleteGeneration.isPending} + className="text-destructive focus:text-destructive" + > + + Delete + + + + ) : null}
); @@ -387,7 +445,8 @@ export function HistoryTable() { Delete Generation - 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. diff --git a/app/src/components/ServerSettings/ConnectionForm.tsx b/app/src/components/ServerSettings/ConnectionForm.tsx index 3b5ad845..e84f20ea 100644 --- a/app/src/components/ServerSettings/ConnectionForm.tsx +++ b/app/src/components/ServerSettings/ConnectionForm.tsx @@ -124,6 +124,7 @@ export function ConnectionForm() {
{ setKeepServerRunningOnClose(checked); @@ -158,6 +159,7 @@ export function ConnectionForm() {
{ setMode(checked ? 'remote' : 'local'); diff --git a/app/src/components/ServerSettings/GenerationSettings.tsx b/app/src/components/ServerSettings/GenerationSettings.tsx index 048bd424..df499e17 100644 --- a/app/src/components/ServerSettings/GenerationSettings.tsx +++ b/app/src/components/ServerSettings/GenerationSettings.tsx @@ -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 ( @@ -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]" />
+ +
+ +
+ +

+ Automatically play audio when a generation completes. +

+
+
diff --git a/app/src/components/Sidebar.tsx b/app/src/components/Sidebar.tsx index a849344f..fc135f24 100644 --- a/app/src/components/Sidebar.tsx +++ b/app/src/components/Sidebar.tsx @@ -1,9 +1,7 @@ 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'; interface SidebarProps { isMacOS?: boolean; @@ -19,9 +17,6 @@ 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(); return ( @@ -42,9 +37,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 ( - - {/* Spacer to push loader to bottom */} -
- - {/* Generation Loader */} - {isGenerating && ( -
- -
- )}
); } diff --git a/app/src/components/ui/checkbox.tsx b/app/src/components/ui/checkbox.tsx index f423fef0..be2cfd29 100644 --- a/app/src/components/ui/checkbox.tsx +++ b/app/src/components/ui/checkbox.tsx @@ -1,5 +1,5 @@ -import * as React from 'react'; import { Check } from 'lucide-react'; +import * as React from 'react'; import { cn } from '@/lib/utils/cn'; export interface CheckboxProps { diff --git a/app/src/index.css b/app/src/index.css index 06381711..65c11d84 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -1,4 +1,5 @@ @import "tailwindcss" source("."); +@import "loaders.css/loaders.min.css"; @theme { --radius-sm: calc(var(--radius) - 4px); @@ -155,3 +156,18 @@ animation: fadeIn 0.5s ease-out 0.15s forwards; opacity: 0; } + +/* react-loaders */ +.line-scale-pulse-out-rapid > div, +.line-scale > div { + background-color: hsl(var(--accent)) !important; +} + +.loader-hidden { + display: block; +} + +.loader-hidden > div > div { + animation-play-state: paused !important; + background-color: hsl(var(--muted-foreground)) !important; +} diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts index dbc4cbdc..e7fd1888 100644 --- a/app/src/lib/api/client.ts +++ b/app/src/lib/api/client.ts @@ -200,6 +200,12 @@ class ApiClient { }); } + async retryGeneration(generationId: string): Promise { + return this.request(`/generate/${generationId}/retry`, { + method: 'POST', + }); + } + // History async listHistory(query?: HistoryQuery): Promise { 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}`; diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts index 3a17eba3..ea06ad57 100644 --- a/app/src/lib/api/types.ts +++ b/app/src/lib/api/types.ts @@ -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; } diff --git a/app/src/lib/hooks/useGenerationForm.ts b/app/src/lib/hooks/useGenerationForm.ts index f79f7ab2..59060d84 100644 --- a/app/src/lib/hooks/useGenerationForm.ts +++ b/app/src/lib/hooks/useGenerationForm.ts @@ -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); } diff --git a/app/src/lib/hooks/useGenerationProgress.ts b/app/src/lib/hooks/useGenerationProgress.ts new file mode 100644 index 00000000..b8c98f78 --- /dev/null +++ b/app/src/lib/hooks/useGenerationProgress.ts @@ -0,0 +1,118 @@ +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'; + 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 isPlaying = usePlayerStore((s) => s.isPlaying); + const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay); + const autoplayOnGenerate = useServerStore((s) => s.autoplayOnGenerate); + + // Keep refs to avoid stale closures in EventSource handlers + const isPlayingRef = useRef(isPlaying); + const autoplayRef = useRef(autoplayOnGenerate); + isPlayingRef.current = isPlaying; + autoplayRef.current = autoplayOnGenerate; + + // Track active EventSource instances + const eventSourcesRef = useRef>(new Map()); + + 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'] }); + + 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') { + source.close(); + currentSources.delete(id); + removePendingGeneration(id); + + queryClient.invalidateQueries({ queryKey: ['history'] }); + + toast({ + title: '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); + } + + return () => { + // Cleanup on unmount + for (const source of currentSources.values()) { + source.close(); + } + currentSources.clear(); + }; + }, [pendingIds, removePendingGeneration, queryClient, toast, setAudioWithAutoPlay]); +} diff --git a/app/src/lib/hooks/useRestoreActiveTasks.tsx b/app/src/lib/hooks/useRestoreActiveTasks.tsx index 191cca94..69c6ef9f 100644 --- a/app/src/lib/hooks/useRestoreActiveTasks.tsx +++ b/app/src/lib/hooks/useRestoreActiveTasks.tsx @@ -17,6 +17,7 @@ export function useRestoreActiveTasks() { const [activeDownloads, setActiveDownloads] = useState([]); const setIsGenerating = useGenerationStore((state) => state.setIsGenerating); const setActiveGenerationId = useGenerationStore((state) => state.setActiveGenerationId); + const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration); // Track which downloads we've seen to detect new ones const seenDownloadsRef = useRef>(new Set()); @@ -25,10 +26,13 @@ export function useRestoreActiveTasks() { try { const tasks = await apiClient.getActiveTasks(); - // Update generation state + // 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; diff --git a/app/src/lib/utils/format.ts b/app/src/lib/utils/format.ts index fbd7a884..e1cec0e6 100644 --- a/app/src/lib/utils/format.ts +++ b/app/src/lib/utils/format.ts @@ -21,10 +21,25 @@ export function formatDate(date: string | Date): string { } else { dateObj = date; } - + return formatDistance(dateObj, new Date(), { addSuffix: true }).replace(/^about /i, ''); } +const ENGINE_DISPLAY_NAMES: Record = { + qwen: 'Qwen', + luxtts: 'LuxTTS', + chatterbox: 'Chatterbox', + chatterbox_turbo: 'Chatterbox Turbo', +}; + +export function formatEngineName(engine?: string, modelSize?: string): string { + const name = ENGINE_DISPLAY_NAMES[engine ?? 'qwen'] ?? engine ?? 'Qwen'; + if (engine === 'qwen' && modelSize) { + return `${name} ${modelSize}`; + } + return name; +} + export function formatFileSize(bytes: number): string { if (bytes === 0) return '0 Bytes'; const k = 1024; diff --git a/app/src/router.tsx b/app/src/router.tsx index dbf94038..01729416 100644 --- a/app/src/router.tsx +++ b/app/src/router.tsx @@ -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 (
diff --git a/app/src/stores/generationStore.ts b/app/src/stores/generationStore.ts index c0d63383..a7395305 100644 --- a/app/src/stores/generationStore.ts +++ b/app/src/stores/generationStore.ts @@ -1,15 +1,37 @@ import { create } from 'zustand'; interface GenerationState { + /** IDs of generations currently in progress */ + pendingGenerationIds: Set; + /** Whether any generation is in progress (derived convenience) */ isGenerating: boolean; - activeGenerationId: string | null; + addPendingGeneration: (id: string) => void; + removePendingGeneration: (id: string) => void; + /** Legacy setter for backward compat with useRestoreActiveTasks */ setIsGenerating: (generating: boolean) => void; setActiveGenerationId: (id: string | null) => void; + activeGenerationId: string | null; } export const useGenerationStore = create((set) => ({ + pendingGenerationIds: new Set(), isGenerating: false, activeGenerationId: null, + + 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 }; + }), + setIsGenerating: (generating) => set({ isGenerating: generating }), setActiveGenerationId: (id) => set({ activeGenerationId: id }), })); diff --git a/app/src/stores/serverStore.ts b/app/src/stores/serverStore.ts index 586e1e8c..8f983049 100644 --- a/app/src/stores/serverStore.ts +++ b/app/src/stores/serverStore.ts @@ -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()( normalizeAudio: true, setNormalizeAudio: (value) => set({ normalizeAudio: value }), + autoplayOnGenerate: true, + setAutoplayOnGenerate: (value) => set({ autoplayOnGenerate: value }), + customModelsDir: null, setCustomModelsDir: (dir) => set({ customModelsDir: dir }), }), diff --git a/backend/database.py b/backend/database.py index 3b9c51ee..d4bfed22 100644 --- a/backend/database.py +++ b/backend/database.py @@ -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).""" diff --git a/backend/history.py b/backend/history.py index 64834d30..b8026b09 100644 --- a/backend/history.py +++ b/backend/history.py @@ -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, )) diff --git a/backend/main.py b/backend/main.py index 9b3aa334..487d35ab 100644 --- a/backend/main.py +++ b/backend/main.py @@ -62,6 +62,9 @@ def _safe_content_disposition(disposition_type: str, filename: str) -> str: # 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" + + # 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, + ) - # 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}" + # Track in task manager + task_manager.start_generation( + task_id=generation_id, + profile_id=data.profile_id, + text=data.text, + ) - 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, - }, - ) + # Kick off TTS in background + async def _run_generation(): + bg_db = next(get_db()) + try: + # Load model + if engine == "qwen": + await tts_model.load_model_async(model_size) + else: + await tts_model.load_model() + + # Create voice prompt + voice_prompt = await profiles.create_voice_prompt_for_profile( + data.profile_id, + bg_db, + use_cache=True, + engine=engine, + ) - # 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" + 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, + ) - 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, - }, - ) + if data.normalize: + from .utils.audio import normalize_audio + audio = normalize_audio(audio) - await tts_model.load_model() - elif engine == "chatterbox": - if not tts_model._is_model_cached(): - model_name = "chatterbox-tts" + duration = len(audio) / sample_rate + audio_path = config.get_generations_dir() / f"{generation_id}.wav" - 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, - }, - ) + from .utils.audio import save_audio + save_audio(audio, str(audio_path), sample_rate) - await tts_model.load_model() - elif engine == "chatterbox_turbo": - if not tts_model._is_model_cached(): - model_name = "chatterbox-turbo" + # 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, + ) - 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, - }, - ) + 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() - await tts_model.load_model() + _enqueue_generation(_run_generation()) - # Create voice prompt from profile - voice_prompt = await profiles.create_voice_prompt_for_profile( - data.profile_id, - db, - use_cache=True, - engine=engine, - ) + return generation - 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) +@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") - # Calculate duration - duration = len(audio) / sample_rate + if (gen.status or "completed") != "failed": + raise HTTPException(status_code=400, detail="Only failed generations can be retried") - # Save audio - audio_path = config.get_generations_dir() / f"{generation_id}.wav" + # Reset the record to generating + gen.status = "generating" + gen.error = None + gen.audio_path = "" + gen.duration = 0 + db.commit() + db.refresh(gen) - from .utils.audio import save_audio - import errno + 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) - except BrokenPipeError: - raise HTTPException( - status_code=500, - detail="Audio save failed: broken pipe (the output stream was closed unexpectedly)", + + await history.update_generation_status( + generation_id=generation_id, + status="completed", + db=bg_db, + audio_path=str(audio_path), + duration=duration, ) - 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 + 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), ) - if err_no == errno.ENOENT: - msg = f"Audio save failed: directory not found — {audio_path.parent}" - elif err_no == errno.EACCES: - msg = f"Audio save failed: permission denied — {audio_path.parent}" - elif err_no == errno.ENOSPC: - msg = "Audio save failed: no disk space remaining" - else: - msg = f"Audio save failed: {save_err}" - raise HTTPException(status_code=500, detail=msg) - - # Create history entry - generation = await history.create_generation( - 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)) + 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()}") diff --git a/backend/models.py b/backend/models.py index 8f9dbf10..c62db310 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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: diff --git a/bun.lock b/bun.lock index d271b5c6..b507da48 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,10 @@ "workspaces": { "": { "name": "voicebox", + "dependencies": { + "loaders.css": "^0.1.2", + "react-loaders": "^3.0.1", + }, "devDependencies": { "@biomejs/biome": "2.3.12", "@types/node": "^20.0.0", @@ -678,6 +682,8 @@ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -874,6 +880,8 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "loaders.css": ["loaders.css@0.1.2", "", {}, "sha512-Rhowlq24ey1VOeor+3wYOt9+MjaxBOJm1u4KlQgNC3+0xJ0LS4wq4iG57D/BPzvuD/7HHDGQOWJ+81oR2EI9bQ=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], @@ -960,6 +968,8 @@ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -970,6 +980,10 @@ "react-hook-form": ["react-hook-form@7.71.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w=="], + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "react-loaders": ["react-loaders@3.0.1", "", { "dependencies": { "classnames": "^2.2.3" }, "peerDependencies": { "prop-types": ">=15.6.0", "react": ">=15" } }, "sha512-4igMNqs9Fb3d4Z+0UHIGQNJsw/37gX0nUO8QxupnEKRn1dtyYC1LGwk5GuaoDciMQCQc/MmPwb4Fn6ZfdoX1FQ=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], diff --git a/package.json b/package.json index f6af4cbd..d8acb287 100644 --- a/package.json +++ b/package.json @@ -40,5 +40,9 @@ "engines": { "bun": ">=1.0.0" }, - "packageManager": "bun@1.3.8" + "packageManager": "bun@1.3.8", + "dependencies": { + "loaders.css": "^0.1.2", + "react-loaders": "^3.0.1" + } } diff --git a/tauri/src-tauri/gen/Assets.car b/tauri/src-tauri/gen/Assets.car index 8065a50c312322e5c7125ad4d3f36ad82851c00d..526afc69a4931b79edfc01f090acb8d8b6fbb8d5 100644 GIT binary patch delta 834 zcmZY8yKWOf00mGx#>9D=KoSy@5C<^tiD&0MDOk_!%qX9bl8z2hQcxgK*llDX8a^OW z8l)8Kk3b5aK!=483Kf*x$a3YCj<&mZ=W2HF<7agMUoJYk3zbTx0V}W!O<08`SUmsJ zxoDoy`tVZrhA&ln*jByaH`zXaubQ>og=j)y6|pHqA_|*`waybSy_cLhZL}eh8pa8| zdIC>e+Q_n`4&J7C;2PB7I?R`2+lcj?rJ!u!jzkqT5mIq9CP@;D zT1$x*r)GN!k+L-D*(nEk<6|wiUeF|XAQ|(X2um~Kc;qCD!3i2-jJ%i)3XXd^4SHKN zHmbQaCdz=1kyd6zn8Fa76_f<3E!Q%$5i`u3JO~!TG-wEMf9<*T}?!kR{03CP;kKi#pfv4s1(X)SlU(kS+ttXkoBti>Iur9+wQ5H-q$!wTC z6T^UJa;8@QwD%kc96%Q+WWa!f09l^)RQWrPE()fCAewuO$R#Cc#;1r>2il$|NG wNn}Dy3rAhuA9W4ba@0Mny<1zaROEO319Ny^;=TFl>)GL6b*ub!cKCVzKg0;+r2qf` delta 833 zcmZwGyKYl47zgmAAvC>)LR;F<(iRAJH}>&811G*6b!6-tupveU1|)`LEfHej0V;Jt z>Tojh07!iWHX?)&Ofd4VqOt@_KY9Cq{yy7-uivTz_;}XdUZ_+m^RNVqFb552!t&{_ z_F40Y)`w3O9e$F%AyxFP+C6=(nzcL>DKcsVp_Wl1loZ5g(vnOH9}1Z{8v@}nGnUZv z6}hHVDpqi{|Kjx(ls?lU`%mX0FcmL?#<8(k%N0v12TZl1yEk_7TktA(1yEk5AMSQcvv1DJo@wdiAq*PB}g)v$8e$}jyq2ZsWkT?dN0IO zPcmsi!=$HH|G4)U2<$@#C@{!?g8*3`_f+{KH%z)1@YYPwxYmHXH6k$*Rgr`;EJ??w x3+h70e+5S!-5+%f*mBhA)?Te_R4VcdzQ7FjpV(L5e?IB%RM*SzC*2RTe*pvD;^+VX From 81f8be1a94232bb85179c66a15c802f47a1bf33d Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Fri, 13 Mar 2026 10:28:20 -0700 Subject: [PATCH 2/4] defer story add until TTS completes, add generating pill to story editor, fix item placement per-track --- .../Generation/FloatingGenerateBox.tsx | 27 ++--------- .../components/StoriesTab/StoryContent.tsx | 38 ++++++++++++--- app/src/lib/hooks/useGenerationProgress.ts | 47 ++++++++++++++++--- app/src/stores/generationStore.ts | 26 +++++++++- backend/stories.py | 12 ++--- 5 files changed, 108 insertions(+), 42 deletions(-) diff --git a/app/src/components/Generation/FloatingGenerateBox.tsx b/app/src/components/Generation/FloatingGenerateBox.tsx index a9b830dc..d3159486 100644 --- a/app/src/components/Generation/FloatingGenerateBox.tsx +++ b/app/src/components/Generation/FloatingGenerateBox.tsx @@ -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); } }, }); diff --git a/app/src/components/StoriesTab/StoryContent.tsx b/app/src/components/StoriesTab/StoryContent.tsx index 483e6657..89d6dbb1 100644 --- a/app/src/components/StoriesTab/StoryContent.tsx +++ b/app/src/components/StoriesTab/StoryContent.tsx @@ -13,8 +13,11 @@ import { sortableKeyboardCoordinates, verticalListSortingStrategy, } from '@dnd-kit/sortable'; +import { Link } from '@tanstack/react-router'; +import { AnimatePresence, motion } from 'framer-motion'; import { Download, Plus } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; +import Loader from 'react-loaders'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; @@ -28,6 +31,7 @@ import { useStory, } from '@/lib/hooks/useStories'; import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback'; +import { useGenerationStore } from '@/stores/generationStore'; import { useStoryStore } from '@/stores/storyStore'; import { SortableStoryChatItem } from './StoryChatItem'; @@ -40,6 +44,7 @@ export function StoryContent() { const addStoryItem = useAddStoryItem(); const { toast } = useToast(); const scrollRef = useRef(null); + const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size); // Add generation popover state const [searchQuery, setSearchQuery] = useState(''); @@ -54,8 +59,7 @@ export function StoryContent() { return historyData.items.filter( (gen) => !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 +271,31 @@ export function StoryContent() {

{story.description}

)}
-
+
+ + {pendingCount > 0 && ( + + +
+
+ +
+
+ + Generating {pendingCount} {pendingCount === 1 ? 'audio' : 'audios'} + + +
+ )} +
{/* Volume Control */} -
+