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
694 lines
22 KiB
Python
694 lines
22 KiB
Python
"""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 调用失败,未知错误")
|