683 lines
33 KiB
TypeScript
683 lines
33 KiB
TypeScript
|
||
import React, { useEffect, useState, useRef } from 'react';
|
||
import { Search, Mic2, Play, Pause, Upload, X, Filter, Plus, Volume2, Sparkles, Wand2, ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
||
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Dialog, Badge } from '../components/UI';
|
||
import { VendorCredential, Voice } from '../types';
|
||
import { createVoice, deleteVoice, fetchVendorCredentials, fetchVoices, previewVoice, saveVendorCredential, updateVoice } from '../services/backendApi';
|
||
|
||
const VENDOR_OPTIONS = [
|
||
{ key: 'siliconflow', label: '硅基流动 (SiliconFlow)' },
|
||
{ key: 'ali', label: 'Ali' },
|
||
{ key: 'volcano', label: 'Volcano' },
|
||
{ key: 'minimax', label: 'Minimax' },
|
||
];
|
||
|
||
export const VoiceLibraryPage: React.FC = () => {
|
||
const [voices, setVoices] = useState<Voice[]>([]);
|
||
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<string | null>(null);
|
||
const [isCloneModalOpen, setIsCloneModalOpen] = useState(false);
|
||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||
const [editingVoice, setEditingVoice] = useState<Voice | null>(null);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [playLoadingId, setPlayLoadingId] = useState<string | null>(null);
|
||
const [vendorCredentials, setVendorCredentials] = useState<Record<string, VendorCredential>>({});
|
||
const [credentialVendorKey, setCredentialVendorKey] = useState('siliconflow');
|
||
const [credentialApiKey, setCredentialApiKey] = useState('');
|
||
const [credentialBaseUrl, setCredentialBaseUrl] = useState('');
|
||
const [isSavingCredential, setIsSavingCredential] = useState(false);
|
||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
const loadVoicesAndCredentials = async () => {
|
||
setIsLoading(true);
|
||
try {
|
||
const [list, credentials] = await Promise.all([fetchVoices(), fetchVendorCredentials()]);
|
||
setVoices(list);
|
||
const mapped = credentials.reduce((acc, item) => {
|
||
acc[item.vendorKey] = item;
|
||
return acc;
|
||
}, {} as Record<string, VendorCredential>);
|
||
setVendorCredentials(mapped);
|
||
} catch (error) {
|
||
console.error(error);
|
||
setVoices([]);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
loadVoicesAndCredentials();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const selected = vendorCredentials[credentialVendorKey];
|
||
setCredentialApiKey(selected?.apiKey || '');
|
||
setCredentialBaseUrl(selected?.baseUrl || '');
|
||
}, [credentialVendorKey, vendorCredentials]);
|
||
|
||
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('试听失败,请检查 SiliconFlow 配置。');
|
||
};
|
||
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));
|
||
};
|
||
|
||
const handleSaveVendorCredential = async () => {
|
||
if (!credentialApiKey.trim()) {
|
||
alert('请填写 API Key');
|
||
return;
|
||
}
|
||
try {
|
||
setIsSavingCredential(true);
|
||
const option = VENDOR_OPTIONS.find((item) => item.key === credentialVendorKey);
|
||
const saved = await saveVendorCredential(credentialVendorKey, {
|
||
vendorName: option?.label || credentialVendorKey,
|
||
apiKey: credentialApiKey.trim(),
|
||
baseUrl: credentialBaseUrl.trim(),
|
||
});
|
||
setVendorCredentials((prev) => ({ ...prev, [saved.vendorKey]: saved }));
|
||
} catch (error: any) {
|
||
alert(error?.message || '保存厂商配置失败');
|
||
} finally {
|
||
setIsSavingCredential(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6 animate-in fade-in py-4 pb-10">
|
||
<div className="flex items-center justify-between">
|
||
<h1 className="text-2xl font-bold tracking-tight text-white">声音资源</h1>
|
||
<div className="flex space-x-3">
|
||
<Button variant="primary" onClick={() => setIsAddModalOpen(true)} className="shadow-[0_0_15px_rgba(6,182,212,0.4)]">
|
||
<Plus className="mr-2 h-4 w-4" /> 添加声音
|
||
</Button>
|
||
<Button variant="primary" onClick={() => setIsCloneModalOpen(true)} className="shadow-[0_0_15px_rgba(6,182,212,0.4)]">
|
||
<Mic2 className="mr-2 h-4 w-4" /> 克隆声音
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Filter Bar */}
|
||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
|
||
<div className="relative">
|
||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
placeholder="搜索声音名称..."
|
||
className="pl-9 border-0 bg-white/5"
|
||
value={searchTerm}
|
||
onChange={e => setSearchTerm(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||
<select
|
||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
|
||
value={vendorFilter}
|
||
onChange={(e) => setVendorFilter(e.target.value as any)}
|
||
>
|
||
<option value="all">所有厂商</option>
|
||
<option value="硅基流动">硅基流动 (SiliconFlow)</option>
|
||
<option value="SiliconFlow">SiliconFlow</option>
|
||
<option value="Ali">阿里 (Ali)</option>
|
||
<option value="Volcano">火山 (Volcano)</option>
|
||
<option value="Minimax">Minimax</option>
|
||
</select>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<select
|
||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
|
||
value={genderFilter}
|
||
onChange={(e) => setGenderFilter(e.target.value as any)}
|
||
>
|
||
<option value="all">所有性别</option>
|
||
<option value="Male">男 (Male)</option>
|
||
<option value="Female">女 (Female)</option>
|
||
</select>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<select
|
||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
|
||
value={langFilter}
|
||
onChange={(e) => setLangFilter(e.target.value as any)}
|
||
>
|
||
<option value="all">所有语言</option>
|
||
<option value="zh">中文 (Chinese)</option>
|
||
<option value="en">英文 (English)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
|
||
<select
|
||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
|
||
value={credentialVendorKey}
|
||
onChange={(e) => setCredentialVendorKey(e.target.value)}
|
||
>
|
||
{VENDOR_OPTIONS.map((item) => (
|
||
<option key={item.key} value={item.key}>{item.label}</option>
|
||
))}
|
||
</select>
|
||
<Input
|
||
type="password"
|
||
placeholder="Vendor API Key (持久化到后端)"
|
||
className="border-0 bg-white/5"
|
||
value={credentialApiKey}
|
||
onChange={e => setCredentialApiKey(e.target.value)}
|
||
/>
|
||
<Input
|
||
placeholder="Base URL (OpenAI compatible, 选填)"
|
||
className="border-0 bg-white/5"
|
||
value={credentialBaseUrl}
|
||
onChange={e => setCredentialBaseUrl(e.target.value)}
|
||
/>
|
||
<Button onClick={handleSaveVendorCredential} disabled={isSavingCredential}>
|
||
{isSavingCredential ? '保存中...' : '保存厂商配置'}
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="rounded-md border border-white/5 bg-card/40 backdrop-blur-md overflow-hidden">
|
||
<table className="w-full text-sm">
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>声音名称</TableHead>
|
||
<TableHead>厂商</TableHead>
|
||
<TableHead>性别</TableHead>
|
||
<TableHead>语言</TableHead>
|
||
<TableHead className="text-right">试听</TableHead>
|
||
<TableHead className="text-right">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<tbody>
|
||
{!isLoading && filteredVoices.map(voice => (
|
||
<TableRow key={voice.id}>
|
||
<TableCell className="font-medium">
|
||
<div className="flex flex-col">
|
||
<span className="flex items-center text-white">
|
||
{voice.vendor === '硅基流动' && <Sparkles className="w-3 h-3 text-primary mr-1.5" />}
|
||
{voice.name}
|
||
</span>
|
||
{voice.description && <span className="text-xs text-muted-foreground">{voice.description}</span>}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant={voice.vendor === '硅基流动' ? 'default' : 'outline'}>{voice.vendor}</Badge>
|
||
</TableCell>
|
||
<TableCell className="text-muted-foreground">{voice.gender === 'Male' ? '男' : '女'}</TableCell>
|
||
<TableCell className="text-muted-foreground">{voice.language === 'zh' ? '中文' : 'English'}</TableCell>
|
||
<TableCell className="text-right">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => handlePlayToggle(voice)}
|
||
disabled={playLoadingId === voice.id}
|
||
className={playingVoiceId === voice.id ? "text-primary animate-pulse" : ""}
|
||
>
|
||
{playingVoiceId === voice.id ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||
</Button>
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<Button variant="ghost" size="icon" onClick={() => setEditingVoice(voice)}>
|
||
<Pencil className="h-4 w-4" />
|
||
</Button>
|
||
<Button variant="ghost" size="icon" onClick={() => handleDelete(voice.id)} className="text-red-400">
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
{!isLoading && filteredVoices.length === 0 && (
|
||
<TableRow>
|
||
<TableCell colSpan={6} className="text-center py-6 text-muted-foreground">暂无声音数据</TableCell>
|
||
</TableRow>
|
||
)}
|
||
{isLoading && (
|
||
<TableRow>
|
||
<TableCell colSpan={6} className="text-center py-6 text-muted-foreground">加载中...</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<AddVoiceModal
|
||
isOpen={isAddModalOpen}
|
||
onClose={() => setIsAddModalOpen(false)}
|
||
onSuccess={handleAddSuccess}
|
||
/>
|
||
|
||
<AddVoiceModal
|
||
isOpen={!!editingVoice}
|
||
onClose={() => setEditingVoice(null)}
|
||
onSuccess={(voice) => handleUpdateSuccess(editingVoice!.id, voice)}
|
||
initialVoice={editingVoice || undefined}
|
||
/>
|
||
|
||
<CloneVoiceModal
|
||
isOpen={isCloneModalOpen}
|
||
onClose={() => setIsCloneModalOpen(false)}
|
||
onSuccess={handleAddSuccess}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// --- Unified Add Voice Modal ---
|
||
const AddVoiceModal: React.FC<{
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
onSuccess: (voice: Voice) => Promise<void>;
|
||
initialVoice?: Voice;
|
||
}> = ({ isOpen, onClose, onSuccess, initialVoice }) => {
|
||
const [vendor, setVendor] = useState<'硅基流动' | 'Ali' | 'Volcano' | 'Minimax'>('硅基流动');
|
||
const [name, setName] = useState('');
|
||
|
||
const [sfModel, setSfModel] = useState('FunAudioLLM/CosyVoice2-0.5B');
|
||
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 [testInput, setTestInput] = useState('你好,正在测试语音合成效果。');
|
||
const [isAuditioning, setIsAuditioning] = useState(false);
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const testAudioRef = useRef<HTMLAudioElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!initialVoice) return;
|
||
const nextVendor = initialVoice.vendor === 'SiliconFlow' ? '硅基流动' : initialVoice.vendor;
|
||
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(initialVoice.model || 'FunAudioLLM/CosyVoice2-0.5B');
|
||
setSfVoiceId(initialVoice.voiceKey || 'FunAudioLLM/CosyVoice2-0.5B:anna');
|
||
setSfSpeed(initialVoice.speed ?? 1);
|
||
setSfGain(initialVoice.gain ?? 0);
|
||
setSfPitch(initialVoice.pitch ?? 0);
|
||
}, [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);
|
||
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 newVoice: Voice = {
|
||
id: initialVoice?.id || `${vendor === '硅基流动' ? 'sf' : 'gen'}-${Date.now()}`,
|
||
name: name,
|
||
vendor: vendor,
|
||
gender: gender,
|
||
language: language,
|
||
description: description || (vendor === '硅基流动' ? `Model: ${sfModel}` : `Model: ${model}`),
|
||
model: vendor === '硅基流动' ? sfModel : model,
|
||
voiceKey: vendor === '硅基流动' ? sfVoiceId : voiceKey,
|
||
speed: sfSpeed,
|
||
gain: sfGain,
|
||
pitch: sfPitch,
|
||
};
|
||
try {
|
||
setIsSaving(true);
|
||
await onSuccess(newVoice);
|
||
setName('');
|
||
setVendor('硅基流动');
|
||
setDescription('');
|
||
setModel('');
|
||
setVoiceKey('');
|
||
} catch (error: any) {
|
||
alert(error?.message || '保存失败');
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Dialog
|
||
isOpen={isOpen}
|
||
onClose={onClose}
|
||
title={initialVoice ? "编辑声音" : "添加声音"}
|
||
footer={
|
||
<>
|
||
<Button variant="ghost" onClick={onClose}>取消</Button>
|
||
<Button onClick={handleSubmit} className="bg-primary hover:bg-primary/90" disabled={isSaving}>
|
||
{isSaving ? '保存中...' : (initialVoice ? '保存修改' : '确认添加')}
|
||
</Button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="space-y-4 max-h-[75vh] overflow-y-auto px-1 custom-scrollbar">
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">厂商 (Vendor)</label>
|
||
<div className="relative">
|
||
<select
|
||
className="flex h-10 w-full rounded-md border border-white/10 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-foreground appearance-none cursor-pointer [&>option]:bg-card"
|
||
value={vendor}
|
||
onChange={(e) => setVendor(e.target.value as any)}
|
||
>
|
||
<option value="硅基流动">硅基流动 (SiliconFlow)</option>
|
||
<option value="Ali">阿里 (Ali)</option>
|
||
<option value="Volcano">火山 (Volcano)</option>
|
||
<option value="Minimax">Minimax</option>
|
||
</select>
|
||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="h-px bg-white/5"></div>
|
||
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">声音名称</label>
|
||
<Input value={name} onChange={e => setName(e.target.value)} placeholder="例如: 客服小美" />
|
||
</div>
|
||
|
||
{vendor === '硅基流动' ? (
|
||
<div className="space-y-4 animate-in fade-in slide-in-from-top-1 duration-200">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">模型 (Model)</label>
|
||
<select
|
||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-foreground [&>option]:bg-card"
|
||
value={sfModel}
|
||
onChange={e => setSfModel(e.target.value)}
|
||
>
|
||
<option value="FunAudioLLM/CosyVoice2-0.5B">FunAudioLLM/CosyVoice2-0.5B</option>
|
||
<option value="fishaudio/fish-speech-1.5">fishaudio/fish-speech-1.5</option>
|
||
<option value="fishaudio/fish-speech-1.4">fishaudio/fish-speech-1.4</option>
|
||
</select>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">声音 ID (Voice)</label>
|
||
<Input value={sfVoiceId} onChange={e => setSfVoiceId(e.target.value)} placeholder="fishaudio:amy" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">语速 (Speed)</label>
|
||
<div className="flex items-center space-x-2">
|
||
<input type="range" min="0.5" max="2" step="0.1" value={sfSpeed} onChange={e => setSfSpeed(parseFloat(e.target.value))} className="flex-1 accent-primary" />
|
||
<span className="text-[10px] font-mono text-primary bg-primary/10 px-1.5 py-0.5 rounded">{sfSpeed}x</span>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">增益 (Gain)</label>
|
||
<div className="flex items-center space-x-2">
|
||
<input type="range" min="-10" max="10" step="1" value={sfGain} onChange={e => setSfGain(parseInt(e.target.value))} className="flex-1 accent-primary" />
|
||
<span className="text-[10px] font-mono text-primary bg-primary/10 px-1.5 py-0.5 rounded">{sfGain}dB</span>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">音调 (Pitch)</label>
|
||
<div className="flex items-center space-x-2">
|
||
<input type="range" min="-12" max="12" step="1" value={sfPitch} onChange={e => setSfPitch(parseInt(e.target.value))} className="flex-1 accent-primary" />
|
||
<span className="text-[10px] font-mono text-primary bg-primary/10 px-1.5 py-0.5 rounded">{sfPitch}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4 animate-in fade-in slide-in-from-top-1 duration-200">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">模型标识</label>
|
||
<Input value={model} onChange={e => setModel(e.target.value)} placeholder="API Model Key" />
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">发音人标识</label>
|
||
<Input value={voiceKey} onChange={e => setVoiceKey(e.target.value)} placeholder="Voice Key" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">性别</label>
|
||
<select
|
||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-foreground [&>option]:bg-card"
|
||
value={gender}
|
||
onChange={e => setGender(e.target.value)}
|
||
>
|
||
<option value="Female">女 (Female)</option>
|
||
<option value="Male">男 (Male)</option>
|
||
</select>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">语言</label>
|
||
<select
|
||
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-foreground [&>option]:bg-card"
|
||
value={language}
|
||
onChange={e => setLanguage(e.target.value)}
|
||
>
|
||
<option value="zh">中文 (Chinese)</option>
|
||
<option value="en">英文 (English)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">备注</label>
|
||
<textarea
|
||
className="flex min-h-[60px] w-full rounded-md border-0 bg-white/5 px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-white"
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
placeholder="记录该声音的特点..."
|
||
/>
|
||
</div>
|
||
|
||
<div className="p-4 rounded-xl border border-primary/20 bg-primary/5 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<h4 className="text-[10px] font-black text-primary flex items-center tracking-widest uppercase">
|
||
<Volume2 className="w-3.5 h-3.5 mr-1.5" /> 参数试听 (Preview)
|
||
</h4>
|
||
{vendor === '硅基流动' && <Badge variant="outline" className="text-[8px] border-primary/20 text-primary/70">SiliconFlow Audio API</Badge>}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
value={testInput}
|
||
onChange={e => setTestInput(e.target.value)}
|
||
placeholder="输入测试文本..."
|
||
className="text-xs bg-black/20"
|
||
/>
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
onClick={handleAudition}
|
||
disabled={isAuditioning || !initialVoice}
|
||
className="shrink-0 h-9"
|
||
>
|
||
{isAuditioning ? <Pause className="h-3.5 w-3.5 animate-pulse" /> : <Play className="h-3.5 w-3.5" />}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Dialog>
|
||
);
|
||
};
|
||
|
||
const CloneVoiceModal: React.FC<{
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
onSuccess: (voice: Voice) => Promise<void>
|
||
}> = ({ isOpen, onClose, onSuccess }) => {
|
||
const [name, setName] = useState('');
|
||
const [description, setDescription] = useState('');
|
||
const [file, setFile] = useState<File | null>(null);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
if (e.target.files && e.target.files[0]) {
|
||
setFile(e.target.files[0]);
|
||
}
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
if (!name || !file) {
|
||
alert("请填写名称并上传音频文件");
|
||
return;
|
||
}
|
||
|
||
const newVoice: Voice = {
|
||
id: `v-${Date.now()}`,
|
||
name: name,
|
||
vendor: 'Volcano',
|
||
gender: 'Female',
|
||
language: 'zh',
|
||
description: description || 'User cloned voice'
|
||
};
|
||
|
||
await onSuccess(newVoice);
|
||
setName('');
|
||
setDescription('');
|
||
setFile(null);
|
||
};
|
||
|
||
return (
|
||
<Dialog
|
||
isOpen={isOpen}
|
||
onClose={onClose}
|
||
title="克隆声音"
|
||
footer={
|
||
<>
|
||
<Button variant="ghost" onClick={onClose}>取消</Button>
|
||
<Button onClick={handleSubmit}>开始克隆</Button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium text-white">语音名称</label>
|
||
<Input
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder="给新声音起个名字"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium text-white">上传音频文件 (参考音频)</label>
|
||
<div
|
||
className="flex flex-col items-center justify-center w-full h-32 rounded-lg border-2 border-dashed border-white/10 bg-white/5 hover:bg-white/10 transition-colors cursor-pointer"
|
||
onClick={() => inputRef.current?.click()}
|
||
>
|
||
<input
|
||
ref={inputRef}
|
||
type="file"
|
||
accept="audio/*"
|
||
className="hidden"
|
||
onChange={handleFileChange}
|
||
/>
|
||
{file ? (
|
||
<div className="flex items-center space-x-2 text-primary">
|
||
<Mic2 className="h-6 w-6" />
|
||
<span className="text-sm font-medium">{file.name}</span>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<Upload className="h-8 w-8 mb-2 text-muted-foreground" />
|
||
<p className="text-sm text-muted-foreground">点击上传 WAV/MP3 文件</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium text-white">语音描述</label>
|
||
<textarea
|
||
className="flex min-h-[80px] w-full rounded-md border-0 bg-white/5 px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-white"
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
placeholder="描述声音特点(如:年轻、沉稳...)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Dialog>
|
||
);
|
||
};
|