feat: Implement voice design management with CRUD operations and integrate into frontend
This commit is contained in:
@@ -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 {
|
||||
|
||||
97
qwen3-tts-backend/api/voice_designs.py
Normal file
97
qwen3-tts-backend/api/voice_designs.py
Normal 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")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
34
qwen3-tts-backend/schemas/voice_design.py
Normal file
34
qwen3-tts-backend/schemas/voice_design.py
Normal 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
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
27
qwen3-tts-frontend/src/types/voice-design.ts
Normal file
27
qwen3-tts-frontend/src/types/voice-design.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user