From 97e3236e760afb5863efb9ce3f047a02206a03f2 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Sun, 8 Feb 2026 23:16:21 +0800 Subject: [PATCH] Update voice libary key form --- api/app/models.py | 13 +- api/app/routers/voices.py | 114 +---- api/app/schemas.py | 24 +- api/tests/test_voices.py | 25 +- web/pages/VoiceLibrary.tsx | 987 +++++++++++++++++-------------------- web/services/backendApi.ts | 33 +- web/types.ts | 9 +- 7 files changed, 503 insertions(+), 702 deletions(-) diff --git a/api/app/models.py b/api/app/models.py index 6cd5134..4e4c21a 100644 --- a/api/app/models.py +++ b/api/app/models.py @@ -28,6 +28,8 @@ class Voice(Base): description: Mapped[str] = mapped_column(String(255), nullable=False) model: Mapped[Optional[str]] = mapped_column(String(128), nullable=True) # 厂商语音模型标识 voice_key: Mapped[Optional[str]] = mapped_column(String(128), nullable=True) # 厂商voice_key + api_key: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) # 每个声音独立 API key + base_url: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) # 每个声音独立 OpenAI-compatible base_url speed: Mapped[float] = mapped_column(Float, default=1.0) gain: Mapped[int] = mapped_column(Integer, default=0) pitch: Mapped[int] = mapped_column(Integer, default=0) @@ -38,17 +40,6 @@ class Voice(Base): user = relationship("User", foreign_keys=[user_id]) -class VendorCredential(Base): - __tablename__ = "vendor_credentials" - - vendor_key: Mapped[str] = mapped_column(String(64), primary_key=True) - vendor_name: Mapped[str] = mapped_column(String(128), nullable=False) - api_key: Mapped[str] = mapped_column(String(512), nullable=False) - base_url: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) - updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) - - # ============ LLM Model ============ class LLMModel(Base): __tablename__ = "llm_models" diff --git a/api/app/routers/voices.py b/api/app/routers/voices.py index f7b078a..eab2298 100644 --- a/api/app/routers/voices.py +++ b/api/app/routers/voices.py @@ -1,7 +1,6 @@ import base64 import os import uuid -from datetime import datetime from typing import Optional import httpx @@ -9,16 +8,8 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from ..db import get_db -from ..models import VendorCredential, Voice -from ..schemas import ( - VendorCredentialOut, - VendorCredentialUpsert, - VoiceCreate, - VoiceOut, - VoicePreviewRequest, - VoicePreviewResponse, - VoiceUpdate, -) +from ..models import Voice +from ..schemas import VoiceCreate, VoiceOut, VoicePreviewRequest, VoicePreviewResponse, VoiceUpdate router = APIRouter(prefix="/voices", tags=["Voices"]) @@ -29,28 +20,10 @@ def _is_siliconflow_vendor(vendor: str) -> bool: return vendor.strip().lower() in {"siliconflow", "硅基流动"} -def _canonical_vendor_key(vendor: str) -> str: - normalized = vendor.strip().lower() - alias_map = { - "硅基流动": "siliconflow", - "siliconflow": "siliconflow", - "ali": "ali", - "volcano": "volcano", - "minimax": "minimax", - } - return alias_map.get(normalized, normalized) - - -def _default_tts_base_url(vendor_key: str) -> Optional[str]: - defaults = { - "siliconflow": "https://api.siliconflow.cn/v1", - } - return defaults.get(vendor_key) - - -def _resolve_vendor_credential(db: Session, vendor: str) -> Optional[VendorCredential]: - vendor_key = _canonical_vendor_key(vendor) - return db.query(VendorCredential).filter(VendorCredential.vendor_key == vendor_key).first() +def _default_base_url(vendor: str) -> Optional[str]: + if _is_siliconflow_vendor(vendor): + return "https://api.siliconflow.cn/v1" + return None def _build_siliconflow_voice_key(voice: Voice, model: str) -> str: @@ -108,6 +81,8 @@ def create_voice(data: VoiceCreate, db: Session = Depends(get_db)): description=data.description, model=model, voice_key=voice_key, + api_key=data.api_key, + base_url=data.base_url, speed=data.speed, gain=data.gain, pitch=data.pitch, @@ -165,56 +140,6 @@ def delete_voice(id: str, db: Session = Depends(get_db)): return {"message": "Deleted successfully"} -@router.get("/vendors/credentials") -def list_vendor_credentials(db: Session = Depends(get_db)): - items = db.query(VendorCredential).order_by(VendorCredential.updated_at.desc()).all() - return {"list": items, "total": len(items)} - - -@router.get("/vendors/credentials/{vendor_key}", response_model=VendorCredentialOut) -def get_vendor_credential(vendor_key: str, db: Session = Depends(get_db)): - key = _canonical_vendor_key(vendor_key) - item = db.query(VendorCredential).filter(VendorCredential.vendor_key == key).first() - if not item: - raise HTTPException(status_code=404, detail="Vendor credential not found") - return item - - -@router.put("/vendors/credentials/{vendor_key}", response_model=VendorCredentialOut) -def upsert_vendor_credential(vendor_key: str, data: VendorCredentialUpsert, db: Session = Depends(get_db)): - key = _canonical_vendor_key(vendor_key) - item = db.query(VendorCredential).filter(VendorCredential.vendor_key == key).first() - - if item: - item.vendor_name = data.vendor_name or item.vendor_name - item.api_key = data.api_key - item.base_url = data.base_url - item.updated_at = datetime.utcnow() - else: - item = VendorCredential( - vendor_key=key, - vendor_name=data.vendor_name or vendor_key, - api_key=data.api_key, - base_url=data.base_url, - ) - db.add(item) - - db.commit() - db.refresh(item) - return item - - -@router.delete("/vendors/credentials/{vendor_key}") -def delete_vendor_credential(vendor_key: str, db: Session = Depends(get_db)): - key = _canonical_vendor_key(vendor_key) - item = db.query(VendorCredential).filter(VendorCredential.vendor_key == key).first() - if not item: - raise HTTPException(status_code=404, detail="Vendor credential not found") - db.delete(item) - db.commit() - return {"message": "Deleted successfully"} - - @router.post("/{id}/preview", response_model=VoicePreviewResponse) def preview_voice(id: str, data: VoicePreviewRequest, db: Session = Depends(get_db)): """试听指定声音,基于 OpenAI-compatible /audio/speech 接口。""" @@ -226,22 +151,17 @@ def preview_voice(id: str, data: VoicePreviewRequest, db: Session = Depends(get_ if not text: raise HTTPException(status_code=400, detail="Preview text cannot be empty") - credential = _resolve_vendor_credential(db, voice.vendor) - api_key = (data.api_key or "").strip() - if not api_key and credential: - api_key = credential.api_key + api_key = (data.api_key or "").strip() or (voice.api_key or "").strip() + if not api_key and _is_siliconflow_vendor(voice.vendor): + api_key = os.getenv("SILICONFLOW_API_KEY", "").strip() if not api_key: - api_key = os.getenv("SILICONFLOW_API_KEY") if _is_siliconflow_vendor(voice.vendor) else "" - if not api_key: - raise HTTPException(status_code=400, detail=f"Vendor API key is required for {voice.vendor}") + raise HTTPException(status_code=400, detail=f"API key is required for voice: {voice.name}") + + base_url = (voice.base_url or "").strip() or (_default_base_url(voice.vendor) or "") + if not base_url: + raise HTTPException(status_code=400, detail=f"Base URL is required for voice: {voice.name}") model = voice.model or SILICONFLOW_DEFAULT_MODEL - vendor_key = _canonical_vendor_key(voice.vendor) - base_url = (credential.base_url.strip() if credential and credential.base_url else "") or _default_tts_base_url(vendor_key) - if not base_url: - raise HTTPException(status_code=400, detail=f"Vendor base_url is required for {voice.vendor}") - tts_api_url = f"{base_url.rstrip('/')}/audio/speech" - payload = { "model": model, "input": text, @@ -253,7 +173,7 @@ def preview_voice(id: str, data: VoicePreviewRequest, db: Session = Depends(get_ try: with httpx.Client(timeout=45.0) as client: response = client.post( - tts_api_url, + f"{base_url.rstrip('/')}/audio/speech", headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, json=payload, ) diff --git a/api/app/schemas.py b/api/app/schemas.py index 9fd7567..5f718da 100644 --- a/api/app/schemas.py +++ b/api/app/schemas.py @@ -53,6 +53,8 @@ class VoiceCreate(VoiceBase): id: Optional[str] = None model: Optional[str] = None # 厂商语音模型标识 voice_key: Optional[str] = None # 厂商voice_key + api_key: Optional[str] = None + base_url: Optional[str] = None speed: float = 1.0 gain: int = 0 pitch: int = 0 @@ -67,6 +69,8 @@ class VoiceUpdate(BaseModel): description: Optional[str] = None model: Optional[str] = None voice_key: Optional[str] = None + api_key: Optional[str] = None + base_url: Optional[str] = None speed: Optional[float] = None gain: Optional[int] = None pitch: Optional[int] = None @@ -78,6 +82,8 @@ class VoiceOut(VoiceBase): user_id: Optional[int] = None model: Optional[str] = None voice_key: Optional[str] = None + api_key: Optional[str] = None + base_url: Optional[str] = None speed: float = 1.0 gain: int = 0 pitch: int = 0 @@ -104,24 +110,6 @@ class VoicePreviewResponse(BaseModel): error: Optional[str] = None -class VendorCredentialUpsert(BaseModel): - vendor_name: Optional[str] = None - api_key: str - base_url: Optional[str] = None - - -class VendorCredentialOut(BaseModel): - vendor_key: str - vendor_name: str - api_key: str - base_url: Optional[str] = None - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - - class Config: - from_attributes = True - - # ============ LLM Model ============ class LLMModelBase(BaseModel): name: str diff --git a/api/tests/test_voices.py b/api/tests/test_voices.py index c77653e..73ed371 100644 --- a/api/tests/test_voices.py +++ b/api/tests/test_voices.py @@ -180,11 +180,12 @@ class TestVoiceAPI: encoded = payload["audio_url"].split(",", 1)[1] assert base64.b64decode(encoded) == b"fake-mp3-bytes" - def test_vendor_credential_persist_and_preview_use_db_key(self, client, monkeypatch): - """Test vendor credential persisted in DB and used by preview endpoint""" + def test_voice_credential_persist_and_preview_use_voice_key(self, client, monkeypatch): + """Test per-voice api_key/base_url persisted and used by preview endpoint""" from app.routers import voices as voice_router captured_auth = {"value": ""} + captured_url = {"value": ""} class DummyResponse: status_code = 200 @@ -207,22 +208,13 @@ class TestVoiceAPI: def post(self, *args, **kwargs): headers = kwargs.get("headers", {}) captured_auth["value"] = headers.get("Authorization", "") + if args: + captured_url["value"] = args[0] return DummyResponse() monkeypatch.delenv("SILICONFLOW_API_KEY", raising=False) monkeypatch.setattr(voice_router.httpx, "Client", DummyClient) - save_cred = client.put( - "/api/voices/vendors/credentials/siliconflow", - json={ - "vendor_name": "SiliconFlow", - "api_key": "db-key-123", - "base_url": "https://api.siliconflow.cn/v1" - }, - ) - assert save_cred.status_code == 200 - assert save_cred.json()["vendor_key"] == "siliconflow" - create_resp = client.post("/api/voices", json={ "id": "anna2", "name": "Anna 2", @@ -231,10 +223,13 @@ class TestVoiceAPI: "language": "zh", "description": "voice", "model": "FunAudioLLM/CosyVoice2-0.5B", - "voice_key": "FunAudioLLM/CosyVoice2-0.5B:anna" + "voice_key": "FunAudioLLM/CosyVoice2-0.5B:anna", + "api_key": "voice-key-123", + "base_url": "https://api.siliconflow.cn/v1" }) assert create_resp.status_code == 200 preview_resp = client.post("/api/voices/anna2/preview", json={"text": "hello"}) assert preview_resp.status_code == 200 - assert captured_auth["value"] == "Bearer db-key-123" + assert captured_auth["value"] == "Bearer voice-key-123" + assert captured_url["value"] == "https://api.siliconflow.cn/v1/audio/speech" diff --git a/web/pages/VoiceLibrary.tsx b/web/pages/VoiceLibrary.tsx index 5453a14..93a13a1 100644 --- a/web/pages/VoiceLibrary.tsx +++ b/web/pages/VoiceLibrary.tsx @@ -1,16 +1,8 @@ - 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 { 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 { 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' }, -]; +import { Voice } from '../types'; +import { createVoice, deleteVoice, fetchVoices, previewVoice, updateVoice } from '../services/backendApi'; export const VoiceLibraryPage: React.FC = () => { const [voices, setVoices] = useState([]); @@ -18,31 +10,20 @@ export const VoiceLibraryPage: React.FC = () => { 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 [vendorCredentials, setVendorCredentials] = useState>({}); - const [credentialVendorKey, setCredentialVendorKey] = useState('siliconflow'); - const [credentialApiKey, setCredentialApiKey] = useState(''); - const [credentialBaseUrl, setCredentialBaseUrl] = useState(''); - const [isSavingCredential, setIsSavingCredential] = useState(false); const audioRef = useRef(null); useEffect(() => { - const loadVoicesAndCredentials = async () => { + const loadVoices = 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); - setVendorCredentials(mapped); + setVoices(await fetchVoices()); } catch (error) { console.error(error); setVoices([]); @@ -50,17 +31,10 @@ export const VoiceLibraryPage: React.FC = () => { setIsLoading(false); } }; - - loadVoicesAndCredentials(); + loadVoices(); }, []); - useEffect(() => { - const selected = vendorCredentials[credentialVendorKey]; - setCredentialApiKey(selected?.apiKey || ''); - setCredentialBaseUrl(selected?.baseUrl || ''); - }, [credentialVendorKey, vendorCredentials]); - - const filteredVoices = voices.filter(voice => { + 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; @@ -75,6 +49,7 @@ export const VoiceLibraryPage: React.FC = () => { setPlayingVoiceId(null); return; } + try { setPlayLoadingId(voice.id); const audioUrl = await previewVoice( @@ -82,15 +57,18 @@ export const VoiceLibraryPage: React.FC = () => { 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 配置。'); + alert('试听失败,请检查该声音的 API Key / Base URL。'); }; + audioRef.current = audio; setPlayingVoiceId(voice.id); await audio.play(); @@ -103,43 +81,22 @@ export const VoiceLibraryPage: React.FC = () => { }; const handleAddSuccess = async (newVoice: Voice) => { - const created = await createVoice(newVoice); - setVoices((prev) => [created, ...prev]); - setIsAddModalOpen(false); - setIsCloneModalOpen(false); + 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 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); - } + if (!confirm('确认删除这个声音吗?')) return; + await deleteVoice(id); + setVoices((prev) => prev.filter((voice) => voice.id !== id)); }; return ( @@ -147,91 +104,62 @@ export const VoiceLibraryPage: React.FC = () => {

声音资源

- - + +
- {/* Filter Bar */}
-
- - setSearchTerm(e.target.value)} - /> -
-
- - -
-
- -
-
- -
-
- -
- setSearchTerm(e.target.value)} + /> +
+
+ + - setCredentialApiKey(e.target.value)} - /> - setCredentialBaseUrl(e.target.value)} - /> - + value={vendorFilter} + onChange={(e) => setVendorFilter(e.target.value as any)} + > + + + + + + + +
+
+ +
+
+ +
@@ -247,62 +175,58 @@ export const VoiceLibraryPage: React.FC = () => { - {!isLoading && filteredVoices.map(voice => ( + {!isLoading && filteredVoices.map((voice) => ( -
- - {voice.vendor === '硅基流动' && } - {voice.name} - - {voice.description && {voice.description}} -
+
+ + {voice.vendor === '硅基流动' && } + {voice.name} + + {voice.description && {voice.description}} +
- {voice.vendor} + {voice.vendor} {voice.gender === 'Male' ? '男' : '女'} {voice.language === 'zh' ? '中文' : 'English'} - + - - + +
))} - {!isLoading && filteredVoices.length === 0 && ( - - 暂无声音数据 - - )} - {isLoading && ( - - 加载中... - - )} + {!isLoading && filteredVoices.length === 0 && ( + + 暂无声音数据 + + )} + {isLoading && ( + + 加载中... + + )}
- setIsAddModalOpen(false)} - onSuccess={handleAddSuccess} - /> + setIsAddModalOpen(false)} onSuccess={handleAddSuccess} /> { initialVoice={editingVoice || undefined} /> - setIsCloneModalOpen(false)} - onSuccess={handleAddSuccess} - /> + setIsCloneModalOpen(false)} onSuccess={handleAddSuccess} /> ); }; -// --- Unified Add Voice Modal --- const AddVoiceModal: React.FC<{ - isOpen: boolean; - onClose: () => void; - onSuccess: (voice: Voice) => Promise; - initialVoice?: Voice; + 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('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 [vendor, setVendor] = useState<'硅基流动' | 'Ali' | 'Volcano' | 'Minimax'>('硅基流动'); + const [name, setName] = useState(''); - 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(null); + 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); - 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 [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 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 [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; + 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); + 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 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 === '硅基流动' ? sfVoiceId : voiceKey, + apiKey, + baseUrl, + speed: sfSpeed, + gain: sfGain, + pitch: sfPitch, }; - const handleSubmit = async () => { - if (!name) { alert("请填写声音显示名称"); return; } + try { + setIsSaving(true); + await onSuccess(newVoice); + setName(''); + setVendor('硅基流动'); + setDescription(''); + setModel(''); + setVoiceKey(''); + setApiKey(''); + setBaseUrl(''); + } catch (error: any) { + alert(error?.message || '保存失败'); + } finally { + setIsSaving(false); + } + }; - 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 ( + + + + + } + > +
+
+ +
+ + +
+
- return ( - - - - - } - > -
-
- -
- - -
-
+
-
+
+ + setName(e.target.value)} placeholder="例如: 客服小美" /> +
-
- - setName(e.target.value)} placeholder="例如: 客服小美" /> -
- - {vendor === '硅基流动' ? ( -
-
-
- - -
-
- - setSfVoiceId(e.target.value)} placeholder="fishaudio:amy" /> -
-
- -
-
- -
- setSfSpeed(parseFloat(e.target.value))} className="flex-1 accent-primary" /> - {sfSpeed}x -
-
-
- -
- setSfGain(parseInt(e.target.value))} className="flex-1 accent-primary" /> - {sfGain}dB -
-
-
- -
- setSfPitch(parseInt(e.target.value))} className="flex-1 accent-primary" /> - {sfPitch} -
-
-
-
- ) : ( -
-
-
- - setModel(e.target.value)} placeholder="API Model Key" /> -
-
- - setVoiceKey(e.target.value)} placeholder="Voice Key" /> -
-
-
- )} - -
-
- - -
-
- - -
-
- -
- -