import asyncio import time import json import uuid import aiohttp import websockets import hmac import base64 import hashlib import asyncio from urllib.parse import urlparse, urlencode from tabulate import tabulate from config.settings import load_config description = "流式TTS语音合成首词耗时测试" class StreamTTSPerformanceTester: def __init__(self, config): self.config = config self.test_texts = [ "你好,这是一句话。" ] self.results = [] async def test_aliyun_tts(self, text=None, test_count=5): """测试阿里云流式TTS首词延迟(测试多次取平均)""" text = text or self.test_texts[0] latencies = [] for i in range(test_count): try: tts_config = self.config["TTS"]["AliyunStreamTTS"] appkey = tts_config["appkey"] token = tts_config["token"] voice = tts_config["voice"] host = tts_config["host"] ws_url = f"wss://{host}/ws/v1" # 统一计时起点:在建立连接前开始计时 start_time = time.time() async with websockets.connect(ws_url, extra_headers={"X-NLS-Token": token}) as ws: task_id = str(uuid.uuid4()) message_id = str(uuid.uuid4()) start_request = { "header": { "message_id": message_id, "task_id": task_id, "namespace": "FlowingSpeechSynthesizer", "name": "StartSynthesis", "appkey": appkey, }, "payload": { "voice": voice, "format": "pcm", "sample_rate": 16000, "volume": 50, "speech_rate": 0, "pitch_rate": 0, "enable_subtitle": True, } } await ws.send(json.dumps(start_request)) start_response = json.loads(await ws.recv()) if start_response["header"]["name"] != "SynthesisStarted": raise Exception("启动合成失败") run_request = { "header": { "message_id": str(uuid.uuid4()), "task_id": task_id, "namespace": "FlowingSpeechSynthesizer", "name": "RunSynthesis", "appkey": appkey, }, "payload": {"text": text} } await ws.send(json.dumps(run_request)) while True: response = await ws.recv() if isinstance(response, bytes): latency = time.time() - start_time latencies.append(latency) print(f"[阿里云TTS] 第{i+1}次 首词延迟: {latency:.3f}s") break elif isinstance(response, str): data = json.loads(response) if data["header"]["name"] == "TaskFailed": raise Exception(f"合成失败: {data['payload']['error_info']}") except Exception as e: print(f"[阿里云TTS] 第{i+1}次测试失败: {str(e)}") latencies.append(None) return self._calculate_result("阿里云TTS", latencies, test_count) async def test_alibl_tts(self, text=None, test_count=5): """测试阿里云百炼CosyVoice流式TTS首词延迟""" text = text or self.test_texts[0] latencies = [] for i in range(test_count): try: tts_config = self.config["TTS"]["AliBLTTS"] api_key = tts_config["api_key"] model = tts_config.get("model", "cosyvoice-v2") voice = tts_config.get("voice", "longxiaochun_v2") format_type = tts_config.get("format", "pcm") sample_rate = int(tts_config.get("sample_rate", "24000")) ws_url = "wss://dashscope.aliyuncs.com/api-ws/v1/inference/" headers = { "Authorization": f"Bearer {api_key}", "X-DashScope-DataInspection": "enable", } start_time = time.time() async with websockets.connect( ws_url, additional_headers=headers, ping_interval=30, ping_timeout=10, close_timeout=10, max_size=10 * 1024 * 1024, ) as ws: session_id = uuid.uuid4().hex # 1. 发送 run-task(启动任务) run_task_message = { "header": { "action": "run-task", "task_id": session_id, "streaming": "duplex", }, "payload": { "task_group": "audio", "task": "tts", "function": "SpeechSynthesizer", "model": model, "parameters": { "text_type": "PlainText", "voice": voice, "format": format_type, "sample_rate": sample_rate, "volume": 50, "rate": 1.0, "pitch": 1.0, }, "input": {} }, } await ws.send(json.dumps(run_task_message)) # 2. 等待 task-started 事件(关键!必须等这个再发文本) task_started = False while not task_started: msg = await ws.recv() if isinstance(msg, str): data = json.loads(msg) header = data.get("header", {}) event = header.get("event") if event == "task-started": task_started = True print(f"[阿里云百炼TTS] 第{i+1}次 任务启动成功") elif event == "task-failed": raise Exception(f"启动失败: {header.get('error_message', '未知错误')}") # 3. 发送 continue-task(发送文本!这是正确动作) continue_task_message = { "header": { "action": "continue-task", # 改回 continue-task "task_id": session_id, "streaming": "duplex", }, "payload": {"input": {"text": text}}, } await ws.send(json.dumps(continue_task_message)) # 4. 发送 finish-task(结束任务) finish_task_message = { "header": { "action": "finish-task", "task_id": session_id, "streaming": "duplex", }, "payload": {"input": {}} } await ws.send(json.dumps(finish_task_message)) # 5. 等待第一个音频数据块 while True: msg = await asyncio.wait_for(ws.recv(), timeout=15.0) if isinstance(msg, (bytes, bytearray)) and len(msg) > 0: latency = time.time() - start_time print(f"[阿里云百炼TTS] 第{i+1}次 首词延迟: {latency:.3f}s") latencies.append(latency) break elif isinstance(msg, str): data = json.loads(msg) event = data.get("header", {}).get("event") if event == "task-failed": raise Exception(f"合成失败: {data}") elif event == "task-finished": if not latencies or latencies[-1] is None: raise Exception("任务结束但未收到音频") except Exception as e: print(f"[阿里云百炼TTS] 第{i+1}次失败: {str(e)}") latencies.append(None) return self._calculate_result("阿里云百炼TTS", latencies, test_count) async def test_doubao_tts(self, text=None, test_count=5): """测试火山引擎流式TTS首词延迟(测试多次取平均)""" text = text or self.test_texts[0] latencies = [] for i in range(test_count): try: tts_config = self.config["TTS"]["HuoshanDoubleStreamTTS"] ws_url = tts_config["ws_url"] app_id = tts_config["appid"] access_token = tts_config["access_token"] resource_id = tts_config["resource_id"] speaker = tts_config["speaker"] start_time = time.time() ws_header = { "X-Api-App-Key": app_id, "X-Api-Access-Key": access_token, "X-Api-Resource-Id": resource_id, "X-Api-Connect-Id": str(uuid.uuid4()), } async with websockets.connect(ws_url, additional_headers=ws_header, max_size=1000000000) as ws: session_id = uuid.uuid4().hex # 发送会话启动请求 header = bytes([ (0b0001 << 4) | 0b0001, 0b0001 << 4 | 0b1011, 0b0001 << 4 | 0b0000, ]) optional = bytearray() optional.extend((1).to_bytes(4, "big", signed=True)) session_id_bytes = session_id.encode() optional.extend(len(session_id_bytes).to_bytes(4, "big", signed=True)) optional.extend(session_id_bytes) payload = json.dumps({"speaker": speaker}).encode() await ws.send(header + optional + len(payload).to_bytes(4, "big", signed=True) + payload) # 发送文本 header = bytes([ (0b0001 << 4) | 0b0001, 0b0001 << 4 | 0b1011, 0b0001 << 4 | 0b0000, 0 ]) optional = bytearray() optional.extend((200).to_bytes(4, "big", signed=True)) session_id_bytes = session_id.encode() optional.extend(len(session_id_bytes).to_bytes(4, "big", signed=True)) optional.extend(session_id_bytes) payload = json.dumps({"text": text, "speaker": speaker}).encode() await ws.send(header + optional + len(payload).to_bytes(4, "big", signed=True) + payload) first_chunk = await ws.recv() latency = time.time() - start_time latencies.append(latency) print(f"[火山引擎TTS] 第{i+1}次 首词延迟: {latency:.3f}s") except Exception as e: print(f"[火山引擎TTS] 第{i+1}次测试失败: {str(e)}") latencies.append(None) return self._calculate_result("火山引擎TTS", latencies, test_count) async def test_paddlespeech_tts(self, text=None, test_count=5): """测试PaddleSpeech流式TTS首词延迟(测试多次取平均)""" text = text or self.test_texts[0] latencies = [] for i in range(test_count): try: tts_config = self.config["TTS"]["PaddleSpeechTTS"] tts_url = tts_config["url"] spk_id = tts_config["spk_id"] speed = tts_config["speed"] volume = tts_config["volume"] start_time = time.time() async with websockets.connect(tts_url) as ws: # 发送开始请求 await ws.send(json.dumps({ "task": "tts", "signal": "start" })) start_response = json.loads(await ws.recv()) if start_response.get("status") != 0: raise Exception("连接失败") # 发送文本数据 await ws.send(json.dumps({ "text": text, "spk_id": spk_id, "speed": speed, "volume": volume })) # 接收第一个数据块 first_chunk = await ws.recv() latency = time.time() - start_time latencies.append(latency) print(f"[PaddleSpeechTTS] 第{i+1}次 首词延迟: {latency:.3f}s") # 发送结束请求 end_request = { "task": "tts", "signal": "end" } await ws.send(json.dumps(end_request)) # 确保连接正常关闭 try: await ws.recv() except websockets.exceptions.ConnectionClosedOK: pass except Exception as e: print(f"[PaddleSpeechTTS] 第{i+1}次测试失败: {str(e)}") latencies.append(None) return self._calculate_result("PaddleSpeechTTS", latencies, test_count) async def test_indexstream_tts(self, text=None, test_count=5): """测试IndexStream流式TTS首词延迟(测试多次取平均)""" text = text or self.test_texts[0] latencies = [] for i in range(test_count): try: tts_config = self.config["TTS"]["IndexStreamTTS"] api_url = tts_config.get("api_url") voice = tts_config.get("voice") # 统一计时起点:在建立连接前开始计时 start_time = time.time() async with aiohttp.ClientSession() as session: payload = {"text": text, "character": voice} async with session.post(api_url, json=payload, timeout=10) as resp: if resp.status != 200: raise Exception(f"请求失败: {resp.status}, {await resp.text()}") async for chunk in resp.content.iter_any(): data = chunk[0] if isinstance(chunk, (list, tuple)) else chunk if not data: continue latency = time.time() - start_time latencies.append(latency) print(f"[IndexStreamTTS] 第{i+1}次 首词延迟: {latency:.3f}s") resp.close() break else: latencies.append(None) except Exception as e: print(f"[IndexStreamTTS] 第{i+1}次测试失败: {str(e)}") latencies.append(None) return self._calculate_result("IndexStreamTTS", latencies, test_count) async def test_linkerai_tts(self, text=None, test_count=5): """测试Linkerai流式TTS首词延迟(测试多次取平均)""" text = text or self.test_texts[0] latencies = [] for i in range(test_count): try: tts_config = self.config["TTS"]["LinkeraiTTS"] api_url = tts_config["api_url"] access_token = tts_config["access_token"] voice = tts_config["voice"] # 统一计时起点:在建立连接前开始计时 start_time = time.time() async with aiohttp.ClientSession() as session: params = { "tts_text": text, "spk_id": voice, "frame_durition": 60, "stream": "true", "target_sr": 16000, "audio_format": "pcm", "instruct_text": "请生成一段自然流畅的语音", } headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", } async with session.get(api_url, params=params, headers=headers, timeout=10) as resp: if resp.status != 200: raise Exception(f"请求失败: {resp.status}, {await resp.text()}") # 接收第一个数据块 async for _ in resp.content.iter_any(): latency = time.time() - start_time latencies.append(latency) print(f"[LinkeraiTTS] 第{i+1}次 首词延迟: {latency:.3f}s") break else: latencies.append(None) except Exception as e: print(f"[LinkeraiTTS] 第{i+1}次测试失败: {str(e)}") latencies.append(None) return self._calculate_result("LinkeraiTTS", latencies, test_count) async def test_xunfei_tts(self, text=None, test_count=5): """测试讯飞流式TTS首词延迟(测试多次取平均)""" text = text or self.test_texts[0] latencies = [] for i in range(test_count): try: # 修正配置节点名称,与配置文件中的XunFeiTTS匹配 tts_config = self.config["TTS"]["XunFeiTTS"] app_id = tts_config["app_id"] api_key = tts_config["api_key"] api_secret = tts_config["api_secret"] api_url = tts_config.get("api_url", "wss://cbm01.cn-huabei-1.xf-yun.com/v1/private/mcd9m97e6") voice = tts_config.get("voice", "x5_lingxiaoxuan_flow") # 生成认证URL auth_url = self._create_xunfei_auth_url(api_key, api_secret, api_url) start_time = time.time() async with websockets.connect( auth_url, ping_interval=30, ping_timeout=10, close_timeout=10, max_size=1000000000 ) as ws: # 构造请求 request = self._build_xunfei_request(app_id, text, voice) await ws.send(json.dumps(request)) # 等待第一个音频数据块 first_audio_received = False while not first_audio_received: msg = await asyncio.wait_for(ws.recv(), timeout=10) data = json.loads(msg) header = data.get("header", {}) code = header.get("code") if code != 0: message = header.get("message", "未知错误") raise Exception(f"合成失败: {code} - {message}") payload = data.get("payload", {}) audio_payload = payload.get("audio", {}) if audio_payload: status = audio_payload.get("status", 0) audio_data = audio_payload.get("audio", "") if status == 1 and audio_data: # 收到第一个音频数据块 latency = time.time() - start_time latencies.append(latency) print(f"[讯飞TTS] 第{i+1}次 首词延迟: {latency:.3f}s") first_audio_received = True break except Exception as e: print(f"[讯飞TTS] 第{i+1}次测试失败: {str(e)}") latencies.append(None) return self._calculate_result("讯飞TTS", latencies, test_count) def _create_xunfei_auth_url(self, api_key, api_secret, api_url): """生成讯飞WebSocket认证URL""" parsed_url = urlparse(api_url) host = parsed_url.netloc path = parsed_url.path # 获取UTC时间,讯飞要求使用RFC1123格式 now = time.gmtime() date = time.strftime('%a, %d %b %Y %H:%M:%S GMT', now) # 构造签名字符串 signature_origin = f"host: {host}\ndate: {date}\nGET {path} HTTP/1.1" # 计算签名 signature_sha = hmac.new( api_secret.encode('utf-8'), signature_origin.encode('utf-8'), digestmod=hashlib.sha256 ).digest() signature_sha_base64 = base64.b64encode(signature_sha).decode(encoding='utf-8') # 构造authorization authorization_origin = f'api_key="{api_key}", algorithm="hmac-sha256", headers="host date request-line", signature="{signature_sha_base64}"' authorization = base64.b64encode(authorization_origin.encode('utf-8')).decode(encoding='utf-8') # 构造最终的WebSocket URL v = { "authorization": authorization, "date": date, "host": host } url = api_url + '?' + urlencode(v) return url def _build_xunfei_request(self, app_id, text, voice): """构建讯飞TTS请求结构""" return { "header": { "app_id": app_id, "status": 2, }, "parameter": { "oral": { "oral_level": "mid", "spark_assist": 1, "stop_split": 0, "remain": 0 }, "tts": { "vcn": voice, "speed": 50, "volume": 50, "pitch": 50, "bgs": 0, "reg": 0, "rdn": 0, "rhy": 0, "audio": { "encoding": "raw", "sample_rate": 24000, "channels": 1, "bit_depth": 16, "frame_size": 0 } } }, "payload": { "text": { "encoding": "utf8", "compress": "raw", "format": "plain", "status": 2, "seq": 1, "text": base64.b64encode(text.encode('utf-8')).decode('utf-8') } } } def _calculate_result(self, service_name, latencies, test_count): """计算测试结果(正确处理None值,剔除失败测试)""" # 剔除失败的测试(None值和<=0延迟),只统计有效延迟 valid_latencies = [l for l in latencies if l is not None and l > 0] if valid_latencies: avg_latency = sum(valid_latencies) / len(valid_latencies) status = f"成功({len(valid_latencies)}/{test_count}次有效)" else: avg_latency = 0 status = "失败: 所有测试均失败" return {"name": service_name, "latency": avg_latency, "status": status} def _print_results(self, test_text, test_count): """打印测试结果""" if not self.results: print("没有有效的TTS测试结果") return print(f"\n{'='*60}") print("流式TTS首词延迟测试结果") print(f"{'='*60}") print(f"测试文本: {test_text}") print(f"测试次数: 每个TTS服务测试 {test_count} 次") # 排序结果:成功优先,按延迟升序 success_results = sorted( [r for r in self.results if "成功" in r["status"]], key=lambda x: x["latency"] ) failed_results = [r for r in self.results if "成功" not in r["status"]] table_data = [ [r["name"], f"{r['latency']:.3f}", r["status"]] for r in success_results + failed_results ] print(tabulate(table_data, headers=["TTS服务", "首词延迟(秒)", "状态"], tablefmt="grid")) print("\n测试说明:测量从建立连接到接收第一个音频数据块的时间(包含握手、鉴权、发送文本),取多次测试平均值") print("- 计时起点: 建立WebSocket/HTTP连接前(统一包含网络建连、握手、发送文本全流程)") print("- 超时控制: 单个请求最大等待时间为10秒") print("- 错误处理: 失败的测试不计入平均值,只统计成功测试的延迟") print("- 排序规则: 按平均耗时从快到慢排序") async def run(self, test_text=None, test_count=5): """执行测试 Args: test_text: 要测试的文本,如果为None则使用默认文本 test_count: 每个TTS服务的测试次数 """ test_text = test_text or self.test_texts[0] print(f"开始流式TTS首词延迟测试...") print(f"测试文本: {test_text}") print(f"每个TTS服务测试次数: {test_count}次") if not self.config.get("TTS"): print("配置文件中未找到TTS配置") return # 测试每种TTS服务 self.results = [] # 测试阿里云TTS result = await self.test_aliyun_tts(test_text, test_count) self.results.append(result) # 测试阿里云百炼TTS if self.config.get("TTS", {}).get("AliBLTTS"): result = await self.test_alibl_tts(test_text, test_count) self.results.append(result) # 测试火山引擎TTS result = await self.test_doubao_tts(test_text, test_count) self.results.append(result) # 测试PaddleSpeech TTS result = await self.test_paddlespeech_tts(test_text, test_count) self.results.append(result) # 测试Linkerai TTS result = await self.test_linkerai_tts(test_text, test_count) self.results.append(result) # 测试IndexStreamTTS result = await self.test_indexstream_tts(test_text, test_count) self.results.append(result) # 测试讯飞TTS if self.config.get("TTS", {}).get("XunFeiTTS"): result = await self.test_xunfei_tts(test_text, test_count) self.results.append(result) # 打印结果 self._print_results(test_text, test_count) async def main(): import argparse parser = argparse.ArgumentParser(description="流式TTS首词延迟测试工具") parser.add_argument("--text", help="要测试的文本内容") parser.add_argument("--count", type=int, default=5, help="每个TTS服务的测试次数") args = parser.parse_args() config = await load_config() await StreamTTSPerformanceTester(config).run(args.text, args.count) if __name__ == "__main__": import asyncio asyncio.run(main())