diff --git a/app/src/App.tsx b/app/src/App.tsx index 84d8fc8d..dfcf9fd8 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { RouterProvider } from '@tanstack/react-router'; import voiceboxLogo from '@/assets/voicebox-logo.png'; import ShinyText from '@/components/ShinyText'; @@ -14,9 +14,6 @@ import { cn } from '@/lib/utils/cn'; import { router } from '@/router'; import { useServerStore } from '@/stores/serverStore'; -// Track if server is starting to prevent duplicate starts -let serverStarting = false; - const LOADING_MESSAGES = [ 'Warming up tensors...', 'Calibrating synthesizer engine...', @@ -43,6 +40,7 @@ const LOADING_MESSAGES = [ function App() { const [serverReady, setServerReady] = useState(false); const [loadingMessageIndex, setLoadingMessageIndex] = useState(0); + const serverStartingRef = useRef(false); // Sync stored setting to Rust on startup useEffect(() => { @@ -78,11 +76,11 @@ function App() { } // Auto-start server in production - if (serverStarting) { + if (serverStartingRef.current) { return; } - serverStarting = true; + serverStartingRef.current = true; console.log('Production mode: Starting bundled server...'); startServer(false) @@ -92,13 +90,11 @@ function App() { useServerStore.getState().setServerUrl(serverUrl); setServerReady(true); // Mark that we started the server (so we know to stop it on close) - // @ts-expect-error - adding property to window window.__voiceboxServerStartedByApp = true; }) .catch((error) => { console.error('Failed to auto-start server:', error); - serverStarting = false; - // @ts-expect-error - adding property to window + serverStartingRef.current = false; window.__voiceboxServerStartedByApp = false; }); @@ -106,7 +102,7 @@ function App() { // Note: Window close is handled separately in Tauri Rust code return () => { // Window close event handles server shutdown based on setting - serverStarting = false; + serverStartingRef.current = false; }; }, []); diff --git a/app/src/components/AudioPlayer/AudioPlayer.tsx b/app/src/components/AudioPlayer/AudioPlayer.tsx index 742f9361..d6b843df 100644 --- a/app/src/components/AudioPlayer/AudioPlayer.tsx +++ b/app/src/components/AudioPlayer/AudioPlayer.tsx @@ -8,6 +8,7 @@ import { Slider } from '@/components/ui/slider'; import { apiClient } from '@/lib/api/client'; import { isTauri } from '@/lib/tauri'; import { formatAudioDuration } from '@/lib/utils/audio'; +import { debug } from '@/lib/utils/debug'; import { usePlayerStore } from '@/stores/playerStore'; export function AudioPlayer() { @@ -49,28 +50,17 @@ export function AudioPlayer() { // Determine if we should use native playback const useNativePlayback = useMemo(() => { - console.log('useNativePlayback memo:', { - isTauri: isTauri(), - profileId, - profileChannels, - channels, - }); - if (!isTauri() || !profileChannels || !channels) { - console.log('useNativePlayback: false - missing requirements'); return false; } const assignedChannels = channels.filter((ch) => profileChannels.channel_ids.includes(ch.id)); - console.log('Assigned channels:', assignedChannels); - // Use native playback if any assigned channel has non-default devices const shouldUseNative = assignedChannels.some( (ch) => ch.device_ids.length > 0 && !ch.is_default, ); - console.log('useNativePlayback result:', shouldUseNative); return shouldUseNative; }, [profileChannels, channels, profileId]); @@ -91,11 +81,11 @@ export function AudioPlayer() { } if (wavesurferRef.current) { - console.log('WaveSurfer already initialized, skipping'); + debug.log('WaveSurfer already initialized, skipping'); return; } - console.log('Creating NEW WaveSurfer instance'); + debug.log('Creating NEW WaveSurfer instance'); // Wait for container to be properly rendered const initWaveSurfer = () => { @@ -121,7 +111,7 @@ export function AudioPlayer() { return; } - console.log('Initializing WaveSurfer...', { + debug.log('Initializing WaveSurfer...', { container, width: rect.width, height: rect.height, @@ -154,9 +144,9 @@ export function AudioPlayer() { }); wavesurferRef.current = wavesurfer; - console.log('WaveSurfer created successfully'); + debug.log('WaveSurfer created successfully'); } catch (error) { - console.error('Failed to create WaveSurfer:', error); + debug.error('Failed to create WaveSurfer:', error); setError( `Failed to initialize waveform: ${error instanceof Error ? error.message : String(error)}`, ); @@ -178,8 +168,8 @@ export function AudioPlayer() { loadingRef.current = false; setIsLoading(false); setError(null); - console.log('Audio ready, duration:', dur); - console.log('Waveform should be visible now'); + debug.log('Audio ready, duration:', dur); + debug.log('Waveform should be visible now'); // Ensure volume is set const currentVolume = usePlayerStore.getState().volume; @@ -191,7 +181,7 @@ export function AudioPlayer() { if (mediaElement && !isUsingNativePlaybackRef.current) { mediaElement.volume = currentVolume; mediaElement.muted = false; - console.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted); + debug.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted); } // Auto-play when ready - check if we should use native playback @@ -199,7 +189,7 @@ export function AudioPlayer() { const currentAudioUrl = usePlayerStore.getState().audioUrl; const currentProfileId = usePlayerStore.getState().profileId; - console.log('Auto-play check - capturing runtime values...'); + debug.log('Auto-play check - capturing runtime values...'); // Fetch profile channels at runtime (not using captured value) let runtimeProfileChannels = null; @@ -208,18 +198,18 @@ export function AudioPlayer() { if (isTauri() && currentProfileId) { try { runtimeProfileChannels = await apiClient.getProfileChannels(currentProfileId); - console.log('Runtime profileChannels:', runtimeProfileChannels); + debug.log('Runtime profileChannels:', runtimeProfileChannels); if (runtimeProfileChannels && runtimeProfileChannels.channel_ids.length > 0) { runtimeChannels = await apiClient.listChannels(); - console.log('Runtime channels:', runtimeChannels); + debug.log('Runtime channels:', runtimeChannels); } } catch (error) { - console.error('Failed to fetch runtime channel data:', error); + debug.error('Failed to fetch runtime channel data:', error); } } - console.log('Auto-play check:', { + debug.log('Auto-play check:', { isTauri: isTauri(), currentAudioUrl, currentProfileId, @@ -234,15 +224,15 @@ export function AudioPlayer() { runtimeProfileChannels && runtimeChannels ) { - console.log('Attempting native audio playback...'); + debug.log('Attempting native audio playback...'); // Stop any existing native playback first if (isUsingNativePlaybackRef.current) { try { await invoke('stop_audio_playback'); - console.log('Stopped existing native playback before starting new one'); + debug.log('Stopped existing native playback before starting new one'); } catch (error) { - console.error('Failed to stop existing playback:', error); + debug.error('Failed to stop existing playback:', error); } } @@ -251,16 +241,16 @@ export function AudioPlayer() { const assignedChannels = runtimeChannels.filter((ch: any) => runtimeProfileChannels.channel_ids.includes(ch.id), ); - console.log('Assigned channels for playback:', assignedChannels); + debug.log('Assigned channels for playback:', assignedChannels); // Check if any assigned channel has non-default devices const shouldUseNative = assignedChannels.some( (ch: any) => ch.device_ids.length > 0 && !ch.is_default, ); - console.log('Should use native playback:', shouldUseNative); + debug.log('Should use native playback:', shouldUseNative); if (!shouldUseNative) { - console.log('No custom devices assigned, falling back to WaveSurfer'); + debug.log('No custom devices assigned, falling back to WaveSurfer'); // Reset native playback flag and unmute WaveSurfer isUsingNativePlaybackRef.current = false; const mediaElement = wavesurfer.getMediaElement(); @@ -268,7 +258,7 @@ export function AudioPlayer() { const currentVolume = usePlayerStore.getState().volume; mediaElement.volume = currentVolume; mediaElement.muted = false; - console.log( + debug.log( 'WaveSurfer unmuted for normal playback - volume:', mediaElement.volume, 'muted:', @@ -277,23 +267,23 @@ export function AudioPlayer() { } } else { const deviceIds = assignedChannels.flatMap((ch: any) => ch.device_ids); - console.log('Device IDs to play to:', deviceIds); + debug.log('Device IDs to play to:', deviceIds); if (deviceIds.length > 0) { - console.log('Fetching audio data from:', currentAudioUrl); + debug.log('Fetching audio data from:', currentAudioUrl); // Fetch audio data const response = await fetch(currentAudioUrl); const audioData = new Uint8Array(await response.arrayBuffer()); - console.log('Audio data size:', audioData.length); + debug.log('Audio data size:', audioData.length); // Play via native audio - console.log('Invoking play_audio_to_devices...'); + debug.log('Invoking play_audio_to_devices...'); try { const result = await invoke('play_audio_to_devices', { audioData: Array.from(audioData), deviceIds: deviceIds, }); - console.log('play_audio_to_devices completed successfully, result:', result); + debug.log('play_audio_to_devices completed successfully, result:', result); // Mark that we're using native playback isUsingNativePlaybackRef.current = true; @@ -304,7 +294,7 @@ export function AudioPlayer() { if (mediaElement) { mediaElement.volume = 0; mediaElement.muted = true; - console.log( + debug.log( 'WaveSurfer muted for native playback - volume:', mediaElement.volume, 'muted:', @@ -314,22 +304,22 @@ export function AudioPlayer() { // Start WaveSurfer playback for visualization (muted) wavesurfer.play().catch((error) => { - console.error('Failed to start WaveSurfer visualization:', error); + debug.error('Failed to start WaveSurfer visualization:', error); }); setIsPlaying(true); - console.log('Auto-playing via native audio routing - SUCCESS'); + debug.log('Auto-playing via native audio routing - SUCCESS'); return; } catch (invokeError) { - console.error('play_audio_to_devices invoke failed:', invokeError); + debug.error('play_audio_to_devices invoke failed:', invokeError); throw invokeError; } } else { - console.log('No device IDs found, falling back to WaveSurfer'); + debug.log('No device IDs found, falling back to WaveSurfer'); } } } catch (error) { - console.error( + debug.error( 'Native playback failed during auto-play, falling back to WaveSurfer:', error, ); @@ -340,7 +330,7 @@ export function AudioPlayer() { const currentVolume = usePlayerStore.getState().volume; mediaElement.volume = currentVolume; mediaElement.muted = false; - console.log( + debug.log( 'WaveSurfer unmuted after native playback failure - volume:', mediaElement.volume, 'muted:', @@ -350,7 +340,7 @@ export function AudioPlayer() { // Fall through to WaveSurfer playback } } else { - console.log('Not using native playback, using WaveSurfer'); + debug.log('Not using native playback, using WaveSurfer'); // Reset native playback flag and unmute WaveSurfer isUsingNativePlaybackRef.current = false; const mediaElement = wavesurfer.getMediaElement(); @@ -358,7 +348,7 @@ export function AudioPlayer() { const currentVolume = usePlayerStore.getState().volume; mediaElement.volume = currentVolume; mediaElement.muted = false; - console.log( + debug.log( 'WaveSurfer unmuted for normal playback - volume:', mediaElement.volume, 'muted:', @@ -371,7 +361,7 @@ export function AudioPlayer() { // Use a small delay to ensure audio element is fully ready setTimeout(() => { wavesurfer.play().catch((error) => { - console.error('Failed to autoplay:', error); + debug.error('Failed to autoplay:', error); // Don't show error for autoplay failures (browser restrictions) }); }, 100); @@ -388,13 +378,13 @@ export function AudioPlayer() { if (isUsingNativePlaybackRef.current) { mediaElement.volume = 0; mediaElement.muted = true; - console.log('Playing (native mode) - WaveSurfer muted for visualization only'); + debug.log('Playing (native mode) - WaveSurfer muted for visualization only'); } else { // Ensure WaveSurfer is unmuted for normal playback const currentVolume = usePlayerStore.getState().volume; mediaElement.volume = currentVolume; mediaElement.muted = false; - console.log( + debug.log( 'Playing (normal mode) - volume:', mediaElement.volume, 'muted:', @@ -417,7 +407,7 @@ export function AudioPlayer() { // Handle errors wavesurfer.on('error', (error) => { - console.error('WaveSurfer error:', error); + debug.error('WaveSurfer error:', error); setIsLoading(false); setError(`Audio error: ${error instanceof Error ? error.message : String(error)}`); }); @@ -432,7 +422,7 @@ export function AudioPlayer() { // Load audio immediately if audioUrl is already set if (audioUrl) { - console.log('WaveSurfer ready, loading audio:', audioUrl); + debug.log('WaveSurfer ready, loading audio:', audioUrl); loadingRef.current = true; setIsLoading(true); // Stop any current playback before loading new audio @@ -442,11 +432,11 @@ export function AudioPlayer() { wavesurfer .load(audioUrl) .then(() => { - console.log('Audio loaded into WaveSurfer'); + debug.log('Audio loaded into WaveSurfer'); loadingRef.current = false; }) .catch((error) => { - console.error('Failed to load audio into WaveSurfer:', error); + debug.error('Failed to load audio into WaveSurfer:', error); loadingRef.current = false; setIsLoading(false); setError( @@ -471,12 +461,12 @@ export function AudioPlayer() { }); return () => { - console.log('Cleaning up WaveSurfer initialization effect'); + debug.log('Cleaning up WaveSurfer initialization effect'); if (rafId1) cancelAnimationFrame(rafId1); if (rafId2) cancelAnimationFrame(rafId2); if (timeoutId) clearTimeout(timeoutId); if (wavesurferRef.current) { - console.log('Destroying WaveSurfer instance'); + debug.log('Destroying WaveSurfer instance'); try { const mediaElement = wavesurferRef.current.getMediaElement(); if (mediaElement) { @@ -485,7 +475,7 @@ export function AudioPlayer() { } wavesurferRef.current.destroy(); } catch (error) { - console.error('Error destroying WaveSurfer:', error); + debug.error('Error destroying WaveSurfer:', error); } wavesurferRef.current = null; } @@ -517,9 +507,9 @@ export function AudioPlayer() { (async () => { try { await invoke('stop_audio_playback'); - console.log('Stopped native audio playback'); + debug.log('Stopped native audio playback'); } catch (error) { - console.error('Failed to stop native playback:', error); + debug.error('Failed to stop native playback:', error); } })(); } @@ -537,30 +527,30 @@ export function AudioPlayer() { // CRITICAL: Force stop any current playback and cancel any pending loads // This must happen BEFORE any early returns - console.log('Audio URL changed to:', audioUrl); + debug.log('Audio URL changed to:', audioUrl); // COMPLETELY stop and destroy the current audio try { // First pause if playing if (wavesurfer.isPlaying()) { - console.log('Pausing current playback'); + debug.log('Pausing current playback'); wavesurfer.pause(); } // Stop the media element explicitly const mediaElement = wavesurfer.getMediaElement(); if (mediaElement) { - console.log('Stopping media element'); + debug.log('Stopping media element'); mediaElement.pause(); mediaElement.currentTime = 0; mediaElement.src = ''; } // Use empty() to completely destroy the waveform and media element - console.log('Calling wavesurfer.empty() to destroy audio'); + debug.log('Calling wavesurfer.empty() to destroy audio'); wavesurfer.empty(); } catch (error) { - console.error('Error stopping previous audio:', error); + debug.error('Error stopping previous audio:', error); // Continue anyway to load new audio } @@ -575,16 +565,16 @@ export function AudioPlayer() { setDuration(0); // Load new audio - console.log('Starting new audio load for:', audioUrl); + debug.log('Starting new audio load for:', audioUrl); wavesurfer .load(audioUrl) .then(() => { - console.log('Audio load promise resolved'); + debug.log('Audio load promise resolved'); // Don't set loading to false here - wait for 'ready' event }) .catch((error) => { - console.error('Failed to load audio:', error); - console.error('Audio URL:', audioUrl); + debug.error('Failed to load audio:', error); + debug.error('Audio URL:', audioUrl); loadingRef.current = false; setIsLoading(false); setError(`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`); @@ -599,7 +589,7 @@ export function AudioPlayer() { if (isPlaying && wavesurferRef.current.isPlaying() === false) { // Only auto-play if audio is ready wavesurferRef.current.play().catch((error) => { - console.error('Failed to play:', error); + debug.error('Failed to play:', error); setIsPlaying(false); setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`); }); @@ -619,11 +609,11 @@ export function AudioPlayer() { if (isUsingNativePlaybackRef.current) { mediaElement.volume = 0; mediaElement.muted = true; - console.log('Volume sync: Using native playback, keeping WaveSurfer muted'); + debug.log('Volume sync: Using native playback, keeping WaveSurfer muted'); } else { mediaElement.volume = volume; mediaElement.muted = volume === 0; - console.log('Volume synced:', volume, 'muted:', mediaElement.muted); + debug.log('Volume synced:', volume, 'muted:', mediaElement.muted); } } } @@ -651,10 +641,10 @@ export function AudioPlayer() { } // Reset to beginning and play - console.log('Restarting current audio from beginning'); + debug.log('Restarting current audio from beginning'); wavesurfer.seekTo(0); wavesurfer.play().catch((error) => { - console.error('Failed to play after restart:', error); + debug.error('Failed to play after restart:', error); setIsPlaying(false); setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`); }); @@ -669,13 +659,13 @@ export function AudioPlayer() { // Standard WaveSurfer playback (works for both normal and native playback modes) // When using native playback, WaveSurfer is muted but still controls visualization if (!wavesurferRef.current) { - console.error('WaveSurfer not initialized'); + debug.error('WaveSurfer not initialized'); return; } // Check if audio is loaded if (duration === 0 && !isLoading) { - console.error('Audio not loaded yet'); + debug.error('Audio not loaded yet'); setError('Audio not loaded. Please wait...'); return; } @@ -686,9 +676,9 @@ export function AudioPlayer() { // Pause: stop native playback and pause WaveSurfer visualization try { await invoke('stop_audio_playback'); - console.log('Stopped native audio playback'); + debug.log('Stopped native audio playback'); } catch (error) { - console.error('Failed to stop native playback:', error); + debug.error('Failed to stop native playback:', error); } wavesurferRef.current.pause(); return; @@ -701,7 +691,7 @@ export function AudioPlayer() { await invoke('stop_audio_playback'); } catch (_error) { // Ignore errors when stopping (might not be playing) - console.log('No existing playback to stop'); + debug.log('No existing playback to stop'); } // Collect all device IDs from assigned channels @@ -733,7 +723,7 @@ export function AudioPlayer() { // Start WaveSurfer for visualization (muted) wavesurferRef.current.play().catch((error) => { - console.error('Failed to start WaveSurfer visualization:', error); + debug.error('Failed to start WaveSurfer visualization:', error); setIsPlaying(false); setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`); }); @@ -741,7 +731,7 @@ export function AudioPlayer() { return; } } catch (error) { - console.error('Native playback failed, falling back to WaveSurfer:', error); + debug.error('Native playback failed, falling back to WaveSurfer:', error); // Fall through to WaveSurfer playback isUsingNativePlaybackRef.current = false; } @@ -761,7 +751,7 @@ export function AudioPlayer() { } wavesurferRef.current.play().catch((error) => { - console.error('Failed to play:', error); + debug.error('Failed to play:', error); setIsPlaying(false); setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`); }); @@ -782,7 +772,7 @@ export function AudioPlayer() { // Stop any native playback if (isUsingNativePlaybackRef.current && isTauri()) { invoke('stop_audio_playback').catch((error) => { - console.error('Failed to stop native playback:', error); + debug.error('Failed to stop native playback:', error); }); } // Stop WaveSurfer diff --git a/app/src/components/Generation/FloatingGenerateBox.tsx b/app/src/components/Generation/FloatingGenerateBox.tsx index 1cf11c83..3cdfd3eb 100644 --- a/app/src/components/Generation/FloatingGenerateBox.tsx +++ b/app/src/components/Generation/FloatingGenerateBox.tsx @@ -1,9 +1,6 @@ -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 { @@ -14,24 +11,11 @@ import { 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 { LANGUAGE_OPTIONS } from '@/lib/constants/languages'; +import { useGenerationForm } from '@/lib/hooks/useGenerationForm'; 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; } @@ -39,27 +23,12 @@ interface FloatingGenerateBoxProps { 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', + const { form, handleSubmit, isPending } = useGenerationForm({ + onSuccess: () => { + setIsExpanded(false); }, }); @@ -93,62 +62,8 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps) }; }, [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, selectedProfileId, 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); - } + async function onSubmit(data: Parameters[0]) { + await handleSubmit(data, selectedProfileId); } return ( @@ -168,7 +83,6 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
- {generation.isPending ? ( + {isPending ? ( ) : ( diff --git a/app/src/components/Generation/GenerationForm.tsx b/app/src/components/Generation/GenerationForm.tsx index 2d13bf9e..31b100f8 100644 --- a/app/src/components/Generation/GenerationForm.tsx +++ b/app/src/components/Generation/GenerationForm.tsx @@ -1,8 +1,4 @@ -import { zodResolver } from '@hookform/resolvers/zod'; import { Loader2, Mic } from 'lucide-react'; -import { useState } from 'react'; -import { useForm } from 'react-hook-form'; -import * as z from 'zod'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { @@ -23,118 +19,19 @@ import { 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 { LANGUAGE_OPTIONS } from '@/lib/constants/languages'; +import { useGenerationForm } from '@/lib/hooks/useGenerationForm'; 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[]]), - seed: z.number().int().optional(), - modelSize: z.enum(['1.7B', '0.6B']).optional(), - instruct: z.string().max(500).optional(), -}); - -type GenerationFormValues = z.infer; - export function GenerationForm() { 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); - // Use the download toast hook to show progress when model is downloading - useModelDownloadToast({ - modelName: downloadingModelName || '', - displayName: downloadingDisplayName || '', - enabled: !!downloadingModelName, - }); + const { form, handleSubmit, isPending } = useGenerationForm(); - const form = useForm({ - resolver: zodResolver(generationSchema), - defaultValues: { - text: '', - language: 'en', - seed: undefined, - modelSize: '1.7B', - instruct: '', - }, - }); - - 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); - - // Determine model name and display name - const modelName = `qwen-tts-${data.modelSize}`; - const displayName = data.modelSize === '1.7B' ? 'Qwen TTS 1.7B' : 'Qwen TTS 0.6B'; - - // Check if model is downloaded before starting generation - try { - const modelStatus = await apiClient.getModelStatus(); - const model = modelStatus.models.find((m) => m.model_name === modelName); - - if (model && !model.downloaded) { - // Model is not downloaded, enable download toast - setDownloadingModelName(modelName); - setDownloadingDisplayName(displayName); - } - } catch (error) { - // If status check fails, continue anyway - generation will handle it - console.error('Failed to check model status:', error); - } - - // Proceed with generation (which will trigger download if needed) - const result = await generation.mutateAsync({ - profile_id: selectedProfileId, - text: data.text, - language: data.language, - seed: data.seed, - model_size: data.modelSize, - instruct: data.instruct || undefined, - }); - - toast({ - title: 'Generation complete!', - description: `Audio generated (${result.duration.toFixed(2)}s)`, - }); - - // Autoplay the generated audio - const audioUrl = apiClient.getAudioUrl(result.id); - setAudio(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50)); - - form.reset(); - } catch (error) { - toast({ - title: 'Generation failed', - description: error instanceof Error ? error.message : 'Failed to generate audio', - variant: 'destructive', - }); - } finally { - setIsGenerating(false); - // Clear download state after generation completes - setDownloadingModelName(null); - setDownloadingDisplayName(null); - } + async function onSubmit(data: Parameters[0]) { + await handleSubmit(data, selectedProfileId); } return ( @@ -276,9 +173,9 @@ export function GenerationForm() { - -
- */} - {history.length === 0 ? (
No voice generations, yet... diff --git a/app/src/components/MainEditor/MainEditor.tsx b/app/src/components/MainEditor/MainEditor.tsx index 754e3f70..9d597b1e 100644 --- a/app/src/components/MainEditor/MainEditor.tsx +++ b/app/src/components/MainEditor/MainEditor.tsx @@ -11,6 +11,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { useToast } from '@/components/ui/use-toast'; import { ProfileList } from '@/components/VoiceProfiles/ProfileList'; import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { useImportProfile } from '@/lib/hooks/useProfiles'; @@ -27,6 +28,7 @@ export function MainEditor() { const fileInputRef = useRef(null); const [importDialogOpen, setImportDialogOpen] = useState(false); const [selectedFile, setSelectedFile] = useState(null); + const { toast } = useToast(); const handleImportClick = () => { fileInputRef.current?.click(); @@ -36,7 +38,11 @@ export function MainEditor() { const file = e.target.files?.[0]; if (file) { if (!file.name.endsWith('.voicebox.zip')) { - alert('Please select a valid .voicebox.zip file'); + toast({ + title: 'Invalid file type', + description: 'Please select a valid .voicebox.zip file', + variant: 'destructive', + }); return; } setSelectedFile(file); @@ -53,9 +59,17 @@ export function MainEditor() { if (fileInputRef.current) { fileInputRef.current.value = ''; } + toast({ + title: 'Profile imported', + description: 'Voice profile imported successfully', + }); }, onError: (error) => { - alert(`Failed to import profile: ${error.message}`); + toast({ + title: 'Failed to import profile', + description: error.message, + variant: 'destructive', + }); }, }); } @@ -102,15 +116,9 @@ export function MainEditor() { )} >
- {/* Profiles - Top Left */}
- - {/* Generator - Bottom Left */} - {/*
- -
*/}
diff --git a/app/src/components/VoiceProfiles/ProfileForm.tsx b/app/src/components/VoiceProfiles/ProfileForm.tsx index 789a5279..de2b3fea 100644 --- a/app/src/components/VoiceProfiles/ProfileForm.tsx +++ b/app/src/components/VoiceProfiles/ProfileForm.tsx @@ -42,45 +42,13 @@ import { import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture'; import { useTranscription } from '@/lib/hooks/useTranscription'; import { isTauri } from '@/lib/tauri'; -import { formatAudioDuration } from '@/lib/utils/audio'; +import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio'; import { useUIStore } from '@/stores/uiStore'; import { AudioSampleRecording } from './AudioSampleRecording'; import { AudioSampleSystem } from './AudioSampleSystem'; import { AudioSampleUpload } from './AudioSampleUpload'; import { SampleList } from './SampleList'; -// Helper function to get audio duration from File -async function getAudioDuration(file: File & { recordedDuration?: number }): Promise { - // If the file has a recordedDuration property (from our recording hooks), - // use that instead of trying to read metadata. This fixes issues on Windows - // where WebM files from MediaRecorder don't have proper duration metadata. - if (file.recordedDuration !== undefined && Number.isFinite(file.recordedDuration)) { - return file.recordedDuration; - } - - return new Promise((resolve, reject) => { - const audio = new Audio(); - const url = URL.createObjectURL(file); - - audio.addEventListener('loadedmetadata', () => { - URL.revokeObjectURL(url); - // Check if duration is valid (not Infinity or NaN) - if (Number.isFinite(audio.duration) && audio.duration > 0) { - resolve(audio.duration); - } else { - reject(new Error('Audio file has invalid duration metadata')); - } - }); - - audio.addEventListener('error', () => { - URL.revokeObjectURL(url); - reject(new Error('Failed to load audio file')); - }); - - audio.src = url; - }); -} - const MAX_AUDIO_DURATION_SECONDS = 30; const baseProfileSchema = z.object({ @@ -187,7 +155,7 @@ export function ProfileForm() { stopRecording, cancelRecording, } = useAudioRecording({ - maxDurationSeconds: 30, + maxDurationSeconds: 29, onRecordingComplete: (blob, recordedDuration) => { const file = new File([blob], `recording-${Date.now()}.webm`, { type: blob.type || 'audio/webm', @@ -213,7 +181,7 @@ export function ProfileForm() { stopRecording: stopSystemRecording, cancelRecording: cancelSystemRecording, } = useSystemAudioCapture({ - maxDurationSeconds: 30, + maxDurationSeconds: 29, onRecordingComplete: (blob, recordedDuration) => { const file = new File([blob], `system-audio-${Date.now()}.wav`, { type: blob.type || 'audio/wav', diff --git a/app/src/components/VoiceProfiles/SampleUpload.tsx b/app/src/components/VoiceProfiles/SampleUpload.tsx index c2f99e2c..0b252311 100644 --- a/app/src/components/VoiceProfiles/SampleUpload.tsx +++ b/app/src/components/VoiceProfiles/SampleUpload.tsx @@ -73,7 +73,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp stopRecording, cancelRecording, } = useAudioRecording({ - maxDurationSeconds: 30, + maxDurationSeconds: 29, onRecordingComplete: (blob, recordedDuration) => { // Convert blob to File object const file = new File([blob], `recording-${Date.now()}.webm`, { @@ -100,7 +100,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp stopRecording: stopSystemRecording, cancelRecording: cancelSystemRecording, } = useSystemAudioCapture({ - maxDurationSeconds: 30, + maxDurationSeconds: 29, onRecordingComplete: (blob, recordedDuration) => { // Convert blob to File object const file = new File([blob], `system-audio-${Date.now()}.wav`, { diff --git a/app/src/global.d.ts b/app/src/global.d.ts new file mode 100644 index 00000000..d405eef5 --- /dev/null +++ b/app/src/global.d.ts @@ -0,0 +1,3 @@ +interface Window { + __voiceboxServerStartedByApp?: boolean; +} diff --git a/app/src/lib/hooks/useAudioRecording.ts b/app/src/lib/hooks/useAudioRecording.ts index 23c6e680..8e2160f3 100644 --- a/app/src/lib/hooks/useAudioRecording.ts +++ b/app/src/lib/hooks/useAudioRecording.ts @@ -8,7 +8,7 @@ interface UseAudioRecordingOptions { } export function useAudioRecording({ - maxDurationSeconds = 30, + maxDurationSeconds = 29, onRecordingComplete, }: UseAudioRecordingOptions = {}) { const [isRecording, setIsRecording] = useState(false); diff --git a/app/src/lib/hooks/useGenerationForm.ts b/app/src/lib/hooks/useGenerationForm.ts new file mode 100644 index 00000000..71aeb1cd --- /dev/null +++ b/app/src/lib/hooks/useGenerationForm.ts @@ -0,0 +1,122 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import * as z from 'zod'; +import { useToast } from '@/components/ui/use-toast'; +import { apiClient } from '@/lib/api/client'; +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'; + +const generationSchema = z.object({ + text: z.string().min(1, 'Text is required').max(5000), + language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]), + seed: z.number().int().optional(), + modelSize: z.enum(['1.7B', '0.6B']).optional(), + instruct: z.string().max(500).optional(), +}); + +export type GenerationFormValues = z.infer; + +interface UseGenerationFormOptions { + onSuccess?: () => void; + defaultValues?: Partial; +} + +export function useGenerationForm(options: UseGenerationFormOptions = {}) { + const { toast } = useToast(); + const generation = useGeneration(); + const setAudio = usePlayerStore((state) => state.setAudio); + const setIsGenerating = useGenerationStore((state) => state.setIsGenerating); + const [downloadingModelName, setDownloadingModelName] = useState(null); + const [downloadingDisplayName, setDownloadingDisplayName] = useState(null); + + useModelDownloadToast({ + modelName: downloadingModelName || '', + displayName: downloadingDisplayName || '', + enabled: !!downloadingModelName, + }); + + const form = useForm({ + resolver: zodResolver(generationSchema), + defaultValues: { + text: '', + language: 'en', + seed: undefined, + modelSize: '1.7B', + instruct: '', + ...options.defaultValues, + }, + }); + + async function handleSubmit( + data: GenerationFormValues, + selectedProfileId: string | null, + ): Promise { + 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, + seed: data.seed, + model_size: data.modelSize, + instruct: data.instruct || undefined, + }); + + toast({ + title: 'Generation complete!', + description: `Audio generated (${result.duration.toFixed(2)}s)`, + }); + + const audioUrl = apiClient.getAudioUrl(result.id); + setAudio(audioUrl, result.id, selectedProfileId, data.text.substring(0, 50)); + + form.reset(); + options.onSuccess?.(); + } 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 { + form, + handleSubmit, + isPending: generation.isPending, + }; +} diff --git a/app/src/lib/hooks/useSystemAudioCapture.ts b/app/src/lib/hooks/useSystemAudioCapture.ts index fb4ddeba..e27cdeae 100644 --- a/app/src/lib/hooks/useSystemAudioCapture.ts +++ b/app/src/lib/hooks/useSystemAudioCapture.ts @@ -12,7 +12,7 @@ interface UseSystemAudioCaptureOptions { * Uses ScreenCaptureKit on macOS and WASAPI loopback on Windows. */ export function useSystemAudioCapture({ - maxDurationSeconds = 30, + maxDurationSeconds = 29, onRecordingComplete, }: UseSystemAudioCaptureOptions = {}) { const [isRecording, setIsRecording] = useState(false); diff --git a/app/src/lib/utils/audio.ts b/app/src/lib/utils/audio.ts index c4cb5d9f..159af57d 100644 --- a/app/src/lib/utils/audio.ts +++ b/app/src/lib/utils/audio.ts @@ -17,6 +17,41 @@ export function formatAudioDuration(seconds: number): string { return `${mins}:${secs.toString().padStart(2, '0')}`; } +/** + * Get audio duration from a File. + * If the file has a recordedDuration property (from recording hooks), + * use that instead of trying to read metadata. This fixes issues on Windows + * where WebM files from MediaRecorder don't have proper duration metadata. + */ +export async function getAudioDuration( + file: File & { recordedDuration?: number }, +): Promise { + if (file.recordedDuration !== undefined && Number.isFinite(file.recordedDuration)) { + return file.recordedDuration; + } + + return new Promise((resolve, reject) => { + const audio = new Audio(); + const url = URL.createObjectURL(file); + + audio.addEventListener('loadedmetadata', () => { + URL.revokeObjectURL(url); + if (Number.isFinite(audio.duration) && audio.duration > 0) { + resolve(audio.duration); + } else { + reject(new Error('Audio file has invalid duration metadata')); + } + }); + + audio.addEventListener('error', () => { + URL.revokeObjectURL(url); + reject(new Error('Failed to load audio file')); + }); + + audio.src = url; + }); +} + /** * Convert any audio blob to WAV format using Web Audio API. * This ensures compatibility without requiring ffmpeg on the backend. diff --git a/app/src/lib/utils/debug.ts b/app/src/lib/utils/debug.ts new file mode 100644 index 00000000..20f7f7ee --- /dev/null +++ b/app/src/lib/utils/debug.ts @@ -0,0 +1,19 @@ +const DEBUG = import.meta.env.DEV; + +export const debug = { + log: (...args: unknown[]) => { + if (DEBUG) { + console.log(...args); + } + }, + error: (...args: unknown[]) => { + if (DEBUG) { + console.error(...args); + } + }, + warn: (...args: unknown[]) => { + if (DEBUG) { + console.warn(...args); + } + }, +};