Files
xiaoxia-saas/packages/application/cosyvoice_service.py
T
灵应 8935196fcd
Deploy / Staging E2E Tests (push) Has been skipped
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Deploy Staging (push) Failing after 138h4m33s
CI/CD Pipeline / Frontend Lint (push) Failing after 138h4m39s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 138h4m39s
style: 后端代码black格式化
2026-07-03 18:49:54 +08:00

694 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""CosyVoice 语音服务 — Phase 3.
封装阿里云 CosyVoice 语音合成 API,提供:
- 预置音色列表查询
- 音色克隆(提交任务 + 轮询状态)
- 语音合成(提交任务 + 轮询状态)
API 文档: https://help.aliyun.com/zh/model-studio/cosyvoice
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from typing import Any, Optional
import httpx
from packages.domain.preset_voices import PresetVoice, get_preset_voices
from packages.shared.config import get_shared_settings
logger = logging.getLogger(__name__)
class CosyVoiceError(Exception):
"""CosyVoice API 调用异常。"""
pass
class CosyVoiceTimeoutError(CosyVoiceError):
"""CosyVoice API 超时。"""
pass
class CosyVoiceAuthError(CosyVoiceError):
"""CosyVoice API 认证失败。"""
pass
@dataclass
class CloneResult:
"""音色克隆结果。"""
voice_id: str
request_id: str = ""
@dataclass
class SynthesizeResult:
"""语音合成结果。"""
audio_url: str
duration: float = 0.0
file_size: int = 0
request_id: str = ""
class CosyVoiceService:
"""CosyVoice 语音服务。
封装阿里云 CosyVoice API,提供音色克隆和语音合成功能。
支持同步和异步两种模式:
- 同步:API 直接返回结果
- 异步:API 返回 task_id,需要轮询状态
使用示例:
service = CosyVoiceService(
api_key="your-api-key",
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")
# 语音合成
result = service.synthesize_speech(text="你好世界", voice_id="longxiaochun")
"""
# 轮询配置
POLL_INTERVAL = 2.0 # 秒
MAX_POLL_ATTEMPTS = 60 # 最多轮询 60 次(2分钟)
# 重试配置
MAX_RETRIES = 3
RETRY_BACKOFF = 1.0 # 秒,指数退避基数
def __init__(
self,
api_key: str = "",
base_url: str = "",
model: str = "",
http_client: Optional[httpx.Client] = None,
) -> None:
"""初始化 CosyVoice 服务。
Args:
api_key: CosyVoice API Key,为空时从配置读取
base_url: CosyVoice API Base URL,为空时从配置读取
model: CosyVoice 模型名称,为空时从配置读取
http_client: 可选的 HTTP 客户端(用于测试注入)
"""
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._client = http_client or httpx.Client(
timeout=httpx.Timeout(30.0, connect=10.0),
)
self._owns_client = http_client is None
def __enter__(self) -> CosyVoiceService:
return self
def __exit__(self, *args: Any) -> None:
self.close()
def close(self) -> None:
"""关闭 HTTP 客户端。"""
if self._owns_client and self._client:
self._client.close()
# ── 预置音色 ─────────────────────────────────────────
def list_preset_voices(self) -> list[PresetVoice]:
"""获取预置音色列表。
Returns:
预置音色列表
"""
return get_preset_voices()
# ── 音色克隆 ─────────────────────────────────────────
def submit_clone_task(
self,
audio_url: str,
voice_name: str = "",
language: str = "zh-CN",
) -> dict:
"""提交音色克隆任务(非阻塞)。
只提交任务到 CosyVoice API,不轮询结果。
返回的 dict 包含 task_id(异步)或 voice_id(同步)。
Args:
audio_url: 参考音频 URL
voice_name: 音色名称(可选)
language: 语言代码
Returns:
dict: {"task_id": str, "voice_id": str, "request_id": str}
task_id 和 voice_id 至少有一个非空
Raises:
CosyVoiceError: API 调用失败
CosyVoiceAuthError: 认证失败
ValueError: 参数无效
"""
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
response = self._call_api(
method="POST",
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", "")
request_id = response.get("request_id", "")
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,
"request_id": request_id,
}
def check_task_status(self, task_id: str) -> dict:
"""查询克隆任务状态(单次查询,不轮询)。
Args:
task_id: 任务 ID
Returns:
dict: {"status": str, "voice_id": str, "message": str}
status 为 SUCCEEDED/FAILED/PENDING/RUNNING
Raises:
CosyVoiceError: API 调用失败
CosyVoiceAuthError: 认证失败
"""
if not self._api_key:
raise CosyVoiceAuthError("CosyVoice API Key 未配置")
response = self._call_api(
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": status,
"voice_id": voice_id,
"message": message,
}
def poll_clone_task(self, task_id: str, timeout: float = 300.0) -> dict:
"""轮询音色克隆任务状态(公开方法)。
供 Celery 后台任务调用,轮询直到完成或超时。
Args:
task_id: CosyVoice 任务 ID
timeout: 超时时间(秒),默认 300
Returns:
dict: {"voice_id": str}
Raises:
CosyVoiceError: 任务失败
CosyVoiceTimeoutError: 超时
"""
return self._poll_clone_task(task_id, timeout=timeout)
def clone_voice(
self,
audio_url: str,
voice_name: str = "",
language: str = "zh-CN",
timeout: float = 300.0,
) -> CloneResult:
"""克隆音色。
提交音色克隆任务到 CosyVoice API,并轮询直到完成或超时。
Args:
audio_url: 参考音频 URL
voice_name: 音色名称(可选)
language: 语言代码
timeout: 超时时间(秒)
Returns:
CloneResult: 克隆结果,包含 voice_id
Raises:
CosyVoiceError: API 调用失败
CosyVoiceTimeoutError: 超时
CosyVoiceAuthError: 认证失败
ValueError: 参数无效
"""
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,
)
# 解析响应
output = response.get("output", {})
# 检查是否有 task_id(异步模式)
task_id = output.get("task_id")
voice_id = output.get("voice_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}")
# ── 语音合成 ─────────────────────────────────────────
def submit_synthesize_task(
self,
text: str,
voice_id: str = "",
sample_rate: int = 0,
format: str = "",
speed: float = 1.0,
) -> dict:
"""提交语音合成任务(非阻塞)。
只提交任务到 CosyVoice API,不轮询结果。
返回的 dict 包含 task_id(异步)或 audio_url(同步)。
Args:
text: 要合成的文本
voice_id: 音色 ID(预置音色或克隆音色)
sample_rate: 采样率(Hz),0 表示使用配置默认值
format: 输出格式(mp3/wav/pcm),空表示使用配置默认值
speed: 语速(0.5-2.0),1.0 为正常速度
Returns:
dict: {"task_id": str, "audio_url": str, "request_id": str}
task_id 和 audio_url 至少有一个非空
Raises:
CosyVoiceError: API 调用失败
CosyVoiceAuthError: 认证失败
ValueError: 参数无效
"""
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,
},
}
response = self._call_api(
method="POST",
path="/services/aigc/text2audio/generation",
json=payload,
timeout=60.0,
)
output = response.get("output", {})
task_id = output.get("task_id", "")
audio_url = output.get("audio_url", "")
request_id = response.get("request_id", "")
if not task_id and not audio_url:
raise CosyVoiceError(f"CosyVoice API 未返回 task_id 或 audio_url: {response}")
return {
"task_id": task_id,
"audio_url": audio_url,
"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:
"""轮询语音合成任务状态(公开方法)。
供 Celery 后台任务调用,轮询直到完成或超时。
Args:
task_id: CosyVoice 任务 ID
timeout: 超时时间(秒),默认 120
Returns:
dict: {"audio_url": str, "duration": float, "file_size": int}
Raises:
CosyVoiceError: 任务失败
CosyVoiceTimeoutError: 超时
"""
return self._poll_synthesize_task(task_id, timeout=timeout)
def synthesize_speech(
self,
text: str,
voice_id: str = "",
sample_rate: int = 0,
format: str = "",
speed: float = 1.0,
timeout: float = 120.0,
) -> SynthesizeResult:
"""语音合成。
提交语音合成任务到 CosyVoice API,并轮询直到完成或超时。
Args:
text: 要合成的文本
voice_id: 音色 ID(预置音色或克隆音色)
sample_rate: 采样率(Hz),0 表示使用配置默认值
format: 输出格式(mp3/wav/pcm),空表示使用配置默认值
speed: 语速(0.5-2.0),1.0 为正常速度
timeout: 超时时间(秒)
Returns:
SynthesizeResult: 合成结果,包含 audio_url
Raises:
CosyVoiceError: API 调用失败
CosyVoiceTimeoutError: 超时
CosyVoiceAuthError: 认证失败
ValueError: 参数无效
"""
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,
)
# 解析响应
output = response.get("output", {})
# 检查是否有 task_id(异步模式)
task_id = output.get("task_id")
audio_url = output.get("audio_url")
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:
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":
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,
method: str,
path: str,
json: Optional[dict] = None,
timeout: float = 30.0,
) -> dict:
"""调用 CosyVoice API。
支持重试和错误处理。
Args:
method: HTTP 方法(GET/POST
path: API 路径
json: 请求体
timeout: 超时时间(秒)
Returns:
API 响应字典
Raises:
CosyVoiceError: API 调用失败
CosyVoiceAuthError: 认证失败
CosyVoiceTimeoutError: 超时
"""
url = f"{self._base_url}{path}"
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
last_error: Optional[Exception] = None
for attempt in range(self.MAX_RETRIES):
try:
response = self._client.request(
method=method,
url=url,
headers=headers,
json=json,
timeout=timeout,
)
# 处理响应
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 >= 500:
# 服务端错误,可重试
last_error = CosyVoiceError(f"CosyVoice API 服务端错误: HTTP {response.status_code}")
logger.warning(
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}"
)
except httpx.TimeoutException as e:
last_error = CosyVoiceTimeoutError(f"请求超时: {e}")
logger.warning(f"CosyVoice API 超时 (尝试 {attempt + 1}/{self.MAX_RETRIES})")
except httpx.RequestError as e:
last_error = CosyVoiceError(f"请求错误: {e}")
logger.warning(f"CosyVoice API 请求错误 (尝试 {attempt + 1}/{self.MAX_RETRIES}): {e}")
# 指数退避
if attempt < self.MAX_RETRIES - 1:
sleep_time = self.RETRY_BACKOFF * (2**attempt)
time.sleep(sleep_time)
# 所有重试都失败
if last_error:
raise last_error
raise CosyVoiceError("CosyVoice API 调用失败,未知错误")