diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 8ce6ca82..fb47fa00 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.1.1 +current_version = 0.1.3 commit = True tag = True tag_name = v{new_version} diff --git a/.gitignore b/.gitignore index cece5001..05f7ef0d 100644 --- a/.gitignore +++ b/.gitignore @@ -30,8 +30,6 @@ target/ *.swo *~ -tauri/src-tauri/gen/Assets.car - # OS .DS_Store Thumbs.db diff --git a/app/package.json b/app/package.json index aaa054c3..b9f48eba 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "@voicebox/app", - "version": "0.1.1", + "version": "0.1.3", "private": true, "type": "module", "scripts": { diff --git a/app/src/App.tsx b/app/src/App.tsx index 202c8ac4..8168494a 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react'; import voiceboxLogo from '@/assets/voicebox-logo.png'; import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer'; -import { GenerationForm } from '@/components/Generation/GenerationForm'; +// import { GenerationForm } from '@/components/Generation/GenerationForm'; +import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox'; import { HistoryTable } from '@/components/History/HistoryTable'; import ShinyText from '@/components/ShinyText'; import { Sidebar } from '@/components/Sidebar'; @@ -20,6 +21,7 @@ import { setupWindowCloseHandler, startServer, } from '@/lib/tauri'; +import { usePlayerStore } from '@/stores/playerStore'; import { useServerStore } from '@/stores/serverStore'; // Track if server is starting to prevent duplicate starts @@ -52,6 +54,7 @@ function App() { const [activeTab, setActiveTab] = useState('main'); const [serverReady, setServerReady] = useState(false); const [loadingMessageIndex, setLoadingMessageIndex] = useState(0); + const audioUrl = usePlayerStore((state) => state.audioUrl); // Monitor active downloads/generations and show toasts for them const activeDownloads = useRestoreActiveTasks(); @@ -175,7 +178,7 @@ function App() {
{activeTab === 'main' && ( // Main view: Profiles top left, Generator bottom left, History right -
+
{/* Left Column */}
{/* Profiles - Top Left */} @@ -184,15 +187,18 @@ function App() {
{/* Generator - Bottom Left */} -
+ {/*
-
+
*/}
{/* Right Column - History */}
+ + {/* Floating Generate Box */} +
)} {activeTab === 'voices' && } diff --git a/app/src/components/Generation/.gitkeep b/app/src/components/Generation/.gitkeep deleted file mode 100644 index 18bc6d98..00000000 --- a/app/src/components/Generation/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# Voice generation components diff --git a/app/src/components/Generation/FloatingGenerateBox.tsx b/app/src/components/Generation/FloatingGenerateBox.tsx new file mode 100644 index 00000000..e0f1daf4 --- /dev/null +++ b/app/src/components/Generation/FloatingGenerateBox.tsx @@ -0,0 +1,282 @@ +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; + +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(null); + const [downloadingDisplayName, setDownloadingDisplayName] = useState(null); + const [isExpanded, setIsExpanded] = useState(false); + const containerRef = useRef(null); + + useModelDownloadToast({ + modelName: downloadingModelName || '', + displayName: downloadingDisplayName || '', + enabled: !!downloadingModelName, + }); + + const form = useForm({ + 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 ( + + +
+ +
+ + ( + + +