feat: enhance audio processing and error handling in TTS backend; refactor user dialog form validation

This commit is contained in:
2026-02-03 17:37:14 +08:00
parent 5a22351a66
commit 244ff94c6a
12 changed files with 117 additions and 169 deletions

View File

@@ -1,42 +0,0 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -16,7 +16,6 @@ const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => {
const [isLoading, setIsLoading] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
const previousAudioUrlRef = useRef<string>('')
const playerRef = useRef<any>(null)
useEffect(() => {
if (!audioUrl || audioUrl === previousAudioUrlRef.current) return

View File

@@ -20,7 +20,6 @@ import { JobDetailDialog } from '@/components/JobDetailDialog'
interface HistoryItemProps {
job: Job
onDelete: (id: number) => void
onLoadParams: (job: Job) => void
}
const jobTypeBadgeVariant = {
@@ -35,7 +34,7 @@ const jobTypeLabel = {
voice_clone: '声音克隆',
}
const HistoryItem = memo(({ job, onDelete, onLoadParams }: HistoryItemProps) => {
const HistoryItem = memo(({ job, onDelete }: HistoryItemProps) => {
const [detailDialogOpen, setDetailDialogOpen] = useState(false)
const getLanguageDisplay = (lang: string | undefined) => {

View File

@@ -5,16 +5,13 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import { Button } from '@/components/ui/button'
import { Loader2, FileAudio, RefreshCw } from 'lucide-react'
import type { JobType } from '@/types/job'
import { toast } from 'sonner'
interface HistorySidebarProps {
open: boolean
onOpenChange: (open: boolean) => void
onLoadParams: (jobId: number, jobType: JobType) => Promise<void>
}
function HistorySidebarContent({ onLoadParams }: Pick<HistorySidebarProps, 'onLoadParams'>) {
function HistorySidebarContent() {
const { jobs, loading, loadingMore, hasMore, loadMore, deleteJob, error, retry } = useHistoryContext()
const observerTarget = useRef<HTMLDivElement>(null)
@@ -35,14 +32,6 @@ function HistorySidebarContent({ onLoadParams }: Pick<HistorySidebarProps, 'onLo
return () => observer.disconnect()
}, [hasMore, loadingMore, loadMore])
const handleLoadParams = async (jobId: number, jobType: JobType) => {
try {
await onLoadParams(jobId, jobType)
} catch (error) {
toast.error('加载参数失败')
}
}
return (
<div className="flex flex-col h-full">
<div className="p-4 border-b">
@@ -79,7 +68,6 @@ function HistorySidebarContent({ onLoadParams }: Pick<HistorySidebarProps, 'onLo
key={job.id}
job={job}
onDelete={deleteJob}
onLoadParams={(job) => handleLoadParams(job.id, job.type)}
/>
))}
@@ -96,16 +84,16 @@ function HistorySidebarContent({ onLoadParams }: Pick<HistorySidebarProps, 'onLo
)
}
export function HistorySidebar({ open, onOpenChange, onLoadParams }: HistorySidebarProps) {
export function HistorySidebar({ open, onOpenChange }: HistorySidebarProps) {
return (
<>
<aside className="hidden lg:block w-[320px] border-r h-full">
<HistorySidebarContent onLoadParams={onLoadParams} />
<HistorySidebarContent />
</aside>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="left" className="w-full sm:max-w-md p-0">
<HistorySidebarContent onLoadParams={onLoadParams} />
<HistorySidebarContent />
</SheetContent>
</Sheet>
</>

View File

@@ -15,11 +15,9 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { ttsApi, jobApi } from '@/lib/api'
import { useJobPolling } from '@/hooks/useJobPolling'
import { useHistoryContext } from '@/contexts/HistoryContext'
import { useUserPreferences } from '@/contexts/UserPreferencesContext'
import { LoadingState } from '@/components/LoadingState'
import { AudioPlayer } from '@/components/AudioPlayer'
import { PresetSelector } from '@/components/PresetSelector'
import { ParamInput } from '@/components/ParamInput'
import { PRESET_INSTRUCTS, ADVANCED_PARAMS_INFO } from '@/lib/constants'
import type { Language, Speaker } from '@/types/tts'
@@ -56,7 +54,6 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling()
const { refresh } = useHistoryContext()
const { preferences } = useUserPreferences()
const {
register,
@@ -90,7 +87,6 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
setValue('top_k', params.top_k || 20)
setValue('top_p', params.top_p || 0.7)
setValue('repetition_penalty', params.repetition_penalty || 1.05)
setValue('backend', params.backend || 'local')
}
}))
@@ -214,11 +210,11 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
<Dialog open={advancedOpen} onOpenChange={(open) => {
if (open) {
setTempAdvancedParams({
max_new_tokens: watch('max_new_tokens'),
temperature: watch('temperature'),
top_k: watch('top_k'),
top_p: watch('top_p'),
repetition_penalty: watch('repetition_penalty')
max_new_tokens: watch('max_new_tokens') || 2048,
temperature: watch('temperature') || 0.3,
top_k: watch('top_k') || 20,
top_p: watch('top_p') || 0.7,
repetition_penalty: watch('repetition_penalty') || 1.05
})
}
setAdvancedOpen(open)
@@ -339,11 +335,11 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
variant="outline"
onClick={() => {
setTempAdvancedParams({
max_new_tokens: watch('max_new_tokens'),
temperature: watch('temperature'),
top_k: watch('top_k'),
top_p: watch('top_p'),
repetition_penalty: watch('repetition_penalty')
max_new_tokens: watch('max_new_tokens') || 2048,
temperature: watch('temperature') || 0.3,
top_k: watch('top_k') || 20,
top_p: watch('top_p') || 0.7,
repetition_penalty: watch('repetition_penalty') || 1.05
})
setAdvancedOpen(false)
}}

View File

@@ -9,14 +9,13 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/components/ui/dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Settings, Globe2, Type, Play, FileText, Mic, Zap, Database, ArrowRight, ArrowLeft } from 'lucide-react'
import { Settings, Globe2, Type, Play, FileText, Mic, ArrowRight, ArrowLeft } from 'lucide-react'
import { toast } from 'sonner'
import { IconLabel } from '@/components/IconLabel'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { ttsApi, jobApi } from '@/lib/api'
import { useJobPolling } from '@/hooks/useJobPolling'
import { useHistoryContext } from '@/contexts/HistoryContext'
import { useUserPreferences } from '@/contexts/UserPreferencesContext'
import { LoadingState } from '@/components/LoadingState'
import { AudioPlayer } from '@/components/AudioPlayer'
import { FileUploader } from '@/components/FileUploader'
@@ -54,7 +53,6 @@ function VoiceCloneForm() {
const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling()
const { refresh } = useHistoryContext()
const { preferences } = useUserPreferences()
const {
register,
@@ -92,6 +90,14 @@ function VoiceCloneForm() {
fetchData()
}, [])
useEffect(() => {
if (inputTab === 'record' && PRESET_REF_TEXTS.length > 0) {
setValue('ref_text', PRESET_REF_TEXTS[0].text)
} else if (inputTab === 'upload') {
setValue('ref_text', '')
}
}, [inputTab])
const handleNextStep = async () => {
// Validate step 1 fields
const valid = await trigger(['ref_audio', 'ref_text'])
@@ -180,22 +186,31 @@ function VoiceCloneForm() {
onSelect={(preset) => setValue('ref_text', preset.text)}
/>
</div>
<Button type="button" className="w-full mt-6" onClick={handleNextStep}>
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</TabsContent>
<TabsContent value="record" className="space-y-4 mt-4">
<div className="space-y-2">
<Label className="text-base font-medium"></Label>
<div className="grid gap-2">
{PRESET_REF_TEXTS.map((preset, i) => (
<div
key={i}
className="p-3 border rounded-lg hover:bg-accent cursor-pointer transition-colors text-sm"
onClick={() => setValue('ref_text', preset.text)}
>
<div className="font-medium mb-1">{preset.label}</div>
<div className="text-muted-foreground line-clamp-2">{preset.text}</div>
</div>
))}
<div className="grid grid-cols-3 gap-2">
{PRESET_REF_TEXTS.map((preset, i) => {
const isSelected = watch('ref_text') === preset.text
return (
<div
key={i}
className={`p-3 border rounded-lg hover:bg-accent cursor-pointer transition-colors text-sm text-center ${
isSelected ? 'border-primary bg-primary/10' : ''
}`}
onClick={() => setValue('ref_text', preset.text)}
>
<div className="font-medium">{preset.label}</div>
</div>
)
})}
</div>
<div className="space-y-0.5 pt-2">
<Label></Label>
@@ -209,28 +224,31 @@ function VoiceCloneForm() {
{/* Mobile-friendly Bottom Recorder Area */}
<div className="fixed bottom-0 left-0 right-0 p-4 bg-background border-t z-50 md:relative md:border-t-0 md:bg-transparent md:p-0 md:z-0">
<Controller
name="ref_audio"
control={control}
render={({ field }) => (
<AudioRecorder
onChange={field.onChange}
/>
<div className="space-y-3">
{watch('ref_audio') && (
<Button type="button" className="w-full" onClick={handleNextStep}>
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
)}
/>
{errors.ref_audio && (
<p className="text-sm text-destructive mt-2 text-center md:text-left">{errors.ref_audio.message}</p>
)}
<Controller
name="ref_audio"
control={control}
render={({ field }) => (
<AudioRecorder
onChange={field.onChange}
/>
)}
/>
{errors.ref_audio && (
<p className="text-sm text-destructive mt-2 text-center md:text-left">{errors.ref_audio.message}</p>
)}
</div>
</div>
{/* Spacer for mobile to prevent content being hidden behind fixed footer */}
<div className="h-24 md:hidden" />
</TabsContent>
</Tabs>
<Button type="button" className="w-full mt-6" onClick={handleNextStep}>
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
<div className={step === 2 ? 'block space-y-4' : 'hidden'}>

View File

@@ -15,11 +15,9 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { ttsApi, jobApi } from '@/lib/api'
import { useJobPolling } from '@/hooks/useJobPolling'
import { useHistoryContext } from '@/contexts/HistoryContext'
import { useUserPreferences } from '@/contexts/UserPreferencesContext'
import { LoadingState } from '@/components/LoadingState'
import { AudioPlayer } from '@/components/AudioPlayer'
import { PresetSelector } from '@/components/PresetSelector'
import { ParamInput } from '@/components/ParamInput'
import { PRESET_VOICE_DESIGNS, ADVANCED_PARAMS_INFO } from '@/lib/constants'
import type { Language } from '@/types/tts'
@@ -54,7 +52,6 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling()
const { refresh } = useHistoryContext()
const { preferences } = useUserPreferences()
const {
register,
@@ -182,11 +179,11 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
<Dialog open={advancedOpen} onOpenChange={(open) => {
if (open) {
setTempAdvancedParams({
max_new_tokens: watch('max_new_tokens'),
temperature: watch('temperature'),
top_k: watch('top_k'),
top_p: watch('top_p'),
repetition_penalty: watch('repetition_penalty')
max_new_tokens: watch('max_new_tokens') || 2048,
temperature: watch('temperature') || 0.3,
top_k: watch('top_k') || 20,
top_p: watch('top_p') || 0.7,
repetition_penalty: watch('repetition_penalty') || 1.05
})
}
setAdvancedOpen(open)
@@ -307,11 +304,11 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
variant="outline"
onClick={() => {
setTempAdvancedParams({
max_new_tokens: watch('max_new_tokens'),
temperature: watch('temperature'),
top_k: watch('top_k'),
top_p: watch('top_p'),
repetition_penalty: watch('repetition_penalty')
max_new_tokens: watch('max_new_tokens') || 2048,
temperature: watch('temperature') || 0.3,
top_k: watch('top_k') || 20,
top_p: watch('top_p') || 0.7,
repetition_penalty: watch('repetition_penalty') || 1.05
})
setAdvancedOpen(false)
}}

View File

@@ -22,23 +22,15 @@ import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import type { User } from '@/types/auth'
const editUserFormSchema = z.object({
const userFormSchema = z.object({
username: z.string().min(3, '用户名至少3个字符').max(20, '用户名最多20个字符'),
email: z.string().email('请输入有效的邮箱地址'),
password: z.string().optional(),
is_active: z.boolean().default(true),
is_superuser: z.boolean().default(false),
is_active: z.boolean(),
is_superuser: z.boolean(),
})
const createUserFormSchema = z.object({
username: z.string().min(3, '用户名至少3个字符').max(20, '用户名最多20个字符'),
email: z.string().email('请输入有效的邮箱地址'),
password: z.string().min(8, '密码至少8个字符'),
is_active: z.boolean().default(true),
is_superuser: z.boolean().default(false),
})
type UserFormValues = z.infer<typeof editUserFormSchema>
type UserFormValues = z.infer<typeof userFormSchema>
interface UserDialogProps {
open: boolean
@@ -58,7 +50,7 @@ export function UserDialog({
const isEditing = !!user
const form = useForm<UserFormValues>({
resolver: zodResolver(isEditing ? editUserFormSchema : createUserFormSchema),
resolver: zodResolver(userFormSchema),
defaultValues: {
username: '',
email: '',

View File

@@ -1,4 +1,4 @@
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
interface ThemeContextType {
theme: 'light' | 'dark'

View File

@@ -52,7 +52,7 @@ export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null
let timeout: ReturnType<typeof setTimeout> | null = null
return function(...args: Parameters<T>) {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => func(...args), wait)

View File

@@ -8,10 +8,6 @@ import type { VoiceDesignFormHandle } from '@/components/tts/VoiceDesignForm'
import { HistorySidebar } from '@/components/HistorySidebar'
import { OnboardingDialog } from '@/components/OnboardingDialog'
import FormSkeleton from '@/components/FormSkeleton'
import type { JobType } from '@/types/job'
import { jobApi } from '@/lib/api'
import { toast } from 'sonner'
import { useJobPolling } from '@/hooks/useJobPolling'
import { useUserPreferences } from '@/contexts/UserPreferencesContext'
const CustomVoiceForm = lazy(() => import('@/components/tts/CustomVoiceForm'))
@@ -22,7 +18,6 @@ function Home() {
const [currentTab, setCurrentTab] = useState('custom-voice')
const [sidebarOpen, setSidebarOpen] = useState(false)
const [showOnboarding, setShowOnboarding] = useState(false)
const { loadCompletedJob } = useJobPolling()
const { preferences } = useUserPreferences()
const customVoiceFormRef = useRef<CustomVoiceFormHandle>(null)
@@ -34,30 +29,6 @@ function Home() {
}
}, [preferences])
const handleLoadParams = async (jobId: number, jobType: JobType) => {
try {
const job = await jobApi.getJob(jobId)
setSidebarOpen(false)
if (jobType === 'custom_voice') {
setCurrentTab('custom-voice')
setTimeout(() => {
customVoiceFormRef.current?.loadParams(job.parameters)
}, 100)
} else if (jobType === 'voice_design') {
setCurrentTab('voice-design')
setTimeout(() => {
voiceDesignFormRef.current?.loadParams(job.parameters)
}, 100)
}
loadCompletedJob(job)
toast.success('参数已加载到表单')
} catch (error) {
toast.error('加载参数失败')
}
}
return (
<div className="h-screen overflow-hidden flex flex-col bg-background">
@@ -72,7 +43,6 @@ function Home() {
<HistorySidebar
open={sidebarOpen}
onOpenChange={setSidebarOpen}
onLoadParams={handleLoadParams}
/>
<main className="flex-1 overflow-y-auto container mx-auto p-3 md:p-6 max-w-[800px] md:max-w-[700px]">