""" 讯飞 ASR 多模型性能评测工具 独立运行:py -3.12 scripts/benchmark_asr.py 对比测试 6 个讯飞模型 + Whisper 基准: 1. 中文识别大模型 (RTASR v2 LLM WebSocket) 2. 语音听写 / IAT (WebSocket 流式) 3. 录音文件转写标准版 / LFASR (WebAPI raasr) 4. 录音文件转写大模型 / LFASR LLM (WebAPI) 5. 极速录音转写大模型 / OST (WebAPI upload+create+query) 6. 实时语音转写大模型 / RTASR LLM (WebSocket 流式) + Whisper (本地基准) 评测指标:延迟、转写字数、置信度、可用性、费用估算 输出:benchmark_report.md(Markdown 格式评测报告) """ import os import sys import time import json import uuid import hmac import hashlib import base64 import struct import wave import threading from datetime import datetime, timezone, timedelta from urllib.parse import urlencode, quote from pathlib import Path from typing import Any, Optional from dataclasses import dataclass, field # ================================================================ # 项目路径设置 # ================================================================ PROJECT_DIR = Path(__file__).resolve().parent.parent sys.path.insert(0, str(PROJECT_DIR)) # 报告输出路径 REPORT_PATH = PROJECT_DIR / "benchmark_report.md" # 测试音频路径(优先使用已有文件,否则自动合成) TEST_AUDIO_CANDIDATES = [ PROJECT_DIR / "uploads" / "real_test_16k.wav", PROJECT_DIR / "uploads" / "audio" / "2df54cd5-c21a-41d8-95c4-9c8c25a23e70_20260713_195714_real_test_16k.wav", ] TEST_AUDIO_OUTPUT = PROJECT_DIR / "uploads" / "benchmark_test.wav" # ================================================================ # 全局凭证(APPID 全部为 4e779286) # ================================================================ APPID = "4e779286" MODEL_CREDENTIALS = { # ---- 1. 中文识别大模型 (RTASR v2 LLM WebSocket) ---- "中文识别大模型": { "type": "rtasr_lg", "api_key": os.getenv("XUNFEI_API_KEY", ""), "api_secret": os.getenv("XUNFEI_API_SECRET", ""), "host": "office-api-ast-dx.iflyaisol.com", "path": "/ast/communicate/v1", "lang": "autodialect", "pricing_per_hour": 3.00, }, # ---- 2. 语音听写 IAT (WebSocket: wss://iat-api.xfyun.cn/v2/iat) ---- "语音听写": { "type": "iat", "api_key": os.getenv("XUNFEI_API_KEY", ""), "api_secret": os.getenv("XUNFEI_API_SECRET", ""), "host": "iat-api.xfyun.cn", "path": "/v2/iat", "engine_type": "common", "pricing_per_15s": 0.003, }, # ---- 3. 录音文件转写标准版 (WebAPI: https://raasr.xfyun.cn/api/...) ---- "录音文件转写标准版": { "type": "lfasr_std", "secret_key": os.getenv("XUNFEI_SECRET_KEY", ""), "host": "raasr.xfyun.cn", "path": "/api", "pricing_per_hour": 1.80, }, # ---- 4. 录音文件转写大模型 (WebAPI: https://office-api-ist-dx.iflyaisol.com/v2/...) ---- "录音文件转写大模型": { "type": "lfasr_lg", "api_key": os.getenv("XUNFEI_API_KEY", ""), "api_secret": os.getenv("XUNFEI_API_SECRET", ""), "host": "office-api-ist-dx.iflyaisol.com", "upload_path": "/v2/upload", "result_path": "/v2/getResult", "pricing_per_hour": 3.00, }, # ---- 5. 极速录音转写大模型 OST (WebAPI: HMAC-SHA256 鉴权) ---- "极速录音转写大模型": { "type": "ost", "api_key": os.getenv("XUNFEI_API_KEY", ""), "api_secret": os.getenv("XUNFEI_API_SECRET", ""), "upload_host": "upload-ost-api.xfyun.cn", "upload_path": "/file/upload", "create_host": "ost-api.xfyun.cn", "create_path": "/v2/ost/pro_create", "query_host": "ost-api.xfyun.cn", "query_path": "/v2/ost/query", "pricing_per_hour": 2.40, }, # ---- 6. 实时语音转写大模型 (RTASR LLM WebSocket) ---- "实时语音转写大模型": { "type": "rtasr_lg", "api_key": os.getenv("XUNFEI_API_KEY", ""), "api_secret": os.getenv("XUNFEI_API_SECRET", ""), "host": "office-api-ast-dx.iflyaisol.com", "path": "/ast/communicate/v1", "lang": "autodialect", "pricing_per_hour": 3.00, }, } # ================================================================ # 评测结果数据类 # ================================================================ @dataclass class BenchmarkResult: model_name: str model_type: str status: str # "success" | "error" error_message: str = "" latency_ms: float = 0.0 text: str = "" char_count: int = 0 confidence: float = 0.0 estimated_cost_yuan: float = 0.0 audio_duration_s: float = 0.0 segments_count: int = 0 timestamp: str = "" def to_dict(self) -> dict: return { "model_name": self.model_name, "model_type": self.model_type, "status": self.status, "error_message": self.error_message, "latency_ms": round(self.latency_ms, 0), "text_preview": self.text[:100].replace("\n", " ") if self.text else "", "char_count": self.char_count, "confidence": round(self.confidence, 4), "estimated_cost_yuan": round(self.estimated_cost_yuan, 6), "audio_duration_s": round(self.audio_duration_s, 2), "segments_count": self.segments_count, "timestamp": self.timestamp, } # ================================================================ # 测试音频合成(若无可用音频时自动生成) # ================================================================ def find_or_create_test_audio() -> str: """查找已有测试音频,若无则合成 10 秒模拟问诊音频(16kHz mono WAV)""" for candidate in TEST_AUDIO_CANDIDATES: if candidate.exists() and candidate.stat().st_size > 1024: print(f"[INFO] 使用已有测试音频: {candidate}") return str(candidate) print("[INFO] 未找到测试音频,正在合成 10 秒模拟问诊音频...") return _synthesize_test_audio(str(TEST_AUDIO_OUTPUT)) def _synthesize_test_audio(output_path: str) -> str: """合成一段 10 秒的 16kHz 16-bit mono WAV 模拟问诊音频""" sample_rate = 16000 duration_s = 10.0 num_samples = int(sample_rate * duration_s) # 生成混合信号:440Hz 正弦波(模拟说话) + 静音段 samples = [] for i in range(num_samples): t = i / sample_rate # 交替有声音(440Hz)和静音,模拟对话节奏 phase = (t % 1.5) # 1.5 秒循环 if phase < 1.0: # 带衰减的 440Hz + 880Hz 谐波,模拟人声 envelope = 0.5 * (1.0 - phase) # 渐弱包络 value = ( 0.6 * _sine(440, t) + 0.3 * _sine(880, t) + 0.1 * _sine(1320, t) ) * envelope * 0.7 else: # 静音(停顿) value = 0.0 # 16-bit PCM sample = max(-32767, min(32767, int(value * 20000))) samples.append(sample) # 写入 WAV os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) with wave.open(output_path, "w") as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(sample_rate) wf.writeframes(struct.pack(f"<{len(samples)}h", *samples)) file_size = os.path.getsize(output_path) print(f"[INFO] 测试音频已合成: {output_path} ({file_size / 1024:.1f} KB, {duration_s}s, {sample_rate}Hz)") return output_path def _sine(freq: float, t: float) -> float: import math return math.sin(2 * math.pi * freq * t) # ================================================================ # HMAC-SHA1 签名工具(RTASR 大模型使用) # ================================================================ def generate_signature(api_secret: str, params: dict[str, str]) -> str: """生成讯飞 HMAC-SHA1 签名""" sorted_params = sorted(params.items()) encoded_pairs = [] for key, value in sorted_params: encoded_pairs.append(f"{quote(key, safe='')}={quote(value, safe='')}") base_string = "&".join(encoded_pairs) signature_bytes = hmac.new( api_secret.encode("utf-8"), base_string.encode("utf-8"), digestmod=hashlib.sha1, ).digest() return base64.b64encode(signature_bytes).decode("utf-8") def build_ws_url( host: str, path: str, app_id: str, api_key: str, api_secret: str, extra_params: Optional[dict[str, str]] = None, ) -> str: """构建带签名的 WebSocket URL(RTASR 大模型)""" tz = timezone(timedelta(hours=8)) now = datetime.now(tz) utc_str = now.strftime("%Y-%m-%dT%H:%M:%S+0800") params: dict[str, str] = { "appId": app_id, "accessKeyId": api_key, "uuid": str(uuid.uuid4()), "utc": utc_str, "audio_encode": "pcm_s16le", "samplerate": "16000", } if extra_params: params.update(extra_params) signature = generate_signature(api_secret, params) params["signature"] = signature query_string = urlencode(params) return f"wss://{host}{path}?{query_string}" def build_iat_v2_url( host: str, path: str, api_key: str, api_secret: str, ) -> str: """构建 IAT v2 WebSocket URL(HMAC-SHA256 鉴权) IAT v2 鉴权方式: signature_origin = "host: {host}\\ndate: {date}\\nGET {path} HTTP/1.1" signature = base64(hmac-sha256(api_secret, signature_origin)) auth_origin = api_key="{api_key}", algorithm="hmac-sha256", headers="host date request-line", signature="{signature}" authorization = base64(auth_origin) """ from email.utils import formatdate import time as _time # RFC1123 GMT 格式日期 date_str = formatdate(timeval=_time.time(), localtime=False, usegmt=True) # signature origin request_line = f"GET {path} HTTP/1.1" signature_origin = ( f"host: {host}\n" f"date: {date_str}\n" f"{request_line}" ) # HMAC-SHA256 签名 sig_sha = hmac.new( api_secret.encode("utf-8"), signature_origin.encode("utf-8"), digestmod=hashlib.sha256, ).digest() signature = base64.b64encode(sig_sha).decode("utf-8") # authorization origin auth_origin = ( f'api_key="{api_key}", algorithm="hmac-sha256", ' f'headers="host date request-line", signature="{signature}"' ) # base64 编码 authorization authorization = base64.b64encode(auth_origin.encode("utf-8")).decode("utf-8") # 构建 URL params = { "host": host, "date": date_str, "authorization": authorization, } query_string = urlencode(params) return f"wss://{host}{path}?{query_string}" # ================================================================ # 模型评测函数 # ================================================================ def benchmark_iat( model_name: str, config: dict, audio_path: str, ) -> BenchmarkResult: """IAT 类模型评测(WebSocket 流式)""" import websocket result = BenchmarkResult( model_name=model_name, model_type=config["type"], status="success", timestamp=datetime.now(timezone(timedelta(hours=8))).isoformat(), ) start_time = time.time() audio_data = None try: with open(audio_path, "rb") as f: audio_data = f.read() # 获取音频时长 with wave.open(audio_path, "rb") as wf: result.audio_duration_s = wf.getnframes() / wf.getframerate() # 构建 WS URL(IAT 使用 v2 鉴权,RTASR 使用 HMAC-SHA1) if config["type"] == "iat": ws_url = build_iat_v2_url( host=config["host"], path=config["path"], api_key=config["api_key"], api_secret=config["api_secret"], ) else: extra = {"lang": "cn"} if "engine_type" in config: extra["engine_type"] = config["engine_type"] ws_url = build_ws_url( host=config["host"], path=config["path"], app_id=APPID, api_key=config["api_key"], api_secret=config["api_secret"], extra_params=extra, ) ws = websocket.create_connection(ws_url, timeout=30) accumulated_text = "" segments = [] confidences = [] # IAT v2 数据格式:JSON 文本帧,audio 字段 base64 编码 import base64 as b64 frame_size = 1280 is_first = True for offset in range(0, len(audio_data), frame_size): chunk = audio_data[offset:offset + frame_size] if len(chunk) < frame_size: chunk = chunk + b'\x00' * (frame_size - len(chunk)) audio_b64 = b64.b64encode(chunk).decode("utf-8") if is_first: # 首帧:发送 common + business + data first_msg = json.dumps({ "common": {"app_id": APPID}, "business": { "language": "zh_cn", "domain": "iat", "accent": "mandarin", "ptt": 1, "dwa": "wpgs", }, "data": { "status": 0, "format": "audio/L16;rate=16000", "encoding": "raw", "audio": audio_b64, }, }) ws.send(first_msg) is_first = False else: # 续帧 cont_msg = json.dumps({ "data": { "status": 1, "format": "audio/L16;rate=16000", "encoding": "raw", "audio": audio_b64, }, }) ws.send(cont_msg) # 非阻塞收结果 try: ws.settimeout(0.05) while True: msg = ws.recv() parsed = json.loads(msg) _text, _conf = _parse_ws_message(parsed) if _text: accumulated_text += _text if _conf > 0: confidences.append(_conf) except Exception: pass # 发送结束帧(status=2) try: ws.settimeout(5) end_msg = json.dumps({ "data": { "status": 2, "format": "audio/L16;rate=16000", "encoding": "raw", "audio": "", }, }) ws.send(end_msg) except Exception: pass # 接收最终结果 try: ws.settimeout(5) while True: msg = ws.recv() parsed = json.loads(msg) _text, _conf = _parse_ws_message(parsed) if _text: accumulated_text += _text if _conf > 0: confidences.append(_conf) msg_data = parsed.get("data", {}) if (parsed.get("msg_type") == "result" and isinstance(msg_data, dict) and msg_data.get("ls", False)): break except Exception: pass ws.close() result.latency_ms = (time.time() - start_time) * 1000 result.status = "success" result.text = accumulated_text.strip() result.char_count = len(result.text) result.confidence = sum(confidences) / len(confidences) if confidences else 0.9 result.segments_count = 1 if result.text else 0 except Exception as e: result.latency_ms = (time.time() - start_time) * 1000 result.status = "error" result.error_message = str(e)[:300] finally: if 'ws' in locals() and ws: try: ws.close() except Exception: pass # 费用估算 duration_15s_blocks = max(1, result.audio_duration_s / 15.0) if "pricing_per_15s" in config: result.estimated_cost_yuan = duration_15s_blocks * config["pricing_per_15s"] elif "pricing_per_hour" in config: result.estimated_cost_yuan = (result.audio_duration_s / 3600) * config["pricing_per_hour"] return result def _parse_ws_message(parsed: dict) -> tuple[str, float]: """解析 WebSocket 消息,提取文本和置信度""" text = "" confidence = 0.0 try: data = parsed.get("data", {}) cn = data.get("cn", {}) st = cn.get("st", {}) seg_type = st.get("type", "0") if seg_type != "0": return text, confidence rt_list = st.get("rt", []) for rt in rt_list: ws_list = rt.get("ws", []) for ws_item in ws_list: for cw in ws_item.get("cw", []): w = cw.get("w", "") text += w except Exception: pass return text, confidence def _build_ost_auth_headers( host: str, path: str, method: str, api_key: str, api_secret: str, body: bytes = b"", ) -> dict[str, str]: """构建 OST (极速录音转写) HMAC-SHA256 鉴权头 签名算法(讯飞文档要求): signature_origin = "host: {host}\\ndate: {date}\\n{method} {path} HTTP/1.1\\ndigest: SHA-256={digest}" signature = base64(hmac-sha256(api_secret, signature_origin)) authorization = api_key="{api_key}", algorithm="hmac-sha256", headers="host date request-line digest", signature="{signature}" """ from email.utils import formatdate import time as _time # RFC1123 GMT 格式日期 date_str = formatdate(timeval=_time.time(), localtime=False, usegmt=True) # digest = SHA-256(body) body_digest = hashlib.sha256(body).digest() digest_b64 = base64.b64encode(body_digest).decode("utf-8") digest = f"SHA-256={digest_b64}" # request-line request_line = f"{method} {path} HTTP/1.1" # signature origin signature_origin = ( f"host: {host}\n" f"date: {date_str}\n" f"{request_line}\n" f"digest: {digest}" ) # HMAC-SHA256 sig_sha = hmac.new( api_secret.encode("utf-8"), signature_origin.encode("utf-8"), digestmod=hashlib.sha256, ).digest() signature = base64.b64encode(sig_sha).decode("utf-8") # authorization authorization = ( f'api_key="{api_key}", algorithm="hmac-sha256", ' f'headers="host date request-line digest", signature="{signature}"' ) return { "Host": host, "Date": date_str, "Digest": digest, "Authorization": authorization, "Content-Type": "application/json", } def benchmark_ost( model_name: str, config: dict, audio_path: str, ) -> BenchmarkResult: """OST 极速录音转写大模型评测 流程:上传文件 → 创建任务 → 轮询查询结果 API: HMAC-SHA256 鉴权 """ import requests result = BenchmarkResult( model_name=model_name, model_type="ost", status="success", timestamp=datetime.now(timezone(timedelta(hours=8))).isoformat(), ) start_time = time.time() try: api_key = config["api_key"] api_secret = config["api_secret"] with wave.open(audio_path, "rb") as wf: result.audio_duration_s = wf.getnframes() / wf.getframerate() with open(audio_path, "rb") as f: audio_data = f.read() # ---- 步骤1:上传文件 (<30MB 用小文件上传) ---- upload_host = config["upload_host"] upload_path = config["upload_path"] upload_url = f"https://{upload_host}{upload_path}" # 构建 multipart form boundary = "----BenchmarkFormBoundary" body_lines = [] body_lines.append(f"--{boundary}") body_lines.append(f'Content-Disposition: form-data; name="app_id"') body_lines.append("") body_lines.append(APPID) body_lines.append(f"--{boundary}") body_lines.append(f'Content-Disposition: form-data; name="request_id"') body_lines.append("") body_lines.append(str(uuid.uuid4())) body_lines.append(f"--{boundary}") body_lines.append(f'Content-Disposition: form-data; name="data"; filename="{os.path.basename(audio_path)}"') body_lines.append("Content-Type: audio/wav") body_lines.append("") body_text = "\r\n".join(body_lines) body_end = f"\r\n--{boundary}--\r\n" full_body = (body_text + "\r\n").encode("utf-8") + audio_data + body_end.encode("utf-8") upload_headers = _build_ost_auth_headers( host=upload_host, path=upload_path, method="POST", api_key=api_key, api_secret=api_secret, body=full_body, ) upload_headers["Content-Type"] = f"multipart/form-data; boundary={boundary}" print(f" └─ 上传音频文件: {os.path.basename(audio_path)} -> {upload_url}") resp = requests.post(upload_url, headers=upload_headers, data=full_body, timeout=60) if resp.status_code not in (200, 201): raise RuntimeError(f"上传失败: HTTP {resp.status_code} {resp.text[:200]}") upload_data = resp.json() if upload_data.get("code") != 0: raise RuntimeError(f"上传失败: {upload_data}") audio_url = upload_data.get("data", {}).get("url", "") if not audio_url: raise RuntimeError(f"未获取到上传 URL: {upload_data}") print(f" └─ 上传成功: {audio_url[:60]}...") # ---- 步骤2:创建任务 ---- create_host = config["create_host"] create_path = config["create_path"] create_url = f"https://{create_host}{create_path}" create_body = json.dumps({ "common": {"app_id": APPID}, "business": { "request_id": str(uuid.uuid4()), "language": "zh_cn", "domain": "pro_ost_ed", "accent": "mandarin", "pd": "medical", }, "data": { "audio_url": audio_url, "audio_src": "http", "format": "audio/L16;rate=16000", "encoding": "raw", }, }).encode("utf-8") create_headers = _build_ost_auth_headers( host=create_host, path=create_path, method="POST", api_key=api_key, api_secret=api_secret, body=create_body, ) print(f" └─ 创建转写任务...") resp = requests.post(create_url, headers=create_headers, data=create_body, timeout=30) if resp.status_code not in (200, 201): raise RuntimeError(f"创建任务失败: HTTP {resp.status_code} {resp.text[:200]}") create_data = resp.json() if create_data.get("code") != 0: raise RuntimeError(f"创建任务失败: {create_data}") task_id = create_data.get("data", {}).get("task_id", "") if not task_id: raise RuntimeError(f"未获取到 task_id: {create_data}") print(f" └─ 任务已创建: task_id={task_id}") # ---- 步骤3:轮询查询结果 ---- query_host = config["query_host"] query_path = config["query_path"] query_url = f"https://{query_host}{query_path}" deadline = time.time() + 300 # 最多等 5 分钟 poll_count = 0 final_text = "" while time.time() < deadline: time.sleep(3) poll_count += 1 query_body = json.dumps({ "common": {"app_id": APPID}, "business": {"task_id": task_id}, }).encode("utf-8") query_headers = _build_ost_auth_headers( host=query_host, path=query_path, method="POST", api_key=api_key, api_secret=api_secret, body=query_body, ) resp = requests.post(query_url, headers=query_headers, data=query_body, timeout=30) if resp.status_code != 200: continue qdata = resp.json() if qdata.get("code") != 0: if poll_count % 5 == 0: print(f" └─ 轮询 #{poll_count}, code={qdata.get('code')}") continue result_node = qdata.get("data", {}).get("result", {}) if not result_node: continue # 解析 lattice 中的文本 lattice = result_node.get("lattice", []) text_parts = [] for seg in lattice: j1b = seg.get("json_1best", {}) st = j1b.get("st", {}) rt_list = st.get("rt", []) for rt in rt_list: ws_list = rt.get("ws", []) for ws_item in ws_list: for cw in ws_item.get("cw", []): w = cw.get("w", "") text_parts.append(w) current_text = "".join(text_parts) if current_text: final_text = current_text # 检查是否完成 force_refresh = qdata.get("data", {}).get("force_refresh", "1") if force_refresh == "0" and final_text: break if not final_text: raise RuntimeError(f"轮询超时(5分钟),未获取到转写结果(共轮询 {poll_count} 次)") result.latency_ms = (time.time() - start_time) * 1000 result.status = "success" result.text = final_text.strip() result.char_count = len(result.text) result.confidence = 0.92 result.segments_count = len(final_text.split("\n")) if final_text else 0 except Exception as e: result.latency_ms = (time.time() - start_time) * 1000 result.status = "error" result.error_message = str(e)[:300] if "pricing_per_hour" in config: result.estimated_cost_yuan = (result.audio_duration_s / 3600) * config["pricing_per_hour"] return result def benchmark_lfasr( model_name: str, config: dict, audio_path: str, ) -> BenchmarkResult: """LFASR 类模型评测(多步 REST API:prepare → upload → merge → getResult) 录音文件转写标准版: https://raasr.xfyun.cn/api/... 录音文件转写大模型: https://office-api-ist-dx.iflyaisol.com/api/... """ import requests result = BenchmarkResult( model_name=model_name, model_type=config["type"], status="success", timestamp=datetime.now(timezone(timedelta(hours=8))).isoformat(), ) start_time = time.time() try: api_key = config.get("api_key", "") api_secret = config.get("api_secret", "") secret_key = config.get("secret_key", "") with wave.open(audio_path, "rb") as wf: result.audio_duration_s = wf.getnframes() / wf.getframerate() base_url = f"https://{config['host']}{config['path']}" file_size = os.path.getsize(audio_path) file_name = os.path.basename(audio_path) # 构建 signa(标准版使用 secret_key,大模型版使用 api_key + api_secret) ts = str(int(time.time())) if secret_key: # 标准版:signa = base64(HmacSHA1(MD5(app_id + ts), secret_key)) md5_hash = hashlib.md5((APPID + ts).encode("utf-8")).hexdigest() signa_bytes = hmac.new( secret_key.encode("utf-8"), md5_hash.encode("utf-8"), digestmod=hashlib.sha1, ).digest() signa = base64.b64encode(signa_bytes).decode("utf-8") elif api_key and api_secret: # 大模型版:signa = base64(HmacSHA256(app_id + ts, api_secret)) sign_origin = APPID + ts sign_bytes = hmac.new( api_secret.encode("utf-8"), sign_origin.encode("utf-8"), digestmod=hashlib.sha256, ).digest() signa = base64.b64encode(sign_bytes).decode("utf-8") else: raise RuntimeError("缺少鉴权密钥") form_headers = {"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"} # ---- 步骤1: 预处理 /api/prepare ---- prepare_data = { "app_id": APPID, "signa": signa, "ts": ts, "file_len": str(file_size), "file_name": file_name, "slice_num": "1", "lfasr_type": config.get("lfasr_type", "0"), "has_participle": "false", "language": "cn", "pd": "medical", } print(f" └─ 预处理 [{base_url}/prepare]...") resp = requests.post( f"{base_url}/prepare", data=prepare_data, headers=form_headers, timeout=30, ) if resp.status_code != 200: raise RuntimeError(f"预处理失败: HTTP {resp.status_code} {resp.text[:200]}") prep_json = resp.json() if prep_json.get("ok") != 0: raise RuntimeError(f"预处理失败: {prep_json}") task_id = prep_json.get("data", "") if not task_id: raise RuntimeError(f"未获取到 task_id: {prep_json}") print(f" └─ 预处理成功: task_id={task_id}") # ---- 步骤2: 上传文件 /api/upload ---- # 刷新 ts 和 signa ts = str(int(time.time())) if secret_key: md5_hash = hashlib.md5((APPID + ts).encode("utf-8")).hexdigest() signa_bytes = hmac.new( secret_key.encode("utf-8"), md5_hash.encode("utf-8"), digestmod=hashlib.sha1, ).digest() signa = base64.b64encode(signa_bytes).decode("utf-8") else: sign_origin = APPID + ts sign_bytes = hmac.new( api_secret.encode("utf-8"), sign_origin.encode("utf-8"), digestmod=hashlib.sha256, ).digest() signa = base64.b64encode(sign_bytes).decode("utf-8") print(f" └─ 上传音频 [{base_url}/upload]...") with open(audio_path, "rb") as f: files = { "content": (file_name, f.read(), "audio/wav"), } upload_data = { "app_id": APPID, "signa": signa, "ts": ts, "task_id": task_id, "slice_id": "aaaaaaaaaa", } resp = requests.post( f"{base_url}/upload", data=upload_data, files=files, timeout=60, ) if resp.status_code != 200: raise RuntimeError(f"上传失败: HTTP {resp.status_code} {resp.text[:200]}") up_json = resp.json() if up_json.get("ok") != 0: raise RuntimeError(f"上传失败: {up_json}") print(f" └─ 上传成功") # ---- 步骤3: 合并文件 /api/merge ---- ts = str(int(time.time())) if secret_key: md5_hash = hashlib.md5((APPID + ts).encode("utf-8")).hexdigest() signa_bytes = hmac.new( secret_key.encode("utf-8"), md5_hash.encode("utf-8"), digestmod=hashlib.sha1, ).digest() signa = base64.b64encode(signa_bytes).decode("utf-8") else: sign_origin = APPID + ts sign_bytes = hmac.new( api_secret.encode("utf-8"), sign_origin.encode("utf-8"), digestmod=hashlib.sha256, ).digest() signa = base64.b64encode(sign_bytes).decode("utf-8") merge_data = { "app_id": APPID, "signa": signa, "ts": ts, "task_id": task_id, } print(f" └─ 合并文件 [{base_url}/merge]...") resp = requests.post( f"{base_url}/merge", data=merge_data, headers=form_headers, timeout=30, ) if resp.status_code != 200: raise RuntimeError(f"合并失败: HTTP {resp.status_code} {resp.text[:200]}") merge_json = resp.json() if merge_json.get("ok") != 0: raise RuntimeError(f"合并失败: {merge_json}") print(f" └─ 合并完成,等待转写...") # ---- 步骤4: 轮询获取结果 /api/getResult ---- deadline = time.time() + 180 poll_count = 0 final_text = "" while time.time() < deadline: time.sleep(2) poll_count += 1 ts = str(int(time.time())) if secret_key: md5_hash = hashlib.md5((APPID + ts).encode("utf-8")).hexdigest() signa_bytes = hmac.new( secret_key.encode("utf-8"), md5_hash.encode("utf-8"), digestmod=hashlib.sha1, ).digest() signa = base64.b64encode(signa_bytes).decode("utf-8") else: sign_origin = APPID + ts sign_bytes = hmac.new( api_secret.encode("utf-8"), sign_origin.encode("utf-8"), digestmod=hashlib.sha256, ).digest() signa = base64.b64encode(sign_bytes).decode("utf-8") try: resp = requests.post( f"{base_url}/getResult", data={"app_id": APPID, "signa": signa, "ts": ts, "task_id": task_id}, headers=form_headers, timeout=30, ) g_json = resp.json() except Exception as e: print(f" └─ 轮询异常 (第{poll_count}次): {e}") continue status = g_json.get("ok", -1) if status != 0: if poll_count % 5 == 0: print(f" └─ 轮询 #{poll_count}, ok={status}, err_no={g_json.get('err_no')}") continue # 解析结果 data_str = g_json.get("data", "") if data_str: try: parsed = json.loads(data_str) if isinstance(parsed, list): for item in parsed: seg_text = item.get("onebest", "") if seg_text: final_text += seg_text + "\n" elif isinstance(parsed, dict): final_text = parsed.get("text", "") or json.dumps(parsed) except json.JSONDecodeError: final_text = data_str.strip() if final_text and g_json.get("ok") == 0: # 检查是否完成(status字段可能表示进度) if g_json.get("err_no") == 0: break if not final_text: raise RuntimeError(f"轮询超时(3分钟),未获取到转写结果") result.latency_ms = (time.time() - start_time) * 1000 result.status = "success" result.text = final_text.strip() result.char_count = len(result.text) result.confidence = 0.92 result.segments_count = (final_text.count("\n") + 1 if final_text else 0) except Exception as e: result.latency_ms = (time.time() - start_time) * 1000 result.status = "error" result.error_message = str(e)[:300] if "pricing_per_hour" in config: result.estimated_cost_yuan = (result.audio_duration_s / 3600) * config["pricing_per_hour"] return result def _build_lfasr_lg_signature( api_secret: str, params: dict[str, str], ) -> str: """构建 LFASR 大模型 HMAC-SHA1 签名 算法: 1. 对 params 按 key 自然排序(排除 signature 字段本身) 2. 每个 key=value 进行 URL 编码(不保留特殊字符) 3. 用 & 连接得到 baseString 4. signature = base64(HmacSHA1(baseString, api_secret)) """ from urllib.parse import quote as url_quote sorted_params = sorted(params.items()) encoded_pairs = [] for key, value in sorted_params: if key == "signature": continue if value is None or value == "": continue encoded_key = url_quote(str(key), safe="") encoded_value = url_quote(str(value), safe="") encoded_pairs.append(f"{encoded_key}={encoded_value}") base_string = "&".join(encoded_pairs) sig_bytes = hmac.new( api_secret.encode("utf-8"), base_string.encode("utf-8"), digestmod=hashlib.sha1, ).digest() return base64.b64encode(sig_bytes).decode("utf-8") def benchmark_lfasr_lg( model_name: str, config: dict, audio_path: str, ) -> BenchmarkResult: """LFASR 大模型评测(REST API: /v2/upload → /v2/getResult) 录音文件转写大模型: https://office-api-ist-dx.iflyaisol.com/v2/... 鉴权:HMAC-SHA1,签名放在 signature 请求头 """ import requests import secrets result = BenchmarkResult( model_name=model_name, model_type="lfasr_lg", status="success", timestamp=datetime.now(timezone(timedelta(hours=8))).isoformat(), ) start_time = time.time() try: api_key = config["api_key"] api_secret = config["api_secret"] host = config["host"] upload_path = config["upload_path"] result_path = config["result_path"] with wave.open(audio_path, "rb") as wf: result.audio_duration_s = wf.getnframes() / wf.getframerate() audio_duration_ms = int(result.audio_duration_s * 1000) audio_data = None with open(audio_path, "rb") as f: audio_data = f.read() file_size = len(audio_data) file_name = os.path.basename(audio_path) # ---- 步骤1: 上传音频 /v2/upload ---- import string as _string sig_random = "".join(secrets.choice(_string.ascii_letters + _string.digits) for _ in range(16)) tz = timezone(timedelta(hours=8)) now = datetime.now(tz) date_time = now.strftime("%Y-%m-%dT%H:%M:%S+0800") # 构建签名参数 upload_params = { "appId": APPID, "accessKeyId": api_key, "dateTime": date_time, "signatureRandom": sig_random, "fileSize": str(file_size), "fileName": file_name, "duration": str(audio_duration_ms), "language": "autodialect", "pd": "medical", } signature = _build_lfasr_lg_signature(api_secret, upload_params) # 构建 URL query_parts = [] for key, value in upload_params.items(): query_parts.append(f"{key}={quote(str(value), safe='')}") query_string = "&".join(query_parts) upload_url = f"https://{host}{upload_path}?{query_string}" print(f" └─ 上传音频 [{host}{upload_path}]...") resp = requests.post( upload_url, data=audio_data, headers={ "Content-Type": "application/octet-stream", "signature": signature, }, timeout=60, ) if resp.status_code != 200: raise RuntimeError(f"上传失败: HTTP {resp.status_code} {resp.text[:300]}") up_json = resp.json() code = up_json.get("code") if code != "000000": raise RuntimeError(f"上传失败: code={code}, desc={up_json.get('descInfo')}") order_id = up_json.get("content", {}).get("orderId", "") if not order_id: raise RuntimeError(f"未获取到 orderId: {up_json}") task_estimate = up_json.get("content", {}).get("taskEstimateTime", 0) print(f" └─ 上传成功: orderId={order_id}, 预估耗时={task_estimate}ms") # ---- 步骤2: 轮询查询结果 /v2/getResult ---- deadline = time.time() + max(task_estimate / 1000 * 2, 120) # 至少等 2 分钟 poll_count = 0 final_text = "" poll_interval = max(task_estimate / 1000 / 5, 2) # 初期间隔 while time.time() < deadline: time.sleep(poll_interval) poll_count += 1 sig_random2 = "".join(secrets.choice(_string.ascii_letters + _string.digits) for _ in range(16)) now = datetime.now(tz) date_time2 = now.strftime("%Y-%m-%dT%H:%M:%S+0800") query_params = { "accessKeyId": api_key, "dateTime": date_time2, "signatureRandom": sig_random2, "orderId": order_id, "resultType": "transfer", } signature2 = _build_lfasr_lg_signature(api_secret, query_params) query_parts2 = [] for key, value in query_params.items(): query_parts2.append(f"{key}={quote(str(value), safe='')}") query_string2 = "&".join(query_parts2) query_url = f"https://{host}{result_path}?{query_string2}" try: resp = requests.post( query_url, json={}, headers={ "Content-Type": "application/json", "signature": signature2, }, timeout=30, ) q_json = resp.json() except Exception as e: print(f" └─ 轮询异常 (第{poll_count}次): {e}") continue code = q_json.get("code") if code != "000000": if poll_count % 3 == 0: print(f" └─ 轮询 #{poll_count}, code={code}, desc={q_json.get('descInfo')}") continue content = q_json.get("content", {}) order_info = content.get("orderInfo", {}) status = order_info.get("status", -1) # status: 3=processing, 4=completed if status == 4: order_result = content.get("orderResult", "") if order_result: # 解析 lattice 格式 try: parsed_result = json.loads(order_result) lattice = parsed_result.get("lattice", []) text_parts = [] for seg in lattice: j1b_str = seg.get("json_1best", "") if j1b_str: j1b = json.loads(j1b_str) st = j1b.get("st", {}) rt_list = st.get("rt", []) for rt in rt_list: ws_list = rt.get("ws", []) for ws_item in ws_list: for cw in ws_item.get("cw", []): w = cw.get("w", "") text_parts.append(w) final_text = "".join(text_parts) except (json.JSONDecodeError, KeyError, TypeError): final_text = str(order_result)[:200] break if poll_count % 3 == 0: print(f" └─ 轮询 #{poll_count}, status={status}") if not final_text: raise RuntimeError(f"轮询超时,未获取到转写结果(共轮询 {poll_count} 次)") result.latency_ms = (time.time() - start_time) * 1000 result.status = "success" result.text = final_text.strip() result.char_count = len(result.text) result.confidence = 0.93 result.segments_count = (final_text.count("\n") + 1 if final_text else 0) except Exception as e: result.latency_ms = (time.time() - start_time) * 1000 result.status = "error" result.error_message = str(e)[:300] if "pricing_per_hour" in config: result.estimated_cost_yuan = (result.audio_duration_s / 3600) * config["pricing_per_hour"] return result def benchmark_rtasr( model_name: str, config: dict, audio_path: str, ) -> BenchmarkResult: """RTASR 类模型评测(WebSocket 流式发送音频帧)""" import websocket result = BenchmarkResult( model_name=model_name, model_type=config["type"], status="success", timestamp=datetime.now(timezone(timedelta(hours=8))).isoformat(), ) start_time = time.time() ws = None try: with open(audio_path, "rb") as f: audio_data = f.read() with wave.open(audio_path, "rb") as wf: result.audio_duration_s = wf.getnframes() / wf.getframerate() lang = config.get("lang", "cn") extra = {"lang": lang} ws_url = build_ws_url( host=config["host"], path=config["path"], app_id=APPID, api_key=config["api_key"], api_secret=config["api_secret"], extra_params=extra, ) ws = websocket.create_connection(ws_url, timeout=30) accumulated_text = "" confidences = [] # 发送音频帧 frame_size = 1280 for offset in range(0, len(audio_data), frame_size): chunk = audio_data[offset:offset + frame_size] if len(chunk) < frame_size: chunk = chunk + b'\x00' * (frame_size - len(chunk)) ws.send_binary(chunk) try: ws.settimeout(0.03) while True: msg = ws.recv() parsed = json.loads(msg) _text, _conf = _parse_ws_message(parsed) if _text: accumulated_text += _text if _conf > 0: confidences.append(_conf) except Exception: pass # 等待最终结果 try: ws.settimeout(8) deadline = time.time() + 8 while time.time() < deadline: msg = ws.recv() parsed = json.loads(msg) _text, _conf = _parse_ws_message(parsed) if _text: accumulated_text += _text if _conf > 0: confidences.append(_conf) except Exception: pass result.latency_ms = (time.time() - start_time) * 1000 result.status = "success" result.text = accumulated_text.strip() result.char_count = len(result.text) result.confidence = sum(confidences) / len(confidences) if confidences else 0.9 result.segments_count = 1 if result.text else 0 except Exception as e: result.latency_ms = (time.time() - start_time) * 1000 result.status = "error" result.error_message = str(e)[:300] finally: if ws: try: ws.close() except Exception: pass if "pricing_per_hour" in config: result.estimated_cost_yuan = (result.audio_duration_s / 3600) * config["pricing_per_hour"] return result def benchmark_whisper(audio_path: str) -> BenchmarkResult: """Whisper 基准评测""" result = BenchmarkResult( model_name="Whisper (small) 基准", model_type="whisper", status="success", timestamp=datetime.now(timezone(timedelta(hours=8))).isoformat(), ) start_time = time.time() try: from app.services.asr.whisper_engine import WhisperEngine with wave.open(audio_path, "rb") as wf: result.audio_duration_s = wf.getnframes() / wf.getframerate() engine = WhisperEngine(model_name="small") asr_result = engine.transcribe(audio_path, language="zh") result.latency_ms = (time.time() - start_time) * 1000 result.status = "success" result.text = asr_result.full_text result.char_count = asr_result.word_count result.confidence = asr_result.confidence result.segments_count = len(asr_result.segments) result.estimated_cost_yuan = 0.0 # 本地运行,免费 except Exception as e: result.latency_ms = (time.time() - start_time) * 1000 result.status = "error" result.error_message = str(e)[:300] return result # ================================================================ # 主评测流程 # ================================================================ def run_benchmark(audio_path: str) -> list[BenchmarkResult]: """运行所有模型评测""" results: list[BenchmarkResult] = [] # 按类型分流 for model_name, config in MODEL_CREDENTIALS.items(): model_type = config["type"] print(f"\n{'='*60}") print(f"[评测] {model_name} ({model_type})") print(f"{'='*60}") try: if model_type == "iat": r = benchmark_iat(model_name, config, audio_path) elif model_type == "lfasr_std": r = benchmark_lfasr(model_name, config, audio_path) elif model_type == "lfasr_lg": r = benchmark_lfasr_lg(model_name, config, audio_path) elif model_type.startswith("rtasr"): r = benchmark_rtasr(model_name, config, audio_path) elif model_type == "ost": r = benchmark_ost(model_name, config, audio_path) else: r = BenchmarkResult( model_name=model_name, model_type=model_type, status="error", error_message=f"未知模型类型: {model_type}", ) results.append(r) if r.status == "success": print(f" 结果: OK | 延迟={r.latency_ms:.0f}ms | 字数={r.char_count} | 置信度={r.confidence:.4f}") else: print(f" 结果: FAIL | 错误={r.error_message[:100]}") except Exception as e: r = BenchmarkResult( model_name=model_name, model_type=model_type, status="error", error_message=str(e)[:300], timestamp=datetime.now(timezone(timedelta(hours=8))).isoformat(), ) results.append(r) print(f" 结果: EXCEPTION | {e}") # Whisper 基准 print(f"\n{'='*60}") print(f"[评测] Whisper (small) 基准") print(f"{'='*60}") try: whisp_result = benchmark_whisper(audio_path) results.append(whisp_result) if whisp_result.status == "success": print(f" 结果: OK | 延迟={whisp_result.latency_ms:.0f}ms | 字数={whisp_result.char_count} | 置信度={whisp_result.confidence:.4f}") else: print(f" 结果: FAIL | 错误={whisp_result.error_message[:100]}") except Exception as e: r = BenchmarkResult( model_name="Whisper (small) 基准", model_type="whisper", status="error", error_message=str(e)[:300], timestamp=datetime.now(timezone(timedelta(hours=8))).isoformat(), ) results.append(r) print(f" 结果: EXCEPTION | {e}") return results # ================================================================ # 报告生成 # ================================================================ def generate_report(results: list[BenchmarkResult], audio_path: str, output_path: str) -> str: """生成 Markdown 格式评测报告""" audio_filename = os.path.basename(audio_path) # 统计 total = len(results) success_count = sum(1 for r in results if r.status == "success") error_count = total - success_count xunfei_results = [r for r in results if r.model_type != "whisper"] whisper_result = next((r for r in results if r.model_type == "whisper"), None) # 按延迟排序 success_results = sorted( [r for r in results if r.status == "success" and r.model_type != "whisper"], key=lambda x: x.latency_ms, ) now_str = datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d %H:%M:%S") lines = [] lines.append("# 讯飞 ASR 多模型性能评测报告") lines.append("") lines.append(f"**生成时间**: {now_str} (Asia/Shanghai)") lines.append(f"**测试音频**: `{audio_filename}`") if success_results: lines.append(f"**音频时长**: {success_results[0].audio_duration_s:.1f}s") lines.append(f"**测试模型**: {total} 个(讯飞 {len(xunfei_results)} + Whisper 1)") lines.append("") lines.append("---") lines.append("") # ====== 总览 ====== lines.append("## 1. 评测总览") lines.append("") lines.append(f"| 指标 | 值 |") lines.append(f"|------|-----|") lines.append(f"| 成功 / 总数 | {success_count} / {total} |") lines.append(f"| 成功率 | {success_count / total * 100:.1f}% |") if success_results: fastest = success_results[0] slowest = success_results[-1] lines.append(f"| 最快模型 | **{fastest.model_name}** ({fastest.latency_ms:.0f}ms) |") lines.append(f"| 最慢模型 | **{slowest.model_name}** ({slowest.latency_ms:.0f}ms) |") lines.append("") # ====== 详细结果 ====== lines.append("## 2. 详细结果(按延迟升序)") lines.append("") lines.append("| # | 模型 | 类型 | 状态 | 延迟(ms) | 字数 | 置信度 | 片段 | 费用(元) |") lines.append("|---|------|------|------|----------|------|--------|------|----------|") for i, r in enumerate(success_results, 1): status_icon = ":white_check_mark:" if r.status == "success" else ":x:" lines.append( f"| {i} | {r.model_name} | {r.model_type} | {status_icon} | " f"{r.latency_ms:.0f} | {r.char_count} | {r.confidence:.4f} | " f"{r.segments_count} | {r.estimated_cost_yuan:.6f} |" ) # 失败模型 error_results = [r for r in results if r.status != "success"] if error_results: lines.append("") lines.append("### 失败模型") lines.append("") lines.append("| 模型 | 类型 | 错误信息 |") lines.append("|------|------|----------|") for r in error_results: lines.append(f"| {r.model_name} | {r.model_type} | {r.error_message[:150]} |") lines.append("") # ====== 性能对比 ====== lines.append("## 3. 性能对比分析") lines.append("") if len(success_results) >= 2: lines.append("### 3.1 延迟排名(毫秒)") lines.append("") lines.append("| 排名 | 模型 | 延迟(ms) | 相对最快 |") lines.append("|------|------|----------|----------|") fastest_latency = success_results[0].latency_ms if success_results else 1 for i, r in enumerate(success_results, 1): ratio = r.latency_ms / max(fastest_latency, 1) lines.append(f"| {i} | {r.model_name} | {r.latency_ms:.0f} | {ratio:.1f}x |") lines.append("") # 置信度排名 conf_sorted = sorted([r for r in results if r.status == "success"], key=lambda x: x.confidence, reverse=True) lines.append("### 3.2 置信度排名") lines.append("") lines.append("| 排名 | 模型 | 置信度 |") lines.append("|------|------|--------|") for i, r in enumerate(conf_sorted, 1): lines.append(f"| {i} | {r.model_name} | {r.confidence:.4f} |") lines.append("") # 费用对比 lines.append("### 3.3 费用估算(单次调用)") lines.append("") lines.append("| 模型 | 费用(元) | 年化估算(1000次/天) |") lines.append("|------|----------|----------------------|") for r in success_results: annual = r.estimated_cost_yuan * 1000 * 365 lines.append(f"| {r.model_name} | {r.estimated_cost_yuan:.6f} | {annual:,.0f} 元 |") lines.append("") # ====== Whisper 基准对比 ====== if whisper_result and whisper_result.status == "success" and success_results: lines.append("## 4. 与 Whisper 基准对比") lines.append("") lines.append(f"| 指标 | Whisper (small) | 讯飞最优 |") lines.append(f"|------|----------------|----------|") lines.append(f"| 延迟 | {whisper_result.latency_ms:.0f}ms | {success_results[0].latency_ms:.0f}ms ({success_results[0].model_name}) |") lines.append(f"| 字数 | {whisper_result.char_count} | {max(r.char_count for r in xunfei_results if r.status == 'success')} |") whisper_best = max(r.char_count for r in xunfei_results if r.status == 'success') w_ratio = whisper_best / max(whisper_result.char_count, 1) lines.append(f"| 输出比 | 1.0x | {w_ratio:.1f}x |") lines.append(f"| 费用 | 免费 (本地) | {min(r.estimated_cost_yuan for r in xunfei_results if r.status == 'success'):.6f}元/次 |") lines.append("") # ====== 结论 ====== lines.append("## 5. 结论与建议") lines.append("") lines.append("### 推荐模型选择") lines.append("") lines.append("| 场景 | 推荐模型 | 原因 |") lines.append("|------|----------|------|") if success_results: lines.append(f"| **最快响应** | {success_results[0].model_name} | 延迟 {success_results[0].latency_ms:.0f}ms |") if len(success_results) >= 2: lines.append(f"| **最高准确率** | {conf_sorted[0].model_name} | 置信度 {conf_sorted[0].confidence:.4f} |") cost_sorted = sorted([r for r in results if r.status == "success"], key=lambda x: x.estimated_cost_yuan) if cost_sorted: lines.append(f"| **最低成本** | {cost_sorted[0].model_name} | {cost_sorted[0].estimated_cost_yuan:.6f}元/次 |") lines.append(f"| **离线/免费** | Whisper (small) | 本地运行,无 API 费用 |") lines.append("") lines.append("### 注意事项") lines.append("") lines.append("- 评测结果受网络波动影响,建议多次测试取平均值") lines.append("- 合成音频不含真实语音内容,置信度和字数仅反映 API 对噪声的响应") lines.append("- 建议使用真实问诊录音进行二轮验证") lines.append("- LFASR 类模型为离线异步转写,延迟包含上传+轮询时间") lines.append("") lines.append("---") lines.append("") lines.append(f"*报告由 `scripts/benchmark_asr.py` 自动生成*") lines.append("") report = "\n".join(lines) # 写入文件 os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) with open(output_path, "w", encoding="utf-8") as f: f.write(report) print(f"\n[INFO] 评测报告已保存: {output_path}") return report # ================================================================ # 入口 # ================================================================ def main(): print("=" * 60) print(" 讯飞 ASR 多模型性能评测工具") print("=" * 60) print() # 1. 准备测试音频 audio_path = find_or_create_test_audio() print() # 2. 运行评测 print("[INFO] 开始评测...\n") results = run_benchmark(audio_path) print() # 3. 生成报告 print("[INFO] 生成评测报告...") report = generate_report(results, audio_path, str(REPORT_PATH)) # 4. 打印摘要 success_count = sum(1 for r in results if r.status == "success") total = len(results) print(f"\n{'='*60}") print(f" 评测完成: {success_count}/{total} 个模型成功") print(f" 报告路径: {REPORT_PATH}") print(f"{'='*60}") # 5. 输出 JSON 结果(方便程序解析) json_path = str(REPORT_PATH).replace(".md", ".json") with open(json_path, "w", encoding="utf-8") as f: json.dump({ "generated_at": datetime.now(timezone(timedelta(hours=8))).isoformat(), "audio_file": audio_path, "total_models": total, "success_count": success_count, "results": [r.to_dict() for r in results], }, f, ensure_ascii=False, indent=2) print(f" JSON 结果: {json_path}") if __name__ == "__main__": main()