mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-29 01:23:52 +08:00
Merge pull request #2665 from xinnan-tech/update_performance_tester
update:统一流式测速工具统计时间区间,新增百炼平台流式TTS测速
This commit is contained in:
@@ -50,7 +50,9 @@ class BaseASRTester:
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def _calculate_result(self, service_name, latencies, test_count):
|
def _calculate_result(self, service_name, latencies, test_count):
|
||||||
valid_latencies = [l for l in latencies if l > 0]
|
"""计算测试结果(修复:正确处理None值,剔除失败测试)"""
|
||||||
|
# 剔除None值(失败的测试)和无效延迟,只统计有效延迟
|
||||||
|
valid_latencies = [l for l in latencies if l is not None and l > 0]
|
||||||
if valid_latencies:
|
if valid_latencies:
|
||||||
avg_latency = sum(valid_latencies) / len(valid_latencies)
|
avg_latency = sum(valid_latencies) / len(valid_latencies)
|
||||||
status = f"成功({len(valid_latencies)}/{test_count}次有效)"
|
status = f"成功({len(valid_latencies)}/{test_count}次有效)"
|
||||||
@@ -64,16 +66,45 @@ class DoubaoStreamASRTester(BaseASRTester):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__("DoubaoStreamASR")
|
super().__init__("DoubaoStreamASR")
|
||||||
|
|
||||||
def _generate_header(self):
|
def _generate_header(
|
||||||
|
self,
|
||||||
|
version=0x01,
|
||||||
|
message_type=0x01,
|
||||||
|
message_type_specific_flags=0x00,
|
||||||
|
serial_method=0x01,
|
||||||
|
compression_type=0x01,
|
||||||
|
reserved_data=0x00,
|
||||||
|
extension_header: bytes = b"",
|
||||||
|
):
|
||||||
|
"""生成协议头(修复:使用正确的Header格式)"""
|
||||||
header = bytearray()
|
header = bytearray()
|
||||||
header.append((0x01 << 4) | 0x01)
|
header_size = int(len(extension_header) / 4) + 1
|
||||||
header.append((0x01 << 4) | 0x00)
|
header.append((version << 4) | header_size)
|
||||||
header.append((0x01 << 4) | 0x01)
|
header.append((message_type << 4) | message_type_specific_flags)
|
||||||
header.append(0x00)
|
header.append((serial_method << 4) | compression_type)
|
||||||
|
header.append(reserved_data)
|
||||||
|
header.extend(extension_header)
|
||||||
return header
|
return header
|
||||||
|
|
||||||
def _generate_audio_default_header(self):
|
def _generate_audio_default_header(self):
|
||||||
return self._generate_header()
|
"""生成音频数据Header"""
|
||||||
|
return self._generate_header(
|
||||||
|
version=0x01,
|
||||||
|
message_type=0x02,
|
||||||
|
message_type_specific_flags=0x00, # 普通音频帧
|
||||||
|
serial_method=0x01,
|
||||||
|
compression_type=0x01,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _generate_last_audio_header(self):
|
||||||
|
"""生成最后一帧音频的Header(标记音频结束)"""
|
||||||
|
return self._generate_header(
|
||||||
|
version=0x01,
|
||||||
|
message_type=0x02,
|
||||||
|
message_type_specific_flags=0x02, # 0x02表示这是最后一帧
|
||||||
|
serial_method=0x01,
|
||||||
|
compression_type=0x01,
|
||||||
|
)
|
||||||
|
|
||||||
def _parse_response(self, res: bytes) -> dict:
|
def _parse_response(self, res: bytes) -> dict:
|
||||||
try:
|
try:
|
||||||
@@ -110,6 +141,7 @@ class DoubaoStreamASRTester(BaseASRTester):
|
|||||||
ws_url = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel"
|
ws_url = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel"
|
||||||
appid = self.asr_config["appid"]
|
appid = self.asr_config["appid"]
|
||||||
access_token = self.asr_config["access_token"]
|
access_token = self.asr_config["access_token"]
|
||||||
|
cluster = self.asr_config.get("cluster", "volcengine_input_common")
|
||||||
uid = self.asr_config.get("uid", "streaming_asr_service")
|
uid = self.asr_config.get("uid", "streaming_asr_service")
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
@@ -130,7 +162,7 @@ class DoubaoStreamASRTester(BaseASRTester):
|
|||||||
close_timeout=10
|
close_timeout=10
|
||||||
) as ws:
|
) as ws:
|
||||||
request_params = {
|
request_params = {
|
||||||
"app": {"appid": appid, "token": access_token},
|
"app": {"appid": appid, "cluster": cluster, "token": access_token},
|
||||||
"user": {"uid": uid},
|
"user": {"uid": uid},
|
||||||
"request": {
|
"request": {
|
||||||
"reqid": str(uuid.uuid4()),
|
"reqid": str(uuid.uuid4()),
|
||||||
@@ -166,8 +198,9 @@ class DoubaoStreamASRTester(BaseASRTester):
|
|||||||
if audio_data.startswith(b'RIFF'):
|
if audio_data.startswith(b'RIFF'):
|
||||||
audio_data = audio_data[44:]
|
audio_data = audio_data[44:]
|
||||||
|
|
||||||
|
# 发送音频数据(使用最后一帧标记,告诉服务端音频已结束)
|
||||||
payload = gzip.compress(audio_data)
|
payload = gzip.compress(audio_data)
|
||||||
audio_request = bytearray(self._generate_audio_default_header())
|
audio_request = bytearray(self._generate_last_audio_header()) # 修复:使用最后一帧Header
|
||||||
audio_request.extend(len(payload).to_bytes(4, "big"))
|
audio_request.extend(len(payload).to_bytes(4, "big"))
|
||||||
audio_request.extend(payload)
|
audio_request.extend(payload)
|
||||||
await ws.send(audio_request)
|
await ws.send(audio_request)
|
||||||
@@ -175,11 +208,12 @@ class DoubaoStreamASRTester(BaseASRTester):
|
|||||||
first_chunk = await ws.recv()
|
first_chunk = await ws.recv()
|
||||||
latency = time.time() - start_time
|
latency = time.time() - start_time
|
||||||
latencies.append(latency)
|
latencies.append(latency)
|
||||||
|
print(f"[豆包ASR] 第{i+1}次 首词延迟: {latency:.3f}s")
|
||||||
await ws.close()
|
await ws.close()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[豆包ASR] 第{i+1}次测试失败: {str(e)}")
|
print(f"[豆包ASR] 第{i+1}次测试失败: {str(e)}")
|
||||||
latencies.append(0)
|
latencies.append(None)
|
||||||
|
|
||||||
return self._calculate_result("豆包流式ASR", latencies, test_count)
|
return self._calculate_result("豆包流式ASR", latencies, test_count)
|
||||||
|
|
||||||
@@ -189,11 +223,12 @@ class QwenASRFlashTester(BaseASRTester):
|
|||||||
super().__init__("Qwen3ASRFlash")
|
super().__init__("Qwen3ASRFlash")
|
||||||
|
|
||||||
async def _test_single(self, audio_file_info):
|
async def _test_single(self, audio_file_info):
|
||||||
start_time = time.time()
|
|
||||||
temp_file_path = None
|
temp_file_path = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
audio_data = audio_file_info['data']
|
audio_data = audio_file_info['data']
|
||||||
|
|
||||||
|
# 优化:将临时文件准备工作移到计时前,减少磁盘IO对性能测试的影响
|
||||||
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
|
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
|
||||||
temp_file_path = f.name
|
temp_file_path = f.name
|
||||||
|
|
||||||
@@ -221,6 +256,9 @@ class QwenASRFlashTester(BaseASRTester):
|
|||||||
|
|
||||||
dashscope.api_key = api_key
|
dashscope.api_key = api_key
|
||||||
|
|
||||||
|
# 统一计时起点:在API调用前开始计时(但文件准备已完成)
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
response = dashscope.MultiModalConversation.call(
|
response = dashscope.MultiModalConversation.call(
|
||||||
model="qwen3-asr-flash",
|
model="qwen3-asr-flash",
|
||||||
messages=messages,
|
messages=messages,
|
||||||
@@ -257,10 +295,10 @@ class QwenASRFlashTester(BaseASRTester):
|
|||||||
# print(f"\n[通义ASR] 开始第 {i+1} 次测试...")
|
# print(f"\n[通义ASR] 开始第 {i+1} 次测试...")
|
||||||
latency = await self._test_single(self.test_audio_files[0])
|
latency = await self._test_single(self.test_audio_files[0])
|
||||||
latencies.append(latency)
|
latencies.append(latency)
|
||||||
# print(f"[通义ASR] 第{i+1}次成功 延迟: {latency:.3f}s")
|
print(f"[通义ASR] 第{i+1}次 首词延迟: {latency:.3f}s")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# print(f"[通义ASR] 第{i+1}次测试失败: {str(e)}")
|
# print(f"[通义ASR] 第{i+1}次测试失败: {str(e)}")
|
||||||
latencies.append(0)
|
latencies.append(None)
|
||||||
|
|
||||||
return self._calculate_result("通义千问ASR", latencies, test_count)
|
return self._calculate_result("通义千问ASR", latencies, test_count)
|
||||||
|
|
||||||
@@ -270,132 +308,113 @@ class XunfeiStreamASRTester(BaseASRTester):
|
|||||||
super().__init__("XunfeiStreamASR")
|
super().__init__("XunfeiStreamASR")
|
||||||
|
|
||||||
def _create_url(self):
|
def _create_url(self):
|
||||||
"""生成讯飞ASR认证URL"""
|
url = "wss://iat-api.xfyun.cn/v2/iat"
|
||||||
url = 'ws://iat.cn-huabei-1.xf-yun.com/v1'
|
|
||||||
# 生成RFC1123格式的时间戳
|
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
date = format_date_time(mktime(now.timetuple()))
|
date = format_date_time(mktime(now.timetuple()))
|
||||||
|
|
||||||
# 拼接字符串
|
signature_origin = f"host: iat-api.xfyun.cn\ndate: {date}\nGET /v2/iat HTTP/1.1"
|
||||||
signature_origin = "host: " + "iat.cn-huabei-1.xf-yun.com" + "\n"
|
signature_sha = hmac.new(
|
||||||
signature_origin += "date: " + date + "\n"
|
self.asr_config["api_secret"].encode('utf-8'),
|
||||||
signature_origin += "GET " + "/v1 " + "HTTP/1.1"
|
signature_origin.encode('utf-8'),
|
||||||
|
hashlib.sha256
|
||||||
|
).digest()
|
||||||
|
signature_sha = base64.b64encode(signature_sha).decode()
|
||||||
|
|
||||||
# 进行hmac-sha256进行加密
|
authorization_origin = f'api_key="{self.asr_config["api_key"]}", algorithm="hmac-sha256", headers="host date request-line", signature="{signature_sha}"'
|
||||||
signature_sha = hmac.new(self.asr_config["api_secret"].encode('utf-8'), signature_origin.encode('utf-8'),
|
authorization = base64.b64encode(authorization_origin.encode()).decode()
|
||||||
digestmod=hashlib.sha256).digest()
|
|
||||||
signature_sha = base64.b64encode(signature_sha).decode(encoding='utf-8')
|
|
||||||
|
|
||||||
authorization_origin = "api_key=\"%s\", algorithm=\"%s\", headers=\"%s\", signature=\"%s\"" % (
|
v = {"authorization": authorization, "date": date, "host": "iat-api.xfyun.cn"}
|
||||||
self.asr_config["api_key"], "hmac-sha256", "host date request-line", signature_sha)
|
return url + "?" + parse.urlencode(v)
|
||||||
authorization = base64.b64encode(authorization_origin.encode('utf-8')).decode(encoding='utf-8')
|
|
||||||
|
|
||||||
# 将请求的鉴权参数组合为字典
|
async def test(self, test_count: int = 5):
|
||||||
v = {
|
|
||||||
"authorization": authorization,
|
|
||||||
"date": date,
|
|
||||||
"host": "iat.cn-huabei-1.xf-yun.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 拼接鉴权参数,生成url
|
|
||||||
url = url + '?' + parse.urlencode(v)
|
|
||||||
return url
|
|
||||||
|
|
||||||
async def test(self, test_count=5):
|
|
||||||
if not self.test_audio_files:
|
if not self.test_audio_files:
|
||||||
return {"name": "讯飞流式ASR", "latency": 0, "status": "失败: 未找到测试音频"}
|
return {"name": "讯飞流式ASR", "latency": 0, "status": "失败: 未找到测试音频"}
|
||||||
if not self.asr_config:
|
if not self.asr_config:
|
||||||
return {"name": "讯飞流式ASR", "latency": 0, "status": "失败: 未配置"}
|
return {"name": "讯飞流式ASR", "latency": 0, "status": "失败: 未配置"}
|
||||||
|
|
||||||
# 检查必要的配置参数
|
required = ["app_id", "api_key", "api_secret"]
|
||||||
required_keys = ["app_id", "api_key", "api_secret"]
|
for k in required:
|
||||||
for key in required_keys:
|
if k not in self.asr_config:
|
||||||
if key not in self.asr_config:
|
return {"name": "讯飞流式ASR", "latency": 0, "status": f"失败: 缺少配置 {k}"}
|
||||||
return {"name": "讯飞流式ASR", "latency": 0, "status": f"失败: 缺少配置项 {key}"}
|
|
||||||
|
|
||||||
latencies = []
|
latencies = []
|
||||||
STATUS_FIRST_FRAME = 0
|
frame_size = 1280
|
||||||
|
audio_raw = self.test_audio_files[0]['data']
|
||||||
|
if audio_raw.startswith(b'RIFF'):
|
||||||
|
audio_raw = audio_raw[44:]
|
||||||
|
|
||||||
for i in range(test_count):
|
for i in range(test_count):
|
||||||
try:
|
try:
|
||||||
# 生成认证URL
|
|
||||||
ws_url = self._create_url()
|
|
||||||
|
|
||||||
# 获取音频数据
|
|
||||||
audio_data = self.test_audio_files[0]['data']
|
|
||||||
if audio_data.startswith(b'RIFF'):
|
|
||||||
audio_data = audio_data[44:] # 跳过WAV文件头
|
|
||||||
|
|
||||||
# 识别参数
|
|
||||||
iat_params = {
|
|
||||||
"domain": self.asr_config.get("domain", "slm"),
|
|
||||||
"language": self.asr_config.get("language", "zh_cn"),
|
|
||||||
"accent": self.asr_config.get("accent", "mandarin"),
|
|
||||||
"dwa": self.asr_config.get("dwa", "wpgs"),
|
|
||||||
"result": {
|
|
||||||
"encoding": "utf8",
|
|
||||||
"compress": "raw",
|
|
||||||
"format": "plain"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# 准备首帧数据
|
|
||||||
first_frame_data = {
|
|
||||||
"header": {
|
|
||||||
"status": STATUS_FIRST_FRAME,
|
|
||||||
"app_id": self.asr_config["app_id"]
|
|
||||||
},
|
|
||||||
"parameter": {
|
|
||||||
"iat": iat_params
|
|
||||||
},
|
|
||||||
"payload": {
|
|
||||||
"audio": {
|
|
||||||
"audio": base64.b64encode(audio_data[:960]).decode('utf-8'),
|
|
||||||
"sample_rate": 16000,
|
|
||||||
"encoding": "raw"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# 启动连接并测量时间
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
ws_url = self._create_url()
|
||||||
|
|
||||||
async with websockets.connect(
|
async with websockets.connect(
|
||||||
ws_url,
|
ws_url,
|
||||||
max_size=1000000000,
|
additional_headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"},
|
||||||
|
max_size=1 << 30,
|
||||||
ping_interval=None,
|
ping_interval=None,
|
||||||
ping_timeout=None,
|
ping_timeout=None,
|
||||||
close_timeout=30,
|
close_timeout=30,
|
||||||
) as ws:
|
) as ws:
|
||||||
# 发送首帧数据
|
|
||||||
await ws.send(json.dumps(first_frame_data, ensure_ascii=False))
|
|
||||||
print(f"[讯飞ASR] 第{i+1}次测试:已发送首帧,等待响应...")
|
|
||||||
|
|
||||||
# 直接等待第一个响应并计算延迟
|
# 第一帧:移除 punc 字段,避免未知参数错误
|
||||||
# 参考豆包和通义千问的实现方式,简化逻辑
|
await ws.send(json.dumps({
|
||||||
response_received = False
|
"common": {"app_id": self.asr_config["app_id"]},
|
||||||
while not response_received:
|
"business": {
|
||||||
try:
|
"domain": "iat",
|
||||||
# 设置较大的超时时间
|
"language": "zh_cn",
|
||||||
response = await asyncio.wait_for(ws.recv(), timeout=30.0)
|
"accent": "mandarin",
|
||||||
|
"dwa": "wpgs",
|
||||||
|
"vad_eos": 5000
|
||||||
|
# 已移除 "punc": True
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"status": 0,
|
||||||
|
"format": "audio/L16;rate=16000",
|
||||||
|
"encoding": "raw",
|
||||||
|
"audio": base64.b64encode(audio_raw[:frame_size]).decode()
|
||||||
|
}
|
||||||
|
}, ensure_ascii=False))
|
||||||
|
|
||||||
# 收到响应立即计算延迟,不管内容是什么
|
# 后续所有帧
|
||||||
# 这样可以准确测量首包到达时间
|
pos = frame_size
|
||||||
latency = time.time() - start_time
|
while pos < len(audio_raw):
|
||||||
latencies.append(latency)
|
chunk = audio_raw[pos:pos + frame_size]
|
||||||
response_received = True
|
status = 2 if (pos + frame_size >= len(audio_raw)) else 1
|
||||||
|
await ws.send(json.dumps({
|
||||||
print(f"[讯飞ASR] 第{i+1}次测试:收到首包响应,延迟: {latency:.3f}s")
|
"data": {
|
||||||
|
"status": status,
|
||||||
|
"format": "audio/L16;rate=16000",
|
||||||
|
"encoding": "raw",
|
||||||
|
"audio": base64.b64encode(chunk).decode()
|
||||||
|
}
|
||||||
|
}, ensure_ascii=False))
|
||||||
|
if status == 2:
|
||||||
break
|
break
|
||||||
except asyncio.TimeoutError:
|
pos += frame_size
|
||||||
print(f"[讯飞ASR] 第{i+1}次测试:响应超时")
|
|
||||||
raise Exception("获取响应超时")
|
# 接收首词
|
||||||
|
first_token = True
|
||||||
|
async for message in ws:
|
||||||
|
data = json.loads(message)
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise Exception(f"讯飞错误: {data.get('message')}")
|
||||||
|
|
||||||
|
ws_result = data.get("data", {}).get("result", {}).get("ws")
|
||||||
|
if ws_result:
|
||||||
|
text = "".join(cw.get("w", "") for seg in ws_result for cw in seg.get("cw", []))
|
||||||
|
if text.strip() and first_token:
|
||||||
|
latency = time.time() - start_time
|
||||||
|
latencies.append(latency)
|
||||||
|
print(f"[讯飞ASR] 第{i+1}次 首词延迟: {latency:.3f}s")
|
||||||
|
first_token = False
|
||||||
|
break
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[讯飞ASR] 第{i+1}次测试失败: {str(e)}")
|
print(f"[讯飞ASR] 第{i+1}次测试失败: {str(e)}")
|
||||||
latencies.append(0)
|
latencies.append(None)
|
||||||
|
|
||||||
return self._calculate_result("讯飞流式ASR", latencies, test_count)
|
return self._calculate_result("讯飞流式ASR", latencies, test_count)
|
||||||
|
|
||||||
class ASRPerformanceSuite:
|
class ASRPerformanceSuite:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.testers = []
|
self.testers = []
|
||||||
@@ -438,8 +457,9 @@ class ASRPerformanceSuite:
|
|||||||
|
|
||||||
print(tabulate(table_data, headers=["ASR服务", "首词延迟", "状态"], tablefmt="grid"))
|
print(tabulate(table_data, headers=["ASR服务", "首词延迟", "状态"], tablefmt="grid"))
|
||||||
print("\n测试说明:")
|
print("\n测试说明:")
|
||||||
print("- 测量从发送请求到接收第一个有效识别文本的时间")
|
print("- 计时起点: 建立连接前(包含握手、发送音频、接收首个识别结果全流程)")
|
||||||
print("- 超时控制: DashScope 默认超时,豆包 WebSocket 超时10秒")
|
print("- 通义千问优化: 临时文件准备在计时前完成,减少磁盘IO对测试的影响")
|
||||||
|
print("- 错误处理: 失败的测试不计入平均值,只统计成功测试的延迟")
|
||||||
print("- 排序规则: 成功的按延迟升序,失败的排在后面")
|
print("- 排序规则: 成功的按延迟升序,失败的排在后面")
|
||||||
|
|
||||||
async def run(self, test_count=5):
|
async def run(self, test_count=5):
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class StreamTTSPerformanceTester:
|
|||||||
host = tts_config["host"]
|
host = tts_config["host"]
|
||||||
ws_url = f"wss://{host}/ws/v1"
|
ws_url = f"wss://{host}/ws/v1"
|
||||||
|
|
||||||
|
# 统一计时起点:在建立连接前开始计时
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
async with websockets.connect(ws_url, extra_headers={"X-NLS-Token": token}) as ws:
|
async with websockets.connect(ws_url, extra_headers={"X-NLS-Token": token}) as ws:
|
||||||
task_id = str(uuid.uuid4())
|
task_id = str(uuid.uuid4())
|
||||||
@@ -55,6 +56,7 @@ class StreamTTSPerformanceTester:
|
|||||||
"volume": 50,
|
"volume": 50,
|
||||||
"speech_rate": 0,
|
"speech_rate": 0,
|
||||||
"pitch_rate": 0,
|
"pitch_rate": 0,
|
||||||
|
"enable_subtitle": True,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await ws.send(json.dumps(start_request))
|
await ws.send(json.dumps(start_request))
|
||||||
@@ -80,6 +82,7 @@ class StreamTTSPerformanceTester:
|
|||||||
if isinstance(response, bytes):
|
if isinstance(response, bytes):
|
||||||
latency = time.time() - start_time
|
latency = time.time() - start_time
|
||||||
latencies.append(latency)
|
latencies.append(latency)
|
||||||
|
print(f"[阿里云TTS] 第{i+1}次 首词延迟: {latency:.3f}s")
|
||||||
break
|
break
|
||||||
elif isinstance(response, str):
|
elif isinstance(response, str):
|
||||||
data = json.loads(response)
|
data = json.loads(response)
|
||||||
@@ -87,10 +90,128 @@ class StreamTTSPerformanceTester:
|
|||||||
raise Exception(f"合成失败: {data['payload']['error_info']}")
|
raise Exception(f"合成失败: {data['payload']['error_info']}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
latencies.append(0)
|
print(f"[阿里云TTS] 第{i+1}次测试失败: {str(e)}")
|
||||||
|
latencies.append(None)
|
||||||
|
|
||||||
return self._calculate_result("阿里云TTS", latencies, test_count)
|
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):
|
async def test_doubao_tts(self, text=None, test_count=5):
|
||||||
"""测试火山引擎流式TTS首词延迟(测试多次取平均)"""
|
"""测试火山引擎流式TTS首词延迟(测试多次取平均)"""
|
||||||
text = text or self.test_texts[0]
|
text = text or self.test_texts[0]
|
||||||
@@ -118,9 +239,8 @@ class StreamTTSPerformanceTester:
|
|||||||
# 发送会话启动请求
|
# 发送会话启动请求
|
||||||
header = bytes([
|
header = bytes([
|
||||||
(0b0001 << 4) | 0b0001,
|
(0b0001 << 4) | 0b0001,
|
||||||
0b0001 << 4 | 0b100,
|
0b0001 << 4 | 0b1011,
|
||||||
0b0001 << 4 | 0b0000,
|
0b0001 << 4 | 0b0000,
|
||||||
0
|
|
||||||
])
|
])
|
||||||
optional = bytearray()
|
optional = bytearray()
|
||||||
optional.extend((1).to_bytes(4, "big", signed=True))
|
optional.extend((1).to_bytes(4, "big", signed=True))
|
||||||
@@ -133,7 +253,7 @@ class StreamTTSPerformanceTester:
|
|||||||
# 发送文本
|
# 发送文本
|
||||||
header = bytes([
|
header = bytes([
|
||||||
(0b0001 << 4) | 0b0001,
|
(0b0001 << 4) | 0b0001,
|
||||||
0b0001 << 4 | 0b100,
|
0b0001 << 4 | 0b1011,
|
||||||
0b0001 << 4 | 0b0000,
|
0b0001 << 4 | 0b0000,
|
||||||
0
|
0
|
||||||
])
|
])
|
||||||
@@ -148,9 +268,11 @@ class StreamTTSPerformanceTester:
|
|||||||
first_chunk = await ws.recv()
|
first_chunk = await ws.recv()
|
||||||
latency = time.time() - start_time
|
latency = time.time() - start_time
|
||||||
latencies.append(latency)
|
latencies.append(latency)
|
||||||
|
print(f"[火山引擎TTS] 第{i+1}次 首词延迟: {latency:.3f}s")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
latencies.append(0)
|
print(f"[火山引擎TTS] 第{i+1}次测试失败: {str(e)}")
|
||||||
|
latencies.append(None)
|
||||||
|
|
||||||
return self._calculate_result("火山引擎TTS", latencies, test_count)
|
return self._calculate_result("火山引擎TTS", latencies, test_count)
|
||||||
|
|
||||||
@@ -191,6 +313,7 @@ class StreamTTSPerformanceTester:
|
|||||||
first_chunk = await ws.recv()
|
first_chunk = await ws.recv()
|
||||||
latency = time.time() - start_time
|
latency = time.time() - start_time
|
||||||
latencies.append(latency)
|
latencies.append(latency)
|
||||||
|
print(f"[PaddleSpeechTTS] 第{i+1}次 首词延迟: {latency:.3f}s")
|
||||||
|
|
||||||
# 发送结束请求
|
# 发送结束请求
|
||||||
end_request = {
|
end_request = {
|
||||||
@@ -206,7 +329,8 @@ class StreamTTSPerformanceTester:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
latencies.append(0)
|
print(f"[PaddleSpeechTTS] 第{i+1}次测试失败: {str(e)}")
|
||||||
|
latencies.append(None)
|
||||||
|
|
||||||
return self._calculate_result("PaddleSpeechTTS", latencies, test_count)
|
return self._calculate_result("PaddleSpeechTTS", latencies, test_count)
|
||||||
|
|
||||||
@@ -221,6 +345,7 @@ class StreamTTSPerformanceTester:
|
|||||||
api_url = tts_config.get("api_url")
|
api_url = tts_config.get("api_url")
|
||||||
voice = tts_config.get("voice")
|
voice = tts_config.get("voice")
|
||||||
|
|
||||||
|
# 统一计时起点:在建立连接前开始计时
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
@@ -236,13 +361,15 @@ class StreamTTSPerformanceTester:
|
|||||||
|
|
||||||
latency = time.time() - start_time
|
latency = time.time() - start_time
|
||||||
latencies.append(latency)
|
latencies.append(latency)
|
||||||
|
print(f"[IndexStreamTTS] 第{i+1}次 首词延迟: {latency:.3f}s")
|
||||||
resp.close()
|
resp.close()
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
latencies.append(0)
|
latencies.append(None)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
latencies.append(0)
|
print(f"[IndexStreamTTS] 第{i+1}次测试失败: {str(e)}")
|
||||||
|
latencies.append(None)
|
||||||
|
|
||||||
return self._calculate_result("IndexStreamTTS", latencies, test_count)
|
return self._calculate_result("IndexStreamTTS", latencies, test_count)
|
||||||
|
|
||||||
@@ -258,6 +385,7 @@ class StreamTTSPerformanceTester:
|
|||||||
access_token = tts_config["access_token"]
|
access_token = tts_config["access_token"]
|
||||||
voice = tts_config["voice"]
|
voice = tts_config["voice"]
|
||||||
|
|
||||||
|
# 统一计时起点:在建立连接前开始计时
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
params = {
|
params = {
|
||||||
@@ -282,12 +410,14 @@ class StreamTTSPerformanceTester:
|
|||||||
async for _ in resp.content.iter_any():
|
async for _ in resp.content.iter_any():
|
||||||
latency = time.time() - start_time
|
latency = time.time() - start_time
|
||||||
latencies.append(latency)
|
latencies.append(latency)
|
||||||
|
print(f"[LinkeraiTTS] 第{i+1}次 首词延迟: {latency:.3f}s")
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
latencies.append(0)
|
latencies.append(None)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
latencies.append(0)
|
print(f"[LinkeraiTTS] 第{i+1}次测试失败: {str(e)}")
|
||||||
|
latencies.append(None)
|
||||||
|
|
||||||
return self._calculate_result("LinkeraiTTS", latencies, test_count)
|
return self._calculate_result("LinkeraiTTS", latencies, test_count)
|
||||||
|
|
||||||
@@ -305,10 +435,9 @@ class StreamTTSPerformanceTester:
|
|||||||
api_secret = tts_config["api_secret"]
|
api_secret = tts_config["api_secret"]
|
||||||
api_url = tts_config.get("api_url", "wss://cbm01.cn-huabei-1.xf-yun.com/v1/private/mcd9m97e6")
|
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")
|
voice = tts_config.get("voice", "x5_lingxiaoxuan_flow")
|
||||||
|
|
||||||
# 生成认证URL
|
# 生成认证URL
|
||||||
auth_url = self._create_xunfei_auth_url(api_key, api_secret, api_url)
|
auth_url = self._create_xunfei_auth_url(api_key, api_secret, api_url)
|
||||||
|
start_time = time.time()
|
||||||
async with websockets.connect(
|
async with websockets.connect(
|
||||||
auth_url,
|
auth_url,
|
||||||
ping_interval=30,
|
ping_interval=30,
|
||||||
@@ -318,10 +447,7 @@ class StreamTTSPerformanceTester:
|
|||||||
) as ws:
|
) as ws:
|
||||||
# 构造请求
|
# 构造请求
|
||||||
request = self._build_xunfei_request(app_id, text, voice)
|
request = self._build_xunfei_request(app_id, text, voice)
|
||||||
# 发送请求后立即计时,确保准确测量从发送文本到接收首块的时间
|
|
||||||
await ws.send(json.dumps(request))
|
await ws.send(json.dumps(request))
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
# 等待第一个音频数据块
|
# 等待第一个音频数据块
|
||||||
first_audio_received = False
|
first_audio_received = False
|
||||||
while not first_audio_received:
|
while not first_audio_received:
|
||||||
@@ -344,10 +470,12 @@ class StreamTTSPerformanceTester:
|
|||||||
# 收到第一个音频数据块
|
# 收到第一个音频数据块
|
||||||
latency = time.time() - start_time
|
latency = time.time() - start_time
|
||||||
latencies.append(latency)
|
latencies.append(latency)
|
||||||
|
print(f"[讯飞TTS] 第{i+1}次 首词延迟: {latency:.3f}s")
|
||||||
first_audio_received = True
|
first_audio_received = True
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
latencies.append(0)
|
print(f"[讯飞TTS] 第{i+1}次测试失败: {str(e)}")
|
||||||
|
latencies.append(None)
|
||||||
|
|
||||||
return self._calculate_result("讯飞TTS", latencies, test_count)
|
return self._calculate_result("讯飞TTS", latencies, test_count)
|
||||||
|
|
||||||
@@ -431,8 +559,9 @@ class StreamTTSPerformanceTester:
|
|||||||
|
|
||||||
|
|
||||||
def _calculate_result(self, service_name, latencies, test_count):
|
def _calculate_result(self, service_name, latencies, test_count):
|
||||||
"""计算测试结果"""
|
"""计算测试结果(正确处理None值,剔除失败测试)"""
|
||||||
valid_latencies = [l for l in latencies if l > 0]
|
# 剔除失败的测试(None值和<=0延迟),只统计有效延迟
|
||||||
|
valid_latencies = [l for l in latencies if l is not None and l > 0]
|
||||||
if valid_latencies:
|
if valid_latencies:
|
||||||
avg_latency = sum(valid_latencies) / len(valid_latencies)
|
avg_latency = sum(valid_latencies) / len(valid_latencies)
|
||||||
status = f"成功({len(valid_latencies)}/{test_count}次有效)"
|
status = f"成功({len(valid_latencies)}/{test_count}次有效)"
|
||||||
@@ -466,9 +595,10 @@ class StreamTTSPerformanceTester:
|
|||||||
]
|
]
|
||||||
|
|
||||||
print(tabulate(table_data, headers=["TTS服务", "首词延迟(秒)", "状态"], tablefmt="grid"))
|
print(tabulate(table_data, headers=["TTS服务", "首词延迟(秒)", "状态"], tablefmt="grid"))
|
||||||
print("\n测试说明:测量从发送请求到接收第一个音频数据块的时间,取多次测试平均值")
|
print("\n测试说明:测量从建立连接到接收第一个音频数据块的时间(包含握手、鉴权、发送文本),取多次测试平均值")
|
||||||
|
print("- 计时起点: 建立WebSocket/HTTP连接前(统一包含网络建连、握手、发送文本全流程)")
|
||||||
print("- 超时控制: 单个请求最大等待时间为10秒")
|
print("- 超时控制: 单个请求最大等待时间为10秒")
|
||||||
print("- 错误处理: 无法连接和超时的列为网络错误")
|
print("- 错误处理: 失败的测试不计入平均值,只统计成功测试的延迟")
|
||||||
print("- 排序规则: 按平均耗时从快到慢排序")
|
print("- 排序规则: 按平均耗时从快到慢排序")
|
||||||
|
|
||||||
|
|
||||||
@@ -495,6 +625,11 @@ class StreamTTSPerformanceTester:
|
|||||||
result = await self.test_aliyun_tts(test_text, test_count)
|
result = await self.test_aliyun_tts(test_text, test_count)
|
||||||
self.results.append(result)
|
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
|
# 测试火山引擎TTS
|
||||||
result = await self.test_doubao_tts(test_text, test_count)
|
result = await self.test_doubao_tts(test_text, test_count)
|
||||||
self.results.append(result)
|
self.results.append(result)
|
||||||
|
|||||||
Reference in New Issue
Block a user