feat: Implement voice design management with CRUD operations and integrate into frontend

This commit is contained in:
2026-02-04 13:57:20 +08:00
parent a694ead4b8
commit ddaa0abfc7
14 changed files with 542 additions and 31 deletions

View File

@@ -98,7 +98,8 @@ async def process_voice_design_job(
user_id: int,
request_data: dict,
backend_type: str,
db_url: str
db_url: str,
saved_voice_id: Optional[str] = None
):
from core.database import SessionLocal
from core.tts_service import TTSServiceFactory
@@ -125,7 +126,10 @@ async def process_voice_design_job(
backend = await TTSServiceFactory.get_backend(backend_type, user_api_key)
audio_bytes, sample_rate = await backend.generate_voice_design(request_data)
if backend_type == "aliyun" and saved_voice_id:
audio_bytes, sample_rate = await backend.generate_voice_design(request_data, saved_voice_id)
else:
audio_bytes, sample_rate = await backend.generate_voice_design(request_data)
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
filename = f"{user_id}_{job_id}_{timestamp}.wav"
@@ -374,7 +378,7 @@ async def create_voice_design_job(
db: Session = Depends(get_db)
):
from core.security import decrypt_api_key
from db.crud import get_user_preferences, can_user_use_local_model
from db.crud import get_user_preferences, can_user_use_local_model, get_voice_design, update_voice_design_usage
user_prefs = get_user_preferences(db, current_user.id)
preferred_backend = user_prefs.get("default_backend", "aliyun")
@@ -383,6 +387,24 @@ async def create_voice_design_job(
backend_type = req_data.backend if hasattr(req_data, 'backend') and req_data.backend else preferred_backend
saved_voice_id = None
if req_data.saved_design_id:
saved_design = get_voice_design(db, req_data.saved_design_id, current_user.id)
if not saved_design:
raise HTTPException(status_code=404, detail="Saved voice design not found")
if saved_design.backend_type != backend_type:
raise HTTPException(
status_code=400,
detail=f"Saved design backend ({saved_design.backend_type}) doesn't match current backend ({backend_type})"
)
req_data.instruct = saved_design.instruct
saved_voice_id = saved_design.aliyun_voice_id
update_voice_design_usage(db, req_data.saved_design_id, current_user.id)
if backend_type == "local" and not can_use_local:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@@ -399,8 +421,9 @@ async def create_voice_design_job(
validate_text_length(req_data.text)
language = validate_language(req_data.language)
if not req_data.instruct or not req_data.instruct.strip():
raise ValueError("Instruct parameter is required for voice design")
if not req_data.saved_design_id:
if not req_data.instruct or not req_data.instruct.strip():
raise ValueError("Instruct parameter is required when saved_design_id is not provided")
params = validate_generation_params({
'max_new_tokens': req_data.max_new_tokens,
@@ -443,7 +466,8 @@ async def create_voice_design_job(
current_user.id,
request_data,
backend_type,
str(settings.DATABASE_URL)
str(settings.DATABASE_URL),
saved_voice_id
)
return {

View File

@@ -0,0 +1,97 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from typing import Optional
from slowapi import Limiter
from slowapi.util import get_remote_address
from core.database import get_db
from api.auth import get_current_user
from db.models import User
from db import crud
from schemas.voice_design import (
VoiceDesignCreate,
VoiceDesignResponse,
VoiceDesignUpdate,
VoiceDesignListResponse
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/voice-designs", tags=["voice-designs"])
limiter = Limiter(key_func=get_remote_address)
@router.post("", response_model=VoiceDesignResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit("30/minute")
async def save_voice_design(
request: Request,
data: VoiceDesignCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
try:
design = crud.create_voice_design(
db=db,
user_id=current_user.id,
name=data.name,
instruct=data.instruct,
backend_type=data.backend_type,
aliyun_voice_id=data.aliyun_voice_id,
meta_data=data.meta_data,
preview_text=data.preview_text
)
return VoiceDesignResponse.from_orm(design)
except Exception as e:
logger.error(f"Failed to save voice design: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Failed to save voice design")
@router.get("", response_model=VoiceDesignListResponse)
@limiter.limit("30/minute")
async def list_voice_designs(
request: Request,
backend_type: Optional[str] = None,
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
designs = crud.list_voice_designs(db, current_user.id, backend_type, skip, limit)
return VoiceDesignListResponse(designs=[VoiceDesignResponse.from_orm(d) for d in designs], total=len(designs))
@router.get("/{design_id}", response_model=VoiceDesignResponse)
@limiter.limit("30/minute")
async def get_voice_design(
request: Request,
design_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
design = crud.get_voice_design(db, design_id, current_user.id)
if not design:
raise HTTPException(status_code=404, detail="Voice design not found")
return VoiceDesignResponse.from_orm(design)
@router.patch("/{design_id}", response_model=VoiceDesignResponse)
@limiter.limit("30/minute")
async def update_voice_design(
request: Request,
design_id: int,
data: VoiceDesignUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
design = crud.update_voice_design(db, design_id, current_user.id, data.name)
if not design:
raise HTTPException(status_code=404, detail="Voice design not found")
return VoiceDesignResponse.from_orm(design)
@router.delete("/{design_id}", status_code=status.HTTP_204_NO_CONTENT)
@limiter.limit("30/minute")
async def delete_voice_design(
request: Request,
design_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
success = crud.delete_voice_design(db, design_id, current_user.id)
if not success:
raise HTTPException(status_code=404, detail="Voice design not found")

View File

@@ -175,13 +175,17 @@ class AliyunTTSBackend(TTSBackend):
language=params['language']
)
async def generate_voice_design(self, params: dict) -> Tuple[bytes, int]:
async def generate_voice_design(self, params: dict, saved_voice_id: Optional[str] = None) -> Tuple[bytes, int]:
from core.config import settings
voice_id = await self._create_voice_design(
instruct=params['instruct'],
preview_text=params['text']
)
if saved_voice_id:
voice_id = saved_voice_id
logger.info(f"Using saved Aliyun voice_id: {voice_id}")
else:
voice_id = await self._create_voice_design(
instruct=params['instruct'],
preview_text=params['text']
)
model = settings.ALIYUN_MODEL_VD

View File

@@ -3,7 +3,7 @@ from typing import Optional, List, Dict, Any
from datetime import datetime
from sqlalchemy.orm import Session
from db.models import User, Job, VoiceCache, SystemSettings
from db.models import User, Job, VoiceCache, SystemSettings, VoiceDesign
def get_user_by_username(db: Session, username: str) -> Optional[User]:
return db.query(User).filter(User.username == username).first()
@@ -271,3 +271,83 @@ def update_system_setting(db: Session, key: str, value: dict) -> SystemSettings:
def can_user_use_local_model(user: User) -> bool:
return user.is_superuser or user.can_use_local_model
def create_voice_design(
db: Session,
user_id: int,
name: str,
instruct: str,
backend_type: str,
aliyun_voice_id: Optional[str] = None,
meta_data: Optional[Dict[str, Any]] = None,
preview_text: Optional[str] = None
) -> VoiceDesign:
design = VoiceDesign(
user_id=user_id,
name=name,
backend_type=backend_type,
instruct=instruct,
aliyun_voice_id=aliyun_voice_id,
meta_data=json.dumps(meta_data) if meta_data else None,
preview_text=preview_text,
created_at=datetime.utcnow(),
last_used=datetime.utcnow()
)
db.add(design)
db.commit()
db.refresh(design)
return design
def get_voice_design(db: Session, design_id: int, user_id: int) -> Optional[VoiceDesign]:
return db.query(VoiceDesign).filter(
VoiceDesign.id == design_id,
VoiceDesign.user_id == user_id,
VoiceDesign.is_active == True
).first()
def list_voice_designs(
db: Session,
user_id: int,
backend_type: Optional[str] = None,
skip: int = 0,
limit: int = 100
) -> List[VoiceDesign]:
query = db.query(VoiceDesign).filter(
VoiceDesign.user_id == user_id,
VoiceDesign.is_active == True
)
if backend_type:
query = query.filter(VoiceDesign.backend_type == backend_type)
return query.order_by(VoiceDesign.last_used.desc()).offset(skip).limit(limit).all()
def update_voice_design_usage(db: Session, design_id: int, user_id: int) -> Optional[VoiceDesign]:
design = get_voice_design(db, design_id, user_id)
if design:
design.last_used = datetime.utcnow()
design.use_count += 1
db.commit()
db.refresh(design)
return design
def update_voice_design(
db: Session,
design_id: int,
user_id: int,
name: Optional[str] = None
) -> Optional[VoiceDesign]:
design = get_voice_design(db, design_id, user_id)
if not design:
return None
if name is not None:
design.name = name
db.commit()
db.refresh(design)
return design
def delete_voice_design(db: Session, design_id: int, user_id: int) -> bool:
design = get_voice_design(db, design_id, user_id)
if not design:
return False
design.is_active = False
db.commit()
return True

View File

@@ -28,6 +28,7 @@ class User(Base):
jobs = relationship("Job", back_populates="user", cascade="all, delete-orphan")
voice_caches = relationship("VoiceCache", back_populates="user", cascade="all, delete-orphan")
voice_designs = relationship("VoiceDesign", back_populates="user", cascade="all, delete-orphan")
class Job(Base):
__tablename__ = "jobs"
@@ -77,3 +78,26 @@ class SystemSettings(Base):
key = Column(String(100), unique=True, nullable=False, index=True)
value = Column(JSON, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
class VoiceDesign(Base):
__tablename__ = "voice_designs"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
name = Column(String(100), nullable=False)
backend_type = Column(String(20), nullable=False, index=True)
instruct = Column(Text, nullable=False)
aliyun_voice_id = Column(String(255), nullable=True)
meta_data = Column(JSON, nullable=True)
preview_text = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
last_used = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
use_count = Column(Integer, default=0, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
user = relationship("User", back_populates="voice_designs")
__table_args__ = (
Index('idx_user_backend', 'user_id', 'backend_type'),
Index('idx_user_active', 'user_id', 'is_active'),
)

View File

@@ -15,7 +15,7 @@ from core.config import settings
from core.database import init_db
from core.model_manager import ModelManager
from core.cleanup import run_scheduled_cleanup
from api import auth, jobs, tts, cache, metrics, users
from api import auth, jobs, tts, cache, metrics, users, voice_designs
from apscheduler.schedulers.asyncio import AsyncIOScheduler
logging.basicConfig(
@@ -134,6 +134,7 @@ app.include_router(tts.router)
app.include_router(cache.router)
app.include_router(metrics.router)
app.include_router(users.router)
app.include_router(voice_designs.router)
@app.get("/health")
async def health_check():

View File

@@ -30,7 +30,8 @@ class CustomVoiceRequest(BaseModel):
class VoiceDesignRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=1000)
language: str = Field(default="Auto")
instruct: str = Field(..., min_length=1)
instruct: Optional[str] = Field(default=None, min_length=1)
saved_design_id: Optional[int] = None
max_new_tokens: Optional[int] = Field(default=2048, ge=128, le=4096)
temperature: Optional[float] = Field(default=0.9, ge=0.1, le=2.0)
top_k: Optional[int] = Field(default=50, ge=1, le=100)

View File

@@ -0,0 +1,34 @@
from typing import Optional, Dict, Any, List
from datetime import datetime
from pydantic import BaseModel, Field
class VoiceDesignCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
instruct: str = Field(..., min_length=1)
backend_type: str = Field(..., pattern="^(local|aliyun)$")
aliyun_voice_id: Optional[str] = None
meta_data: Optional[Dict[str, Any]] = None
preview_text: Optional[str] = None
class VoiceDesignUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
class VoiceDesignResponse(BaseModel):
id: int
user_id: int
name: str
backend_type: str
instruct: str
aliyun_voice_id: Optional[str]
meta_data: Optional[Dict[str, Any]]
preview_text: Optional[str]
created_at: datetime
last_used: datetime
use_count: int
class Config:
from_attributes = True
class VoiceDesignListResponse(BaseModel):
designs: List[VoiceDesignResponse]
total: int

View File

@@ -5,14 +5,14 @@ import { useEffect, useState, forwardRef, useImperativeHandle, useMemo } from 'r
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectGroup, SelectLabel } from '@/components/ui/select'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Globe2, User, Type, Sparkles, Play, Settings } 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 { ttsApi, jobApi, voiceDesignApi } from '@/lib/api'
import { useJobPolling } from '@/hooks/useJobPolling'
import { useHistoryContext } from '@/contexts/HistoryContext'
import { useUserPreferences } from '@/contexts/UserPreferencesContext'
@@ -20,7 +20,7 @@ import { LoadingState } from '@/components/LoadingState'
import { AudioPlayer } from '@/components/AudioPlayer'
import { PresetSelector } from '@/components/PresetSelector'
import { PRESET_INSTRUCTS, ADVANCED_PARAMS_INFO } from '@/lib/constants'
import type { Language, Speaker } from '@/types/tts'
import type { Language, UnifiedSpeakerItem } from '@/types/tts'
const formSchema = z.object({
text: z.string().min(1, '请输入要合成的文本').max(5000, '文本长度不能超过 5000 字符'),
@@ -42,7 +42,8 @@ export interface CustomVoiceFormHandle {
const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
const [languages, setLanguages] = useState<Language[]>([])
const [speakers, setSpeakers] = useState<Speaker[]>([])
const [unifiedSpeakers, setUnifiedSpeakers] = useState<UnifiedSpeakerItem[]>([])
const [selectedSpeakerId, setSelectedSpeakerId] = useState<string>('')
const [isLoading, setIsLoading] = useState(false)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [tempAdvancedParams, setTempAdvancedParams] = useState({
@@ -83,6 +84,16 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
setValue('text', params.text || '')
setValue('language', params.language || 'Auto')
setValue('speaker', params.speaker || '')
if (params.speaker) {
const item = unifiedSpeakers.find(s =>
s.source === 'builtin' && s.id === params.speaker
)
if (item) {
setSelectedSpeakerId(item.id)
}
}
setValue('instruct', params.instruct || '')
setValue('max_new_tokens', params.max_new_tokens || 2048)
setValue('temperature', params.temperature || 0.3)
@@ -96,12 +107,31 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
const fetchData = async () => {
try {
const backend = preferences?.default_backend || 'local'
const [langs, spks] = await Promise.all([
const [langs, builtinSpeakers, savedDesigns] = await Promise.all([
ttsApi.getLanguages(),
ttsApi.getSpeakers(backend),
voiceDesignApi.list(backend)
])
const designItems: UnifiedSpeakerItem[] = savedDesigns.designs.map(d => ({
id: `design-${d.id}`,
displayName: `${d.name} (自定义)`,
description: d.instruct.substring(0, 60) + (d.instruct.length > 60 ? '...' : ''),
source: 'saved-design',
designId: d.id,
instruct: d.instruct,
backendType: d.backend_type
}))
const builtinItems: UnifiedSpeakerItem[] = builtinSpeakers.map(s => ({
id: s.name,
displayName: s.name,
description: s.description,
source: 'builtin'
}))
setLanguages(langs)
setSpeakers(spks)
setUnifiedSpeakers([...designItems, ...builtinItems])
} catch (error) {
toast.error('加载数据失败')
}
@@ -113,7 +143,25 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
const onSubmit = async (data: FormData) => {
setIsLoading(true)
try {
const result = await ttsApi.createCustomVoiceJob(data)
const selectedItem = unifiedSpeakers.find(s => s.id === selectedSpeakerId)
let result
if (selectedItem?.source === 'saved-design') {
result = await ttsApi.createVoiceDesignJob({
text: data.text,
language: data.language,
instruct: selectedItem.instruct!,
saved_design_id: selectedItem.designId,
max_new_tokens: data.max_new_tokens,
temperature: data.temperature,
top_k: data.top_k,
top_p: data.top_p,
repetition_penalty: data.repetition_penalty,
})
} else {
result = await ttsApi.createCustomVoiceJob(data)
}
toast.success('任务已创建')
startPolling(result.job_id)
try {
@@ -158,18 +206,54 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
<div className="space-y-0.5">
<IconLabel icon={User} tooltip="发音人" required />
<Select
value={watch('speaker')}
onValueChange={(value: string) => setValue('speaker', value)}
value={selectedSpeakerId}
onValueChange={(value: string) => {
setSelectedSpeakerId(value)
const item = unifiedSpeakers.find(s => s.id === value)
if (item?.source === 'builtin') {
setValue('speaker', item.id)
}
}}
>
<SelectTrigger>
<SelectValue placeholder="选择发音人" />
<SelectValue placeholder="选择发音人">
{selectedSpeakerId && (() => {
const item = unifiedSpeakers.find(s => s.id === selectedSpeakerId)
if (!item) return null
if (item.source === 'saved-design') {
return item.displayName
}
return `${item.displayName} - ${item.description}`
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{speakers.map((speaker) => (
<SelectItem key={speaker.name} value={speaker.name}>
{speaker.name} - {speaker.description}
</SelectItem>
))}
{unifiedSpeakers.filter(s => s.source === 'saved-design').length > 0 && (
<SelectGroup>
<SelectLabel className="text-xs text-muted-foreground"></SelectLabel>
{unifiedSpeakers
.filter(s => s.source === 'saved-design')
.map(item => (
<SelectItem key={item.id} value={item.id}>
<div className="flex flex-col">
<span className="font-medium">{item.displayName}</span>
<span className="text-xs text-muted-foreground">{item.description}</span>
</div>
</SelectItem>
))}
</SelectGroup>
)}
<SelectGroup>
<SelectLabel className="text-xs text-muted-foreground"></SelectLabel>
{unifiedSpeakers
.filter(s => s.source === 'builtin')
.map(item => (
<SelectItem key={item.id} value={item.id}>
{item.displayName} - {item.description}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{errors.speaker && (

View File

@@ -8,11 +8,11 @@ import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Settings, Globe2, Type, Play, Palette } from 'lucide-react'
import { Settings, Globe2, Type, Play, Palette, Save } 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 { ttsApi, jobApi, voiceDesignApi } from '@/lib/api'
import { useJobPolling } from '@/hooks/useJobPolling'
import { useHistoryContext } from '@/contexts/HistoryContext'
import { LoadingState } from '@/components/LoadingState'
@@ -49,6 +49,8 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
top_p: 0.7,
repetition_penalty: 1.05
})
const [showSaveDialog, setShowSaveDialog] = useState(false)
const [saveDesignName, setSaveDesignName] = useState('')
const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling()
const { refresh } = useHistoryContext()
@@ -114,6 +116,30 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
}
}
const handleSaveDesign = async () => {
const instruct = watch('instruct')
if (!instruct || instruct.length < 10) {
toast.error('请先填写音色描述')
return
}
if (!saveDesignName.trim()) {
toast.error('请输入设计名称')
return
}
try {
await voiceDesignApi.create({
name: saveDesignName,
instruct: instruct,
backend_type: 'local'
})
toast.success('音色设计已保存')
setShowSaveDialog(false)
setSaveDesignName('')
} catch (error) {
toast.error('保存失败')
}
}
const memoizedAudioUrl = useMemo(() => {
if (!currentJob) return ''
return jobApi.getAudioUrl(currentJob.id, currentJob.audio_url)
@@ -176,6 +202,47 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
)}
</div>
<Dialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>便使</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="design-name"></Label>
<Input
id="design-name"
placeholder="例如:磁性男声"
value={saveDesignName}
onChange={(e) => setSaveDesignName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSaveDesign()
}
}}
/>
</div>
<div className="space-y-2">
<Label></Label>
<p className="text-sm text-muted-foreground">{watch('instruct')}</p>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => {
setShowSaveDialog(false)
setSaveDesignName('')
}}>
</Button>
<Button type="button" onClick={handleSaveDesign}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={advancedOpen} onOpenChange={(open) => {
if (open) {
setTempAdvancedParams({
@@ -355,6 +422,15 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
audioUrl={memoizedAudioUrl}
jobId={currentJob.id}
/>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => setShowSaveDialog(true)}
>
<Save className="mr-2 h-4 w-4" />
</Button>
</div>
)}
</form>

View File

@@ -3,6 +3,7 @@ import type { LoginRequest, LoginResponse, User, PasswordChangeRequest, UserPref
import type { Job, JobCreateResponse, JobListResponse, JobType } from '@/types/job'
import type { Language, Speaker, CustomVoiceForm, VoiceDesignForm, VoiceCloneForm } from '@/types/tts'
import type { UserCreateRequest, UserUpdateRequest, UserListResponse } from '@/types/user'
import type { VoiceDesign, VoiceDesignCreate, VoiceDesignListResponse } from '@/types/voice-design'
import { API_ENDPOINTS, LANGUAGE_NAMES, SPEAKER_DESCRIPTIONS_ZH } from '@/lib/constants'
const apiClient = axios.create({
@@ -385,4 +386,42 @@ export const userApi = {
},
}
export const voiceDesignApi = {
list: async (backend?: string): Promise<VoiceDesignListResponse> => {
const params = backend ? { backend_type: backend } : {}
const response = await apiClient.get<VoiceDesignListResponse>(
API_ENDPOINTS.VOICE_DESIGNS.LIST,
{ params }
)
return response.data
},
get: async (id: number): Promise<VoiceDesign> => {
const response = await apiClient.get<VoiceDesign>(
API_ENDPOINTS.VOICE_DESIGNS.GET(id)
)
return response.data
},
create: async (data: VoiceDesignCreate): Promise<VoiceDesign> => {
const response = await apiClient.post<VoiceDesign>(
API_ENDPOINTS.VOICE_DESIGNS.CREATE,
data
)
return response.data
},
update: async (id: number, name: string): Promise<VoiceDesign> => {
const response = await apiClient.patch<VoiceDesign>(
API_ENDPOINTS.VOICE_DESIGNS.UPDATE(id),
{ name }
)
return response.data
},
delete: async (id: number): Promise<void> => {
await apiClient.delete(API_ENDPOINTS.VOICE_DESIGNS.DELETE(id))
},
}
export default apiClient

View File

@@ -27,6 +27,13 @@ export const API_ENDPOINTS = {
UPDATE: (id: number) => `/users/${id}`,
DELETE: (id: number) => `/users/${id}`,
},
VOICE_DESIGNS: {
LIST: '/voice-designs',
CREATE: '/voice-designs',
GET: (id: number) => `/voice-designs/${id}`,
UPDATE: (id: number) => `/voice-designs/${id}`,
DELETE: (id: number) => `/voice-designs/${id}`,
},
} as const
export const LANGUAGE_NAMES: Record<string, string> = {

View File

@@ -25,6 +25,7 @@ export interface VoiceDesignForm {
text: string
language: string
instruct: string
saved_design_id?: number
max_new_tokens?: number
temperature?: number
top_k?: number
@@ -47,3 +48,15 @@ export interface VoiceCloneForm {
repetition_penalty?: number
backend?: string
}
export type SpeakerSource = 'builtin' | 'saved-design'
export interface UnifiedSpeakerItem {
id: string
displayName: string
description: string
source: SpeakerSource
designId?: number
instruct?: string
backendType?: 'local' | 'aliyun'
}

View File

@@ -0,0 +1,27 @@
export interface VoiceDesign {
id: number
user_id: number
name: string
backend_type: 'local' | 'aliyun'
instruct: string
aliyun_voice_id?: string
meta_data?: Record<string, any>
preview_text?: string
created_at: string
last_used: string
use_count: number
}
export interface VoiceDesignCreate {
name: string
instruct: string
backend_type: 'local' | 'aliyun'
aliyun_voice_id?: string
meta_data?: Record<string, any>
preview_text?: string
}
export interface VoiceDesignListResponse {
designs: VoiceDesign[]
total: number
}