diff --git a/qwen3-tts-backend/api/tts.py b/qwen3-tts-backend/api/tts.py index ab0f264..6313ac1 100644 --- a/qwen3-tts-backend/api/tts.py +++ b/qwen3-tts-backend/api/tts.py @@ -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 { diff --git a/qwen3-tts-backend/api/voice_designs.py b/qwen3-tts-backend/api/voice_designs.py new file mode 100644 index 0000000..a4c4a08 --- /dev/null +++ b/qwen3-tts-backend/api/voice_designs.py @@ -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") diff --git a/qwen3-tts-backend/core/tts_service.py b/qwen3-tts-backend/core/tts_service.py index c30a347..be2db9f 100644 --- a/qwen3-tts-backend/core/tts_service.py +++ b/qwen3-tts-backend/core/tts_service.py @@ -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 diff --git a/qwen3-tts-backend/db/crud.py b/qwen3-tts-backend/db/crud.py index e5bc284..182e55c 100644 --- a/qwen3-tts-backend/db/crud.py +++ b/qwen3-tts-backend/db/crud.py @@ -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 diff --git a/qwen3-tts-backend/db/models.py b/qwen3-tts-backend/db/models.py index 35c6417..d5a79c1 100644 --- a/qwen3-tts-backend/db/models.py +++ b/qwen3-tts-backend/db/models.py @@ -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'), + ) diff --git a/qwen3-tts-backend/main.py b/qwen3-tts-backend/main.py index 1d9130a..0bd2389 100644 --- a/qwen3-tts-backend/main.py +++ b/qwen3-tts-backend/main.py @@ -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(): diff --git a/qwen3-tts-backend/schemas/tts.py b/qwen3-tts-backend/schemas/tts.py index f1e12ec..6303691 100644 --- a/qwen3-tts-backend/schemas/tts.py +++ b/qwen3-tts-backend/schemas/tts.py @@ -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) diff --git a/qwen3-tts-backend/schemas/voice_design.py b/qwen3-tts-backend/schemas/voice_design.py new file mode 100644 index 0000000..2e3f599 --- /dev/null +++ b/qwen3-tts-backend/schemas/voice_design.py @@ -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 diff --git a/qwen3-tts-frontend/src/components/tts/CustomVoiceForm.tsx b/qwen3-tts-frontend/src/components/tts/CustomVoiceForm.tsx index 9c95bea..f155c23 100644 --- a/qwen3-tts-frontend/src/components/tts/CustomVoiceForm.tsx +++ b/qwen3-tts-frontend/src/components/tts/CustomVoiceForm.tsx @@ -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((_props, ref) => { const [languages, setLanguages] = useState([]) - const [speakers, setSpeakers] = useState([]) + const [unifiedSpeakers, setUnifiedSpeakers] = useState([]) + const [selectedSpeakerId, setSelectedSpeakerId] = useState('') const [isLoading, setIsLoading] = useState(false) const [advancedOpen, setAdvancedOpen] = useState(false) const [tempAdvancedParams, setTempAdvancedParams] = useState({ @@ -83,6 +84,16 @@ const CustomVoiceForm = forwardRef((_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((_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((_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((_props, ref) => {
{errors.speaker && ( diff --git a/qwen3-tts-frontend/src/components/tts/VoiceDesignForm.tsx b/qwen3-tts-frontend/src/components/tts/VoiceDesignForm.tsx index d9f6a9b..8236a9e 100644 --- a/qwen3-tts-frontend/src/components/tts/VoiceDesignForm.tsx +++ b/qwen3-tts-frontend/src/components/tts/VoiceDesignForm.tsx @@ -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((_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((_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((_props, ref) => { )}
+ + + + 保存音色设计 + 为当前音色设计命名并保存,以便后续快速使用 + +
+
+ + setSaveDesignName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleSaveDesign() + } + }} + /> +
+
+ +

{watch('instruct')}

+
+
+ + + + +
+
+ { if (open) { setTempAdvancedParams({ @@ -355,6 +422,15 @@ const VoiceDesignForm = forwardRef((_props, ref) => { audioUrl={memoizedAudioUrl} jobId={currentJob.id} /> + )} diff --git a/qwen3-tts-frontend/src/lib/api.ts b/qwen3-tts-frontend/src/lib/api.ts index b8b3a19..c05752b 100644 --- a/qwen3-tts-frontend/src/lib/api.ts +++ b/qwen3-tts-frontend/src/lib/api.ts @@ -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 => { + const params = backend ? { backend_type: backend } : {} + const response = await apiClient.get( + API_ENDPOINTS.VOICE_DESIGNS.LIST, + { params } + ) + return response.data + }, + + get: async (id: number): Promise => { + const response = await apiClient.get( + API_ENDPOINTS.VOICE_DESIGNS.GET(id) + ) + return response.data + }, + + create: async (data: VoiceDesignCreate): Promise => { + const response = await apiClient.post( + API_ENDPOINTS.VOICE_DESIGNS.CREATE, + data + ) + return response.data + }, + + update: async (id: number, name: string): Promise => { + const response = await apiClient.patch( + API_ENDPOINTS.VOICE_DESIGNS.UPDATE(id), + { name } + ) + return response.data + }, + + delete: async (id: number): Promise => { + await apiClient.delete(API_ENDPOINTS.VOICE_DESIGNS.DELETE(id)) + }, +} + export default apiClient diff --git a/qwen3-tts-frontend/src/lib/constants.ts b/qwen3-tts-frontend/src/lib/constants.ts index c5a5ea3..2679419 100644 --- a/qwen3-tts-frontend/src/lib/constants.ts +++ b/qwen3-tts-frontend/src/lib/constants.ts @@ -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 = { diff --git a/qwen3-tts-frontend/src/types/tts.ts b/qwen3-tts-frontend/src/types/tts.ts index cf8ae60..b62f396 100644 --- a/qwen3-tts-frontend/src/types/tts.ts +++ b/qwen3-tts-frontend/src/types/tts.ts @@ -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' +} diff --git a/qwen3-tts-frontend/src/types/voice-design.ts b/qwen3-tts-frontend/src/types/voice-design.ts new file mode 100644 index 0000000..9e7dda2 --- /dev/null +++ b/qwen3-tts-frontend/src/types/voice-design.ts @@ -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 + 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 + preview_text?: string +} + +export interface VoiceDesignListResponse { + designs: VoiceDesign[] + total: number +}