import asyncio
|
import time
|
import json
|
import uuid
|
import os
|
import websockets
|
import gzip
|
import random
|
from urllib import parse
|
from tabulate import tabulate
|
from config.settings import load_config
|
import tempfile
|
import wave
|
import hmac
|
import base64
|
import hashlib
|
from datetime import datetime
|
from wsgiref.handlers import format_date_time
|
from time import mktime
|
description = "流式ASR首词延迟测试"
|
try:
|
import dashscope
|
except ImportError:
|
dashscope = None
|
|
class BaseASRTester:
|
def __init__(self, config_key: str, config):
|
self.config = config
|
self.config_key = config_key
|
self.asr_config = self.config.get("ASR", {}).get(config_key, {})
|
self.test_audio_files = self._load_test_audio_files()
|
self.results = []
|
|
def _load_test_audio_files(self):
|
audio_root = os.path.join(os.getcwd(), "config", "assets")
|
test_files = []
|
if os.path.exists(audio_root):
|
for file_name in os.listdir(audio_root):
|
if file_name.endswith(('.wav', '.pcm')):
|
file_path = os.path.join(audio_root, file_name)
|
with open(file_path, 'rb') as f:
|
test_files.append({
|
'data': f.read(),
|
'path': file_path,
|
'name': file_name
|
})
|
return test_files
|
|
async def test(self, test_count=5):
|
raise NotImplementedError
|
|
def _calculate_result(self, service_name, latencies, test_count):
|
"""计算测试结果(修复:正确处理None值,剔除失败测试)"""
|
# 剔除None值(失败的测试)和无效延迟,只统计有效延迟
|
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}
|
|
|
class DoubaoStreamASRTester(BaseASRTester):
|
def __init__(self, config):
|
super().__init__("DoubaoStreamASR", config)
|
from core.providers.asr.doubao_stream import ASRProvider as DoubaoStreamProvider
|
self.provider = DoubaoStreamProvider(self.asr_config, delete_audio_file=False)
|
|
async def test(self, test_count=5):
|
if not self.test_audio_files:
|
return {"name": "豆包流式ASR", "latency": 0, "status": "失败: 未找到测试音频"}
|
if not self.asr_config:
|
return {"name": "豆包流式ASR", "latency": 0, "status": "失败: 未配置"}
|
|
latencies = []
|
for i in range(test_count):
|
try:
|
start_time = time.time()
|
|
headers = self.provider.token_auth()
|
|
async with websockets.connect(
|
self.provider.ws_url,
|
additional_headers=headers,
|
max_size=1000000000,
|
ping_interval=None,
|
ping_timeout=None,
|
close_timeout=10
|
) as ws:
|
request_params = self.provider.construct_request(str(uuid.uuid4()))
|
|
payload_bytes = str.encode(json.dumps(request_params))
|
payload_bytes = gzip.compress(payload_bytes)
|
full_client_request = bytearray(self.provider.generate_header())
|
full_client_request.extend((len(payload_bytes)).to_bytes(4, "big"))
|
full_client_request.extend(payload_bytes)
|
await ws.send(full_client_request)
|
|
init_res = await ws.recv()
|
result = self.provider.parse_response(init_res)
|
if "code" in result and result["code"] != 1000:
|
raise Exception(f"初始化失败: {result.get('payload_msg', {}).get('error', '未知错误')}")
|
|
audio_data = self.test_audio_files[0]['data']
|
if audio_data.startswith(b'RIFF'):
|
audio_data = audio_data[44:]
|
|
# 发送音频数据(使用最后一帧标记,告诉服务端音频已结束)
|
payload = gzip.compress(audio_data)
|
audio_request = bytearray(self.provider.generate_last_audio_default_header())
|
audio_request.extend(len(payload).to_bytes(4, "big"))
|
audio_request.extend(payload)
|
await ws.send(audio_request)
|
|
first_chunk = await ws.recv()
|
latency = time.time() - start_time
|
latencies.append(latency)
|
print(f"[豆包ASR] 第{i+1}次 首词延迟: {latency:.3f}s")
|
await ws.close()
|
|
except Exception as e:
|
print(f"[豆包ASR] 第{i+1}次测试失败: {str(e)}")
|
latencies.append(None)
|
|
return self._calculate_result("豆包流式ASR", latencies, test_count)
|
|
|
class QwenASRFlashTester(BaseASRTester):
|
def __init__(self, config):
|
super().__init__("Qwen3ASRFlash", config)
|
|
async def _test_single(self, audio_file_info):
|
temp_file_path = None
|
|
try:
|
audio_data = audio_file_info['data']
|
|
# 优化:将临时文件准备工作移到计时前,减少磁盘IO对性能测试的影响
|
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
|
temp_file_path = f.name
|
|
with wave.open(temp_file_path, 'wb') as wav_file:
|
wav_file.setnchannels(1)
|
wav_file.setsampwidth(2)
|
wav_file.setframerate(16000)
|
wav_file.writeframes(audio_data)
|
|
messages = [
|
{
|
"role": "user",
|
"content": [
|
{"audio": temp_file_path}
|
]
|
}
|
]
|
|
api_key = self.asr_config.get("api_key") or os.getenv("DASHSCOPE_API_KEY")
|
if not api_key:
|
raise ValueError("未配置 api_key")
|
|
if dashscope is None:
|
raise RuntimeError("未安装 dashscope 库")
|
|
dashscope.api_key = api_key
|
|
# 统一计时起点:在API调用前开始计时(但文件准备已完成)
|
start_time = time.time()
|
|
response = dashscope.MultiModalConversation.call(
|
model="qwen3-asr-flash",
|
messages=messages,
|
result_format="message",
|
asr_options={"enable_lid": True, "enable_itn": False},
|
stream=True
|
)
|
|
for chunk in response:
|
latency = time.time() - start_time
|
return latency
|
|
raise Exception("流式结束,未收到任何响应")
|
|
except Exception as e:
|
raise Exception(f"通义ASR流式失败: {str(e)}")
|
|
finally:
|
if temp_file_path and os.path.exists(temp_file_path):
|
try:
|
os.unlink(temp_file_path)
|
except:
|
pass
|
|
async def test(self, test_count=5):
|
if not self.test_audio_files:
|
return {"name": "通义千问ASR", "latency": 0, "status": "失败: 未找到测试音频"}
|
if not self.asr_config and not os.getenv("DASHSCOPE_API_KEY"):
|
return {"name": "通义千问ASR", "latency": 0, "status": "失败: 未配置 api_key"}
|
|
latencies = []
|
for i in range(test_count):
|
try:
|
# print(f"\n[通义ASR] 开始第 {i+1} 次测试...")
|
latency = await self._test_single(self.test_audio_files[0])
|
latencies.append(latency)
|
print(f"[通义ASR] 第{i+1}次 首词延迟: {latency:.3f}s")
|
except Exception as e:
|
# print(f"[通义ASR] 第{i+1}次测试失败: {str(e)}")
|
latencies.append(None)
|
|
return self._calculate_result("通义千问ASR", latencies, test_count)
|
|
|
class XunfeiStreamASRTester(BaseASRTester):
|
def __init__(self, config):
|
super().__init__("XunfeiStreamASR", config)
|
|
def _create_url(self):
|
url = "wss://iat-api.xfyun.cn/v2/iat"
|
now = datetime.now()
|
date = format_date_time(mktime(now.timetuple()))
|
|
signature_origin = f"host: iat-api.xfyun.cn\ndate: {date}\nGET /v2/iat HTTP/1.1"
|
signature_sha = hmac.new(
|
self.asr_config["api_secret"].encode('utf-8'),
|
signature_origin.encode('utf-8'),
|
hashlib.sha256
|
).digest()
|
signature_sha = base64.b64encode(signature_sha).decode()
|
|
authorization_origin = f'api_key="{self.asr_config["api_key"]}", algorithm="hmac-sha256", headers="host date request-line", signature="{signature_sha}"'
|
authorization = base64.b64encode(authorization_origin.encode()).decode()
|
|
v = {"authorization": authorization, "date": date, "host": "iat-api.xfyun.cn"}
|
return url + "?" + parse.urlencode(v)
|
|
async def test(self, test_count: int = 5):
|
if not self.test_audio_files:
|
return {"name": "讯飞流式ASR", "latency": 0, "status": "失败: 未找到测试音频"}
|
if not self.asr_config:
|
return {"name": "讯飞流式ASR", "latency": 0, "status": "失败: 未配置"}
|
|
required = ["app_id", "api_key", "api_secret"]
|
for k in required:
|
if k not in self.asr_config:
|
return {"name": "讯飞流式ASR", "latency": 0, "status": f"失败: 缺少配置 {k}"}
|
|
latencies = []
|
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):
|
try:
|
start_time = time.time()
|
ws_url = self._create_url()
|
|
async with websockets.connect(
|
ws_url,
|
additional_headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"},
|
max_size=1 << 30,
|
ping_interval=None,
|
ping_timeout=None,
|
close_timeout=30,
|
) as ws:
|
|
# 第一帧:移除 punc 字段,避免未知参数错误
|
await ws.send(json.dumps({
|
"common": {"app_id": self.asr_config["app_id"]},
|
"business": {
|
"domain": "iat",
|
"language": "zh_cn",
|
"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
|
while pos < len(audio_raw):
|
chunk = audio_raw[pos:pos + frame_size]
|
status = 2 if (pos + frame_size >= len(audio_raw)) else 1
|
await ws.send(json.dumps({
|
"data": {
|
"status": status,
|
"format": "audio/L16;rate=16000",
|
"encoding": "raw",
|
"audio": base64.b64encode(chunk).decode()
|
}
|
}, ensure_ascii=False))
|
if status == 2:
|
break
|
pos += frame_size
|
|
# 接收首词
|
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:
|
print(f"[讯飞ASR] 第{i+1}次测试失败: {str(e)}")
|
latencies.append(None)
|
|
return self._calculate_result("讯飞流式ASR", latencies, test_count)
|
class ASRPerformanceSuite:
|
def __init__(self, config):
|
self.config = config
|
self.testers = []
|
self.results = []
|
|
def register_tester(self, tester_class):
|
try:
|
tester = tester_class(self.config)
|
self.testers.append(tester)
|
print(f"已注册测试器: {tester.config_key}")
|
except Exception as e:
|
name_map = {
|
"DoubaoStreamASRTester": "豆包流式ASR",
|
"QwenASRFlashTester": "通义千问ASR",
|
"XunfeiStreamASRTester": "讯飞流式ASR"
|
}
|
name = name_map.get(tester_class.__name__, tester_class.__name__)
|
print(f"跳过 {name}: {str(e)}")
|
|
def _print_results(self, test_count):
|
if not self.results:
|
print("没有有效的ASR测试结果")
|
return
|
|
print(f"\n{'='*60}")
|
print("流式ASR首词响应时间测试结果")
|
print(f"{'='*60}")
|
print(f"测试次数: 每个ASR服务测试 {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}s" if r['latency'] > 0 else "N/A", r["status"]]
|
for r in success_results + failed_results
|
]
|
|
print(tabulate(table_data, headers=["ASR服务", "首词延迟", "状态"], tablefmt="grid"))
|
print("\n测试说明:")
|
print("- 计时起点: 建立连接前(包含握手、发送音频、接收首个识别结果全流程)")
|
print("- 通义千问优化: 临时文件准备在计时前完成,减少磁盘IO对测试的影响")
|
print("- 错误处理: 失败的测试不计入平均值,只统计成功测试的延迟")
|
print("- 排序规则: 成功的按延迟升序,失败的排在后面")
|
|
async def run(self, test_count=5):
|
print(f"开始流式ASR首词响应时间测试...")
|
print(f"每个ASR服务测试次数: {test_count}次\n")
|
|
self.results = []
|
for tester in self.testers:
|
print(f"\n--- 测试 {tester.config_key} ---")
|
result = await tester.test(test_count)
|
self.results.append(result)
|
|
self._print_results(test_count)
|
|
|
async def main():
|
import argparse
|
parser = argparse.ArgumentParser(description="流式ASR首词响应时间测试工具")
|
parser.add_argument("--count", type=int, default=5, help="测试次数")
|
args = parser.parse_args()
|
|
config = await load_config()
|
suite = ASRPerformanceSuite(config)
|
suite.register_tester(DoubaoStreamASRTester)
|
suite.register_tester(QwenASRFlashTester)
|
suite.register_tester(XunfeiStreamASRTester)
|
|
await suite.run(args.count)
|
|
|
if __name__ == "__main__":
|
asyncio.run(main())
|