mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-19 23:00:45 -07:00
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
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
AudioWaveform,
|
||||
Download,
|
||||
FileArchive,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Loader from 'react-loaders';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -36,7 +38,8 @@ import {
|
||||
useImportGeneration,
|
||||
} from '@/lib/hooks/useHistory';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { formatDate, formatDuration } from '@/lib/utils/format';
|
||||
import { formatDate, formatDuration, formatEngineName } from '@/lib/utils/format';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
|
||||
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
|
||||
@@ -54,9 +57,12 @@ export function HistoryTable() {
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(null);
|
||||
const [generationToDelete, setGenerationToDelete] = useState<{ id: string; name: string } | null>(
|
||||
null,
|
||||
);
|
||||
const limit = 20;
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: historyData,
|
||||
@@ -71,6 +77,7 @@ export function HistoryTable() {
|
||||
const exportGeneration = useExportGeneration();
|
||||
const exportGenerationAudio = useExportGenerationAudio();
|
||||
const importGeneration = useImportGeneration();
|
||||
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
|
||||
const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay);
|
||||
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
|
||||
const currentAudioId = usePlayerStore((state) => state.audioId);
|
||||
@@ -194,6 +201,20 @@ export function HistoryTable() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = async (generationId: string) => {
|
||||
try {
|
||||
const result = await apiClient.retryGeneration(generationId);
|
||||
addPendingGeneration(result.id);
|
||||
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Retry failed',
|
||||
description: error instanceof Error ? error.message : 'Could not retry generation',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportConfirm = () => {
|
||||
if (selectedFile) {
|
||||
importGeneration.mutate(selectedFile, {
|
||||
@@ -250,22 +271,30 @@ export function HistoryTable() {
|
||||
>
|
||||
{history.map((gen) => {
|
||||
const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying;
|
||||
const isGenerating = gen.status === 'generating';
|
||||
const isFailed = gen.status === 'failed';
|
||||
const isPlayable = !isGenerating && !isFailed;
|
||||
return (
|
||||
<div
|
||||
key={gen.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
role={isPlayable ? 'button' : undefined}
|
||||
tabIndex={isPlayable ? 0 : undefined}
|
||||
className={cn(
|
||||
'flex items-stretch gap-4 h-26 border rounded-md p-3 bg-card hover:bg-muted/70 transition-colors text-left w-full',
|
||||
'flex items-stretch gap-4 h-26 border rounded-md p-3 bg-card transition-colors text-left w-full',
|
||||
isPlayable && 'hover:bg-muted/70 cursor-pointer',
|
||||
isCurrentlyPlaying && 'bg-muted/70',
|
||||
)}
|
||||
aria-label={
|
||||
isCurrentlyPlaying
|
||||
? `Sample from ${gen.profile_name}, ${formatDuration(gen.duration)}, ${formatDate(gen.created_at)}. Playing. Press Enter to restart.`
|
||||
: `Sample from ${gen.profile_name}, ${formatDuration(gen.duration)}, ${formatDate(gen.created_at)}. Press Enter to play.`
|
||||
isGenerating
|
||||
? `Generating speech for ${gen.profile_name}...`
|
||||
: isFailed
|
||||
? `Generation failed for ${gen.profile_name}`
|
||||
: isCurrentlyPlaying
|
||||
? `Sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}, ${formatDate(gen.created_at)}. Playing. Press Enter to restart.`
|
||||
: `Sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}, ${formatDate(gen.created_at)}. Press Enter to play.`
|
||||
}
|
||||
onMouseDown={(e) => {
|
||||
// Don't trigger play if clicking on textarea or if text is selected
|
||||
if (!isPlayable) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('textarea') || window.getSelection()?.toString()) {
|
||||
return;
|
||||
@@ -273,6 +302,7 @@ export function HistoryTable() {
|
||||
handlePlay(gen.id, gen.text, gen.profile_id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (!isPlayable) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('textarea') || target.closest('button')) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -281,9 +311,14 @@ export function HistoryTable() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Waveform icon */}
|
||||
<div className="flex items-center shrink-0">
|
||||
<AudioWaveform className="h-5 w-5 text-muted-foreground" />
|
||||
{/* Status icon */}
|
||||
<div className="flex items-center shrink-0 w-10 justify-center overflow-hidden">
|
||||
<div className="scale-50">
|
||||
<Loader
|
||||
type={isGenerating ? 'line-scale' : 'line-scale-pulse-out-rapid'}
|
||||
active={isGenerating || isCurrentlyPlaying}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Left side - Meta information */}
|
||||
@@ -294,11 +329,22 @@ export function HistoryTable() {
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{gen.language}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDuration(gen.duration)}
|
||||
{formatEngineName(gen.engine, gen.model_size)}
|
||||
</span>
|
||||
{isFailed ? (
|
||||
<span className="text-xs text-destructive">Failed</span>
|
||||
) : !isGenerating ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDuration(gen.duration ?? 0)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDate(gen.created_at)}
|
||||
{isGenerating ? (
|
||||
<span className="text-accent">Generating...</span>
|
||||
) : (
|
||||
formatDate(gen.created_at)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -308,58 +354,70 @@ export function HistoryTable() {
|
||||
value={gen.text}
|
||||
className="flex-1 resize-none text-sm text-muted-foreground select-text"
|
||||
readOnly
|
||||
aria-label={`Transcript for sample from ${gen.profile_name}, ${formatDuration(gen.duration)}`}
|
||||
aria-label={`Transcript for sample from ${gen.profile_name}, ${formatDuration(gen.duration ?? 0)}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Far right - Ellipsis actions */}
|
||||
{/* Far right - Actions */}
|
||||
<div
|
||||
className="w-10 shrink-0 flex justify-end"
|
||||
className="w-10 shrink-0 flex justify-end items-center"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label="Actions"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Play
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDownloadAudio(gen.id, gen.text)}
|
||||
disabled={exportGenerationAudio.isPending}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export Audio
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExportPackage(gen.id, gen.text)}
|
||||
disabled={exportGeneration.isPending}
|
||||
>
|
||||
<FileArchive className="mr-2 h-4 w-4" />
|
||||
Export Package
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
||||
disabled={deleteGeneration.isPending}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{isFailed ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label="Retry generation"
|
||||
onClick={() => handleRetry(gen.id)}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
) : isPlayable ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label="Actions"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Play
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDownloadAudio(gen.id, gen.text)}
|
||||
disabled={exportGenerationAudio.isPending}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export Audio
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExportPackage(gen.id, gen.text)}
|
||||
disabled={exportGeneration.isPending}
|
||||
>
|
||||
<FileArchive className="mr-2 h-4 w-4" />
|
||||
Export Package
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(gen.id, gen.profile_name)}
|
||||
disabled={deleteGeneration.isPending}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -387,7 +445,8 @@ export function HistoryTable() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Generation</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this generation from "{generationToDelete?.name}"? This action cannot be undone.
|
||||
Are you sure you want to delete this generation from "{generationToDelete?.name}"?
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -124,6 +124,7 @@ export function ConnectionForm() {
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="keepServerRunning"
|
||||
className="mt-[6px]"
|
||||
checked={keepServerRunningOnClose}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
setKeepServerRunningOnClose(checked);
|
||||
@@ -158,6 +159,7 @@ export function ConnectionForm() {
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="allowNetworkAccess"
|
||||
className="mt-[6px]"
|
||||
checked={mode === 'remote'}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
setMode(checked ? 'remote' : 'local');
|
||||
|
||||
@@ -10,6 +10,8 @@ export function GenerationSettings() {
|
||||
const setCrossfadeMs = useServerStore((state) => state.setCrossfadeMs);
|
||||
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
|
||||
const setNormalizeAudio = useServerStore((state) => state.setNormalizeAudio);
|
||||
const autoplayOnGenerate = useServerStore((state) => state.autoplayOnGenerate);
|
||||
const setAutoplayOnGenerate = useServerStore((state) => state.setAutoplayOnGenerate);
|
||||
|
||||
return (
|
||||
<Card role="region" aria-label="Generation Settings" tabIndex={0}>
|
||||
@@ -35,7 +37,7 @@ export function GenerationSettings() {
|
||||
value={[maxChunkChars]}
|
||||
onValueChange={([value]) => setMaxChunkChars(value)}
|
||||
min={100}
|
||||
max={2000}
|
||||
max={5000}
|
||||
step={50}
|
||||
aria-label="Auto-chunking character limit"
|
||||
/>
|
||||
@@ -73,6 +75,7 @@ export function GenerationSettings() {
|
||||
id="normalizeAudio"
|
||||
checked={normalizeAudio}
|
||||
onCheckedChange={setNormalizeAudio}
|
||||
className="mt-[6px]"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
@@ -86,6 +89,26 @@ export function GenerationSettings() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="autoplayOnGenerate"
|
||||
checked={autoplayOnGenerate}
|
||||
onCheckedChange={setAutoplayOnGenerate}
|
||||
className="mt-[6px]"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="autoplayOnGenerate"
|
||||
className="text-sm font-medium leading-none cursor-pointer"
|
||||
>
|
||||
Autoplay on generate
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically play audio when a generation completes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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 (
|
||||
<Link
|
||||
@@ -63,21 +56,6 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Spacer to push loader to bottom */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Generation Loader */}
|
||||
{isGenerating && (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full flex items-center justify-center transition-all duration-200',
|
||||
isPlayerVisible ? 'mb-[120px]' : 'mb-0',
|
||||
)}
|
||||
>
|
||||
<Loader2 className="h-6 w-6 text-accent animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -200,6 +200,12 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async retryGeneration(generationId: string): Promise<GenerationResponse> {
|
||||
return this.request<GenerationResponse>(`/generate/${generationId}/retry`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// History
|
||||
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
@@ -278,6 +284,11 @@ class ApiClient {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Generation status SSE
|
||||
getGenerationStatusUrl(generationId: string): string {
|
||||
return `${this.getBaseUrl()}/generate/${generationId}/status`;
|
||||
}
|
||||
|
||||
// Audio
|
||||
getAudioUrl(audioId: string): string {
|
||||
return `${this.getBaseUrl()}/audio/${audioId}`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<Map<string, EventSource>>(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]);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export function useRestoreActiveTasks() {
|
||||
const [activeDownloads, setActiveDownloads] = useState<ActiveDownloadTask[]>([]);
|
||||
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
|
||||
const setActiveGenerationId = useGenerationStore((state) => state.setActiveGenerationId);
|
||||
const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration);
|
||||
|
||||
// Track which downloads we've seen to detect new ones
|
||||
const seenDownloadsRef = useRef<Set<string>>(new Set());
|
||||
@@ -25,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;
|
||||
|
||||
@@ -21,10 +21,25 @@ export function formatDate(date: string | Date): string {
|
||||
} else {
|
||||
dateObj = date;
|
||||
}
|
||||
|
||||
|
||||
return formatDistance(dateObj, new Date(), { addSuffix: true }).replace(/^about /i, '');
|
||||
}
|
||||
|
||||
const ENGINE_DISPLAY_NAMES: Record<string, string> = {
|
||||
qwen: 'Qwen',
|
||||
luxtts: 'LuxTTS',
|
||||
chatterbox: 'Chatterbox',
|
||||
chatterbox_turbo: 'Chatterbox Turbo',
|
||||
};
|
||||
|
||||
export function formatEngineName(engine?: string, modelSize?: string): string {
|
||||
const name = ENGINE_DISPLAY_NAMES[engine ?? 'qwen'] ?? engine ?? 'Qwen';
|
||||
if (engine === 'qwen' && modelSize) {
|
||||
return `${name} ${modelSize}`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
|
||||
@@ -8,8 +8,10 @@ import { Sidebar } from '@/components/Sidebar';
|
||||
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
|
||||
import { useGenerationProgress } from '@/lib/hooks/useGenerationProgress';
|
||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
|
||||
|
||||
// Simple platform check that works in both web and Tauri
|
||||
const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
|
||||
|
||||
@@ -18,6 +20,9 @@ function RootLayout() {
|
||||
// Monitor active downloads/generations and show toasts for them
|
||||
const activeDownloads = useRestoreActiveTasks();
|
||||
|
||||
// Subscribe to SSE for pending generations — handles completion, auto-play, and history refresh
|
||||
useGenerationProgress();
|
||||
|
||||
return (
|
||||
<AppFrame>
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface GenerationState {
|
||||
/** IDs of generations currently in progress */
|
||||
pendingGenerationIds: Set<string>;
|
||||
/** 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<GenerationState>((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 }),
|
||||
}));
|
||||
|
||||
@@ -23,6 +23,9 @@ interface ServerStore {
|
||||
normalizeAudio: boolean;
|
||||
setNormalizeAudio: (value: boolean) => void;
|
||||
|
||||
autoplayOnGenerate: boolean;
|
||||
setAutoplayOnGenerate: (value: boolean) => void;
|
||||
|
||||
customModelsDir: string | null;
|
||||
setCustomModelsDir: (dir: string | null) => void;
|
||||
}
|
||||
@@ -51,6 +54,9 @@ export const useServerStore = create<ServerStore>()(
|
||||
normalizeAudio: true,
|
||||
setNormalizeAudio: (value) => set({ normalizeAudio: value }),
|
||||
|
||||
autoplayOnGenerate: true,
|
||||
setAutoplayOnGenerate: (value) => set({ autoplayOnGenerate: value }),
|
||||
|
||||
customModelsDir: null,
|
||||
setCustomModelsDir: (dir) => set({ customModelsDir: dir }),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user