Refactor audio generation components and improve debugging capabilities

- Introduced useGenerationForm hook to streamline audio generation form handling, including validation and model download management.
- Updated FloatingGenerateBox and GenerationForm components to utilize the new hook, enhancing code organization and reducing duplication.
- Replaced console logging with a debug utility for better logging control during audio playback and generation processes.
- Improved error handling in HistoryTable and MainEditor components by integrating toast notifications for user feedback.
- Adjusted audio recording duration limits across various components for consistency.
This commit is contained in:
Jamie Pine
2026-01-28 14:30:33 -08:00
parent 07a91a2381
commit 7208f51eee
14 changed files with 320 additions and 364 deletions
+70 -80
View File
@@ -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
@@ -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<typeof generationSchema>;
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<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',
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<typeof handleSubmit>[0]) {
await handleSubmit(data, selectedProfileId);
}
return (
@@ -168,7 +83,6 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
<div className="flex gap-2">
<motion.div
className="flex-1"
// animate={{ marginBottom: isExpanded ? '0.75rem' : '0' }}
transition={{ duration: 0.3, ease: 'easeOut' }}
>
<FormField
@@ -202,11 +116,11 @@ export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps)
<Button
type="submit"
disabled={generation.isPending || !selectedProfileId}
disabled={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 ? (
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
@@ -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<typeof generationSchema>;
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<string | null>(null);
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(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<GenerationFormValues>({
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<typeof handleSubmit>[0]) {
await handleSubmit(data, selectedProfileId);
}
return (
@@ -276,9 +173,9 @@ export function GenerationForm() {
<Button
type="submit"
className="w-full"
disabled={generation.isPending || !selectedProfileId}
disabled={isPending || !selectedProfileId}
>
{generation.isPending ? (
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating...
+26 -22
View File
@@ -16,6 +16,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import {
@@ -41,6 +42,7 @@ export function HistoryTable() {
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const limit = 20;
const { toast } = useToast();
const { data: historyData, isLoading } = useHistory({
limit,
@@ -86,7 +88,11 @@ export function HistoryTable() {
{ generationId, text },
{
onError: (error) => {
alert(`Failed to download audio: ${error.message}`);
toast({
title: 'Failed to download audio',
description: error.message,
variant: 'destructive',
});
},
},
);
@@ -97,7 +103,11 @@ export function HistoryTable() {
{ generationId, text },
{
onError: (error) => {
alert(`Failed to export generation: ${error.message}`);
toast({
title: 'Failed to export generation',
description: error.message,
variant: 'destructive',
});
},
},
);
@@ -112,7 +122,11 @@ export function HistoryTable() {
if (file) {
// Validate file extension
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);
@@ -129,10 +143,17 @@ export function HistoryTable() {
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
alert(data.message || 'Generation imported successfully');
toast({
title: 'Generation imported',
description: data.message || 'Generation imported successfully',
});
},
onError: (error) => {
alert(`Failed to import generation: ${error.message}`);
toast({
title: 'Failed to import generation',
description: error.message,
variant: 'destructive',
});
},
});
}
@@ -148,23 +169,6 @@ export function HistoryTable() {
return (
<div className="flex flex-col h-full min-h-0 relative">
{/* <div className="flex justify-between items-center mb-4 shrink-0">
<h2 className="text-2xl font-bold">History</h2>
<div className="flex gap-2">
<Button variant="outline" onClick={handleImportClick}>
<Upload className="mr-2 h-4 w-4" />
Import Generation
</Button>
<input
ref={fileInputRef}
type="file"
accept=".voicebox.zip"
onChange={handleFileChange}
className="hidden"
/>
</div>
</div> */}
{history.length === 0 ? (
<div className="text-center py-12 px-5 border-2 border-dashed mb-5 border-muted rounded-md text-muted-foreground flex-1 flex items-center justify-center">
No voice generations, yet...
+16 -8
View File
@@ -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<HTMLInputElement>(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(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() {
)}
>
<div className="flex flex-col gap-6">
{/* Profiles - Top Left */}
<div className="shrink-0 flex flex-col">
<ProfileList />
</div>
{/* Generator - Bottom Left */}
{/* <div className="shrink-0">
<GenerationForm />
</div> */}
</div>
</div>
</div>
@@ -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<number> {
// 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',
@@ -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`, {