Compare commits

..

4 Commits

Author SHA1 Message Date
用户CI Test 7fa0906352 fix: 修复 flake8 F541 f-string 无占位符
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 20s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 19s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m37s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m25s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
移除 generation.py:452 处多余的 f 前缀,消除 CI lint 报错。
2026-07-10 22:53:49 +08:00
用户CI Test 57923eeae6 fix: P2 API异常信息泄露 + P3-1 逐素材下载日志
P2: generation_tasks.py:260 异常detail改为通用消息,避免泄露内部错误
P3-1: _download_library_assets 添加逐素材日志(asset_id/耗时/大小/成功失败)
      通过 gen_task.append_log 持久化到任务日志,前端可查
2026-07-10 22:53:46 +08:00
用户CI Test d32c86e71b fix: 更新 schema-metadata-snapshot 支持 logs 字段 2026-07-10 22:53:43 +08:00
用户CI Test 849a068a46 feat: 一键生成链路日志最小集
- Alembic 037: generation_tasks 表添加 logs TEXT 字段
- Domain 层: GenerationTask 添加 append_log/get_logs 方法(上限200条)
- Repository 层: _to_domain/create/update 映射 logs 字段
- API Schema: GenerationTaskResponse 添加 logs 字段 + JSON validator
- API Route: create_generation_task 添加请求/校验/入队/错误日志
- Worker: generate_video 全流程结构化日志
  - [task_id=xxx] [阶段] 消息 统一格式
  - 接收任务/下载素材/剪辑计划/渲染/OSS上传/完成/失败 各阶段埋点
  - _flush_logs 实时持久化到 DB(独立 session,异常安全)
- 18 个单元测试全部通过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:53:37 +08:00
11 changed files with 915 additions and 1011 deletions
+10 -45
View File
@@ -37,43 +37,6 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _safe_enqueue_generation_task(
task: Any,
generation_task_repository: Any,
) -> bool:
"""安全入队:send_task 失败时自动把任务标记为 failed,避免留下 pending 僵尸任务。
Returns:
True 表示入队成功,False 表示入队失败(已标记为 failed)
"""
try:
celery_app.send_task("worker.generate_video", args=[task.id])
logger.info(
"[生成任务] 入队成功: task_id=%s, status=%s",
task.id,
task.status,
)
return True
except Exception as e:
logger.error(
"[生成任务] 入队失败,标记为失败: task_id=%s error=%s",
task.id,
e,
exc_info=True,
)
try:
task.mark_failed(f"任务入队失败: {e}")
generation_task_repository.update(task)
except Exception as update_err:
logger.error(
"[生成任务] 入队失败后更新状态也失败: task_id=%s error=%s",
task.id,
update_err,
exc_info=True,
)
return False
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
"""检查用户是否有项目访问权限"""
project = project_repository.find_by_id(project_id)
@@ -265,7 +228,6 @@ def create_generation_task(
use_case = CreateGenerationTaskUseCase(generation_task_repository)
count = request.count
created_tasks = []
failed_tasks = []
# 同批次任务共享 batch_id,用于视频查重时批次内比对
batch_id = uuid.uuid4().hex if count > 1 else ""
@@ -287,15 +249,19 @@ def create_generation_task(
batch_id=batch_id,
)
)
if _safe_enqueue_generation_task(task, generation_task_repository):
created_tasks.append(task)
else:
failed_tasks.append(task)
celery_app.send_task("worker.generate_video", args=[task.id])
created_tasks.append(task)
logger.info(
"[生成任务] 入队成功: task_id=%s, status=%s, batch_id=%s",
task.id,
task.status,
batch_id,
)
except Exception as e:
logger.error("[生成任务] 创建失败: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志")
items = [_to_generation_task_response(t) for t in created_tasks + failed_tasks]
items = [_to_generation_task_response(t) for t in created_tasks]
return BatchGenerationTaskResponse(items=items, total=len(items))
@@ -381,6 +347,5 @@ def retry_generation_task(
asset_select_mode=getattr(task, "asset_select_mode", ""),
)
)
if not _safe_enqueue_generation_task(retried, generation_task_repository):
logger.warning("[生成任务] 重试入队失败: task_id=%s", retried.id)
celery_app.send_task("worker.generate_video", args=[retried.id])
return _to_generation_task_response(retried)
+2 -33
View File
@@ -25,35 +25,6 @@ from packages.application import (
router = APIRouter()
def _safe_enqueue_generation_task(
task: Any,
generation_task_repository: Any,
) -> bool:
"""安全入队:send_task 失败时自动把任务标记为 failed,避免留下 pending 僵尸任务。"""
try:
celery_app.send_task("worker.generate_video", args=[task.id])
logger.info("[任务中心] 生成任务入队成功: task_id=%s", task.id)
return True
except Exception as e:
logger.error(
"[任务中心] 生成任务入队失败,标记为失败: task_id=%s error=%s",
task.id,
e,
exc_info=True,
)
try:
task.mark_failed(f"任务入队失败: {e}")
generation_task_repository.update(task)
except Exception as update_err:
logger.error(
"[任务中心] 入队失败后更新状态也失败: task_id=%s error=%s",
task.id,
update_err,
exc_info=True,
)
return False
def _humanize_task_error(error_message: str) -> str:
raw = (error_message or "").strip()
if not raw:
@@ -182,8 +153,7 @@ def retry_task_by_id(
created_by_user_id=authenticated_user.user.id,
)
)
if not _safe_enqueue_generation_task(retried, generation_task_repository):
logger.warning("[任务中心] 用户级重试入队失败: task_id=%s", retried.id)
celery_app.send_task("worker.generate_video", args=[retried.id])
return UserTaskResponse(
id=f"generation:{retried.id}",
task_type="generation",
@@ -265,8 +235,7 @@ def retry_project_task(
created_by_user_id=authenticated_user.user.id,
)
)
if not _safe_enqueue_generation_task(retried, generation_task_repository):
logger.warning("[任务中心] 项目级重试用队失败: task_id=%s", retried.id)
celery_app.send_task("worker.generate_video", args=[retried.id])
return _generation_task_to_project_response(retried)
if task_type == "ingest":
job = ingest_job_repository.get(source_id)
+2 -13
View File
@@ -201,18 +201,7 @@ def get_voice_clone_profile_repository(
def get_cosyvoice_service():
"""Provide the CosyVoice service instance.
注入 OSS 音频URL预签名函数,确保私有bucket下的参考音频
能被 CosyVoice 服务器下载。
"""
from app.core.storage import get_storage_service
"""Provide the CosyVoice service instance."""
from packages.application.cosyvoice_service import CosyVoiceService
storage = get_storage_service()
def _sign_audio_url(url: str) -> str:
"""对音频URL做预签名,私有bucket下 CosyVoice 服务器才能下载."""
return storage.get_download_url(url, expires_seconds=86400)
return CosyVoiceService(audio_url_signer=_sign_audio_url)
return CosyVoiceService()
+1 -4
View File
@@ -16,7 +16,6 @@ from packages.application.cosyvoice_service import (
CosyVoiceTimeoutError,
)
from packages.application.voice_clone.workflow import VoiceCloneWorkflowService
from video_processing.oss_helpers import get_signed_download_url
logger = logging.getLogger(__name__)
@@ -49,9 +48,7 @@ def process_voice_clone(self: Task, profile_id: str) -> dict:
repo = SQLAlchemyVoiceCloneProfileRepository(session)
workflow = VoiceCloneWorkflowService(
repository=repo,
cosyvoice_service=CosyVoiceService(
audio_url_signer=lambda url: get_signed_download_url(url, expires_seconds=86400) or url
),
cosyvoice_service=CosyVoiceService(),
)
updated_profile = workflow.poll_and_process_clone(profile_id, timeout=300)
+307 -282
View File
@@ -1,13 +1,11 @@
"""CosyVoice 语音服务 — 适配阿里云百炼 DashScope API.
"""CosyVoice 语音服务 — Phase 3.
封装阿里云百炼 CosyVoice 语音合成 API,提供:
封装阿里云 CosyVoice 语音合成 API,提供:
- 预置音色列表查询
- 音色克隆(提交 + 轮询状态)
- 语音合成(同步非流式调用
- 音色克隆(提交任务 + 轮询状态)
- 语音合成(提交任务 + 轮询状态
API 文档:
- 音色克隆: https://help.aliyun.com/document_detail/3027318.html
- 语音合成: https://help.aliyun.com/zh/model-studio/cosyvoice-tts-http-api
API 文档: https://help.aliyun.com/zh/model-studio/cosyvoice
"""
from __future__ import annotations
@@ -62,24 +60,23 @@ class SynthesizeResult:
class CosyVoiceService:
"""CosyVoice 语音服务.
"""CosyVoice 语音服务
封装阿里云百炼 CosyVoice API,提供音色克隆和语音合成功能.
接口总览:
- 音色克隆: POST /services/audio/tts/customization (model=voice-enrollment)
- action=create_voice: 创建克隆音色,返回 voice_id(状态 DEPLOYING
- action=query_voice: 查询音色状态(DEPLOYING / OK / UNDEPLOYED
- 语音合成: POST /services/audio/tts/SpeechSynthesizer (model=cosyvoice-v3.5-plus)
- 非流式: 同步返回音频 URL
封装阿里云 CosyVoice API,提供音色克隆和语音合成功能
支持同步和异步两种模式:
- 同步:API 直接返回结果
- 异步:API 返回 task_id,需要轮询状态
使用示例:
service = CosyVoiceService(
api_key="your-api-key",
base_url="https://dashscope.aliyuncs.com/api/v1",
model="cosyvoice-v3.5-plus",
base_url="https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio",
model="cosyvoice-v1",
)
# 获取预置音色
voices = service.list_preset_voices()
# 音色克隆
result = service.clone_voice(audio_url="https://example.com/audio.mp3")
@@ -87,9 +84,9 @@ class CosyVoiceService:
result = service.synthesize_speech(text="你好世界", voice_id="longxiaochun")
"""
# 音色状态轮询配置
CLONE_POLL_INTERVAL = 5.0 # 秒
CLONE_MAX_POLL_ATTEMPTS = 60 # 最多轮询 60 次(5分钟)
# 轮询配置
POLL_INTERVAL = 2.0 # 秒
MAX_POLL_ATTEMPTS = 60 # 最多轮询 60 次(2分钟)
# 重试配置
MAX_RETRIES = 3
@@ -100,34 +97,24 @@ class CosyVoiceService:
api_key: str = "",
base_url: str = "",
model: str = "",
clone_model: str = "",
http_client: Optional[httpx.Client] = None,
audio_url_signer: Optional[callable] = None,
) -> None:
"""初始化 CosyVoice 服务.
"""初始化 CosyVoice 服务
Args:
api_key: DashScope API Key,为空时从配置读取
base_url: DashScope API Base URL,为空时从配置读取
model: 语音合成模型名称,为空时从配置读取
clone_model: 音色克隆模型名称,为空时从配置读取
api_key: CosyVoice API Key,为空时从配置读取
base_url: CosyVoice API Base URL,为空时从配置读取
model: CosyVoice 模型名称,为空时从配置读取
http_client: 可选的 HTTP 客户端(用于测试注入)
audio_url_signer: 可选的音频URL预签名函数,签名式 fn(url) -> str.
用于私有 bucket 下,将裸 URL 转为预签名 URL,
确保 CosyVoice 服务器能下载参考音频.
"""
settings = get_shared_settings()
self._api_key = api_key or settings.cosyvoice_api_key
self._base_url = base_url or settings.cosyvoice_base_url
self._model = model or settings.cosyvoice_model
self._clone_model = clone_model or getattr(
settings, "cosyvoice_clone_model", "voice-enrollment"
)
self._audio_url_signer = audio_url_signer
self._client = http_client or httpx.Client(
timeout=httpx.Timeout(60.0, connect=10.0),
timeout=httpx.Timeout(30.0, connect=10.0),
)
self._owns_client = http_client is None
@@ -145,7 +132,7 @@ class CosyVoiceService:
# ── 预置音色 ─────────────────────────────────────────
def list_preset_voices(self) -> list[PresetVoice]:
"""获取预置音色列表.
"""获取预置音色列表
Returns:
预置音色列表
@@ -159,22 +146,20 @@ class CosyVoiceService:
audio_url: str,
voice_name: str = "",
language: str = "zh-CN",
target_model: str = "",
) -> dict:
"""提交音色克隆任务(非阻塞).
"""提交音色克隆任务(非阻塞)
调用百炼 voice-enrollment API 创建克隆音色.
创建后音色状态为 DEPLOYING,需通过 query_voice_status 轮询直到 OK.
只提交任务到 CosyVoice API,不轮询结果。
返回的 dict 包含 task_id(异步)或 voice_id(同步)。
Args:
audio_url: 参考音频 URL(必须公网可访问)
voice_name: 音色名称前缀(字母数字,最多10字符
language: 语言代码zh-CN 会转换为 zh
target_model: 目标合成模型,默认使用当前 model
audio_url: 参考音频 URL
voice_name: 音色名称(可选
language: 语言代码
Returns:
dict: {"voice_id": str, "status": str, "request_id": str}
voice_id 非空,status 通常为 DEPLOYING
dict: {"task_id": str, "voice_id": str, "request_id": str}
task_id 和 voice_id 至少有一个非空
Raises:
CosyVoiceError: API 调用失败
@@ -186,68 +171,48 @@ class CosyVoiceService:
if not self._api_key:
raise CosyVoiceAuthError("CosyVoice API Key 未配置")
# voice_name 作为 prefix,限制字母数字,最多10字符
# 不符合要求的做清洗
prefix = self._sanitize_prefix(voice_name) if voice_name else "clone"
# 语言转换:zh-CN → zh,保留 ISO 639-1 格式
lang_code = language.split("-")[0].lower() if language else "zh"
target = target_model or self._model
# 如果配置了 audio_url_signer,对音频URL做预签名
# (私有 bucket 下 CosyVoice 服务器无法直接访问裸 URL)
signed_audio_url = audio_url
if self._audio_url_signer:
try:
signed_audio_url = self._audio_url_signer(audio_url)
logger.info("音频URL已预签名: original=%s signed_prefix=%s",
audio_url[:80], signed_audio_url[:80])
except Exception as e:
logger.warning("音频URL预签名失败,使用原始URL: %s", e)
payload = {
"model": self._clone_model,
"model": self._model,
"input": {
"action": "create_voice",
"target_model": target,
"prefix": prefix,
"url": signed_audio_url,
"language_hints": [lang_code],
"audio_url": audio_url,
},
"parameters": {
"language": language,
},
}
if voice_name:
payload["parameters"]["voice_name"] = voice_name
response = self._call_api(
method="POST",
path="/services/audio/tts/customization",
path="/services/audio/voice-clone",
json=payload,
timeout=60.0,
)
output = response.get("output", {})
task_id = output.get("task_id", "")
voice_id = output.get("voice_id", "")
status = output.get("status", "DEPLOYING")
request_id = response.get("request_id", "")
if not voice_id:
raise CosyVoiceError(f"CosyVoice API 未返回 voice_id: {response}")
if not task_id and not voice_id:
raise CosyVoiceError(f"CosyVoice API 未返回 task_id 或 voice_id: {response}")
return {
"task_id": task_id,
"voice_id": voice_id,
"status": status,
"request_id": request_id,
}
def query_voice_status(self, voice_id: str) -> dict:
"""查询音色状态(单次查询,不轮询).
def check_task_status(self, task_id: str) -> dict:
"""查询克隆任务状态(单次查询,不轮询)
Args:
voice_id: 音色 ID
task_id: 任务 ID
Returns:
dict: {"status": str, "target_model": str, "gmt_create": str,
"gmt_modified": str, "resource_link": str}
status 为 DEPLOYING / OK / UNDEPLOYED
dict: {"status": str, "voice_id": str, "message": str}
status 为 SUCCEEDED/FAILED/PENDING/RUNNING
Raises:
CosyVoiceError: API 调用失败
@@ -256,98 +221,40 @@ class CosyVoiceService:
if not self._api_key:
raise CosyVoiceAuthError("CosyVoice API Key 未配置")
if not voice_id:
raise ValueError("voice_id 不能为空")
payload = {
"model": self._clone_model,
"input": {
"action": "query_voice",
"voice_id": voice_id,
},
}
response = self._call_api(
method="POST",
path="/services/audio/tts/customization",
json=payload,
method="GET",
path=f"/tasks/{task_id}",
timeout=30.0,
)
output = response.get("output", {})
status = output.get("task_status", "").upper()
voice_id = output.get("voice_id", "")
message = output.get("message", "")
return {
"status": output.get("status", ""),
"target_model": output.get("target_model", ""),
"gmt_create": output.get("gmt_create", ""),
"gmt_modified": output.get("gmt_modified", ""),
"resource_link": output.get("resource_link", ""),
"status": status,
"voice_id": voice_id,
"message": message,
}
def check_task_status(self, task_id: str) -> dict:
"""查询克隆任务状态(兼容旧接口,实际用 voice_id 查询).
def poll_clone_task(self, task_id: str, timeout: float = 300.0) -> dict:
"""轮询音色克隆任务状态(公开方法)。
为了兼容旧代码,task_id 参数名保留,但实际传的是 voice_id.
供 Celery 后台任务调用,轮询直到完成或超时。
Args:
task_id: 音色 ID(兼容旧接口名)
Returns:
dict: {"status": str, "voice_id": str, "message": str}
"""
result = self.query_voice_status(task_id)
return {
"status": result["status"],
"voice_id": task_id,
"message": "",
}
def poll_clone_task(self, voice_id: str, timeout: float = 300.0) -> dict:
"""轮询音色克隆状态直到完成或超时.
供 Celery 后台任务调用,轮询直到状态变为 OK 或 UNDEPLOYED.
Args:
voice_id: 音色 ID
task_id: CosyVoice 任务 ID
timeout: 超时时间(秒),默认 300
Returns:
dict: {"voice_id": str}
Raises:
CosyVoiceError: 任务失败(状态 UNDEPLOYED
CosyVoiceError: 任务失败
CosyVoiceTimeoutError: 超时
"""
start_time = time.time()
attempts = 0
while attempts < self.CLONE_MAX_POLL_ATTEMPTS:
elapsed = time.time() - start_time
if elapsed > timeout:
raise CosyVoiceTimeoutError(
f"音色克隆任务超时({timeout}秒): voice_id={voice_id}"
)
result = self.query_voice_status(voice_id)
status = result.get("status", "").upper()
if status == "OK":
return {"voice_id": voice_id}
elif status == "UNDEPLOYED":
raise CosyVoiceError(
f"音色克隆任务失败(审核未通过): voice_id={voice_id}"
)
elif status in ("DEPLOYING", "PENDING", "PROCESSING", ""):
# 继续轮询
time.sleep(self.CLONE_POLL_INTERVAL)
attempts += 1
else:
logger.warning("未知的音色状态: %s (voice_id=%s)", status, voice_id)
time.sleep(self.CLONE_POLL_INTERVAL)
attempts += 1
raise CosyVoiceTimeoutError(
f"音色克隆任务轮询次数超限: voice_id={voice_id}"
)
return self._poll_clone_task(task_id, timeout=timeout)
def clone_voice(
self,
@@ -355,45 +262,122 @@ class CosyVoiceService:
voice_name: str = "",
language: str = "zh-CN",
timeout: float = 300.0,
target_model: str = "",
) -> CloneResult:
"""克隆音色(阻塞,直到完成或超时).
"""克隆音色
提交音色克隆到百炼 API,并轮询直到状态变为 OK 或超时.
提交音色克隆任务到 CosyVoice API,并轮询直到完成或超时
Args:
audio_url: 参考音频 URL(必须公网可访问)
voice_name: 音色名称前缀
audio_url: 参考音频 URL
voice_name: 音色名称(可选)
language: 语言代码
timeout: 超时时间(秒)
target_model: 目标合成模型
Returns:
CloneResult: 克隆结果,包含 voice_id
Raises:
CosyVoiceError: API 调用失败或克隆失败
CosyVoiceError: API 调用失败
CosyVoiceTimeoutError: 超时
CosyVoiceAuthError: 认证失败
ValueError: 参数无效
"""
submit_result = self.submit_clone_task(
audio_url=audio_url,
voice_name=voice_name,
language=language,
target_model=target_model,
if not audio_url:
raise ValueError("audio_url 不能为空")
if not self._api_key:
raise CosyVoiceAuthError("CosyVoice API Key 未配置")
# 构建请求
payload = {
"model": self._model,
"input": {
"audio_url": audio_url,
},
"parameters": {
"language": language,
},
}
if voice_name:
payload["parameters"]["voice_name"] = voice_name
# 调用 API
response = self._call_api(
method="POST",
path="/services/audio/voice-clone",
json=payload,
timeout=timeout,
)
voice_id = submit_result["voice_id"]
request_id = submit_result["request_id"]
# 解析响应
output = response.get("output", {})
# 如果创建时已经是 OK 状态,直接返回
if submit_result.get("status", "").upper() == "OK":
return CloneResult(voice_id=voice_id, request_id=request_id)
# 检查是否有 task_id(异步模式)
task_id = output.get("task_id")
voice_id = output.get("voice_id")
# 否则轮询
result = self.poll_clone_task(voice_id, timeout=timeout)
return CloneResult(voice_id=result["voice_id"], request_id=request_id)
if task_id:
# 异步模式:轮询任务状态
result = self._poll_clone_task(task_id, timeout)
return CloneResult(
voice_id=result["voice_id"],
request_id=response.get("request_id", ""),
)
elif voice_id:
# 同步模式:直接返回结果
return CloneResult(
voice_id=voice_id,
request_id=response.get("request_id", ""),
)
else:
raise CosyVoiceError(f"CosyVoice API 未返回 task_id 或 voice_id: {response}")
def _poll_clone_task(self, task_id: str, timeout: float) -> dict:
"""轮询音色克隆任务状态。
Args:
task_id: 任务 ID
timeout: 超时时间(秒)
Returns:
任务结果字典
Raises:
CosyVoiceError: 任务失败
CosyVoiceTimeoutError: 超时
"""
start_time = time.time()
attempts = 0
while attempts < self.MAX_POLL_ATTEMPTS:
elapsed = time.time() - start_time
if elapsed > timeout:
raise CosyVoiceTimeoutError(f"音色克隆任务超时({timeout}秒): task_id={task_id}")
response = self._call_api(
method="GET",
path=f"/tasks/{task_id}",
timeout=30.0,
)
output = response.get("output", {})
status = output.get("task_status", "").upper()
if status == "SUCCEEDED":
voice_id = output.get("voice_id", "")
if not voice_id:
raise CosyVoiceError(f"音色克隆任务成功但未返回 voice_id: {response}")
return {"voice_id": voice_id}
elif status == "FAILED":
error_msg = output.get("message", "未知错误")
raise CosyVoiceError(f"音色克隆任务失败: {error_msg}")
elif status in ("PENDING", "RUNNING"):
# 继续轮询
time.sleep(self.POLL_INTERVAL)
attempts += 1
else:
raise CosyVoiceError(f"未知的任务状态: {status}")
raise CosyVoiceTimeoutError(f"音色克隆任务轮询次数超限: task_id={task_id}")
# ── 语音合成 ─────────────────────────────────────────
@@ -404,12 +388,11 @@ class CosyVoiceService:
sample_rate: int = 0,
format: str = "",
speed: float = 1.0,
volume: int = 50,
) -> dict:
"""提交语音合成任务(同步非流式,直接返回结果).
"""提交语音合成任务(非阻塞)。
CosyVoice SpeechSynthesizer 非流式接口是同步的,
调用后直接返回音频 URL. 此方法保持与旧接口兼容.
只提交任务到 CosyVoice API,不轮询结果。
返回的 dict 包含 task_id(异步)或 audio_url(同步)。
Args:
text: 要合成的文本
@@ -417,11 +400,10 @@ class CosyVoiceService:
sample_rate: 采样率(Hz),0 表示使用配置默认值
format: 输出格式(mp3/wav/pcm),空表示使用配置默认值
speed: 语速(0.5-2.0),1.0 为正常速度
volume: 音量(0-100),默认 50
Returns:
dict: {"audio_url": str, "request_id": str,
"duration": float, "file_size": int}
dict: {"task_id": str, "audio_url": str, "request_id": str}
task_id 和 audio_url 至少有一个非空
Raises:
CosyVoiceError: API 调用失败
@@ -441,54 +423,55 @@ class CosyVoiceService:
"model": self._model,
"input": {
"text": text,
},
"parameters": {
"voice": voice_id,
"format": format or settings.cosyvoice_format,
"sample_rate": sample_rate or settings.cosyvoice_sample_rate,
"format": format or settings.cosyvoice_format,
"rate": speed,
"volume": volume,
},
}
response = self._call_api(
method="POST",
path="/services/audio/tts/SpeechSynthesizer",
path="/services/aigc/text2audio/generation",
json=payload,
timeout=120.0,
timeout=60.0,
)
output = response.get("output", {})
audio = output.get("audio", {})
audio_url = audio.get("url", "")
task_id = output.get("task_id", "")
audio_url = output.get("audio_url", "")
request_id = response.get("request_id", "")
if not audio_url:
raise CosyVoiceError(
f"CosyVoice API 未返回 audio_url: {response}"
)
if not task_id and not audio_url:
raise CosyVoiceError(f"CosyVoice API 未返回 task_id 或 audio_url: {response}")
return {
"task_id": "", # 同步接口无 task_id,兼容旧接口
"task_id": task_id,
"audio_url": audio_url,
"duration": 0.0, # 同步接口不返回 duration
"file_size": 0, # 同步接口不返回 file_size
"duration": output.get("duration", 0.0),
"file_size": output.get("file_size", 0),
"request_id": request_id,
}
def poll_synthesize_task(
self, task_id: str, timeout: float = 120.0
) -> dict:
"""轮询合成任务(同步接口无需轮询,保留兼容).
def poll_synthesize_task(self, task_id: str, timeout: float = 120.0) -> dict:
"""轮询语音合成任务状态(公开方法)。
CosyVoice SpeechSynthesizer 非流式接口是同步的,
此方法仅为保持接口兼容,实际调用时 task_id 应该为空.
供 Celery 后台任务调用,轮询直到完成或超时。
Args:
task_id: CosyVoice 任务 ID
timeout: 超时时间(秒),默认 120
Returns:
dict: {"audio_url": str, "duration": float, "file_size": int}
Raises:
CosyVoiceError: 同步接口无需轮询
CosyVoiceError: 任务失败
CosyVoiceTimeoutError: 超时
"""
raise CosyVoiceError(
"CosyVoice 非流式合成接口是同步的,无需轮询. "
"请直接使用 submit_synthesize_task()."
)
return self._poll_synthesize_task(task_id, timeout=timeout)
def synthesize_speech(
self,
@@ -497,13 +480,11 @@ class CosyVoiceService:
sample_rate: int = 0,
format: str = "",
speed: float = 1.0,
volume: int = 50,
timeout: float = 120.0,
) -> SynthesizeResult:
"""语音合成(同步非流式).
"""语音合成
调用百炼 CosyVoice SpeechSynthesizer 非流式接口,
直接返回合成音频 URL.
提交语音合成任务到 CosyVoice API,并轮询直到完成或超时。
Args:
text: 要合成的文本
@@ -511,52 +492,128 @@ class CosyVoiceService:
sample_rate: 采样率(Hz),0 表示使用配置默认值
format: 输出格式(mp3/wav/pcm),空表示使用配置默认值
speed: 语速(0.5-2.0),1.0 为正常速度
volume: 音量(0-100),默认 50
timeout: 超时时间(秒),保留参数兼容
timeout: 超时时间(秒)
Returns:
SynthesizeResult: 合成结果,包含 audio_url
Raises:
CosyVoiceError: API 调用失败
CosyVoiceTimeoutError: 超时
CosyVoiceAuthError: 认证失败
ValueError: 参数无效
"""
result = self.submit_synthesize_task(
text=text,
voice_id=voice_id,
sample_rate=sample_rate,
format=format,
speed=speed,
volume=volume,
if not text:
raise ValueError("text 不能为空")
if not voice_id:
raise ValueError("voice_id 不能为空")
if not self._api_key:
raise CosyVoiceAuthError("CosyVoice API Key 未配置")
settings = get_shared_settings()
# 构建请求
payload = {
"model": self._model,
"input": {
"text": text,
},
"parameters": {
"voice": voice_id,
"sample_rate": sample_rate or settings.cosyvoice_sample_rate,
"format": format or settings.cosyvoice_format,
"rate": speed,
},
}
# 调用 API
response = self._call_api(
method="POST",
path="/services/aigc/text2audio/generation",
json=payload,
timeout=timeout,
)
return SynthesizeResult(
audio_url=result["audio_url"],
duration=result.get("duration", 0.0),
file_size=result.get("file_size", 0),
request_id=result.get("request_id", ""),
)
# 解析响应
output = response.get("output", {})
# ── 内部方法 ─────────────────────────────────────────
# 检查是否有 task_id(异步模式)
task_id = output.get("task_id")
audio_url = output.get("audio_url")
def _sanitize_prefix(self, name: str) -> str:
"""清洗音色名称为合法的 prefix(字母数字,最多10字符).
if task_id:
# 异步模式:轮询任务状态
result = self._poll_synthesize_task(task_id, timeout)
return SynthesizeResult(
audio_url=result["audio_url"],
duration=result.get("duration", 0.0),
file_size=result.get("file_size", 0),
request_id=response.get("request_id", ""),
)
elif audio_url:
# 同步模式:直接返回结果
return SynthesizeResult(
audio_url=audio_url,
duration=output.get("duration", 0.0),
file_size=output.get("file_size", 0),
request_id=response.get("request_id", ""),
)
else:
raise CosyVoiceError(f"CosyVoice API 未返回 audio_url 或 task_id: {response}")
def _poll_synthesize_task(self, task_id: str, timeout: float) -> dict:
"""轮询语音合成任务状态。
Args:
name: 原始音色名称
task_id: 任务 ID
timeout: 超时时间(秒)
Returns:
清洗后的 prefix
任务结果字典
Raises:
CosyVoiceError: 任务失败
CosyVoiceTimeoutError: 超时
"""
# 只保留字母和数字
cleaned = "".join(c for c in name if c.isalnum())
# 最多10字符
cleaned = cleaned[:10]
# 如果清洗后为空,用默认值
if not cleaned:
cleaned = "clone"
return cleaned
start_time = time.time()
attempts = 0
while attempts < self.MAX_POLL_ATTEMPTS:
elapsed = time.time() - start_time
if elapsed > timeout:
raise CosyVoiceTimeoutError(f"语音合成任务超时({timeout}秒): task_id={task_id}")
response = self._call_api(
method="GET",
path=f"/tasks/{task_id}",
timeout=30.0,
)
output = response.get("output", {})
status = output.get("task_status", "").upper()
if status == "SUCCEEDED":
audio_url = output.get("audio_url", "")
if not audio_url:
raise CosyVoiceError(f"语音合成任务成功但未返回 audio_url: {response}")
return {
"audio_url": audio_url,
"duration": output.get("duration", 0.0),
"file_size": output.get("file_size", 0),
}
elif status == "FAILED":
error_msg = output.get("message", "未知错误")
raise CosyVoiceError(f"语音合成任务失败: {error_msg}")
elif status in ("PENDING", "RUNNING"):
# 继续轮询
time.sleep(self.POLL_INTERVAL)
attempts += 1
else:
raise CosyVoiceError(f"未知的任务状态: {status}")
raise CosyVoiceTimeoutError(f"语音合成任务轮询次数超限: task_id={task_id}")
# ── 内部方法 ─────────────────────────────────────────
def _call_api(
self,
@@ -565,13 +622,13 @@ class CosyVoiceService:
json: Optional[dict] = None,
timeout: float = 30.0,
) -> dict:
"""调用 DashScope API.
"""调用 CosyVoice API
支持重试和错误处理.
支持重试和错误处理
Args:
method: HTTP 方法(GET/POST
path: API 路径(以 / 开头)
path: API 路径
json: 请求体
timeout: 超时时间(秒)
@@ -605,57 +662,25 @@ class CosyVoiceService:
if response.status_code == 200:
return response.json()
elif response.status_code in (401, 403):
raise CosyVoiceAuthError(
f"CosyVoice API 认证失败: HTTP {response.status_code}"
)
elif response.status_code == 400:
# 客户端错误,不重试
body_text = response.text
try:
body = response.json()
code = body.get("code", "")
message = body.get("message", "")
raise CosyVoiceError(
f"CosyVoice API 参数错误: HTTP 400, "
f"code={code}, message={message}"
)
except ValueError:
raise CosyVoiceError(
f"CosyVoice API 调用失败: HTTP 400, body={body_text}"
)
raise CosyVoiceAuthError(f"CosyVoice API 认证失败: HTTP {response.status_code}")
elif response.status_code >= 500:
# 服务端错误,可重试
last_error = CosyVoiceError(
f"CosyVoice API 服务端错误: HTTP {response.status_code}"
)
last_error = CosyVoiceError(f"CosyVoice API 服务端错误: HTTP {response.status_code}")
logger.warning(
"CosyVoice API 失败 (尝试 %d/%d): HTTP %d",
attempt + 1,
self.MAX_RETRIES,
response.status_code,
f"CosyVoice API 失败 (尝试 {attempt + 1}/{self.MAX_RETRIES}): " f"HTTP {response.status_code}"
)
else:
# 其他客户端错误,不重试
# 客户端错误,不重试
raise CosyVoiceError(
f"CosyVoice API 调用失败: HTTP {response.status_code}, "
f"body={response.text}"
f"CosyVoice API 调用失败: HTTP {response.status_code}, " f"body={response.text}"
)
except httpx.TimeoutException as e:
last_error = CosyVoiceTimeoutError(f"请求超时: {e}")
logger.warning(
"CosyVoice API 超时 (尝试 %d/%d)",
attempt + 1,
self.MAX_RETRIES,
)
logger.warning(f"CosyVoice API 超时 (尝试 {attempt + 1}/{self.MAX_RETRIES})")
except httpx.RequestError as e:
last_error = CosyVoiceError(f"请求错误: {e}")
logger.warning(
"CosyVoice API 请求错误 (尝试 %d/%d): %s",
attempt + 1,
self.MAX_RETRIES,
e,
)
logger.warning(f"CosyVoice API 请求错误 (尝试 {attempt + 1}/{self.MAX_RETRIES}): {e}")
# 指数退避
if attempt < self.MAX_RETRIES - 1:
+60 -155
View File
@@ -14,6 +14,7 @@ import logging
import os
import shutil
import tempfile
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Optional
@@ -189,13 +190,10 @@ class TTSWorkflowService:
return job
def poll_and_process_synthesis(self, job_id: str, timeout: float = 120.0) -> TTSJob:
"""轮询/检查 CosyVoice 合成任务并处理结果.
"""轮询 CosyVoice 合成任务并处理结果
新 CosyVoice SpeechSynthesizer 非流式接口是同步的,
start_synthesis 阶段通常已经完成. 此方法用于:
1. job 已 completed → 直接返回(同步路径已处理)
2. job 仍在 processing → 重新提交合成(兜底)
3. 分段任务 → 检查分段状态
从 job.metadata 获取 task_id,调用 CosyVoiceService.poll_synthesize_task()
轮询状态,然后通过 process_synthesis_result / process_synthesis_failure 更新 job。
供 Celery 后台任务调用。
"""
@@ -203,42 +201,22 @@ class TTSWorkflowService:
if job is None:
raise TTSJobNotFoundError(f"TTS job {job_id} not found")
# 已完成直接返回(同步路径在 start_synthesis 里已处理)
if job.status == TTSJobStatus.COMPLETED.value:
logger.info(f"TTS 任务已完成,跳过轮询: job_id={job_id}")
return job
# 检查是否为分段合成任务
segment_task_ids = (job.metadata or {}).get("segment_task_ids", [])
if segment_task_ids:
return self._poll_segment_tasks(job)
# 单段模式:同步接口下通常不会走到这里,
# 但如果因为异常导致仍在 processing,重新提交一次
task_id = (job.metadata or {}).get("cosyvoice_task_id", "")
# 新接口(同步):没有 task_id,重新合成
if not task_id:
logger.info(
f"TTS 任务无 task_id,重新同步合成: job_id={job_id}"
)
return self._resynthesize_and_complete(job)
raise ValueError(f"TTSJob {job_id} has no cosyvoice_task_id in metadata")
# 旧接口遗留的 task_id,尝试轮询(兼容过渡)
try:
result = self.cosyvoice_service.poll_synthesize_task(task_id, timeout=timeout)
return self.process_synthesis_result(
job_id,
audio_url=result["audio_url"],
duration=result.get("duration", 0.0),
file_size=result.get("file_size", 0),
)
except CosyVoiceError:
# 旧接口轮询失败,重新同步合成
logger.warning(
f"旧 task_id 轮询失败,重新同步合成: job_id={job_id}, task_id={task_id}"
)
return self._resynthesize_and_complete(job)
result = self.cosyvoice_service.poll_synthesize_task(task_id, timeout=timeout)
return self.process_synthesis_result(
job_id,
audio_url=result["audio_url"],
duration=result.get("duration", 0.0),
file_size=result.get("file_size", 0),
)
def process_synthesis_result(
self,
@@ -279,40 +257,6 @@ class TTSWorkflowService:
logger.info(f"TTS 合成成功: job_id={job_id}, audio_url={permanent_url}")
return job
def _resynthesize_and_complete(self, job: TTSJob) -> TTSJob:
"""重新同步合成并完成任务(兜底路径).
当 poll_and_process_synthesis 发现 job 仍在 processing 且无 task_id 时,
重新调用同步合成接口,转存 OSS 后标记完成。
"""
try:
# 从 metadata 读取合成参数(兼容旧数据,无则用默认值)
job_metadata = job.metadata or {}
speed = float(job_metadata.get("speed", 1.0))
volume = int(job_metadata.get("volume", 50))
result = self.cosyvoice_service.submit_synthesize_task(
text=job.input_text,
voice_id=job.voice_id,
sample_rate=job.sample_rate,
format=job.format,
speed=speed,
volume=volume,
)
audio_url = result.get("audio_url", "")
if not audio_url:
raise CosyVoiceError("重新合成未返回 audio_url")
return self.process_synthesis_result(
job.id,
audio_url=audio_url,
duration=result.get("duration", 0.0),
file_size=result.get("file_size", 0),
)
except Exception as e:
logger.error(f"重新同步合成失败: job_id={job.id}, error={e}")
return self.process_synthesis_failure(job.id, str(e))
def process_synthesis_failure(self, job_id: str, error_message: str) -> TTSJob:
"""处理合成失败结果。
@@ -485,108 +429,69 @@ class TTSWorkflowService:
shutil.rmtree(temp_dir, ignore_errors=True)
def _poll_segment_tasks(self, job: TTSJob) -> TTSJob:
"""分段任务完成检查(适配新同步接口).
新 CosyVoice SpeechSynthesizer 非流式接口为同步接口,
分段任务在提交时应已同步返回 audio_url。
若历史任务处于 processing 且有 segment_task_ids 但缺少 audio_url
则对缺失分段重新同步合成,全部完成后合并音频。
"""
"""轮询所有分段异步任务,全部完成后合并音频。"""
segment_task_ids: list[str] = (job.metadata or {}).get("segment_task_ids", [])
segment_audio_urls: list[str] = (job.metadata or {}).get("segment_audio_urls", [])
segment_count = len(segment_task_ids)
if segment_count == 0:
logger.warning(f"分段任务无 task_id: job_id={job.id}")
self._handle_segment_failure(job, "分段任务数据异常:无分段信息")
return self.repository.get(job.id)
poll_start = time.monotonic()
poll_timeout = 300.0 # 分段任务超时更长
poll_interval = 2.0
# 从 metadata 读取合成参数
job_metadata = job.metadata or {}
speed = float(job_metadata.get("speed", 1.0))
volume = int(job_metadata.get("volume", 50))
while time.monotonic() - poll_start < poll_timeout:
all_done = True
results: list[dict | None] = [None] * segment_count
# 分段文本(用于缺失段重新合成)
segments = split_text(job.input_text, max_chars=_SEGMENT_THRESHOLD)
for idx, task_id in enumerate(segment_task_ids):
# 已经有音频的分段跳过轮询
if idx < len(segment_audio_urls) and segment_audio_urls[idx]:
results[idx] = {
"audio_url": segment_audio_urls[idx],
"duration": 0.0,
"file_size": 0,
}
continue
results: list[dict | None] = [None] * segment_count
try:
result = self.cosyvoice_service.poll_synthesize_task(task_id, timeout=poll_timeout)
results[idx] = result
except Exception as e:
logger.error(f"分段任务轮询失败: job_id={job.id}, " f"segment={idx}, error={e}")
self._handle_segment_failure(job, f"分段 {idx + 1} 轮询失败: {e}")
return self.repository.get(job.id)
# 已有音频的分段直接用
for idx in range(segment_count):
if idx < len(segment_audio_urls) and segment_audio_urls[idx]:
results[idx] = {
"audio_url": segment_audio_urls[idx],
"duration": 0.0,
"file_size": 0,
}
if results[idx] is None:
all_done = False
# 找出缺失音频的分段索引
missing_indices = [i for i in range(segment_count) if results[i] is None]
if all_done and all(r is not None for r in results):
# 所有分段完成,下载合并
try:
merged_data, total_duration = self._download_and_merge_segments(results, job)
if missing_indices:
logger.info(
f"分段任务重新合成缺失段: job_id={job.id}, "
f"缺失={len(missing_indices)}/{segment_count}"
)
# 并发重新合成缺失分段
max_workers = min(len(missing_indices), _MAX_SEGMENT_WORKERS)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_idx = {}
for idx in missing_indices:
segment_text = segments[idx] if idx < len(segments) else ""
future = executor.submit(
self.cosyvoice_service.submit_synthesize_task,
text=segment_text,
voice_id=job.voice_id,
sample_rate=job.sample_rate,
format=job.format,
speed=speed,
volume=volume,
# 转存 OSS
permanent_url, storage_key = self._upload_merged_to_oss(
merged_data, job.user_id, job.id, job.format
)
future_to_idx[future] = idx
for future in as_completed(future_to_idx):
idx = future_to_idx[future]
try:
results[idx] = future.result()
except Exception as e:
logger.error(
f"分段重新合成失败: job_id={job.id}, "
f"segment={idx}, error={e}"
)
self._handle_segment_failure(
job, f"分段 {idx + 1} 重新合成失败: {e}"
)
return self.repository.get(job.id)
job.mark_completed(
output_audio_url=permanent_url,
output_audio_key=storage_key,
duration=total_duration,
file_size=len(merged_data),
)
job = self.repository.update(job)
logger.info(f"分段合成轮询完成: job_id={job.id}, " f"merged_size={len(merged_data)}")
return job
# 所有分段完成,下载合并
if all(r is not None for r in results):
try:
merged_data, total_duration = self._download_and_merge_segments(results, job)
except Exception as e:
self._handle_segment_failure(job, f"分段合并失败: {e}")
return self.repository.get(job.id)
permanent_url, storage_key = self._upload_merged_to_oss(
merged_data, job.user_id, job.id, job.format
)
# 等待后重试
time.sleep(poll_interval)
job.mark_completed(
output_audio_url=permanent_url,
output_audio_key=storage_key,
duration=total_duration,
file_size=len(merged_data),
)
job = self.repository.update(job)
logger.info(
f"分段合成完成(重新合成路径): job_id={job.id}, "
f"merged_size={len(merged_data)}"
)
return job
except Exception as e:
self._handle_segment_failure(job, f"分段合并失败: {e}")
return self.repository.get(job.id)
# 理论上不会到这里(全部重新合成要么成功要么失败)
self._handle_segment_failure(job, "分段合成结果不完整")
# 超时
self._handle_segment_failure(job, "分段合成轮询超时(300 秒)")
return self.repository.get(job.id)
def _handle_segment_failure(self, job: TTSJob, error_message: str) -> None:
+7 -10
View File
@@ -115,16 +115,14 @@ class VoiceCloneWorkflowService:
language=language,
)
# 4. 保存 voice_id / request_id 到 metadata
# 注意:key 保留 cosyvoice_task_id 以兼容旧数据,实际存的是 voice_id
# 4. 保存 task_id / voice_id 到 metadata
task_metadata = dict(profile.metadata)
task_metadata["cosyvoice_task_id"] = submit_result.get("voice_id", "")
task_metadata["cosyvoice_task_id"] = submit_result.get("task_id", "")
task_metadata["cosyvoice_request_id"] = submit_result.get("request_id", "")
# 如果 CosyVoice 直接返回了 OK 状态,直接标记 ready
# 如果 CosyVoice 同步返回了 voice_id,直接标记 ready
voice_id = submit_result.get("voice_id", "")
status = submit_result.get("status", "").upper()
if voice_id and status == "OK":
if voice_id:
profile.mark_ready(voice_id)
profile.metadata = task_metadata
profile = self.repository.update(profile)
@@ -133,7 +131,7 @@ class VoiceCloneWorkflowService:
profile.metadata = task_metadata
profile = self.repository.update(profile)
logger.info(f"音色克隆任务已提交: profile_id={profile.id}, " f"voice_id={submit_result.get('voice_id')}")
logger.info(f"音色克隆任务已提交: profile_id={profile.id}, " f"task_id={submit_result.get('task_id')}")
except (CosyVoiceError, CosyVoiceAuthError) as e:
# CosyVoice 提交失败,标记为 failed
@@ -250,12 +248,11 @@ class VoiceCloneWorkflowService:
)
task_metadata = dict(profile.metadata)
task_metadata["cosyvoice_task_id"] = submit_result.get("voice_id", "")
task_metadata["cosyvoice_task_id"] = submit_result.get("task_id", "")
task_metadata["cosyvoice_request_id"] = submit_result.get("request_id", "")
voice_id = submit_result.get("voice_id", "")
status = submit_result.get("status", "").upper()
if voice_id and status == "OK":
if voice_id:
profile.mark_ready(voice_id)
profile.metadata = task_metadata
profile = self.repository.update(profile)
+3 -5
View File
@@ -29,15 +29,13 @@ class SharedSettings(BaseSettings):
oss_access_key_secret: str = ""
oss_bucket_name: str = "xiaoxia-autocut"
# CosyVoice (阿里云百炼语音合成)
# CosyVoice (阿里云语音合成)
cosyvoice_api_key: str = ""
cosyvoice_base_url: str = "https://dashscope.aliyuncs.com/api/v1"
cosyvoice_model: str = "cosyvoice-v3.5-plus"
cosyvoice_base_url: str = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio"
cosyvoice_model: str = "cosyvoice-v1"
cosyvoice_voice: str = "longxiaochun" # 默认音色
cosyvoice_sample_rate: int = 22050
cosyvoice_format: str = "mp3" # 输出格式:mp3/wav/pcm
# 音色克隆模型名(固定为 voice-enrollment
cosyvoice_clone_model: str = "voice-enrollment"
# Environment
environment: str = "development"
+491 -391
View File
File diff suppressed because it is too large Load Diff
+16 -57
View File
@@ -353,11 +353,16 @@ class TestHandleSegmentFailure:
class TestPollSegmentTasks:
"""测试 _poll_segment_tasks 分段缺失重新合成(适配同步接口)"""
"""测试 _poll_segment_tasks 异步轮询"""
@patch("packages.application.tts_job.workflow.time")
@patch("packages.application.tts_job.workflow.httpx")
def test_all_segments_done(self, mock_httpx: MagicMock) -> None:
"""所有分段缺少 audio_url 时重新同步合成,合并标记完成。"""
def test_all_segments_done(self, mock_httpx: MagicMock, mock_time: MagicMock) -> None:
"""所有分段完成后合并标记完成。"""
# Mock time.monotonic 让循环只执行一次
mock_time.monotonic.side_effect = [0.0, 1.0, 2.0]
mock_time.sleep = MagicMock()
# Mock 下载分段音频
mock_resp = MagicMock()
mock_resp.content = b"seg audio"
@@ -365,7 +370,7 @@ class TestPollSegmentTasks:
mock_httpx.get.return_value = mock_resp
service = MagicMock(spec=CosyVoiceService)
service.submit_synthesize_task.side_effect = [
service.poll_synthesize_task.side_effect = [
{"audio_url": "https://temp.com/seg1.mp3", "duration": 2.0, "file_size": 100},
{"audio_url": "https://temp.com/seg2.mp3", "duration": 3.0, "file_size": 200},
]
@@ -376,8 +381,6 @@ class TestPollSegmentTasks:
repo = MagicMock()
job = _make_job(
status=TTSJobStatus.PROCESSING,
# 长文本触发分段,用于重新合成时切分
input_text="这是一段很长的测试文本。" * 30,
metadata={
"segment_task_ids": ["task_1", "task_2"],
"segment_audio_urls": ["", ""],
@@ -397,18 +400,19 @@ class TestPollSegmentTasks:
result = workflow._poll_segment_tasks(job)
assert result.status == TTSJobStatus.COMPLETED
# 两个缺失分段都重新合成了
assert service.submit_synthesize_task.call_count == 2
def test_segment_resynthesis_failure(self) -> None:
"""分段重新合成失败时标记 job failed。"""
@patch("packages.application.tts_job.workflow.time")
def test_segment_poll_failure(self, mock_time: MagicMock) -> None:
"""分段轮询失败时标记 job failed。"""
mock_time.monotonic.side_effect = [0.0, 1.0]
mock_time.sleep = MagicMock()
service = MagicMock(spec=CosyVoiceService)
service.submit_synthesize_task.side_effect = CosyVoiceError("Synthesis failed")
service.poll_synthesize_task.side_effect = CosyVoiceError("Poll failed")
repo = MagicMock()
job = _make_job(
status=TTSJobStatus.PROCESSING,
input_text="这是一段很长的测试文本。" * 30,
metadata={
"segment_task_ids": ["task_1"],
"segment_audio_urls": [""],
@@ -422,51 +426,6 @@ class TestPollSegmentTasks:
assert result.status == TTSJobStatus.FAILED
@patch("packages.application.tts_job.workflow.httpx")
def test_partial_audio_urls_reuse_existing(self, mock_httpx: MagicMock) -> None:
"""部分分段已有 audio_url 时直接复用,缺失的重新合成。"""
mock_resp = MagicMock()
mock_resp.content = b"seg audio"
mock_resp.raise_for_status.return_value = None
mock_httpx.get.return_value = mock_resp
service = MagicMock(spec=CosyVoiceService)
# 只有 1 个分段需要重新合成
service.submit_synthesize_task.return_value = {
"audio_url": "https://temp.com/seg2.mp3",
"duration": 3.0,
"file_size": 200,
}
storage = MagicMock()
storage.upload_file.return_value = "https://oss.example.com/merged.mp3"
repo = MagicMock()
job = _make_job(
status=TTSJobStatus.PROCESSING,
input_text="这是一段很长的测试文本。" * 30,
metadata={
"segment_task_ids": ["task_1", "task_2"],
"segment_audio_urls": ["https://temp.com/seg1.mp3", ""],
"segment_format": "mp3",
},
)
repo.get.return_value = job
repo.update.side_effect = lambda j: j
workflow = _make_workflow(cosyvoice_service=service, repo=repo, storage=storage)
with patch("packages.application.tts_job.workflow.AudioMerger") as MockMerger:
mock_merger = MagicMock()
mock_merger.merge.return_value = b"merged data"
MockMerger.return_value = mock_merger
result = workflow._poll_segment_tasks(job)
assert result.status == TTSJobStatus.COMPLETED
# 只有 1 个缺失分段被重新合成
assert service.submit_synthesize_task.call_count == 1
class TestPollAndProcessSynthesisSegmentDetection:
"""测试 poll_and_process_synthesis 正确识别分段任务。"""
+16 -16
View File
@@ -57,15 +57,15 @@ def _make_service(
class TestStartClone:
"""测试 start_clone 方法。"""
def test_start_clone_with_deploying(self) -> None:
"""提交克隆后返回 DEPLOYING 状态profile 保持 processing"""
def test_start_clone_with_async_task(self) -> None:
"""异步模式:提交任务后返回 processing 状态profile。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
# CosyVoice 返回 voice_id + DEPLOYING 状态(需轮询
# CosyVoice 返回 task_id(异步模式
mock_cosyvoice.submit_clone_task.return_value = {
"voice_id": "voice-abc",
"status": "DEPLOYING",
"task_id": "task-abc",
"voice_id": "",
"request_id": "req-123",
}
@@ -81,21 +81,21 @@ class TestStartClone:
)
assert profile.status == VoiceCloneStatus.PROCESSING
assert profile.metadata["cosyvoice_task_id"] == "voice-abc"
assert profile.metadata["cosyvoice_task_id"] == "task-abc"
assert profile.metadata["cosyvoice_request_id"] == "req-123"
mock_cosyvoice.submit_clone_task.assert_called_once()
assert mock_repo.create.call_count == 1
# update 至少调用 2 次:mark_processing + 保存 voice_id
# update 至少调用 2 次:mark_processing + 保存 task_id
assert mock_repo.update.call_count >= 2
def test_start_clone_with_ok_status(self) -> None:
"""CosyVoice 直接返回 OK 状态profile 变为 ready。"""
def test_start_clone_with_sync_result(self) -> None:
"""同步模式:CosyVoice 直接返回 voice_idprofile 变为 ready。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
mock_cosyvoice.submit_clone_task.return_value = {
"task_id": "",
"voice_id": "voice-sync-123",
"status": "OK",
"request_id": "req-456",
}
@@ -244,8 +244,8 @@ class TestRetryClone:
mock_repo.update.side_effect = lambda p: p
mock_cosyvoice.submit_clone_task.return_value = {
"voice_id": "voice-retry",
"status": "DEPLOYING",
"task_id": "task-retry",
"voice_id": "",
"request_id": "req-retry",
}
@@ -253,11 +253,11 @@ class TestRetryClone:
result = service.retry_clone(profile.id, "user-123")
assert result.status == VoiceCloneStatus.PROCESSING
assert result.metadata["cosyvoice_task_id"] == "voice-retry"
assert result.metadata["cosyvoice_task_id"] == "task-retry"
assert result.retry_count == 2 # prepare_retry 增加了一次
def test_retry_clone_with_ok_status(self) -> None:
"""重试成功,直接返回 OK 状态"""
def test_retry_clone_with_sync_result(self) -> None:
"""重试成功,同步模式"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
@@ -266,8 +266,8 @@ class TestRetryClone:
mock_repo.update.side_effect = lambda p: p
mock_cosyvoice.submit_clone_task.return_value = {
"task_id": "",
"voice_id": "voice-retry-sync",
"status": "OK",
"request_id": "req-retry",
}