"""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 调用失败,未知错误")