8b69a6e18b
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 12s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m4s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 49s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 2m34s
CI/CD Pipeline / Validate - Style (push) Successful in 4m26s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m32s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m42s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m19s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m29s
CI/CD Pipeline / Unit Tests (push) Successful in 9m55s
CI/CD Pipeline / Validate - Security (push) Successful in 11m28s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Failing after 15m20s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 12h25m7s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 12h34m51s
CI/CD Pipeline / PR Build API Image (push) Failing after 12h36m7s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 12h35m21s
CI/CD Pipeline / PR Build Web Image (push) Failing after 12h35m21s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 12h34m6s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 12h34m6s
CI/CD Pipeline / CI Gate (push) Failing after 12h20m24s
CI/CD Pipeline / Build Production Web Image (push) Failing after 12h24m22s
CI/CD Pipeline / Canary Release to Production (push) Failing after 12h24m20s
CI/CD Pipeline / Deploy Production (push) Failing after 12h24m20s
CI/CD Pipeline / Build Production API Image (push) Failing after 12h24m22s
CI/CD Pipeline / Frontend Lint (push) Failing after 12h35m22s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 12h35m53s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
459 lines
18 KiB
Python
Executable File
459 lines
18 KiB
Python
Executable File
"""音色克隆 API 路由。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import math
|
|
from typing import Optional
|
|
|
|
from app.auth import AuthenticatedUser, get_current_user
|
|
from app.config import settings
|
|
from app.core.celery_app import celery_app
|
|
from app.core.storage import get_storage_service
|
|
from app.dependencies import (
|
|
get_asset_repository,
|
|
get_cosyvoice_service,
|
|
get_db_session,
|
|
get_project_repository,
|
|
get_voice_clone_profile_repository,
|
|
)
|
|
from app.schemas.voice_clone import (
|
|
CreateVoiceCloneRequest,
|
|
ListVoiceCloneResponse,
|
|
VoiceClonePreviewResponse,
|
|
VoiceCloneProfileResponse,
|
|
VoiceCloneStatusResponse,
|
|
)
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import (
|
|
SQLAlchemyVoiceCloneProfileRepository,
|
|
)
|
|
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
|
from packages.application.voice_clone.use_cases import (
|
|
DeleteVoiceCloneUseCase,
|
|
GetVoiceCloneStatusUseCase,
|
|
GetVoiceCloneUseCase,
|
|
ListVoiceClonesUseCase,
|
|
VoiceCloneNotFoundError,
|
|
VoiceCloneNotRetryableError,
|
|
)
|
|
from packages.application.voice_clone.workflow import (
|
|
VoiceCloneWorkflowService,
|
|
)
|
|
from packages.domain.points_rules import calculate_points_cost
|
|
from packages.domain.points_service import PointsService
|
|
|
|
# remove duplicate
|
|
_DUMMY_DELETED = ()
|
|
from packages.ports.asset_repository import AssetRepository
|
|
from packages.ports.project_repository import ProjectRepository
|
|
from packages.shared.storage import SharedStorageService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
# 克隆音色试听缓存(减少重复TTS调用)
|
|
# key: clone_id, value: (audio_url, duration, file_size, text, timestamp)
|
|
_clone_preview_cache: dict[str, tuple[str, float, int, str, float]] = {}
|
|
CLONE_PREVIEW_CACHE_TTL = 7 * 24 * 3600 # 7天TTL
|
|
# 默认试听文本
|
|
CLONE_PREVIEW_TEMPLATE = "你好,这是我的克隆音色,很高兴能为你配音。"
|
|
|
|
|
|
def _to_response(profile) -> VoiceCloneProfileResponse:
|
|
# source_audio_url 是用户传入的原始 URL(可能是外部地址),不做预签名转换
|
|
return VoiceCloneProfileResponse(
|
|
id=profile.id,
|
|
user_id=profile.user_id,
|
|
name=profile.name,
|
|
description=profile.description,
|
|
source_audio_url=profile.source_audio_url,
|
|
voice_id=profile.voice_id,
|
|
voice_model=profile.voice_model,
|
|
language=profile.language,
|
|
gender=profile.gender,
|
|
status=profile.status,
|
|
error_message=profile.error_message,
|
|
retry_count=profile.retry_count,
|
|
max_retries=profile.max_retries,
|
|
metadata=profile.metadata,
|
|
created_at=profile.created_at,
|
|
updated_at=profile.updated_at,
|
|
)
|
|
|
|
|
|
def _get_workflow_service(
|
|
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
|
|
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
|
|
) -> VoiceCloneWorkflowService:
|
|
return VoiceCloneWorkflowService(repository=repository, cosyvoice_service=cosyvoice_service)
|
|
|
|
|
|
@router.post(
|
|
"",
|
|
response_model=VoiceCloneProfileResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def create_voice_clone(
|
|
request: CreateVoiceCloneRequest,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
workflow: VoiceCloneWorkflowService = Depends(_get_workflow_service),
|
|
asset_repository: AssetRepository = Depends(get_asset_repository),
|
|
project_repository: ProjectRepository = Depends(get_project_repository),
|
|
storage_service: SharedStorageService = Depends(get_storage_service),
|
|
) -> VoiceCloneProfileResponse:
|
|
"""创建音色克隆任务。
|
|
|
|
创建 VoiceCloneProfile → 提交 CosyVoice 克隆任务 → 触发 Celery 异步轮询。
|
|
参考音频两种来源(二选一):
|
|
- source_audio_url:前端直传后的音频 URL(兼容旧流程)
|
|
- asset_id:配音素材库中的音频素材,服务端用其 OSS storage_key 生成
|
|
预签名下载 URL(不依赖前端签名,避免签名过期导致克隆失败)
|
|
如果有参考音频,状态会变为 processing;否则保持 pending。
|
|
"""
|
|
user_id = authenticated_user.user.id
|
|
|
|
source_audio_url = request.source_audio_url
|
|
clone_metadata = dict(request.metadata_ or {})
|
|
|
|
if request.asset_id:
|
|
if source_audio_url:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="asset_id 与 source_audio_url 只能传一个",
|
|
)
|
|
asset = asset_repository.find_by_id(request.asset_id)
|
|
if asset is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="素材不存在",
|
|
)
|
|
# 归属校验:素材挂在项目素材库下,用户必须能访问该项目
|
|
project = project_repository.find_by_id(asset.project_id)
|
|
if project is None or not project.can_access(user_id):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="无权使用该素材",
|
|
)
|
|
# 类型校验:仅支持音频素材
|
|
if asset.file_type != "audio":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="仅支持音频素材进行音色克隆",
|
|
)
|
|
if not asset.storage_key:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="该素材缺少音频文件,无法用于克隆",
|
|
)
|
|
# 用 OSS storage_key 生成服务端预签名 URL(7 天有效,覆盖克隆重试周期)
|
|
source_audio_url = storage_service.get_download_url(asset.storage_key, expires_seconds=7 * 24 * 3600)
|
|
clone_metadata["source_asset_id"] = asset.id
|
|
|
|
profile = workflow.start_clone(
|
|
user_id=user_id,
|
|
name=request.name,
|
|
description=request.description,
|
|
source_audio_url=source_audio_url,
|
|
voice_model=request.voice_model,
|
|
language=request.language,
|
|
gender=request.gender,
|
|
max_retries=request.max_retries,
|
|
metadata=clone_metadata,
|
|
)
|
|
|
|
# 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询
|
|
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
|
|
if profile.status == "processing" and task_id:
|
|
try:
|
|
celery_app.send_task("worker.process_voice_clone", args=[profile.id])
|
|
logger.info(f"Celery task dispatched for voice clone {profile.id}")
|
|
except Exception as e:
|
|
logger.exception("Failed to dispatch Celery task")
|
|
# P2-3: Celery 调度失败时标记 profile 为 failed,避免永久卡在 processing
|
|
try:
|
|
workflow.process_clone_failure(profile.id, f"Celery 任务调度失败: {e}")
|
|
except Exception:
|
|
logger.exception("Failed to mark profile as failed after dispatch error")
|
|
|
|
return _to_response(profile)
|
|
|
|
|
|
@router.get("", response_model=ListVoiceCloneResponse)
|
|
def list_voice_clones(
|
|
status_filter: Optional[str] = Query(None, alias="status"),
|
|
skip: int = Query(0, ge=0),
|
|
limit: int = Query(50, ge=1, le=200),
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
|
|
) -> ListVoiceCloneResponse:
|
|
"""获取用户的音色克隆列表。"""
|
|
user_id = authenticated_user.user.id
|
|
use_case = ListVoiceClonesUseCase(repository)
|
|
items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit)
|
|
return ListVoiceCloneResponse(
|
|
items=[_to_response(p) for p in items],
|
|
total=total,
|
|
)
|
|
|
|
|
|
@router.get("/{clone_id}", response_model=VoiceCloneProfileResponse)
|
|
def get_voice_clone(
|
|
clone_id: str,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
|
|
) -> VoiceCloneProfileResponse:
|
|
"""获取音色克隆详情。"""
|
|
user_id = authenticated_user.user.id
|
|
use_case = GetVoiceCloneUseCase(repository)
|
|
try:
|
|
profile = use_case.execute(clone_id, user_id)
|
|
except VoiceCloneNotFoundError as _e:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
|
|
return _to_response(profile)
|
|
|
|
|
|
@router.get("/{clone_id}/status", response_model=VoiceCloneStatusResponse)
|
|
def get_voice_clone_status(
|
|
clone_id: str,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
|
|
) -> VoiceCloneStatusResponse:
|
|
"""查询音色克隆状态(用于前端轮询)。"""
|
|
user_id = authenticated_user.user.id
|
|
use_case = GetVoiceCloneStatusUseCase(repository)
|
|
try:
|
|
profile = use_case.execute(clone_id, user_id)
|
|
except VoiceCloneNotFoundError as _e:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
|
|
return VoiceCloneStatusResponse(
|
|
id=profile.id,
|
|
status=profile.status,
|
|
error_message=profile.error_message,
|
|
voice_id=profile.voice_id,
|
|
retry_count=profile.retry_count,
|
|
)
|
|
|
|
|
|
@router.delete(
|
|
"/{clone_id}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
response_model=None,
|
|
response_class=Response,
|
|
)
|
|
def delete_voice_clone(
|
|
clone_id: str,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
|
|
) -> Response:
|
|
"""删除音色克隆档案。"""
|
|
user_id = authenticated_user.user.id
|
|
use_case = DeleteVoiceCloneUseCase(repository)
|
|
deleted = use_case.execute(clone_id, user_id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
|
|
return
|
|
|
|
|
|
@router.post("/{clone_id}/retry", response_model=VoiceCloneProfileResponse)
|
|
def retry_voice_clone(
|
|
clone_id: str,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
workflow: VoiceCloneWorkflowService = Depends(_get_workflow_service),
|
|
) -> VoiceCloneProfileResponse:
|
|
"""重试失败的音色克隆。
|
|
|
|
仅当状态为 failed 时可重试,重试后重新提交 CosyVoice 克隆任务。
|
|
"""
|
|
user_id = authenticated_user.user.id
|
|
try:
|
|
profile = workflow.retry_clone(clone_id, user_id)
|
|
except VoiceCloneNotFoundError as _e:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
|
|
except VoiceCloneNotRetryableError as _e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Voice clone is not retryable (only failed clones can be retried)",
|
|
) from _e
|
|
|
|
# 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询
|
|
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
|
|
if profile.status == "processing" and task_id:
|
|
try:
|
|
celery_app.send_task("worker.process_voice_clone", args=[profile.id])
|
|
logger.info(f"Celery task dispatched for voice clone retry {profile.id}")
|
|
except Exception as e:
|
|
logger.exception("Failed to dispatch Celery task")
|
|
# P2-3: Celery 调度失败时标记 profile 为 failed,避免永久卡在 processing
|
|
try:
|
|
workflow.process_clone_failure(profile.id, f"Celery 任务调度失败: {e}")
|
|
except Exception:
|
|
logger.exception("Failed to mark profile as failed after dispatch error")
|
|
|
|
return _to_response(profile)
|
|
|
|
|
|
_ALLOWED_PREVIEW_EMOTIONS = {
|
|
"",
|
|
# 7 种标准英文枚举(CosyVoice v3 官方值)
|
|
"neutral",
|
|
"happy",
|
|
"sad",
|
|
"angry",
|
|
"surprised",
|
|
"fearful",
|
|
"disgusted",
|
|
# 前端中文 7 标签
|
|
"中立",
|
|
"开心",
|
|
"难过",
|
|
"生气",
|
|
"惊讶",
|
|
"恐惧",
|
|
"厌恶",
|
|
# 旧英文 4 枚举 + 常见中文别名兼容
|
|
"natural",
|
|
"excited",
|
|
"calm",
|
|
"friendly",
|
|
"自然",
|
|
"愉快",
|
|
"高兴",
|
|
"快乐",
|
|
"兴奋",
|
|
"悲伤",
|
|
"愤怒",
|
|
"惊奇",
|
|
"吃惊",
|
|
"害怕",
|
|
"讨厌",
|
|
# 灵应 P1 指定别名
|
|
"中性",
|
|
"伤心",
|
|
"沉稳",
|
|
"亲切",
|
|
}
|
|
|
|
|
|
@router.get("/{clone_id}/preview", response_model=VoiceClonePreviewResponse)
|
|
def get_voice_clone_preview(
|
|
clone_id: str,
|
|
text: str = Query("", description="自定义试听文本,为空则使用默认示例"),
|
|
speed: float = Query(1.0, ge=0.5, le=2.0, description="语速,0.5-2.0,默认 1.0"),
|
|
emotion: str = Query(
|
|
"",
|
|
description="情绪:neutral/happy/sad/angry/surprised/fearful/disgusted,兼容旧值 natural/excited/calm/friendly,空为默认自然",
|
|
),
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
db: Session = Depends(get_db_session),
|
|
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
|
|
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
|
|
) -> VoiceClonePreviewResponse:
|
|
"""获取克隆音色试听音频(实时 TTS 合成)。
|
|
|
|
- 克隆音色必须处于 ready 状态
|
|
- 使用默认试听文本时,结果缓存 7 天(仅默认 text+speed=1.0+emotion=空 组合缓存)
|
|
- 可传入自定义 text/speed/emotion 试听不同效果
|
|
"""
|
|
import time
|
|
|
|
user_id = authenticated_user.user.id
|
|
_points_deducted = 0
|
|
_points_scene = "voice_clone_synth"
|
|
_points_svc = PointsService() if settings.points_enabled else None
|
|
_preview_text_for_points = text.strip() or CLONE_PREVIEW_TEMPLATE
|
|
if _points_svc is not None:
|
|
est_minutes = max(1.0, math.ceil(len(_preview_text_for_points) / 240))
|
|
_points_deducted = calculate_points_cost(
|
|
_points_scene,
|
|
is_member=getattr(authenticated_user.user, "is_member", False),
|
|
duration_minutes=est_minutes,
|
|
member_type=getattr(authenticated_user.user, "member_type", None),
|
|
)
|
|
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
|
|
if not _deduct_res["success"]:
|
|
raise HTTPException(
|
|
status_code=402,
|
|
detail={
|
|
"code": "INSUFFICIENT_POINTS",
|
|
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
|
|
"required": _points_deducted,
|
|
"balance": _deduct_res["balance"],
|
|
},
|
|
)
|
|
|
|
if emotion not in _ALLOWED_PREVIEW_EMOTIONS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"不支持的 emotion 值: {emotion},可选: neutral/happy/sad/angry/surprised/fearful/disgusted 或中文 中立/中性/开心/难过/伤心/生气/愤怒/惊讶/吃惊/恐惧/害怕/厌恶/讨厌 或留空",
|
|
)
|
|
|
|
use_case = GetVoiceCloneUseCase(repository)
|
|
try:
|
|
profile = use_case.execute(clone_id, authenticated_user.user.id)
|
|
except VoiceCloneNotFoundError as _e:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
|
|
|
|
if not profile.is_ready:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Voice clone is not ready (current status: {profile.status})",
|
|
)
|
|
|
|
# 仅默认试听文本 + 默认 speed + 默认 emotion 时使用缓存
|
|
use_cache = (not text.strip()) and abs(speed - 1.0) < 1e-6 and (not emotion)
|
|
|
|
if use_cache and clone_id in _clone_preview_cache:
|
|
audio_url, duration, file_size, cached_text, cached_at = _clone_preview_cache[clone_id]
|
|
if time.time() - cached_at < CLONE_PREVIEW_CACHE_TTL:
|
|
return VoiceClonePreviewResponse(
|
|
clone_id=clone_id,
|
|
voice_id=profile.voice_id,
|
|
audio_url=audio_url,
|
|
text=cached_text,
|
|
duration=duration,
|
|
file_size=file_size,
|
|
)
|
|
|
|
# 合成试听音频
|
|
preview_text = text.strip() or CLONE_PREVIEW_TEMPLATE
|
|
try:
|
|
result = cosyvoice.synthesize_speech(
|
|
text=preview_text,
|
|
voice_id=profile.voice_id,
|
|
format="mp3",
|
|
speed=speed,
|
|
emotion=emotion,
|
|
)
|
|
except (CosyVoiceError, ValueError) as e:
|
|
if _points_deducted > 0 and _points_svc is not None:
|
|
try:
|
|
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db)
|
|
except Exception as refund_err:
|
|
logger.warning(f"克隆音色试听失败退积分异常: clone_id={clone_id}, err={refund_err}")
|
|
if isinstance(e, CosyVoiceError):
|
|
raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
|
|
|
# 缓存(仅默认参数组合)
|
|
if use_cache:
|
|
_clone_preview_cache[clone_id] = (
|
|
result.audio_url,
|
|
result.duration,
|
|
result.file_size,
|
|
preview_text,
|
|
time.time(),
|
|
)
|
|
|
|
return VoiceClonePreviewResponse(
|
|
clone_id=clone_id,
|
|
voice_id=profile.voice_id,
|
|
audio_url=result.audio_url,
|
|
text=preview_text,
|
|
duration=result.duration,
|
|
file_size=result.file_size,
|
|
)
|