Files
banban/talkingq-url/test/volcano_tts_simple.py
2026-03-24 15:04:36 +08:00

304 lines
12 KiB
Python
Raw Permalink 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.

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())