mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-16 13:20:39 -07:00
- Introduced the FloatingGenerateBox component for audio generation, enhancing user interaction with voice profiles. - Updated App component to integrate FloatingGenerateBox and removed the GenerationForm component. - Enhanced the layout for better responsiveness and added functionality to manage audio playback state. - Updated UpdateStatus component to include new update handling logic and improved UI feedback for update readiness.
283 lines
11 KiB
TypeScript
283 lines
11 KiB
TypeScript
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { AnimatePresence, motion } from 'framer-motion';
|
|
import { Loader2, Sparkles } from 'lucide-react';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { useForm } from 'react-hook-form';
|
|
import * as z from 'zod';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { useToast } from '@/components/ui/use-toast';
|
|
import { apiClient } from '@/lib/api/client';
|
|
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
|
|
import { useGeneration } from '@/lib/hooks/useGeneration';
|
|
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
|
import { useProfile } from '@/lib/hooks/useProfiles';
|
|
import { useGenerationStore } from '@/stores/generationStore';
|
|
import { usePlayerStore } from '@/stores/playerStore';
|
|
import { useUIStore } from '@/stores/uiStore';
|
|
|
|
const generationSchema = z.object({
|
|
text: z.string().min(1, 'Text is required').max(5000),
|
|
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
|
modelSize: z.enum(['1.7B', '0.6B']).optional(),
|
|
});
|
|
|
|
type GenerationFormValues = z.infer<typeof generationSchema>;
|
|
|
|
interface FloatingGenerateBoxProps {
|
|
isPlayerOpen: boolean;
|
|
}
|
|
|
|
export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps) {
|
|
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
|
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
|
const generation = useGeneration();
|
|
const { toast } = useToast();
|
|
const setAudio = usePlayerStore((state) => state.setAudio);
|
|
const setIsGenerating = useGenerationStore((state) => state.setIsGenerating);
|
|
const [downloadingModelName, setDownloadingModelName] = useState<string | null>(null);
|
|
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
useModelDownloadToast({
|
|
modelName: downloadingModelName || '',
|
|
displayName: downloadingDisplayName || '',
|
|
enabled: !!downloadingModelName,
|
|
});
|
|
|
|
const form = useForm<GenerationFormValues>({
|
|
resolver: zodResolver(generationSchema),
|
|
defaultValues: {
|
|
text: '',
|
|
language: 'en',
|
|
modelSize: '1.7B',
|
|
},
|
|
});
|
|
|
|
// Click away handler to collapse the box
|
|
useEffect(() => {
|
|
function handleClickOutside(event: MouseEvent) {
|
|
const target = event.target as HTMLElement;
|
|
|
|
// Don't collapse if clicking inside the container
|
|
if (containerRef.current?.contains(target)) {
|
|
return;
|
|
}
|
|
|
|
// Don't collapse if clicking on a Select dropdown (which renders in a portal)
|
|
if (
|
|
target.closest('[role="listbox"]') ||
|
|
target.closest('[data-radix-popper-content-wrapper]')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
setIsExpanded(false);
|
|
}
|
|
|
|
if (isExpanded) {
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
};
|
|
}, [isExpanded]);
|
|
|
|
async function onSubmit(data: GenerationFormValues) {
|
|
if (!selectedProfileId) {
|
|
toast({
|
|
title: 'No profile selected',
|
|
description: 'Please select a voice profile from the cards above.',
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsGenerating(true);
|
|
|
|
const modelName = `qwen-tts-${data.modelSize}`;
|
|
const displayName = data.modelSize === '1.7B' ? 'Qwen TTS 1.7B' : 'Qwen TTS 0.6B';
|
|
|
|
try {
|
|
const modelStatus = await apiClient.getModelStatus();
|
|
const model = modelStatus.models.find((m) => m.model_name === modelName);
|
|
|
|
if (model && !model.downloaded) {
|
|
setDownloadingModelName(modelName);
|
|
setDownloadingDisplayName(displayName);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to check model status:', error);
|
|
}
|
|
|
|
const result = await generation.mutateAsync({
|
|
profile_id: selectedProfileId,
|
|
text: data.text,
|
|
language: data.language,
|
|
model_size: data.modelSize,
|
|
});
|
|
|
|
toast({
|
|
title: 'Generation complete!',
|
|
description: `Audio generated (${result.duration.toFixed(2)}s)`,
|
|
});
|
|
|
|
const audioUrl = apiClient.getAudioUrl(result.id);
|
|
setAudio(audioUrl, result.id, data.text.substring(0, 50));
|
|
|
|
form.reset();
|
|
setIsExpanded(false);
|
|
} catch (error) {
|
|
toast({
|
|
title: 'Generation failed',
|
|
description: error instanceof Error ? error.message : 'Failed to generate audio',
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setIsGenerating(false);
|
|
setDownloadingModelName(null);
|
|
setDownloadingDisplayName(null);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<motion.div
|
|
ref={containerRef}
|
|
className="fixed left-[calc(5rem+2rem)] right-auto w-[calc((100%-5rem-4rem)/2-1rem)]"
|
|
style={{
|
|
bottom: isPlayerOpen ? 'calc(7rem + 1.5rem)' : '1.5rem',
|
|
}}
|
|
>
|
|
<motion.div
|
|
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[2rem] shadow-2xl hover:bg-background/40 hover:border-accent/20 transition-all duration-300 overflow-hidden p-3"
|
|
transition={{ duration: 0.6, ease: 'easeInOut' }}
|
|
>
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
<div className="flex gap-2">
|
|
<motion.div
|
|
className="flex-1"
|
|
// animate={{ marginBottom: isExpanded ? '0.75rem' : '0' }}
|
|
transition={{ duration: 0.3, ease: 'easeOut' }}
|
|
>
|
|
<FormField
|
|
control={form.control}
|
|
name="text"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder={
|
|
selectedProfile
|
|
? `Generate speech using ${selectedProfile.name}...`
|
|
: 'Select a voice profile above...'
|
|
}
|
|
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 overflow-hidden transition-all"
|
|
style={{
|
|
minHeight: isExpanded ? '100px' : '32px',
|
|
height: isExpanded ? '100px' : '32px',
|
|
}}
|
|
disabled={!selectedProfileId}
|
|
onClick={() => setIsExpanded(true)}
|
|
onFocus={() => setIsExpanded(true)}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage className="text-xs" />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</motion.div>
|
|
|
|
<Button
|
|
type="submit"
|
|
disabled={generation.isPending || !selectedProfileId}
|
|
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 shrink-0 transition-all duration-200"
|
|
size="icon"
|
|
>
|
|
{generation.isPending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Sparkles className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.3, ease: 'easeOut' }}
|
|
className=" mt-3"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<FormField
|
|
control={form.control}
|
|
name="language"
|
|
render={({ field }) => (
|
|
<FormItem className="flex-1">
|
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
|
<FormControl>
|
|
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
{LANGUAGE_OPTIONS.map((lang) => (
|
|
<SelectItem key={lang.value} value={lang.value} className="text-xs">
|
|
{lang.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage className="text-xs" />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="modelSize"
|
|
render={({ field }) => (
|
|
<FormItem className="flex-1">
|
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
|
<FormControl>
|
|
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem value="1.7B" className="text-xs text-muted-foreground">
|
|
Qwen3-TTS 1.7B
|
|
</SelectItem>
|
|
<SelectItem value="0.6B" className="text-xs text-muted-foreground">
|
|
Qwen3-TTS 0.6B
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage className="text-xs" />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</form>
|
|
</Form>
|
|
</motion.div>
|
|
</motion.div>
|
|
);
|
|
}
|