d4a13ece17
ruff --select F401 --fix 自动修复: - deps.py: user_crud - caption.py: ApiResponse, VolcengineCaptionService - points.py: UTC - tasks.py: json - voice.py: asyncio - main.py: init_db - broll_category.py: Text, ARRAY
288 lines
8.9 KiB
Python
288 lines
8.9 KiB
Python
"""
|
||
语音合成与克隆 API 路由
|
||
=======================
|
||
|
||
提供 TTS 语音合成、声音复刻等功能。
|
||
基于 Vidu API。
|
||
"""
|
||
|
||
import logging
|
||
import re
|
||
import time
|
||
import uuid
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.api.deps import get_current_user
|
||
from app.core.exceptions import PlatformError
|
||
from app.db.session import get_db
|
||
from app.models.user import User
|
||
from app.schemas.common import ApiResponse, success_response
|
||
from app.services import point_service as ps
|
||
from app.services.vidu_service import (
|
||
DEFAULT_VOICE_ID,
|
||
ViduService,
|
||
get_preset_voices,
|
||
get_vidu_service,
|
||
)
|
||
from app.utils.audio_utils import get_audio_duration
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter(prefix="/voice", tags=["Voice"])
|
||
|
||
|
||
# ========== 请求/响应模型 ==========
|
||
|
||
|
||
class TTSSynthesizeRequest(BaseModel):
|
||
"""TTS 合成请求"""
|
||
|
||
text: str = Field(..., min_length=1, max_length=10000, description="待合成文本(≤10000字符)")
|
||
voice_id: str | None = Field(None, description="音色 ID(默认:甜美女性)")
|
||
speed: float = Field(default=1.0, ge=0.5, le=2.0, description="语速 0.5-2.0")
|
||
voice_language: str = Field(default="zh", description="音色语种 (zh/en)")
|
||
volume: int = Field(default=0, ge=0, le=10, description="音量 0-10(0=正常)")
|
||
pitch: int = Field(default=0, ge=-12, le=12, description="音调 -12 到 12")
|
||
|
||
|
||
class VoiceCloneSubmitRequest(BaseModel):
|
||
"""声音复刻提交请求"""
|
||
|
||
source_audio_url: str | None = Field(None, description="源音频 URL(5-30秒,mp3/wav,需公开可访问)")
|
||
source_video_url: str | None = Field(None, description="源视频 URL(可选)")
|
||
video_id: str | None = Field(None, description="历史作品ID(可选)")
|
||
voice_name: str | None = Field(None, description="自定义音色名称(≤20字符)")
|
||
|
||
|
||
class VoiceCloneTaskResponse(BaseModel):
|
||
"""克隆任务响应"""
|
||
|
||
task_id: str
|
||
status: str
|
||
voice_id: str | None = None
|
||
trial_url: str | None = None
|
||
error_message: str | None = None
|
||
|
||
|
||
class VoiceInfo(BaseModel):
|
||
"""音色信息"""
|
||
|
||
voice_id: str
|
||
name: str
|
||
description: str = ""
|
||
language: str = "zh"
|
||
recommended: bool = False
|
||
previewUrl: str | None = None
|
||
|
||
|
||
# ========== API 路由 ==========
|
||
|
||
|
||
@router.get("/voices", response_model=ApiResponse[list[VoiceInfo]])
|
||
async def list_voices(
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""
|
||
获取可用音色列表
|
||
|
||
返回预设的音色选项,用户可选择喜欢的音色进行 TTS 合成。
|
||
"""
|
||
voices = get_preset_voices()
|
||
return success_response(
|
||
data=[VoiceInfo(**v) for v in voices],
|
||
message="获取音色列表成功",
|
||
)
|
||
|
||
|
||
@router.post("/synthesize", response_model=ApiResponse[dict])
|
||
async def synthesize_speech(
|
||
request: TTSSynthesizeRequest,
|
||
service: ViduService = Depends(get_vidu_service),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""
|
||
同步 TTS 合成
|
||
|
||
将文本转换为语音,返回音频 URL。
|
||
适用于短文本(≤10000字符)。
|
||
"""
|
||
# 宽松预检:余额为负或零时阻止,避免浪费第三方资源
|
||
balance_info = await ps.get_user_balance(db, current_user.id)
|
||
if balance_info["balance"] <= 0:
|
||
raise HTTPException(status_code=402, detail="余额不足,请先充值")
|
||
|
||
try:
|
||
audio_url = await service.synthesize(
|
||
text=request.text,
|
||
voice_id=request.voice_id,
|
||
speed=request.speed,
|
||
volume=request.volume,
|
||
pitch=request.pitch,
|
||
)
|
||
|
||
# 探测音频时长并扣费
|
||
try:
|
||
seconds = await get_audio_duration(audio_url)
|
||
points = ps._calculate_cost("tts", {"seconds": seconds})
|
||
await ps.consume(
|
||
db,
|
||
user_id=current_user.id,
|
||
points=points,
|
||
source_type="tts",
|
||
source_id=f"tts_{current_user.id}_{int(time.time() * 1000)}",
|
||
description="【配音合成】",
|
||
duration=seconds,
|
||
allow_negative=True,
|
||
)
|
||
await db.commit()
|
||
except ValueError as e:
|
||
if "积分不足" in str(e):
|
||
raise HTTPException(status_code=402, detail=str(e))
|
||
logger.error(f"[Voice] TTS 扣费失败: {e}")
|
||
except Exception as e:
|
||
logger.error(f"[Voice] TTS 扣费失败: {e}")
|
||
|
||
return success_response(
|
||
data={
|
||
"audio_url": audio_url,
|
||
"format": "mp3",
|
||
"text": request.text,
|
||
"voice_id": request.voice_id or DEFAULT_VOICE_ID,
|
||
"consumed_points": points,
|
||
"duration": seconds,
|
||
},
|
||
message="合成成功",
|
||
)
|
||
|
||
except HTTPException:
|
||
raise
|
||
except PlatformError:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"[Voice] TTS 合成失败: {e}")
|
||
raise HTTPException(status_code=500, detail="语音合成失败,请稍后重试")
|
||
|
||
|
||
|
||
def _normalize_voice_id(name: str | None) -> str:
|
||
"""
|
||
将用户输入的名称规范化为 Vidu 合法的 voice_id。
|
||
|
||
Vidu 要求:8~256 字符,首字符必须是字母。
|
||
"""
|
||
if not name:
|
||
return f"vidu_{uuid.uuid4().hex[:8]}"
|
||
|
||
# 只保留字母、数字、下划线
|
||
cleaned = re.sub(r"[^a-zA-Z0-9_]", "", name)
|
||
|
||
# 确保首字符是字母
|
||
if cleaned and not cleaned[0].isalpha():
|
||
cleaned = "v" + cleaned
|
||
elif not cleaned:
|
||
cleaned = "voice"
|
||
|
||
# 长度不足 8,补足随机字符
|
||
if len(cleaned) < 8:
|
||
cleaned = cleaned + uuid.uuid4().hex[: (8 - len(cleaned))]
|
||
|
||
# 长度超过 256,截断
|
||
if len(cleaned) > 256:
|
||
cleaned = cleaned[:256]
|
||
|
||
return cleaned
|
||
|
||
|
||
@router.post("/clone/submit", response_model=ApiResponse[VoiceCloneTaskResponse])
|
||
async def submit_clone_task(
|
||
request: VoiceCloneSubmitRequest,
|
||
service: ViduService = Depends(get_vidu_service),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""
|
||
提交声音复刻任务(Vidu)
|
||
|
||
Vidu 声音复刻是同步接口,直接返回结果。
|
||
"""
|
||
# 前置积分检查
|
||
required_points = ps._calculate_cost("voice_clone")
|
||
check = await ps.check_balance(db, current_user.id, required_points)
|
||
if not check["sufficient"]:
|
||
raise HTTPException(
|
||
status_code=402,
|
||
detail=f"积分不足,需要 {required_points} 积分,当前余额 {check['balance']}",
|
||
)
|
||
|
||
try:
|
||
voice_id = _normalize_voice_id(request.voice_name)
|
||
result = await service.clone_voice(
|
||
audio_url=request.source_audio_url or "",
|
||
voice_id=voice_id,
|
||
)
|
||
|
||
# 扣费
|
||
try:
|
||
points = ps._calculate_cost("voice_clone")
|
||
await ps.consume(
|
||
db,
|
||
user_id=current_user.id,
|
||
points=points,
|
||
source_type="voice_clone",
|
||
source_id=f"voice_clone_{current_user.id}_{int(time.time() * 1000)}_{result.get('voice_id', 'unknown')}",
|
||
description="【声音复刻】",
|
||
)
|
||
await db.commit()
|
||
except ValueError as e:
|
||
if "积分不足" in str(e):
|
||
raise HTTPException(status_code=402, detail=str(e))
|
||
logger.error(f"[Voice] 克隆扣费失败: {e}")
|
||
except Exception as e:
|
||
logger.error(f"[Voice] 克隆扣费失败: {e}")
|
||
|
||
# Vidu 同步返回,状态直接为 succeeded
|
||
return success_response(
|
||
data=VoiceCloneTaskResponse(
|
||
task_id=result.get("task_id", ""),
|
||
status="succeeded",
|
||
voice_id=result.get("voice_id"),
|
||
trial_url=result.get("demo_audio"),
|
||
),
|
||
message="克隆成功",
|
||
)
|
||
|
||
except HTTPException:
|
||
raise
|
||
except PlatformError:
|
||
raise
|
||
except ValueError as e:
|
||
logger.error(f"[Voice] 提交克隆任务失败: {e}")
|
||
raise HTTPException(status_code=500, detail=f"参数错误: {e}")
|
||
except Exception as e:
|
||
logger.error(f"[Voice] 提交克隆任务失败: {e}")
|
||
raise HTTPException(status_code=500, detail=f"任务提交失败: {e}")
|
||
|
||
|
||
@router.get("/clone/query/{task_id}", response_model=ApiResponse[VoiceCloneTaskResponse])
|
||
async def query_clone_task(
|
||
task_id: str,
|
||
blocking: bool = False,
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""
|
||
查询声音复刻任务状态(Vidu)
|
||
|
||
Vidu 声音复刻是同步接口,此端点仅做兼容,直接返回成功状态。
|
||
"""
|
||
return success_response(
|
||
data=VoiceCloneTaskResponse(
|
||
task_id=task_id,
|
||
status="succeeded",
|
||
),
|
||
message="克隆已完成",
|
||
)
|
||
|
||
|