import React, { useEffect, useState, useRef } from 'react'; import { Search, Mic2, Play, Pause, Upload, Filter, Plus, Volume2, Sparkles, ChevronDown, Pencil, Trash2 } from 'lucide-react'; import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Dialog, Badge } from '../components/UI'; import { Voice } from '../types'; import { createVoice, deleteVoice, fetchVoices, previewVoice, updateVoice } from '../services/backendApi'; const SILICONFLOW_DEFAULT_MODEL = 'FunAudioLLM/CosyVoice2-0.5B'; const SILICONFLOW_MODEL_SUGGESTIONS = [ 'FunAudioLLM/CosyVoice2-0.5B', 'fishaudio/fish-speech-1.5', 'fishaudio/fish-speech-1.4', ]; const buildSiliconflowVoiceKey = (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 [voices, setVoices] = useState([]); const [searchTerm, setSearchTerm] = useState(''); const [vendorFilter, setVendorFilter] = useState<'all' | 'Ali' | 'Volcano' | 'Minimax' | '硅基流动' | 'SiliconFlow'>('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 [isLoading, setIsLoading] = useState(true); const [playLoadingId, setPlayLoadingId] = useState(null); const audioRef = useRef(null); useEffect(() => { const loadVoices = async () => { setIsLoading(true); try { setVoices(await fetchVoices()); } catch (error) { console.error(error); setVoices([]); } finally { setIsLoading(false); } }; loadVoices(); }, []); 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) => { const created = await createVoice(newVoice); setVoices((prev) => [created, ...prev]); setIsAddModalOpen(false); setIsCloneModalOpen(false); }; const handleUpdateSuccess = async (id: string, data: Voice) => { const updated = await updateVoice(id, data); setVoices((prev) => prev.map((voice) => (voice.id === id ? updated : voice))); setEditingVoice(null); }; const handleDelete = async (id: string) => { if (!confirm('确认删除这个声音吗?')) return; await deleteVoice(id); setVoices((prev) => prev.filter((voice) => voice.id !== id)); }; return (

声音资源

setSearchTerm(e.target.value)} />
声音名称 厂商 性别 语言 试听 操作 {!isLoading && filteredVoices.map((voice) => (
{voice.vendor === '硅基流动' && } {voice.name} {voice.description && {voice.description}}
{voice.vendor} {voice.gender === 'Male' ? '男' : '女'} {voice.language === 'zh' ? '中文' : 'English'}
))} {!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<'硅基流动' | 'Ali' | 'Volcano' | 'Minimax'>('硅基流动'); const [name, setName] = useState(''); const [sfModel, setSfModel] = useState(SILICONFLOW_DEFAULT_MODEL); const [sfVoiceId, setSfVoiceId] = useState('FunAudioLLM/CosyVoice2-0.5B:anna'); const [sfSpeed, setSfSpeed] = useState(1); const [sfGain, setSfGain] = useState(0); const [sfPitch, setSfPitch] = useState(0); const [model, setModel] = useState(''); const [voiceKey, setVoiceKey] = useState(''); 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 (!initialVoice) return; const nextVendor = initialVoice.vendor === 'SiliconFlow' ? '硅基流动' : initialVoice.vendor; const nextModel = initialVoice.model || SILICONFLOW_DEFAULT_MODEL; const defaultVoiceKey = buildSiliconflowVoiceKey(initialVoice.id || initialVoice.name || '', nextModel); setVendor((nextVendor as any) || '硅基流动'); setName(initialVoice.name || ''); setGender(initialVoice.gender || 'Female'); setLanguage(initialVoice.language || 'zh'); setDescription(initialVoice.description || ''); setModel(initialVoice.model || ''); setVoiceKey(initialVoice.voiceKey || ''); setSfModel(nextModel); setSfVoiceId((initialVoice.voiceKey || '').trim() || defaultVoiceKey); setSfSpeed(initialVoice.speed ?? 1); setSfGain(initialVoice.gain ?? 0); setSfPitch(initialVoice.pitch ?? 0); setApiKey(initialVoice.apiKey || ''); setBaseUrl(initialVoice.baseUrl || ''); }, [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 resolvedSiliconflowVoiceKey = (() => { const current = (sfVoiceId || '').trim(); if (current) return current; return buildSiliconflowVoiceKey(initialVoice?.id || name, sfModel || SILICONFLOW_DEFAULT_MODEL); })(); const newVoice: Voice = { id: initialVoice?.id || `${vendor === '硅基流动' ? 'sf' : 'gen'}-${Date.now()}`, name, vendor, gender, language, description: description || (vendor === '硅基流动' ? `Model: ${sfModel}` : `Model: ${model}`), model: vendor === '硅基流动' ? sfModel : model, voiceKey: vendor === '硅基流动' ? resolvedSiliconflowVoiceKey : voiceKey, apiKey, baseUrl, speed: sfSpeed, gain: sfGain, pitch: sfPitch, }; try { setIsSaving(true); await onSuccess(newVoice); setName(''); setVendor('硅基流动'); setDescription(''); setModel(''); setVoiceKey(''); setApiKey(''); setBaseUrl(''); } catch (error: any) { alert(error?.message || '保存失败'); } finally { setIsSaving(false); } }; return ( } >
setName(e.target.value)} placeholder="例如: 客服小美" />
{vendor === '硅基流动' ? (
setSfModel(e.target.value)} placeholder="例如: FunAudioLLM/CosyVoice2-0.5B" list="siliconflow-model-options" /> {SILICONFLOW_MODEL_SUGGESTIONS.map((m) => (
setSfVoiceId(e.target.value)} placeholder="FunAudioLLM/CosyVoice2-0.5B:anna" />
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}
) : (
setModel(e.target.value)} placeholder="API Model Key" />
setVoiceKey(e.target.value)} placeholder="Voice Key" />
)}
setApiKey(e.target.value)} placeholder="每个声音独立 API Key" />
setBaseUrl(e.target.value)} placeholder="https://.../v1" />