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,
|
user_id: int,
|
||||||
request_data: dict,
|
request_data: dict,
|
||||||
backend_type: str,
|
backend_type: str,
|
||||||
db_url: str
|
db_url: str,
|
||||||
|
saved_voice_id: Optional[str] = None
|
||||||
):
|
):
|
||||||
from core.database import SessionLocal
|
from core.database import SessionLocal
|
||||||
from core.tts_service import TTSServiceFactory
|
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)
|
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")
|
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||||
filename = f"{user_id}_{job_id}_{timestamp}.wav"
|
filename = f"{user_id}_{job_id}_{timestamp}.wav"
|
||||||
@@ -374,7 +378,7 @@ async def create_voice_design_job(
|
|||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
from core.security import decrypt_api_key
|
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)
|
user_prefs = get_user_preferences(db, current_user.id)
|
||||||
preferred_backend = user_prefs.get("default_backend", "aliyun")
|
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
|
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:
|
if backend_type == "local" and not can_use_local:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
@@ -399,8 +421,9 @@ async def create_voice_design_job(
|
|||||||
validate_text_length(req_data.text)
|
validate_text_length(req_data.text)
|
||||||
language = validate_language(req_data.language)
|
language = validate_language(req_data.language)
|
||||||
|
|
||||||
if not req_data.instruct or not req_data.instruct.strip():
|
if not req_data.saved_design_id:
|
||||||
raise ValueError("Instruct parameter is required for voice design")
|
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({
|
params = validate_generation_params({
|
||||||
'max_new_tokens': req_data.max_new_tokens,
|
'max_new_tokens': req_data.max_new_tokens,
|
||||||
@@ -443,7 +466,8 @@ async def create_voice_design_job(
|
|||||||
current_user.id,
|
current_user.id,
|
||||||
request_data,
|
request_data,
|
||||||
backend_type,
|
backend_type,
|
||||||
str(settings.DATABASE_URL)
|
str(settings.DATABASE_URL),
|
||||||
|
saved_voice_id
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
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']
|
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
|
from core.config import settings
|
||||||
|
|
||||||
voice_id = await self._create_voice_design(
|
if saved_voice_id:
|
||||||
instruct=params['instruct'],
|
voice_id = saved_voice_id
|
||||||
preview_text=params['text']
|
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
|
model = settings.ALIYUN_MODEL_VD
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from typing import Optional, List, Dict, Any
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy.orm import Session
|
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]:
|
def get_user_by_username(db: Session, username: str) -> Optional[User]:
|
||||||
return db.query(User).filter(User.username == username).first()
|
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:
|
def can_user_use_local_model(user: User) -> bool:
|
||||||
return user.is_superuser or user.can_use_local_model
|
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")
|
jobs = relationship("Job", back_populates="user", cascade="all, delete-orphan")
|
||||||
voice_caches = relationship("VoiceCache", 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):
|
class Job(Base):
|
||||||
__tablename__ = "jobs"
|
__tablename__ = "jobs"
|
||||||
@@ -77,3 +78,26 @@ class SystemSettings(Base):
|
|||||||
key = Column(String(100), unique=True, nullable=False, index=True)
|
key = Column(String(100), unique=True, nullable=False, index=True)
|
||||||
value = Column(JSON, nullable=False)
|
value = Column(JSON, nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, 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.database import init_db
|
||||||
from core.model_manager import ModelManager
|
from core.model_manager import ModelManager
|
||||||
from core.cleanup import run_scheduled_cleanup
|
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
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -134,6 +134,7 @@ app.include_router(tts.router)
|
|||||||
app.include_router(cache.router)
|
app.include_router(cache.router)
|
||||||
app.include_router(metrics.router)
|
app.include_router(metrics.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
|
app.include_router(voice_designs.router)
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ class CustomVoiceRequest(BaseModel):
|
|||||||
class VoiceDesignRequest(BaseModel):
|
class VoiceDesignRequest(BaseModel):
|
||||||
text: str = Field(..., min_length=1, max_length=1000)
|
text: str = Field(..., min_length=1, max_length=1000)
|
||||||
language: str = Field(default="Auto")
|
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)
|
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)
|
temperature: Optional[float] = Field(default=0.9, ge=0.1, le=2.0)
|
||||||
top_k: Optional[int] = Field(default=50, ge=1, le=100)
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/components/ui/dialog'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Globe2, User, Type, Sparkles, Play, Settings } from 'lucide-react'
|
import { Globe2, User, Type, Sparkles, Play, Settings } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { IconLabel } from '@/components/IconLabel'
|
import { IconLabel } from '@/components/IconLabel'
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
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 { useJobPolling } from '@/hooks/useJobPolling'
|
||||||
import { useHistoryContext } from '@/contexts/HistoryContext'
|
import { useHistoryContext } from '@/contexts/HistoryContext'
|
||||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext'
|
import { useUserPreferences } from '@/contexts/UserPreferencesContext'
|
||||||
@@ -20,7 +20,7 @@ import { LoadingState } from '@/components/LoadingState'
|
|||||||
import { AudioPlayer } from '@/components/AudioPlayer'
|
import { AudioPlayer } from '@/components/AudioPlayer'
|
||||||
import { PresetSelector } from '@/components/PresetSelector'
|
import { PresetSelector } from '@/components/PresetSelector'
|
||||||
import { PRESET_INSTRUCTS, ADVANCED_PARAMS_INFO } from '@/lib/constants'
|
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({
|
const formSchema = z.object({
|
||||||
text: z.string().min(1, '请输入要合成的文本').max(5000, '文本长度不能超过 5000 字符'),
|
text: z.string().min(1, '请输入要合成的文本').max(5000, '文本长度不能超过 5000 字符'),
|
||||||
@@ -42,7 +42,8 @@ export interface CustomVoiceFormHandle {
|
|||||||
|
|
||||||
const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
|
const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
|
||||||
const [languages, setLanguages] = useState<Language[]>([])
|
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 [isLoading, setIsLoading] = useState(false)
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||||
const [tempAdvancedParams, setTempAdvancedParams] = useState({
|
const [tempAdvancedParams, setTempAdvancedParams] = useState({
|
||||||
@@ -83,6 +84,16 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
|
|||||||
setValue('text', params.text || '')
|
setValue('text', params.text || '')
|
||||||
setValue('language', params.language || 'Auto')
|
setValue('language', params.language || 'Auto')
|
||||||
setValue('speaker', params.speaker || '')
|
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('instruct', params.instruct || '')
|
||||||
setValue('max_new_tokens', params.max_new_tokens || 2048)
|
setValue('max_new_tokens', params.max_new_tokens || 2048)
|
||||||
setValue('temperature', params.temperature || 0.3)
|
setValue('temperature', params.temperature || 0.3)
|
||||||
@@ -96,12 +107,31 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const backend = preferences?.default_backend || 'local'
|
const backend = preferences?.default_backend || 'local'
|
||||||
const [langs, spks] = await Promise.all([
|
const [langs, builtinSpeakers, savedDesigns] = await Promise.all([
|
||||||
ttsApi.getLanguages(),
|
ttsApi.getLanguages(),
|
||||||
ttsApi.getSpeakers(backend),
|
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)
|
setLanguages(langs)
|
||||||
setSpeakers(spks)
|
setUnifiedSpeakers([...designItems, ...builtinItems])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('加载数据失败')
|
toast.error('加载数据失败')
|
||||||
}
|
}
|
||||||
@@ -113,7 +143,25 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
|
|||||||
const onSubmit = async (data: FormData) => {
|
const onSubmit = async (data: FormData) => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
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('任务已创建')
|
toast.success('任务已创建')
|
||||||
startPolling(result.job_id)
|
startPolling(result.job_id)
|
||||||
try {
|
try {
|
||||||
@@ -158,18 +206,54 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
|
|||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<IconLabel icon={User} tooltip="发音人" required />
|
<IconLabel icon={User} tooltip="发音人" required />
|
||||||
<Select
|
<Select
|
||||||
value={watch('speaker')}
|
value={selectedSpeakerId}
|
||||||
onValueChange={(value: string) => setValue('speaker', value)}
|
onValueChange={(value: string) => {
|
||||||
|
setSelectedSpeakerId(value)
|
||||||
|
const item = unifiedSpeakers.find(s => s.id === value)
|
||||||
|
if (item?.source === 'builtin') {
|
||||||
|
setValue('speaker', item.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<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>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{speakers.map((speaker) => (
|
{unifiedSpeakers.filter(s => s.source === 'saved-design').length > 0 && (
|
||||||
<SelectItem key={speaker.name} value={speaker.name}>
|
<SelectGroup>
|
||||||
{speaker.name} - {speaker.description}
|
<SelectLabel className="text-xs text-muted-foreground">我的音色设计</SelectLabel>
|
||||||
</SelectItem>
|
{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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{errors.speaker && (
|
{errors.speaker && (
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import { Textarea } from '@/components/ui/textarea'
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/components/ui/dialog'
|
||||||
import { Label } from '@/components/ui/label'
|
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 { toast } from 'sonner'
|
||||||
import { IconLabel } from '@/components/IconLabel'
|
import { IconLabel } from '@/components/IconLabel'
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
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 { useJobPolling } from '@/hooks/useJobPolling'
|
||||||
import { useHistoryContext } from '@/contexts/HistoryContext'
|
import { useHistoryContext } from '@/contexts/HistoryContext'
|
||||||
import { LoadingState } from '@/components/LoadingState'
|
import { LoadingState } from '@/components/LoadingState'
|
||||||
@@ -49,6 +49,8 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
|
|||||||
top_p: 0.7,
|
top_p: 0.7,
|
||||||
repetition_penalty: 1.05
|
repetition_penalty: 1.05
|
||||||
})
|
})
|
||||||
|
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||||
|
const [saveDesignName, setSaveDesignName] = useState('')
|
||||||
|
|
||||||
const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling()
|
const { currentJob, isPolling, isCompleted, startPolling, elapsedTime } = useJobPolling()
|
||||||
const { refresh } = useHistoryContext()
|
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(() => {
|
const memoizedAudioUrl = useMemo(() => {
|
||||||
if (!currentJob) return ''
|
if (!currentJob) return ''
|
||||||
return jobApi.getAudioUrl(currentJob.id, currentJob.audio_url)
|
return jobApi.getAudioUrl(currentJob.id, currentJob.audio_url)
|
||||||
@@ -176,6 +202,47 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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) => {
|
<Dialog open={advancedOpen} onOpenChange={(open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setTempAdvancedParams({
|
setTempAdvancedParams({
|
||||||
@@ -355,6 +422,15 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
|
|||||||
audioUrl={memoizedAudioUrl}
|
audioUrl={memoizedAudioUrl}
|
||||||
jobId={currentJob.id}
|
jobId={currentJob.id}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setShowSaveDialog(true)}
|
||||||
|
>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
保存音色设计
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { LoginRequest, LoginResponse, User, PasswordChangeRequest, UserPref
|
|||||||
import type { Job, JobCreateResponse, JobListResponse, JobType } from '@/types/job'
|
import type { Job, JobCreateResponse, JobListResponse, JobType } from '@/types/job'
|
||||||
import type { Language, Speaker, CustomVoiceForm, VoiceDesignForm, VoiceCloneForm } from '@/types/tts'
|
import type { Language, Speaker, CustomVoiceForm, VoiceDesignForm, VoiceCloneForm } from '@/types/tts'
|
||||||
import type { UserCreateRequest, UserUpdateRequest, UserListResponse } from '@/types/user'
|
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'
|
import { API_ENDPOINTS, LANGUAGE_NAMES, SPEAKER_DESCRIPTIONS_ZH } from '@/lib/constants'
|
||||||
|
|
||||||
const apiClient = axios.create({
|
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
|
export default apiClient
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ export const API_ENDPOINTS = {
|
|||||||
UPDATE: (id: number) => `/users/${id}`,
|
UPDATE: (id: number) => `/users/${id}`,
|
||||||
DELETE: (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
|
} as const
|
||||||
|
|
||||||
export const LANGUAGE_NAMES: Record<string, string> = {
|
export const LANGUAGE_NAMES: Record<string, string> = {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface VoiceDesignForm {
|
|||||||
text: string
|
text: string
|
||||||
language: string
|
language: string
|
||||||
instruct: string
|
instruct: string
|
||||||
|
saved_design_id?: number
|
||||||
max_new_tokens?: number
|
max_new_tokens?: number
|
||||||
temperature?: number
|
temperature?: number
|
||||||
top_k?: number
|
top_k?: number
|
||||||
@@ -47,3 +48,15 @@ export interface VoiceCloneForm {
|
|||||||
repetition_penalty?: number
|
repetition_penalty?: number
|
||||||
backend?: string
|
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