304 lines
12 KiB
Python
304 lines
12 KiB
Python
import os
|
||
import asyncio
|
||
import aiohttp
|
||
import base64
|
||
from pathlib import Path
|
||
from pydub import AudioSegment
|
||
import io
|
||
import logging
|
||
from dotenv import load_dotenv
|
||
import shutil
|
||
import uuid
|
||
import yaml
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||
)
|
||
logger = logging.getLogger("volcano_tts_test")
|
||
|
||
|
||
def check_dependencies():
|
||
dependencies = ["ffmpeg", "ffprobe"]
|
||
missing = []
|
||
for dep in dependencies:
|
||
if not shutil.which(dep):
|
||
missing.append(dep)
|
||
if missing:
|
||
logger.error(f"缺少必要依赖: {', '.join(missing)}")
|
||
logger.error(
|
||
"请安装缺失的依赖项。在Ubuntu上可以使用: sudo apt-get install ffmpeg"
|
||
)
|
||
return False
|
||
return True
|
||
|
||
|
||
env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "volcano.env")
|
||
if (os.path.exists(env_path)):
|
||
load_dotenv(env_path)
|
||
logger.info(f"已加载环境变量文件: {env_path}")
|
||
else:
|
||
logger.warning(f"环境变量文件不存在: {env_path}")
|
||
|
||
|
||
PHRASES_TEMPLATES = {
|
||
"zh": {
|
||
"welcome": "你好!我是{name},你想和我聊聊吗?",
|
||
"tts_error": "抱歉,我没听清楚。",
|
||
"low_battery": "我的电池快没电了,你能帮我充电吗?",
|
||
"sleep": "没人和我说话,我要小睡一会儿。"
|
||
},
|
||
"en": {
|
||
"welcome": "Hello! I'm {name}. Would you like to chat with me?",
|
||
"tts_error": "Sorry, I didn't catch that.",
|
||
"low_battery": "My battery is running low. Could you help me recharge?",
|
||
"sleep": "Nobody is talking to me. I'm going to take a short nap."
|
||
}
|
||
}
|
||
|
||
file_prefixes = ["welcome", "tts_error", "low_battery", "sleep"]
|
||
|
||
|
||
def find_role_definition_files(base_dir="assets/roles_definitions"):
|
||
"""查找所有角色定义YAML文件"""
|
||
base_path = Path(base_dir)
|
||
if not base_path.exists():
|
||
logger.error(f"角色定义目录不存在: {base_dir}")
|
||
return []
|
||
|
||
yaml_files = list(base_path.glob("**/*.yml")) + list(base_path.glob("**/*.yaml"))
|
||
return yaml_files
|
||
|
||
|
||
def load_role_definition(yaml_file):
|
||
"""加载并解析角色定义YAML文件"""
|
||
try:
|
||
with open(yaml_file, 'r', encoding='utf-8') as f:
|
||
return yaml.safe_load(f)
|
||
except Exception as e:
|
||
logger.error(f"解析YAML文件失败 {yaml_file}: {str(e)}")
|
||
return None
|
||
|
||
|
||
def generate_phrases_for_language(lang_code, role_name):
|
||
"""为指定语言生成适当的短语"""
|
||
if lang_code not in PHRASES_TEMPLATES:
|
||
logger.warning(f"不支持的语言代码: {lang_code}")
|
||
return []
|
||
|
||
templates = PHRASES_TEMPLATES[lang_code]
|
||
phrases = []
|
||
for key in file_prefixes:
|
||
if key in templates:
|
||
phrases.append(templates[key].format(name=role_name))
|
||
else:
|
||
logger.warning(f"在{lang_code}语言中找不到{key}模板")
|
||
phrases.append("")
|
||
|
||
return phrases
|
||
|
||
|
||
def _determine_cluster_from_voice_type(voice_type: str) -> str:
|
||
"""根据音色ID自动确定集群类型"""
|
||
if voice_type and voice_type.startswith("S_"):
|
||
return "volcano_icl"
|
||
return "volcano_tts"
|
||
|
||
|
||
async def generate_audio_for_role_language(role_config, lang_code, base_dir="assets"):
|
||
"""为角色的特定语言生成音频文件"""
|
||
if 'multilingual' not in role_config or lang_code not in role_config['multilingual']:
|
||
logger.warning(f"角色缺少{lang_code}语言配置")
|
||
return False
|
||
|
||
lang_config = role_config['multilingual'][lang_code]
|
||
if 'name' not in lang_config or 'url' not in lang_config:
|
||
logger.warning(f"角色的{lang_code}语言配置缺少必要字段")
|
||
return False
|
||
|
||
role_name = lang_config['name']
|
||
url_path = lang_config['url']
|
||
|
||
voice_type = lang_config.get('volcano_voice_type', None)
|
||
if not voice_type and 'volcano_voice_type' in role_config:
|
||
voice_type = role_config['volcano_voice_type']
|
||
logger.info(f"语言{lang_code}配置中未找到音色,使用顶层默认音色: {voice_type}")
|
||
|
||
if not voice_type:
|
||
logger.warning(f"角色的{lang_code}语言配置缺少volcano_voice_type,顶层也未定义")
|
||
return False
|
||
|
||
output_dir = Path(base_dir) / url_path
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
api_access_token = os.getenv("VOLCANO_ACCESS_TOKEN", "")
|
||
appid = os.getenv("VOLCANO_APP_ID", "")
|
||
if not api_access_token or not appid:
|
||
logger.error("缺少必要的配置: VOLCANO_ACCESS_TOKEN 或 VOLCANO_APP_ID")
|
||
return False
|
||
|
||
cluster = _determine_cluster_from_voice_type(voice_type)
|
||
logger.info(f"音色 {voice_type} 自动选择集群: {cluster}")
|
||
|
||
base_url = os.getenv(
|
||
"VOLCANO_TTS_BASE_URL", "https://openspeech.bytedance.com/api/v1/tts"
|
||
)
|
||
speed_ratio = float(os.getenv("SPEED_RATIO", "1.0"))
|
||
|
||
logger.info(f"开始生成角色[{role_name}]的{lang_code}语言音频,音色: {voice_type}, 集群: {cluster}")
|
||
|
||
phrases = generate_phrases_for_language(lang_code, role_name)
|
||
if not phrases:
|
||
logger.warning(f"没有为{lang_code}语言生成短语")
|
||
return False
|
||
|
||
for phrase, file_prefix in zip(phrases, file_prefixes):
|
||
if not phrase:
|
||
logger.warning(f"跳过空短语: {file_prefix}")
|
||
continue
|
||
|
||
output_file_prefix = str(output_dir / file_prefix)
|
||
output_file = f"{output_file_prefix}.mp3"
|
||
|
||
unique_reqid = str(uuid.uuid4())
|
||
payload = {
|
||
"app": {
|
||
"appid": appid,
|
||
"token": "access_token",
|
||
"cluster": cluster,
|
||
},
|
||
"user": {"uid": "test_user"},
|
||
"audio": {
|
||
"voice_type": voice_type,
|
||
"encoding": "mp3",
|
||
"speed_ratio": float(speed_ratio),
|
||
},
|
||
"request": {"reqid": unique_reqid, "text": phrase, "operation": "query"},
|
||
}
|
||
headers = {
|
||
"Authorization": f"Bearer;{api_access_token}",
|
||
"Content-Type": "application/json",
|
||
}
|
||
logger.info(f"准备发送请求,文本内容: {phrase}, 请求ID: {unique_reqid}")
|
||
try:
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.post(
|
||
base_url, headers=headers, json=payload
|
||
) as response:
|
||
logid = response.headers.get('X-Tt-Logid', 'unknown')
|
||
logger.debug(f"服务端返回Logid: {logid}")
|
||
|
||
if response.status == 200:
|
||
resp_json = await response.json()
|
||
if "code" in resp_json:
|
||
code = resp_json["code"]
|
||
if code == 3000: # 成功状态码
|
||
audio_base64 = resp_json.get("data")
|
||
if audio_base64:
|
||
audio_data = base64.b64decode(audio_base64)
|
||
audio_stream = io.BytesIO(audio_data)
|
||
sound = AudioSegment.from_file(
|
||
audio_stream, format="mp3"
|
||
)
|
||
sound = (
|
||
sound.set_frame_rate(16000)
|
||
.set_sample_width(2)
|
||
.set_channels(1)
|
||
)
|
||
sound.export(output_file, format="mp3", bitrate="16k")
|
||
logger.info(f"TTS合成成功!")
|
||
logger.info(f"保存音频到: {output_file}")
|
||
else:
|
||
error_msg = "音频数据不存在"
|
||
logger.error(f"TTS合成失败: {error_msg}")
|
||
else:
|
||
error_code = resp_json.get('code')
|
||
error_msg = resp_json.get('message', '未知错误')
|
||
error_info = get_error_description(error_code, error_msg)
|
||
logger.error(f"TTS合成失败: {error_info} (LogID: {logid})")
|
||
else:
|
||
logger.error(f"返回数据格式异常,缺少code字段: {resp_json}")
|
||
else:
|
||
error_text = await response.text()
|
||
logger.error(
|
||
f"TTS合成失败,状态码: {response.status}, 错误: {error_text} (LogID: {logid})"
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"TTS请求出错: {str(e)}")
|
||
await asyncio.sleep(1)
|
||
|
||
return True
|
||
|
||
|
||
async def process_all_roles():
|
||
"""处理所有角色定义文件并生成对应语言的音频"""
|
||
if not check_dependencies():
|
||
return
|
||
|
||
yaml_files = find_role_definition_files()
|
||
logger.info(f"找到 {len(yaml_files)} 个角色定义文件")
|
||
|
||
target_languages = ['zh', 'en']
|
||
assets_base_dir = os.getenv("ASSETS_DIR", "assets")
|
||
|
||
for yaml_file in yaml_files:
|
||
logger.info(f"处理角色定义文件: {yaml_file}")
|
||
role_config = load_role_definition(yaml_file)
|
||
|
||
if not role_config:
|
||
continue
|
||
|
||
if 'multilingual' not in role_config:
|
||
logger.warning(f"角色定义文件 {yaml_file} 不包含多语言配置")
|
||
continue
|
||
|
||
role_name = role_config.get('name', Path(yaml_file).stem)
|
||
logger.info(f"开始处理角色: {role_name}")
|
||
|
||
for lang_code in target_languages:
|
||
if lang_code in role_config['multilingual']:
|
||
logger.info(f"为角色[{role_name}]处理 {lang_code} 语言配置")
|
||
success = await generate_audio_for_role_language(
|
||
role_config, lang_code, assets_base_dir
|
||
)
|
||
if success:
|
||
logger.info(f"角色[{role_name}]的 {lang_code} 语言音频生成完成")
|
||
else:
|
||
logger.warning(f"角色[{role_name}]的 {lang_code} 语言音频生成失败")
|
||
else:
|
||
logger.info(f"角色[{role_name}]没有 {lang_code} 语言配置")
|
||
|
||
def get_error_description(code, message):
|
||
"""根据错误码返回详细的错误描述"""
|
||
error_descriptions = {
|
||
3001: "无效的请求,请检查参数",
|
||
3003: "并发超限,请降低请求频率或增购并发",
|
||
3005: "后端服务忙,请稍后重试",
|
||
3006: "服务中断,请求已完成/失败之后,相同reqid再次请求",
|
||
3010: "文本长度超限,请减少文本长度",
|
||
3011: "无效文本,请检查文本内容",
|
||
3030: "处理超时,请重试或检查文本",
|
||
3031: "处理错误,后端出现异常",
|
||
3032: "等待获取音频超时,请重试",
|
||
3040: "后端链路连接错误,请重试",
|
||
3050: "音色不存在,请检查voice_type参数"
|
||
}
|
||
|
||
if "quota exceeded for types: xxxxxxxxx_lifetime" in message:
|
||
return "试用版用量用完,需开通正式版才能继续使用"
|
||
elif "quota exceeded for types: concurrency" in message:
|
||
return "并发超过限定值,需减少并发调用或增购并发"
|
||
elif "Init Engine Instance failed" in message:
|
||
return "voice_type或cluster参数错误"
|
||
elif "illegal input text" in message:
|
||
return "文本无效,无可合成的有效内容"
|
||
elif "requested grant not found" in message:
|
||
return "鉴权失败,请检查appid和token是否正确"
|
||
elif "access denied" in message:
|
||
return "未拥有当前音色授权,请在控制台购买该音色"
|
||
|
||
return f"错误码: {code}, 错误信息: {message} - {error_descriptions.get(code, '未知错误')}"
|
||
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(process_all_roles())
|