diff --git a/app/src/components/Generation/FloatingGenerateBox.tsx b/app/src/components/Generation/FloatingGenerateBox.tsx index f1cd571d..276f40a0 100644 --- a/app/src/components/Generation/FloatingGenerateBox.tsx +++ b/app/src/components/Generation/FloatingGenerateBox.tsx @@ -1,10 +1,12 @@ import { useQuery } from '@tanstack/react-query'; import { useMatchRoute } from '@tanstack/react-router'; import { AnimatePresence, motion } from 'framer-motion'; -import { Loader2, Sparkles } from 'lucide-react'; +import { CheckCircle, Loader2, SlidersHorizontal, Sparkles } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Select, SelectContent, @@ -12,6 +14,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Slider } from '@/components/ui/slider'; import { Textarea } from '@/components/ui/textarea'; import { apiClient } from '@/lib/api/client'; import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages'; @@ -40,15 +43,19 @@ export function FloatingGenerateBox({ const { data: selectedProfile } = useProfile(selectedProfileId || ''); const { data: profiles } = useProfiles(); const [isExpanded, setIsExpanded] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); const [selectedPresetId, setSelectedPresetId] = useState(null); const containerRef = useRef(null); const textareaRef = useRef(null); + const reuseEffectsChainRef = useRef(null); const matchRoute = useMatchRoute(); const isStoriesRoute = matchRoute({ to: '/stories' }); const selectedStoryId = useStoryStore((state) => state.selectedStoryId); const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight); const { data: currentStory } = useStory(selectedStoryId); const addPendingStoryAdd = useGenerationStore((s) => s.addPendingStoryAdd); + const reuseParams = useGenerationStore((s) => s.reuseParams); + const setReuseParams = useGenerationStore((s) => s.setReuseParams); // Fetch effect presets for the dropdown const { data: effectPresets } = useQuery({ @@ -56,6 +63,13 @@ export function FloatingGenerateBox({ queryFn: () => apiClient.listEffectPresets(), }); + // Fetch suggested params for the selected profile + const { data: suggestedParams } = useQuery({ + queryKey: ['suggestedParams', selectedProfileId], + queryFn: () => apiClient.getSuggestedParams(selectedProfileId!), + enabled: !!selectedProfileId, + }); + // Calculate if track editor is visible (on stories route with items) const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0; @@ -73,6 +87,10 @@ export function FloatingGenerateBox({ if (selectedPresetId === '_profile') { return selectedProfile?.effects_chain ?? undefined; } + // Effects chain reused from history (no matching preset) + if (selectedPresetId === '_reuse') { + return reuseEffectsChainRef.current ?? undefined; + } if (!effectPresets) return undefined; const preset = effectPresets.find((p) => p.id === selectedPresetId); return preset?.effects_chain; @@ -217,6 +235,63 @@ export function FloatingGenerateBox({ }; }, [isExpanded]); + // Apply params from history "Reuse" button + useEffect(() => { + if (!reuseParams) return; + form.setValue('text', reuseParams.text); + if (reuseParams.language) form.setValue('language', reuseParams.language as LanguageCode); + if (reuseParams.engine) + form.setValue( + 'engine', + reuseParams.engine as + | 'qwen' + | 'qwen_custom_voice' + | 'luxtts' + | 'chatterbox' + | 'chatterbox_turbo' + | 'tada' + | 'kokoro', + ); + if (reuseParams.temperature != null) form.setValue('temperature', reuseParams.temperature); + if (reuseParams.top_k != null) form.setValue('top_k', Math.round(reuseParams.top_k)); + if (reuseParams.top_p != null) form.setValue('top_p', reuseParams.top_p); + if (reuseParams.repetition_penalty != null) + form.setValue('repetition_penalty', reuseParams.repetition_penalty); + if (reuseParams.speed != null) form.setValue('speed', reuseParams.speed); + // Apply effects chain if present + if (reuseParams.effects_chain && reuseParams.effects_chain.length > 0) { + reuseEffectsChainRef.current = reuseParams.effects_chain; + if (effectPresets) { + const chainJson = JSON.stringify(reuseParams.effects_chain); + const matchingPreset = effectPresets.find( + (p) => JSON.stringify(p.effects_chain) === chainJson, + ); + if (matchingPreset) { + setSelectedPresetId(matchingPreset.id); + } else { + // No matching preset — use sentinel so getEffectsChain returns the stored chain + setSelectedPresetId('_reuse'); + } + } else { + setSelectedPresetId('_reuse'); + } + } else { + reuseEffectsChainRef.current = null; + } + setIsExpanded(true); + // Consume the params so this effect doesn't re-fire + setReuseParams(null); + }, [reuseParams]); // eslint-disable-line react-hooks/exhaustive-deps + + function applySuggestedParams() { + if (!suggestedParams) return; + if (suggestedParams.temperature != null) form.setValue('temperature', suggestedParams.temperature); + if (suggestedParams.top_k != null) form.setValue('top_k', Math.round(suggestedParams.top_k)); + if (suggestedParams.top_p != null) form.setValue('top_p', suggestedParams.top_p); + if (suggestedParams.repetition_penalty != null) form.setValue('repetition_penalty', suggestedParams.repetition_penalty); + if (suggestedParams.speed != null) form.setValue('speed', suggestedParams.speed); + } + async function onSubmit(data: Parameters[0]) { await handleSubmit(data, selectedProfileId); } @@ -262,7 +337,7 @@ export function FloatingGenerateBox({ transition={{ duration: 0.15, ease: 'easeOut' }} style={{ overflow: 'hidden' }} > - {form.watch('engine') === 'chatterbox_turbo' ? ( + {(form.watch('engine') === 'chatterbox_turbo' || form.watch('engine') === 'qwen') ? ( -
+
+ {/* Settings / Advanced popover */} + + + + + +

Advanced settings

+ + {/* Row 1: Temperature + Speed */} +
+ ( + +
+ + + {field.value?.toFixed(2) ?? '—'} + +
+ + field.onChange(v)} + className="h-3" + /> + +
+ )} + /> + ( + +
+ + + {field.value?.toFixed(2) ?? '—'} + +
+ + field.onChange(v)} + className="h-3" + /> + +
+ )} + /> +
+ + {/* Row 2: Top-K + Top-P */} +
+ ( + +
+ + + {field.value !== undefined ? field.value : '—'} + +
+ + field.onChange(v)} + className="h-3" + /> + +
+ )} + /> + ( + +
+ + + {field.value?.toFixed(2) ?? '—'} + +
+ + field.onChange(v)} + className="h-3" + /> + +
+ )} + /> +
+ + {/* Row 3: Repetition Penalty (half-width, left column) */} +
+ ( + +
+ + + {field.value?.toFixed(2) ?? '—'} + +
+ + field.onChange(v)} + className="h-3" + /> + +
+ )} + /> +
+ + {/* Row 5: Humanize text + intensity */} +
+ ( + + +
+ + +
+
+
+ )} + /> + ( + + + + + + )} + /> +
+ + {/* Row 6: Inject breaths + Jitter */} +
+ ( + + +
+ + +
+
+
+ )} + /> + ( + +
+ + + {field.value !== undefined ? `${field.value}ms` : '—'} + +
+ + field.onChange(v)} + className="h-3" + /> + +
+ )} + /> +
+
+
+ + {/* Generate button */}
+
+ )}
{showVoiceSelector && (
@@ -414,9 +770,10 @@ export function FloatingGenerateBox({
+ diff --git a/app/src/components/Generation/GenerationForm.tsx b/app/src/components/Generation/GenerationForm.tsx index ef3ff2c0..060d273c 100644 --- a/app/src/components/Generation/GenerationForm.tsx +++ b/app/src/components/Generation/GenerationForm.tsx @@ -1,7 +1,8 @@ -import { useEffect } from 'react'; -import { Loader2, Mic } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { ChevronDown, ChevronUp, Loader2, Mic } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; import { Form, FormControl, @@ -19,6 +20,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Slider } from '@/components/ui/slider'; import { Textarea } from '@/components/ui/textarea'; import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages'; import { useGenerationForm } from '@/lib/hooks/useGenerationForm'; @@ -39,6 +41,7 @@ export function GenerationForm() { const { data: selectedProfile } = useProfile(selectedProfileId || ''); const { form, handleSubmit, isPending } = useGenerationForm(); + const [advancedOpen, setAdvancedOpen] = useState(false); useEffect(() => { if (!selectedProfile) { @@ -59,6 +62,11 @@ export function GenerationForm() { await handleSubmit(data, selectedProfileId); } + const engine = form.watch('engine'); + const humanizeText = form.watch('humanize_text'); + const showParalinguistic = engine === 'chatterbox_turbo' || engine === 'qwen'; + const showSpeed = engine === 'qwen' || engine === 'qwen_custom_voice'; + return ( @@ -89,7 +97,7 @@ export function GenerationForm() { Text to Speak - {form.watch('engine') === 'chatterbox_turbo' ? ( + {showParalinguistic ? ( - {form.watch('engine') === 'chatterbox_turbo' - ? 'Max 5000 characters. Type / to insert sound effects.' - : 'Max 5000 characters'} + {showParalinguistic ? ( + <> + Max 5000 characters. Type / to insert sound effects. + {engine === 'qwen' && ( + + Tags like [laugh] route to Chatterbox Turbo internally. + + )} + + ) : ( + 'Max 5000 characters' + )} )} /> - {(form.watch('engine') === 'qwen' || form.watch('engine') === 'qwen_custom_voice') && ( + {(engine === 'qwen' || engine === 'qwen_custom_voice') && ( Model - {getEngineDescription(form.watch('engine') || 'qwen')} + {getEngineDescription(engine || 'qwen')} @@ -151,7 +168,7 @@ export function GenerationForm() { control={form.control} name="language" render={({ field }) => { - const engineLangs = getLanguageOptionsForEngine(form.watch('engine') || 'qwen'); + const engineLangs = getLanguageOptionsForEngine(engine || 'qwen'); return ( Language @@ -198,6 +215,274 @@ export function GenerationForm() { />
+ {/* Advanced section */} +
+ + + {advancedOpen && ( +
+ {/* Sampling Parameters */} +
+

+ Sampling +

+ + ( + +
+ Temperature + + {field.value ?? '—'} + +
+ + field.onChange(v)} + /> + + 0.0 – 2.0 · default ~0.9 +
+ )} + /> + + ( + +
+ Top-P + + {field.value ?? '—'} + +
+ + field.onChange(v)} + /> + + 0.0 – 1.0 +
+ )} + /> + + ( + +
+ Repetition Penalty + + {field.value ?? '—'} + +
+ + field.onChange(v)} + /> + + 0.5 – 3.0 +
+ )} + /> + + ( + + Top-K + + + field.onChange( + e.target.value ? parseInt(e.target.value, 10) : undefined, + ) + } + /> + + 0 – 5000 + + )} + /> + + {showSpeed && ( + ( + +
+ Speed + + {field.value !== undefined ? `${field.value}×` : '—'} + +
+ + field.onChange(v)} + /> + + 0.5× – 2.0× +
+ )} + /> + )} +
+ + {/* Humanization */} +
+

+ Humanization +

+ + ( + +
+ + + + + Humanize text + +
+ + Pre-process text with LLM to add natural speech patterns + +
+ )} + /> + + {humanizeText && ( + ( + + Intensity +
+ {(['light', 'medium', 'heavy'] as const).map((level) => ( + + ))} +
+ +
+ )} + /> + )} + + ( + +
+ + + + + Inject breaths + +
+ + Insert natural breath sounds between sentences + +
+ )} + /> + + ( + +
+ Timing jitter + + {field.value !== undefined ? `${field.value} ms` : '—'} + +
+ + field.onChange(v)} + /> + + + Random timing offset per chunk (0 – 50 ms) + +
+ )} + /> +
+
+ )} +
+ + + +

Generation params

+ {gen.temperature != null && ( +
+ temp + {gen.temperature.toFixed(2)} +
+ )} + {gen.speed != null && ( +
+ speed + {gen.speed.toFixed(2)} +
+ )} + {gen.top_k != null && ( +
+ top_k + {gen.top_k} +
+ )} + {gen.top_p != null && ( +
+ top_p + {gen.top_p.toFixed(2)} +
+ )} + {gen.repetition_penalty != null && ( +
+ rep_penalty + {gen.repetition_penalty.toFixed(2)} +
+ )} + {gen.humanize_text === true && ( +
+ humanize + {gen.humanize_intensity ?? 'on'} +
+ )} + {gen.jitter_ms != null && gen.jitter_ms > 0 && ( +
+ jitter + {gen.jitter_ms}ms +
+ )} + {effectsChain && effectsChain.length > 0 && ( +
+ effects + {effectsChain.map((e) => e.type).join(', ')} +
+ )} +
+ + ); +} + // ─── Audio Bars ───────────────────────────────────────────────────────────── function AudioBars({ mode }: { mode: 'idle' | 'generating' | 'playing' }) { @@ -128,6 +215,7 @@ export function HistoryTable() { const exportGenerationAudio = useExportGenerationAudio(); const importGeneration = useImportGeneration(); const addPendingGeneration = useGenerationStore((state) => state.addPendingGeneration); + const setReuseParams = useGenerationStore((state) => state.setReuseParams); const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay); const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio); const currentAudioId = usePlayerStore((state) => state.audioId); @@ -307,6 +395,23 @@ export function HistoryTable() { } }; + const handleRate = async (generationId: string, rating: number, currentRating: number | null | undefined) => { + // Clicking the same rating again clears it — not supported by the API directly, + // but we can send rating 0 — backend won't accept it, so we just skip toggling for now. + // If user clicks the already-active thumb, we do nothing. + if (currentRating === rating) return; + try { + await apiClient.rateGeneration(generationId, rating); + queryClient.invalidateQueries({ queryKey: ['history'] }); + } catch (error) { + toast({ + title: 'Failed to rate generation', + description: error instanceof Error ? error.message : 'Unknown error', + variant: 'destructive', + }); + } + }; + const handleApplyEffects = (generationId: string) => { const gen = allHistory.find((g) => g.id === generationId); const versions = gen?.versions ?? []; @@ -357,6 +462,25 @@ export function HistoryTable() { } }; + const handleReuseParams = (gen: HistoryResponse) => { + // Get effects chain from the active/default version of this generation + const activeVersion = gen.versions?.find((v) => v.is_default) ?? gen.versions?.[0]; + const effectsChain = activeVersion?.effects_chain ?? null; + setReuseParams({ + text: gen.text, + language: gen.language, + engine: gen.engine ?? undefined, + model_size: gen.model_size ?? undefined, + temperature: gen.temperature, + top_k: gen.top_k, + top_p: gen.top_p, + repetition_penalty: gen.repetition_penalty, + speed: gen.speed, + effects_chain: effectsChain, + }); + toast({ title: 'Params applied', description: 'Generation settings loaded into the form.' }); + }; + const handleSwitchVersion = async (generationId: string, versionId: string) => { try { await apiClient.setDefaultVersion(generationId, versionId); @@ -538,6 +662,17 @@ export function HistoryTable() { onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} > + + + {/* Rating: thumbs up (5) / thumbs down (1) */} + + {hasVersions && (
)} + {/* RECORDING: interactive guide */} {isRecording && ( -
+
{showWaveform && audioStream && ( )} -
+ + {/* Timer row */} +
{formatAudioDuration(duration)}
+ + {formatAudioDuration(40 - duration)} left + +
+ + {/* Progress dots */} +
+ {SCRIPT_LINES.map((_, i) => ( +
+ ))} + + {currentLineIndex + 1}/{SCRIPT_LINES.length} + +
+ + {/* Scrollable script */} +
+ {SCRIPT_LINES.map((line, i) => { + const isCurrent = i === currentLineIndex; + const isPast = i < currentLineIndex; + + return ( +
+

+ [{line.cue}] +

+

+ {line.text} +

+
+ ); + })} +
+ + {/* Tips */} +
+

⚠ Say it with real intention, not like a tongue twister

+

⚠ Vary volume, rhythm and emotion between lines

+
+ + {/* Controls */} +
+ +
- -

- {formatAudioDuration(30 - duration)} remaining -

)} + {/* POST-RECORDING: completion state — unchanged */} {file && !isRecording && (
@@ -139,6 +291,7 @@ export function AudioSampleRecording({ Recording complete

File: {file.name}

+

Transcript auto-filled from guide

-