import React, { useEffect, useMemo, useState, useRef } from 'react'; import { Search, Mic2, Play, Pause, Upload, Filter, Plus, Volume2, Pencil, Trash2 } from 'lucide-react'; import { Button, Input, Select, TableHeader, TableRow, TableHead, TableCell, Dialog, Badge, LibraryPageShell, TableStatusRow, LibraryActionCell } from '../components/UI'; import { Voice } from '../types'; import { previewVoice } from '../services/backendApi'; import { useCreateVoiceMutation, useDeleteVoiceMutation, useUpdateVoiceMutation, useVoicesQuery, } from '../services/queries'; const OPENAI_COMPATIBLE_DEFAULT_MODEL = 'FunAudioLLM/CosyVoice2-0.5B'; const OPENAI_COMPATIBLE_DEFAULT_VOICE = 'FunAudioLLM/CosyVoice2-0.5B:anna'; const DASHSCOPE_DEFAULT_MODEL = 'qwen3-tts-flash-realtime'; const DASHSCOPE_DEFAULT_VOICE = 'Cherry'; const DASHSCOPE_DEFAULT_BASE_URL = 'wss://dashscope.aliyuncs.com/api-ws/v1/realtime'; type VoiceVendor = 'OpenAI Compatible' | 'DashScope'; const buildOpenAICompatibleVoiceKey = (rawId: string, model: string): string => { const id = (rawId || '').trim(); if (!id) return `${model}:anna`; return id.includes(':') ? id : `${model}:${id}`; }; export const VoiceLibraryPage: React.FC = () => { const [searchTerm, setSearchTerm] = useState(''); const [vendorFilter, setVendorFilter] = useState('all'); const [genderFilter, setGenderFilter] = useState<'all' | 'Male' | 'Female'>('all'); const [langFilter, setLangFilter] = useState<'all' | 'zh' | 'en'>('all'); const [playingVoiceId, setPlayingVoiceId] = useState(null); const [isCloneModalOpen, setIsCloneModalOpen] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [editingVoice, setEditingVoice] = useState(null); const [playLoadingId, setPlayLoadingId] = useState(null); const audioRef = useRef(null); const voicesQuery = useVoicesQuery(); const voices = voicesQuery.data || []; const isLoading = voicesQuery.isLoading; const createVoiceMutation = useCreateVoiceMutation(); const updateVoiceMutation = useUpdateVoiceMutation(); const deleteVoiceMutation = useDeleteVoiceMutation(); const vendorOptions = useMemo( () => Array.from(new Set(voices.map((v) => String(v.vendor || '').trim()).filter(Boolean))).sort(), [voices] ); const filteredVoices = voices.filter((voice) => { const matchesSearch = voice.name.toLowerCase().includes(searchTerm.toLowerCase()); const matchesVendor = vendorFilter === 'all' || voice.vendor === vendorFilter; const matchesGender = genderFilter === 'all' || voice.gender === genderFilter; const matchesLang = langFilter === 'all' || voice.language === langFilter; return matchesSearch && matchesVendor && matchesGender && matchesLang; }); const handlePlayToggle = async (voice: Voice) => { if (playingVoiceId === voice.id && audioRef.current) { audioRef.current.pause(); audioRef.current.currentTime = 0; setPlayingVoiceId(null); return; } try { setPlayLoadingId(voice.id); const audioUrl = await previewVoice( voice.id, voice.language === 'en' ? 'Hello, this is a voice preview.' : '你好,这是一段语音试听。', voice.speed ); if (audioRef.current) { audioRef.current.pause(); } const audio = new Audio(audioUrl); audio.onended = () => setPlayingVoiceId(null); audio.onerror = () => { setPlayingVoiceId(null); alert('试听失败,请检查该声音的 API Key / Base URL。'); }; audioRef.current = audio; setPlayingVoiceId(voice.id); await audio.play(); } catch (error: any) { alert(error?.message || '试听失败'); setPlayingVoiceId(null); } finally { setPlayLoadingId(null); } }; const handleAddSuccess = async (newVoice: Voice) => { await createVoiceMutation.mutateAsync(newVoice); setIsAddModalOpen(false); setIsCloneModalOpen(false); }; const handleUpdateSuccess = async (id: string, data: Voice) => { await updateVoiceMutation.mutateAsync({ id, data }); setEditingVoice(null); }; const handleDelete = async (id: string) => { if (!confirm('确认删除该声音吗?该操作不可恢复。')) return; await deleteVoiceMutation.mutateAsync(id); }; return ( )} filterBar={( <>
setSearchTerm(e.target.value)} />
)} >
声音名称 厂商 性别 语言 试听 操作 {!isLoading && filteredVoices.map((voice) => (
{voice.name} {voice.description && {voice.description}}
{voice.vendor} {voice.gender === 'Male' ? '男' : '女'} {voice.language === 'zh' ? '中文' : 'English'} setEditingVoice(voice)} title="编辑声音"> )} deleteAction={( )} />
))} {!isLoading && filteredVoices.length === 0 && } {isLoading && }
setIsAddModalOpen(false)} onSuccess={handleAddSuccess} /> setEditingVoice(null)} onSuccess={(voice) => handleUpdateSuccess(editingVoice!.id, voice)} initialVoice={editingVoice || undefined} /> setIsCloneModalOpen(false)} onSuccess={handleAddSuccess} />
); }; const AddVoiceModal: React.FC<{ isOpen: boolean; onClose: () => void; onSuccess: (voice: Voice) => Promise; initialVoice?: Voice; }> = ({ isOpen, onClose, onSuccess, initialVoice }) => { const [vendor, setVendor] = useState('OpenAI Compatible'); const [name, setName] = useState(''); const [openaiCompatibleModel, setOpenaiCompatibleModel] = useState(OPENAI_COMPATIBLE_DEFAULT_MODEL); const [sfVoiceId, setSfVoiceId] = useState(OPENAI_COMPATIBLE_DEFAULT_VOICE); const [sfSpeed, setSfSpeed] = useState(1); const [sfGain, setSfGain] = useState(0); const [sfPitch, setSfPitch] = useState(0); const [gender, setGender] = useState('Female'); const [language, setLanguage] = useState('zh'); const [description, setDescription] = useState(''); const [apiKey, setApiKey] = useState(''); const [baseUrl, setBaseUrl] = useState(''); const [testInput, setTestInput] = useState('你好,正在测试语音合成效果。'); const [isAuditioning, setIsAuditioning] = useState(false); const [isSaving, setIsSaving] = useState(false); const testAudioRef = useRef(null); useEffect(() => { if (!isOpen) return; if (!initialVoice) { setVendor('OpenAI Compatible'); setName(''); setGender('Female'); setLanguage('zh'); setDescription(''); setOpenaiCompatibleModel(OPENAI_COMPATIBLE_DEFAULT_MODEL); setSfVoiceId(OPENAI_COMPATIBLE_DEFAULT_VOICE); setSfSpeed(1); setSfGain(0); setSfPitch(0); setApiKey(''); setBaseUrl(''); setTestInput('你好,正在测试语音合成效果。'); return; } const nextVendor: VoiceVendor = String(initialVoice.vendor || '').trim().toLowerCase() === 'dashscope' ? 'DashScope' : 'OpenAI Compatible'; const nextModel = (initialVoice.model || (nextVendor === 'DashScope' ? DASHSCOPE_DEFAULT_MODEL : OPENAI_COMPATIBLE_DEFAULT_MODEL)).trim(); const defaultVoiceKey = nextVendor === 'DashScope' ? DASHSCOPE_DEFAULT_VOICE : buildOpenAICompatibleVoiceKey(initialVoice.id || initialVoice.name || '', nextModel); setVendor(nextVendor); setName(initialVoice.name || ''); setGender(initialVoice.gender || 'Female'); setLanguage(initialVoice.language || 'zh'); setDescription(initialVoice.description || ''); setOpenaiCompatibleModel(nextModel); setSfVoiceId((initialVoice.voiceKey || '').trim() || defaultVoiceKey); setSfSpeed(initialVoice.speed ?? 1); setSfGain(initialVoice.gain ?? 0); setSfPitch(initialVoice.pitch ?? 0); setApiKey(initialVoice.apiKey || ''); setBaseUrl(initialVoice.baseUrl || (nextVendor === 'DashScope' ? DASHSCOPE_DEFAULT_BASE_URL : '')); }, [initialVoice, isOpen]); const handleAudition = async () => { if (!testInput.trim()) return; if (!initialVoice?.id) { alert('请先创建声音,再进行试听。'); return; } try { setIsAuditioning(true); const audioUrl = await previewVoice(initialVoice.id, testInput, sfSpeed, apiKey || undefined); if (testAudioRef.current) { testAudioRef.current.pause(); } const audio = new Audio(audioUrl); testAudioRef.current = audio; await audio.play(); } catch (error: any) { alert(error?.message || '试听失败'); } finally { setIsAuditioning(false); } }; const handleSubmit = async () => { if (!name) { alert('请填写声音显示名称'); return; } const resolvedModel = (() => { const current = (openaiCompatibleModel || '').trim(); if (current) return current; return vendor === 'DashScope' ? DASHSCOPE_DEFAULT_MODEL : OPENAI_COMPATIBLE_DEFAULT_MODEL; })(); const resolvedVoiceKey = (() => { const current = (sfVoiceId || '').trim(); if (current) return current; if (vendor === 'DashScope') return DASHSCOPE_DEFAULT_VOICE; return buildOpenAICompatibleVoiceKey(initialVoice?.id || name, resolvedModel); })(); const resolvedBaseUrl = (() => { const current = (baseUrl || '').trim(); if (current) return current; return vendor === 'DashScope' ? DASHSCOPE_DEFAULT_BASE_URL : ''; })(); const newVoice: Voice = { id: initialVoice?.id || `oa-${Date.now()}`, name, vendor, gender, language, description: description || `Model: ${resolvedModel}`, model: resolvedModel, voiceKey: resolvedVoiceKey, apiKey, baseUrl: resolvedBaseUrl, speed: sfSpeed, gain: sfGain, pitch: sfPitch, }; try { setIsSaving(true); await onSuccess(newVoice); setName(''); setVendor('OpenAI Compatible'); setDescription(''); setApiKey(''); setBaseUrl(''); setOpenaiCompatibleModel(OPENAI_COMPATIBLE_DEFAULT_MODEL); setSfVoiceId(OPENAI_COMPATIBLE_DEFAULT_VOICE); setSfSpeed(1); setSfGain(0); setSfPitch(0); } catch (error: any) { alert(error?.message || '保存失败'); } finally { setIsSaving(false); } }; return ( } >
setName(e.target.value)} placeholder="例如: 客服小美" />
setOpenaiCompatibleModel(e.target.value)} placeholder={vendor === 'DashScope' ? DASHSCOPE_DEFAULT_MODEL : OPENAI_COMPATIBLE_DEFAULT_MODEL} />
setSfVoiceId(e.target.value)} placeholder={vendor === 'DashScope' ? DASHSCOPE_DEFAULT_VOICE : OPENAI_COMPATIBLE_DEFAULT_VOICE} />
setSfSpeed(parseFloat(e.target.value))} className="flex-1 accent-primary" /> {sfSpeed}x
setSfGain(parseInt(e.target.value, 10))} className="flex-1 accent-primary" /> {sfGain}dB
setSfPitch(parseInt(e.target.value, 10))} className="flex-1 accent-primary" /> {sfPitch}
setApiKey(e.target.value)} placeholder="每个声音独立 API Key" />
setBaseUrl(e.target.value)} placeholder={vendor === 'DashScope' ? DASHSCOPE_DEFAULT_BASE_URL : 'https://.../v1'} />