feat(eval): ASR 模型评估框架

横向对比云端 gummy 与本地开源模型(faster-whisper/SenseVoice/Paraformer),
重点覆盖中英混说,产出准确率(CER/WER/MER)/速度(延迟/RTF)/资源(cpu/mem/模型大小)
对比报告。公共集(ASCEND/AISHELL/LibriSpeech)统一走 HF 适配器 + 自定义 JSONL manifest。
gummy 引擎对照 server/internal/asr/gummy.go 协议移植。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 11:25:04 +08:00
parent 40760aa884
commit 25acf9db6e
25 changed files with 1594 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# 数据集、模型权重、结果、缓存全部不入库(体积大)
# 注意:锚定到 eval/ 根(前导 /),否则会误伤 asr_eval/datasets/ 源码包
/datasets/
/models/
/results/
*.wav
*.mp3
*.flac
*.aiff
__pycache__/
*.egg-info/
.venv/
venv/
.pytest_cache/
+63
View File
@@ -0,0 +1,63 @@
# dudu ASR 模型评估框架
横向对比**云端 gummy**DashScope `gummy-realtime-v1`)与**本地开源模型**faster-whisper /
SenseVoice / Paraformer),重点覆盖**中英混说(code-switching**,产出三类指标:
| 维度 | 指标 |
|---|---|
| 准确率 | **MER**(中英混合错误率,headline)/ CER(字级)/ WER(词级) |
| 速度 | 首包延迟、定稿延迟、RTF(处理时长 / 音频时长) |
| 资源 | 本地:峰值内存 / 平均 CPU / 模型大小;云端:成本(¥/分钟) |
## 安装
```bash
cd eval
python -m venv .venv && source .venv/bin/activate
pip install -e . # 核心管线(含云端 gummy 评估能力)
pip install -e '.[whisper]' # 加 faster-whisper 本地引擎
pip install -e '.[funasr]' # 加 SenseVoice / Paraformer(含 torch
pip install -e '.[all]' # 全部本地引擎
```
## 跑评估
```bash
# 冒烟:每个数据集取 5 条,只跑 gummy + whisper-small
rbw get dashscope-api-key | python -m asr_eval run --sample-limit 5 --engines gummy,whisper-small
# 只验证 gummy 连通
rbw get dashscope-api-key | python -m asr_eval run --engines gummy --datasets custom --sample-limit 3
# 全量(放开 sample_limit、在 config.yaml 里 enable 想跑的引擎/数据集)
rbw get dashscope-api-key | python -m asr_eval run --config config.yaml
```
> gummy 的 API key 优先读环境变量 `DASHSCOPE_API_KEY`,否则读 stdin 首行——
> 遵全局规则用 `rbw get dashscope-api-key | ...` 管道传入,不落盘、不写 env 文件。
报告写到 `results/<时间戳>/``report.md`(终端友好)、`report.html`(表格)、
`report.json`(机器可读)、`results.jsonl`(每条样本明细,便于 debug 个案)。
## 配置(`config.yaml`
- **数据集**:公共集统一 `type: hf`,差异全在配置(`hf_id`/`split`/`text_field`/`lang`)——
广覆盖 / 换数据集只改配置不动代码。预置 ASCEND(中英混说)、AISHELL-1(纯中)、LibriSpeech(纯英)。
- **自定义集**`type: custom` 指向 JSONL manifest,把自录/业务音频按 `manifests/custom.example.jsonl`
格式丢进来即可与公共集同管线评估。
- **引擎**`enabled` 开关;本地引擎可选 `device`(cpu / 苹果芯片可试 mps)、whisper 的 `compute_type`
## 设计要点
- **gummy 协议**对照后端 `server/internal/asr/gummy.go` 移植(run-task/finish-task、`transcription`
`sentence` 双字段、`sentence_end` 的 bool/字符串兼容、3200B/16k/pcm 分帧)。
- **错误率语料级聚合**:累加 S/D/I/N 再求率,不对每条样本求率再平均(短句不会被放大)。
- **评分前归一化**:去标点、英文小写、繁→简、全/半角统一,否则中英混说分数失真。
- **公平性声明**:流式(gummy) vs 离线(本地)延迟口径不同(gummy RTF 含推流倍速与网络);
资源仅本地引擎测量,含 Python 进程基底——报告会标注硬件环境。
## 已知约束
- ASCEND 在 HuggingFace 开放;AISHELL/LibriSpeech 首次会下载(数 GB,已 gitignore)。
- SEAME(更权威的中英混说集)需 LDC 付费授权,默认不含;有授权可加一个 hf/custom 适配条目。
- 本地模型权重大(whisper-large ≈1.5GB、SenseVoice ≈900MB),按需 enable。
+7
View File
@@ -0,0 +1,7 @@
"""dudu ASR 模型评估框架。
云端 gummy ↔ 本地开源模型横向对比,覆盖中英混说,产出准确率/速度/资源三类指标。
入口:python -m asr_eval run --config config.yaml
"""
__version__ = "0.1.0"
+4
View File
@@ -0,0 +1,4 @@
from asr_eval.cli import main
if __name__ == "__main__":
main()
+42
View File
@@ -0,0 +1,42 @@
"""音频加载与重采样工具:统一到 16k/mono,供 gummy(pcm16) 与本地引擎(float) 使用。"""
from __future__ import annotations
import numpy as np
import soundfile as sf
TARGET_SR = 16000
def _resample(data: np.ndarray, sr: int, target_sr: int) -> np.ndarray:
if sr == target_sr:
return data
import soxr
return soxr.resample(data, sr, target_sr)
def load_pcm16(path: str, target_sr: int = TARGET_SR) -> tuple[np.ndarray, int]:
"""读为 int16 单声道 PCMgummy 推流用)。"""
data, sr = sf.read(path, dtype="float32", always_2d=False)
if data.ndim > 1:
data = data.mean(axis=1)
data = _resample(data, sr, target_sr)
# float32(-1,1) -> int16
pcm = np.clip(data, -1.0, 1.0)
pcm = (pcm * 32767.0).astype(np.int16)
return pcm, target_sr
def load_float(path: str, target_sr: int = TARGET_SR) -> tuple[np.ndarray, int]:
"""读为 float32 单声道(本地引擎用)。"""
data, sr = sf.read(path, dtype="float32", always_2d=False)
if data.ndim > 1:
data = data.mean(axis=1)
data = _resample(data, sr, target_sr)
return data.astype(np.float32), target_sr
def duration_sec(path: str) -> float:
info = sf.info(path)
return info.frames / info.samplerate
+200
View File
@@ -0,0 +1,200 @@
"""命令行入口:python -m asr_eval run --config config.yaml
流程:载 config → 物料化样本 → 逐引擎逐样本识别(本地引擎包资源探针) → 算分 → 出报告。
gummy 的 API key:优先 env DASHSCOPE_API_KEY,否则读 stdin 首行
(遵全局规则:rbw get dashscope-api-key | python -m asr_eval run ...)。
"""
from __future__ import annotations
import argparse
import os
import sys
from datetime import datetime
import yaml
from rich.console import Console
from rich.progress import BarColumn, Progress, TextColumn, TimeElapsedColumn
from .datasets import build_dataset
from .engines import build_engine
from .metrics.err import score_sample
from .metrics.resource import ResourceProbe
from .report import aggregate, render_markdown, write_reports
console = Console()
def _resolve_api_key() -> str:
key = os.environ.get("DASHSCOPE_API_KEY", "").strip()
if key:
return key
if not sys.stdin.isatty():
line = sys.stdin.readline().strip()
if line:
return line
return ""
def _load_samples(cfg: dict, only: set[str] | None, limit: int | None) -> list:
audio_cache = cfg.get("audio_cache", "datasets/_audio_cache")
samples = []
for dcfg in cfg.get("datasets", []):
if only and dcfg["name"] not in only:
continue
ds = build_dataset(dcfg, audio_cache)
console.print(f"[cyan]载入数据集[/] {dcfg['name']} (type={dcfg['type']}) ...")
try:
got = list(ds.samples(limit=limit))
except Exception as ex:
console.print(f"[red]数据集 {dcfg['name']} 载入失败,跳过:{ex}[/]")
continue
console.print(f"{len(got)}")
samples.extend(got)
return samples
def _enabled_engines(cfg: dict, only: set[str] | None, api_key: str) -> list[dict]:
out = []
for ecfg in cfg.get("engines", []):
if not ecfg.get("enabled", True):
continue
if only and ecfg["name"] not in only:
continue
if ecfg["type"] == "gummy":
ecfg = {**ecfg, "api_key": api_key}
out.append(ecfg)
return out
def cmd_run(args: argparse.Namespace) -> int:
with open(args.config, encoding="utf-8") as f:
cfg = yaml.safe_load(f)
limit = args.sample_limit if args.sample_limit is not None else cfg.get("sample_limit")
only_engines = set(args.engines.split(",")) if args.engines else None
only_datasets = set(args.datasets.split(",")) if args.datasets else None
engine_cfgs = _enabled_engines(cfg, only_engines, "")
need_key = any(e["type"] == "gummy" for e in engine_cfgs)
api_key = _resolve_api_key() if need_key else ""
if need_key and not api_key:
console.print("[red]gummy 已启用但未取到 API key(设 DASHSCOPE_API_KEY 或 stdin 传入)[/]")
return 2
engine_cfgs = _enabled_engines(cfg, only_engines, api_key)
samples = _load_samples(cfg, only_datasets, limit)
if not samples:
console.print("[red]没有可评估的样本,检查 datasets 配置或 manifest[/]")
return 1
rows: list[dict] = []
engines_meta: dict[str, dict] = {}
for ecfg in engine_cfgs:
name = ecfg["name"]
console.print(f"\n[bold magenta]引擎 {name}[/] (type={ecfg['type']}) 加载中 ...")
engine = build_engine(ecfg)
try:
engine.load()
except Exception as ex:
console.print(f"[red]引擎 {name} 加载失败,跳过:{ex}[/]")
continue
engines_meta[name] = {
"is_local": engine.is_local,
"kind": engine.kind,
"model_size_mb": engine.model_size_mb(),
"cost_per_min": engine.cost_per_min(),
}
with Progress(
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TextColumn("{task.completed}/{task.total}"),
TimeElapsedColumn(),
console=console,
) as prog:
task = prog.add_task(f"{name}", total=len(samples))
for s in samples:
tr = _transcribe_one(engine, s.audio_path)
sc = score_sample(s.ref_text, tr.text)
rows.append({
"engine": name,
"dataset": s.dataset,
"category": s.category,
"lang": s.lang,
"domain": s.domain,
"sample_id": s.id,
"ref": s.ref_text,
"hyp": tr.text,
"counts": sc,
"audio_sec": tr.audio_sec,
"proc_sec": tr.proc_sec,
"rtf": tr.rtf,
"first_partial_sec": tr.first_partial_sec,
"finalize_sec": tr.finalize_sec,
"peak_rss_mb": tr.peak_rss_mb,
"avg_cpu": tr.avg_cpu,
"error": tr.error,
})
prog.advance(task)
engine.unload()
if not rows:
console.print("[red]无结果(所有引擎都失败了?)[/]")
return 1
report = aggregate(rows, engines_meta)
out_dir = os.path.join(cfg.get("output_dir", "results"), datetime.now().strftime("%Y%m%d-%H%M%S"))
paths = write_reports(report, out_dir)
_dump_raw(rows, out_dir)
console.print("\n" + render_markdown(report))
console.print(f"\n[green]报告已写入[/] {out_dir}/ (report.json / report.md / report.html)")
return 0
def _transcribe_one(engine, audio_path: str):
"""本地引擎包资源探针;云端引擎直接调用。"""
if engine.is_local:
with ResourceProbe() as probe:
tr = engine.transcribe(audio_path)
if tr.peak_rss_mb is None:
tr.peak_rss_mb = probe.peak_rss_mb
if tr.avg_cpu is None:
tr.avg_cpu = probe.avg_cpu
return tr
return engine.transcribe(audio_path)
def _dump_raw(rows: list[dict], out_dir: str) -> None:
import json
path = os.path.join(out_dir, "results.jsonl")
with open(path, "w", encoding="utf-8") as f:
for r in rows:
rec = {k: v for k, v in r.items() if k != "counts"}
rec["cer"] = r["counts"]["cer"].rate
rec["wer"] = r["counts"]["wer"].rate
rec["mer"] = r["counts"]["mer"].rate
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
def main() -> None:
ap = argparse.ArgumentParser(prog="asr_eval", description="dudu ASR 模型评估框架")
sub = ap.add_subparsers(dest="cmd", required=True)
run = sub.add_parser("run", help="跑评估")
run.add_argument("--config", default="config.yaml")
run.add_argument("--engines", help="只跑这些引擎(逗号分隔,覆盖 enabled)")
run.add_argument("--datasets", help="只跑这些数据集(逗号分隔)")
run.add_argument("--sample-limit", type=int, default=None, help="每个数据集采样上限(覆盖 config)")
run.set_defaults(func=cmd_run)
args = ap.parse_args()
sys.exit(args.func(args))
if __name__ == "__main__":
main()
+25
View File
@@ -0,0 +1,25 @@
"""数据集注册表:config 的 type(hf|custom) 映射到适配器。
公共集(ASCEND/AISHELL/LibriSpeech)统一走 HFDataset,差异全在 config.yaml 里描述
hf_id/split/text_field/lang),所以"广覆盖 + 自定义"只需改配置,不必加代码。
"""
from __future__ import annotations
from .base import Dataset
def build_dataset(cfg: dict, audio_cache: str) -> Dataset:
dtype = cfg["type"]
if dtype == "hf":
from .hf import HFDataset
return HFDataset(cfg, audio_cache)
if dtype == "custom":
from .custom import CustomDataset
return CustomDataset(cfg)
raise ValueError(f"未知数据集类型: {dtype}")
__all__ = ["Dataset", "build_dataset"]
+34
View File
@@ -0,0 +1,34 @@
"""数据集适配器基类与音频物料化工具。"""
from __future__ import annotations
import os
from collections.abc import Iterator
import numpy as np
import soundfile as sf
from ..manifest import Sample
TARGET_SR = 16000
class Dataset:
name: str = "base"
def samples(self, limit: int | None = None) -> Iterator[Sample]:
raise NotImplementedError
def write_wav_16k(array: np.ndarray, sr: int, out_path: str) -> None:
"""把(可能任意采样率的)单/多声道数组写成 16k/mono/16bit wav。"""
os.makedirs(os.path.dirname(out_path), exist_ok=True)
data = np.asarray(array, dtype=np.float32)
if data.ndim > 1:
data = data.mean(axis=1)
if sr != TARGET_SR:
import soxr
data = soxr.resample(data, sr, TARGET_SR)
data = np.clip(data, -1.0, 1.0)
sf.write(out_path, (data * 32767.0).astype(np.int16), TARGET_SR, subtype="PCM_16")
+62
View File
@@ -0,0 +1,62 @@
"""自定义集:JSONL manifest,每行一条样本。
格式(audio 路径相对 manifest 文件所在目录解析):
{"audio": "clips/a.wav", "text": "帮我把这份 weekly report 整理一下", "lang": "zh-en", "domain": "tech"}
lang 缺省按文本自动猜(含中文且含 ASCII 字母→zh-en;纯 ASCII→en;否则 zh)。
"""
from __future__ import annotations
import json
import os
import re
from collections.abc import Iterator
from ..manifest import Sample
_CJK = re.compile(r"[一-鿿]")
_ASCII_ALPHA = re.compile(r"[A-Za-z]")
def _guess_lang(text: str) -> str:
has_cjk = bool(_CJK.search(text))
has_en = bool(_ASCII_ALPHA.search(text))
if has_cjk and has_en:
return "zh-en"
if has_en and not has_cjk:
return "en"
return "zh"
class CustomDataset:
def __init__(self, cfg: dict):
self.name = cfg.get("name", "custom")
self.manifest = cfg["manifest"]
self.domain_default = cfg.get("domain", "general")
self._base = os.path.dirname(os.path.abspath(self.manifest))
def samples(self, limit: int | None = None) -> Iterator[Sample]:
with open(self.manifest, encoding="utf-8") as f:
n = 0
for i, line in enumerate(f):
line = line.strip()
if not line or line.startswith("#"):
continue
if limit is not None and n >= limit:
break
row = json.loads(line)
text = (row.get("text") or "").strip()
audio = row["audio"]
if not os.path.isabs(audio):
audio = os.path.join(self._base, audio)
lang = row.get("lang") or _guess_lang(text)
n += 1
yield Sample(
id=row.get("id", f"{self.name}-{i:06d}"),
audio_path=audio,
ref_text=text,
lang=lang,
domain=row.get("domain", self.domain_default),
dataset=self.name,
)
+89
View File
@@ -0,0 +1,89 @@
"""通用 HuggingFace 数据集适配器(流式)。
config 条目示例:
- name: ascend
type: hf
hf_id: CAiRE/ASCEND
split: test
text_field: transcription
audio_field: audio # 默认 audio
lang: zh-en
domain: general
trust_remote_code: false
流式(streaming=True)避免为跑小样而下载整个数据集;每条把音频物料化为 16k wav 缓存。
"""
from __future__ import annotations
import io
import os
from collections.abc import Iterator
import soundfile as sf
from ..manifest import Sample
from .base import Dataset, write_wav_16k
class HFDataset(Dataset):
def __init__(self, cfg: dict, audio_cache: str):
self.name = cfg["name"]
self.hf_id = cfg["hf_id"]
self.hf_config = cfg.get("hf_config")
self.split = cfg.get("split", "test")
self.text_field = cfg["text_field"]
self.audio_field = cfg.get("audio_field", "audio")
self.lang = cfg.get("lang", "zh")
self.domain = cfg.get("domain", "general")
self.trust_remote_code = cfg.get("trust_remote_code", False)
self.cache_dir = os.path.join(audio_cache, self.name)
def samples(self, limit: int | None = None) -> Iterator[Sample]:
from datasets import Audio, load_dataset
ds = load_dataset(
self.hf_id,
self.hf_config,
split=self.split,
streaming=True,
trust_remote_code=self.trust_remote_code,
)
# 关闭自动解码(新版 datasets 需 torchcodec):拿原始 bytes/path 自己用 soundfile 读,避开重依赖。
ds = ds.cast_column(self.audio_field, Audio(decode=False))
n = 0
for i, row in enumerate(ds):
if limit is not None and n >= limit:
break
text = (row.get(self.text_field) or "").strip()
if not text:
continue
audio = row.get(self.audio_field)
if not audio:
continue
wav_path = os.path.join(self.cache_dir, f"{i:06d}.wav")
if not os.path.exists(wav_path):
array, sr = self._decode(audio)
if array is None:
continue
write_wav_16k(array, sr, wav_path)
n += 1
yield Sample(
id=f"{self.name}-{i:06d}",
audio_path=wav_path,
ref_text=text,
lang=self.lang,
domain=self.domain,
dataset=self.name,
)
@staticmethod
def _decode(audio: dict):
"""从未解码的 HF 音频列读出 (array, sr):优先 bytes,其次本地 path。"""
if audio.get("bytes"):
data, sr = sf.read(io.BytesIO(audio["bytes"]), dtype="float32", always_2d=False)
return data, sr
if audio.get("path") and os.path.exists(audio["path"]):
data, sr = sf.read(audio["path"], dtype="float32", always_2d=False)
return data, sr
return None, None
+45
View File
@@ -0,0 +1,45 @@
"""引擎注册表:config.yaml 里的 type 映射到具体引擎类(懒加载,避免未装的重依赖被导入)。"""
from __future__ import annotations
from .base import Engine, Transcript
def build_engine(cfg: dict) -> Engine:
"""按 config 的单个 engine 条目构造引擎实例。"""
etype = cfg["type"]
name = cfg.get("name", etype)
if etype == "gummy":
from .gummy import GummyEngine
return GummyEngine(
name=name,
api_key=cfg["api_key"],
model=cfg.get("model", "gummy-realtime-v1"),
cost_per_min=cfg.get("cost_per_min"),
realtime_factor=cfg.get("realtime_factor", 2.0),
)
if etype == "whisper":
from .whisper import WhisperEngine
return WhisperEngine(
name=name,
model_size=cfg.get("model_size", "small"),
device=cfg.get("device", "cpu"),
compute_type=cfg.get("compute_type", "int8"),
model_dir=cfg.get("model_dir"),
)
if etype == "sensevoice":
from .funasr import SenseVoiceEngine
return SenseVoiceEngine(name=name, device=cfg.get("device", "cpu"), model_dir=cfg.get("model_dir"))
if etype == "funasr":
from .funasr import ParaformerEngine
return ParaformerEngine(name=name, device=cfg.get("device", "cpu"), model_dir=cfg.get("model_dir"))
raise ValueError(f"未知引擎类型: {etype}")
__all__ = ["Engine", "Transcript", "build_engine"]
+53
View File
@@ -0,0 +1,53 @@
"""引擎接口与单次识别结果。
两类引擎统一到 transcribe(),但计时口径不同:
- streaminggummy):有首包延迟 first_partial_sec、定稿延迟 finalize_sec。
- offline(本地):只有总处理时长与 RTF;资源探针在 runner 层包裹(仅本地)。
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class Transcript:
text: str
audio_sec: float
proc_sec: float # transcribe 墙钟耗时
first_partial_sec: float | None = None
finalize_sec: float | None = None
peak_rss_mb: float | None = None # 由 runner 的 ResourceProbe 回填(本地引擎)
avg_cpu: float | None = None
error: str | None = None
@property
def rtf(self) -> float | None:
if not self.audio_sec:
return None
return self.proc_sec / self.audio_sec
class Engine:
"""引擎基类。子类至少实现 transcribe()load()/unload() 处理重模型生命周期。"""
name: str = "base"
kind: str = "offline" # "offline" | "streaming"
is_local: bool = True
def load(self) -> None:
"""加载模型(本地引擎重操作)。云端引擎可空实现。"""
def transcribe(self, audio_path: str) -> Transcript:
raise NotImplementedError
def unload(self) -> None:
"""释放模型,便于多引擎串行评估时回收内存。"""
def model_size_mb(self) -> float | None:
"""磁盘上模型权重大小(MB);云端返回 None。"""
return None
def cost_per_min(self) -> float | None:
"""每分钟成本(云端);本地返回 None。"""
return None
+87
View File
@@ -0,0 +1,87 @@
"""本地 FunASR 引擎:SenseVoice-Small(多语,中英混说强)与 Paraformer-zh(纯中 SOTA)。
依赖:pip install '.[funasr]'
两者都走 funasr.AutoModel,离线整段识别。SenseVoice 输出带 <|zh|><|EMO|> 等富标签,需剥离。
"""
from __future__ import annotations
import os
import re
import time
from ..audio import duration_sec
from ..metrics.resource import dir_size_mb
from .base import Engine, Transcript
_TAG = re.compile(r"<\|[^|]*\|>")
def _strip_tags(text: str) -> str:
return _TAG.sub("", text).strip()
class _FunASRBase(Engine):
kind = "offline"
is_local = True
repo: str = ""
def __init__(self, name: str, device: str = "cpu", model_dir: str | None = None):
self.name = name
self.device = device
self.model_dir = model_dir
self.model = None
self._weights_path: str | None = None
def load(self) -> None:
from funasr import AutoModel
kwargs = {"model": self.repo, "disable_update": True, "device": self.device}
if self.model_dir:
kwargs["cache_dir"] = self.model_dir
self.model = AutoModel(**kwargs)
self._resolve_weights_path()
def _resolve_weights_path(self) -> None:
# funasr 默认从 modelscope 下载;尝试常见属性与缓存目录(best-effort
for attr in ("model_path", "model_pth", "kwargs"):
val = getattr(self.model, attr, None)
if isinstance(val, str) and os.path.exists(val):
self._weights_path = val
return
if isinstance(val, dict) and isinstance(val.get("model_path"), str):
self._weights_path = val["model_path"]
return
self._weights_path = self.model_dir
def transcribe(self, audio_path: str) -> Transcript:
if self.model is None:
self.load()
t0 = time.monotonic()
res = self.model.generate(input=audio_path)
proc = time.monotonic() - t0
text = ""
if res and isinstance(res, list) and res[0].get("text"):
text = _strip_tags(res[0]["text"])
return Transcript(text=text, audio_sec=duration_sec(audio_path), proc_sec=proc)
def model_size_mb(self) -> float | None:
size = dir_size_mb(self._weights_path) if self._weights_path else 0.0
return size or None
def unload(self) -> None:
self.model = None
class SenseVoiceEngine(_FunASRBase):
repo = "iic/SenseVoiceSmall"
def __init__(self, name: str = "sensevoice", device: str = "cpu", model_dir: str | None = None):
super().__init__(name=name, device=device, model_dir=model_dir)
class ParaformerEngine(_FunASRBase):
repo = "paraformer-zh"
def __init__(self, name: str = "paraformer-zh", device: str = "cpu", model_dir: str | None = None):
super().__init__(name=name, device=device, model_dir=model_dir)
+180
View File
@@ -0,0 +1,180 @@
"""云端 gummy 引擎:阿里云百炼 DashScope 流式 ASRgummy-realtime-v1)。
协议对照 server/internal/asr/gummy.go
run-task → 等 task-started → 推二进制 PCM(3200B/16k/pcm) → finish-task → 收 task-finished。
下行解析 payload.output.transcription(gummy) 或 sentence(paraformer)
sentence_end 兼容 bool 与字符串 "true"/"false"gummy.go 的 flexBool 怪癖)。
"""
from __future__ import annotations
import json
import threading
import time
import uuid
import websocket # websocket-client
from ..audio import load_pcm16
from .base import Engine, Transcript
WS_URL = "wss://dashscope.aliyuncs.com/api-ws/v1/inference"
FRAME = 3200 # 1600 samples = 100ms @16k
def _flex_bool(v) -> bool:
return v is True or v == "true"
class GummyEngine(Engine):
name = "gummy"
kind = "streaming"
is_local = False
def __init__(
self,
name: str = "gummy",
api_key: str = "",
model: str = "gummy-realtime-v1",
cost_per_min: float | None = None,
realtime_factor: float = 2.0,
):
self.name = name
self.api_key = api_key
self.model = model
self._cost = cost_per_min
self.rt = realtime_factor # 推流倍速;2.0 = 2x 实时(同 gummycheck
def cost_per_min(self) -> float | None:
return self._cost
def _run_task_msg(self, task_id: str, sample_rate: int) -> str:
return json.dumps(
{
"header": {"action": "run-task", "task_id": task_id, "streaming": "duplex"},
"payload": {
"task_group": "audio",
"task": "asr",
"function": "recognition",
"model": self.model,
"parameters": {
"sample_rate": sample_rate,
"format": "pcm",
"transcription_enabled": True,
"translation_enabled": False,
},
"input": {},
},
}
)
def _finish_task_msg(self, task_id: str) -> str:
return json.dumps(
{
"header": {"action": "finish-task", "task_id": task_id, "streaming": "duplex"},
"payload": {"input": {}},
}
)
def transcribe(self, audio_path: str) -> Transcript:
pcm, sr = load_pcm16(audio_path, 16000)
audio = pcm.tobytes()
audio_sec = len(pcm) / sr
task_id = uuid.uuid4().hex
started = threading.Event()
finished = threading.Event()
results: list[tuple[float, str, bool]] = [] # (rel_t, text, is_final)
err: list[str | None] = [None]
first_partial: list[float | None] = [None]
t0 = time.monotonic()
try:
ws = websocket.create_connection(
WS_URL,
header=[
f"Authorization: bearer {self.api_key}",
"X-DashScope-DataInspection: enable",
],
timeout=20,
)
except Exception as ex: # 连接失败
return Transcript(text="", audio_sec=audio_sec, proc_sec=time.monotonic() - t0, error=f"dial: {ex}")
def reader() -> None:
try:
while True:
msg = ws.recv()
if not msg:
continue
ev = json.loads(msg)
h = ev.get("header", {})
event = h.get("event")
if event == "task-started":
started.set()
elif event == "result-generated":
out = ev.get("payload", {}).get("output", {})
sen = out.get("transcription") or out.get("sentence")
if not sen or not sen.get("text"):
continue
is_final = _flex_bool(sen.get("sentence_end")) or _flex_bool(sen.get("is_sentence_end"))
if first_partial[0] is None:
first_partial[0] = time.monotonic() - t0
results.append((time.monotonic() - t0, sen["text"], is_final))
elif event == "task-finished":
finished.set()
break
elif event == "task-failed":
err[0] = h.get("error_message", "task-failed")
finished.set()
break
except Exception as ex:
if not finished.is_set():
err[0] = str(ex)
finished.set()
rt = threading.Thread(target=reader, daemon=True)
rt.start()
try:
ws.send(self._run_task_msg(task_id, sr))
if not started.wait(timeout=10):
ws.close()
return Transcript(
text="", audio_sec=audio_sec, proc_sec=time.monotonic() - t0, error="task-started timeout"
)
# 推流:每帧 100ms 音频,按倍速 sleep2x → 50ms
per_frame_sleep = 0.1 / self.rt
for off in range(0, len(audio), FRAME):
ws.send_binary(audio[off : off + FRAME])
time.sleep(per_frame_sleep)
last_audio_t = time.monotonic()
ws.send(self._finish_task_msg(task_id))
finished.wait(timeout=30)
except Exception as ex:
err[0] = err[0] or str(ex)
finally:
try:
ws.close()
except Exception:
pass
# gummy 按句给 final;拼接所有 final,无 final 则回退最后一条 partial
finals = [t for _, t, f in results if f]
text = "".join(finals) if finals else (results[-1][1] if results else "")
finalize_sec = None
final_rel_times = [rel for rel, _, f in results if f]
if final_rel_times:
finalize_sec = max(0.0, (t0 + final_rel_times[-1]) - last_audio_t)
return Transcript(
text=text,
audio_sec=audio_sec,
proc_sec=time.monotonic() - t0,
first_partial_sec=first_partial[0],
finalize_sec=finalize_sec,
error=err[0],
)
+70
View File
@@ -0,0 +1,70 @@
"""本地 faster-whisper 引擎(CTranslate2CPU-first)。多语,对中英混说有基础能力。
依赖:pip install '.[whisper]'
"""
from __future__ import annotations
import time
from ..metrics.resource import dir_size_mb
from .base import Engine, Transcript
class WhisperEngine(Engine):
kind = "offline"
is_local = True
def __init__(
self,
name: str = "whisper-small",
model_size: str = "small",
device: str = "cpu",
compute_type: str = "int8",
model_dir: str | None = None,
):
self.name = name
self.model_size = model_size
self.device = device
self.compute_type = compute_type
self.model_dir = model_dir
self.model = None
self._weights_path: str | None = None
def load(self) -> None:
from faster_whisper import WhisperModel
self.model = WhisperModel(
self.model_size,
device=self.device,
compute_type=self.compute_type,
download_root=self.model_dir,
)
self._resolve_weights_path()
def _resolve_weights_path(self) -> None:
# 解析磁盘权重路径用于报模型大小(best-effort
try:
from huggingface_hub import snapshot_download
repo = f"Systran/faster-whisper-{self.model_size}"
self._weights_path = snapshot_download(repo, local_files_only=True, cache_dir=self.model_dir)
except Exception:
self._weights_path = self.model_dir
def transcribe(self, audio_path: str) -> Transcript:
if self.model is None:
self.load()
t0 = time.monotonic()
# language=None → 自动检测;中英混说交给模型自身
segments, info = self.model.transcribe(audio_path, language=None, beam_size=5)
text = "".join(seg.text for seg in segments) # 生成器在此 join 时才真正解码
proc = time.monotonic() - t0
return Transcript(text=text.strip(), audio_sec=float(info.duration), proc_sec=proc)
def model_size_mb(self) -> float | None:
size = dir_size_mb(self._weights_path) if self._weights_path else 0.0
return size or None
def unload(self) -> None:
self.model = None
+32
View File
@@ -0,0 +1,32 @@
"""统一样本模型:所有数据集适配器都产出 Sample,下游引擎/指标只认它。"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class Sample:
"""一条评估样本。
lang 取值约定(决定 breakdown 归类):
- "zh" 纯普通话
- "en" 纯英文
- "zh-en" 中英混说(code-switching,本框架重点)
"""
id: str
audio_path: str # 16k/mono/16bit wav 优先;非此规格引擎侧会重采样
ref_text: str # 参考(标注)文本
lang: str = "zh"
domain: str = "general"
dataset: str = "custom"
@property
def category(self) -> str:
"""报告里的粗分类。"""
if self.lang == "zh-en":
return "code-switch"
if self.lang == "en":
return "pure-en"
return "pure-zh"
View File
+98
View File
@@ -0,0 +1,98 @@
"""准确率指标:CER(字级)/ WER(词级)/ MER(中英混合)。
分词策略:
- CER : 去空格后逐字符(中文每字、英文每字母各一 token)。
- WER : 按空白切词(主要对英文有意义)。
- MER : 中文按【字】、英文/数字按【词】,混合 token 序列算编辑距离——这是中英混说的 headline 指标。
聚合:错误率必须在语料级聚合(累加 S/D/I/N 后再求率),不能对每条样本的率求平均,
否则短句权重被放大、结果失真。
"""
from __future__ import annotations
import re
from dataclasses import dataclass
import jiwer
from .text_norm import normalize
_CJK = re.compile(r"[一-鿿㐀-䶿]")
@dataclass
class Counts:
"""一组编辑距离统计。N = 参考长度 = S + D + H。"""
S: int = 0 # 替换
D: int = 0 # 删除
I: int = 0 # 插入
N: int = 0 # 参考 token 数
def __add__(self, o: "Counts") -> "Counts":
return Counts(self.S + o.S, self.D + o.D, self.I + o.I, self.N + o.N)
@property
def rate(self) -> float:
if self.N == 0:
return 1.0 if (self.S + self.D + self.I) > 0 else 0.0
return (self.S + self.D + self.I) / self.N
def char_tokens(text: str) -> list[str]:
return [c for c in text if not c.isspace()]
def word_tokens(text: str) -> list[str]:
return text.split()
def mixed_tokens(text: str) -> list[str]:
"""中文按字、其余按词。"""
tokens: list[str] = []
buf = ""
for ch in text:
if _CJK.match(ch):
if buf.strip():
tokens.extend(buf.split())
buf = ""
tokens.append(ch)
else:
buf += ch
if buf.strip():
tokens.extend(buf.split())
return tokens
def _counts(ref_tokens: list[str], hyp_tokens: list[str]) -> Counts:
if not ref_tokens:
return Counts(S=0, D=0, I=len(hyp_tokens), N=0)
out = jiwer.process_words(" ".join(ref_tokens), " ".join(hyp_tokens))
return Counts(
S=out.substitutions,
D=out.deletions,
I=out.insertions,
N=out.substitutions + out.deletions + out.hits,
)
def score_sample(ref_text: str, hyp_text: str) -> dict[str, Counts]:
"""对一条样本算 CER/WER/MER 的原始计数(供语料级聚合)。"""
ref = normalize(ref_text)
hyp = normalize(hyp_text)
return {
"cer": _counts(char_tokens(ref), char_tokens(hyp)),
"wer": _counts(word_tokens(ref), word_tokens(hyp)),
"mer": _counts(mixed_tokens(ref), mixed_tokens(hyp)),
}
def empty_counts() -> dict[str, Counts]:
return {"cer": Counts(), "wer": Counts(), "mer": Counts()}
def add_counts(acc: dict[str, Counts], one: dict[str, Counts]) -> dict[str, Counts]:
for k in acc:
acc[k] = acc[k] + one[k]
return acc
+85
View File
@@ -0,0 +1,85 @@
"""资源探针:本地引擎推理时采样峰值内存/CPU;模型大小读磁盘。
注意:psutil 测的是整个 Python 进程 RSS(含解释器与已加载库),用于本地模型横向对比是
合理代理量;报告会标注硬件环境,且只在本地引擎启用,云端 gummy 不测(填 N/A)。
"""
from __future__ import annotations
import os
import threading
import time
import psutil
class ResourceProbe:
"""上下文管理器:with 块内后台采样进程 RSS / CPU%"""
def __init__(self, interval: float = 0.05):
self.interval = interval
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._proc = psutil.Process(os.getpid())
self.peak_rss = 0
self._cpu: list[float] = []
def __enter__(self) -> "ResourceProbe":
self._proc.cpu_percent(None) # 预热:首次调用返回 0,丢弃
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
return self
def _run(self) -> None:
while not self._stop.is_set():
try:
self.peak_rss = max(self.peak_rss, self._proc.memory_info().rss)
c = self._proc.cpu_percent(None)
if c > 0:
self._cpu.append(c)
except Exception:
pass
time.sleep(self.interval)
def __exit__(self, *_exc) -> None:
self._stop.set()
if self._thread:
self._thread.join(timeout=1.0)
@property
def peak_rss_mb(self) -> float:
return self.peak_rss / 1024 / 1024
@property
def avg_cpu(self) -> float:
return sum(self._cpu) / len(self._cpu) if self._cpu else 0.0
def dir_size_mb(path: str) -> float:
"""目录下所有文件体积之和(MB);路径不存在返回 0。"""
total = 0
if not path or not os.path.exists(path):
return 0.0
if os.path.isfile(path):
return os.path.getsize(path) / 1024 / 1024
for root, _dirs, files in os.walk(path):
for f in files:
try:
total += os.path.getsize(os.path.join(root, f))
except OSError:
pass
return total / 1024 / 1024
def hardware_info() -> dict:
"""报告里标注的硬件环境。"""
import platform
vm = psutil.virtual_memory()
return {
"platform": platform.platform(),
"machine": platform.machine(),
"cpu_count": psutil.cpu_count(logical=True),
"cpu_count_physical": psutil.cpu_count(logical=False),
"total_mem_gb": round(vm.total / 1024**3, 1),
}
+53
View File
@@ -0,0 +1,53 @@
"""评分前文本归一化。
中英混说不归一化就算分会严重失真(标点、繁简、全半角、大小写都会被算成错)。
本模块做最小必要归一化;繁→简依赖 opencc,缺失时自动降级(仅告警一次)。
"""
from __future__ import annotations
import re
import sys
import unicodedata
_cc = None
_cc_warned = False
def _converter():
global _cc, _cc_warned
if _cc is not None:
return _cc
try:
from opencc import OpenCC
_cc = OpenCC("t2s")
except Exception:
_cc = False # 标记尝试过且失败
if not _cc_warned:
print("[text_norm] 警告:opencc 不可用,跳过繁→简归一化", file=sys.stderr)
_cc_warned = True
return _cc
# 中英文常见标点(评分时整体移除)
_PUNCT = set(
",。!?、;:“”‘’()《》【】〔〕…—~·.,!?;:\"'()<>[]{}~`@#$%^&*-_=+|\\/。."
)
_WS = re.compile(r"\s+")
def normalize(text: str, *, t2s: bool = True, lower: bool = True) -> str:
if not text:
return ""
# 全角→半角 + Unicode 兼容分解(NFKC 会把 ABC→ABC、123→123)
text = unicodedata.normalize("NFKC", text)
if t2s:
cc = _converter()
if cc:
text = cc.convert(text)
if lower:
text = text.lower()
text = "".join(ch for ch in text if ch not in _PUNCT)
text = _WS.sub(" ", text).strip()
return text
+234
View File
@@ -0,0 +1,234 @@
"""汇总每条样本结果 → 语料级指标 + 延迟分位 + 资源,产出 JSON / Markdown / HTML。
错误率在语料级聚合(累加 S/D/I/N 再求率)。延迟取 p50/p95。资源取均值。
"""
from __future__ import annotations
import json
import os
from datetime import datetime
import numpy as np
from .metrics.err import Counts
from .metrics.resource import hardware_info
def _pct(values: list[float], p: float) -> float | None:
vals = [v for v in values if v is not None]
if not vals:
return None
return float(np.percentile(vals, p))
def _mean(values: list[float]) -> float | None:
vals = [v for v in values if v is not None]
if not vals:
return None
return float(np.mean(vals))
def _group_stats(rows: list[dict], cost_per_min: float | None) -> dict:
acc = {"cer": Counts(), "wer": Counts(), "mer": Counts()}
for r in rows:
for k in acc:
acc[k] = acc[k] + r["counts"][k]
audio_total = sum(r["audio_sec"] for r in rows)
return {
"samples": len(rows),
"errors": sum(1 for r in rows if r["error"]),
"cer": acc["cer"].rate,
"wer": acc["wer"].rate,
"mer": acc["mer"].rate,
"rtf_mean": _mean([r["rtf"] for r in rows]),
"first_partial_p50": _pct([r["first_partial_sec"] for r in rows], 50),
"first_partial_p95": _pct([r["first_partial_sec"] for r in rows], 95),
"finalize_p50": _pct([r["finalize_sec"] for r in rows], 50),
"finalize_p95": _pct([r["finalize_sec"] for r in rows], 95),
"peak_rss_mb_mean": _mean([r["peak_rss_mb"] for r in rows]),
"avg_cpu_mean": _mean([r["avg_cpu"] for r in rows]),
"audio_sec_total": audio_total,
"cost_total": (audio_total / 60.0 * cost_per_min) if cost_per_min else None,
}
def aggregate(rows: list[dict], engines_meta: dict) -> dict:
report = {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"hardware": hardware_info(),
"engines": {},
}
by_engine: dict[str, list[dict]] = {}
for r in rows:
by_engine.setdefault(r["engine"], []).append(r)
for eng, erows in by_engine.items():
meta = engines_meta.get(eng, {})
cost = meta.get("cost_per_min")
cats: dict[str, list[dict]] = {}
dsets: dict[str, list[dict]] = {}
for r in erows:
cats.setdefault(r["category"], []).append(r)
dsets.setdefault(r["dataset"], []).append(r)
report["engines"][eng] = {
"is_local": meta.get("is_local"),
"kind": meta.get("kind"),
"model_size_mb": meta.get("model_size_mb"),
"cost_per_min": cost,
"overall": _group_stats(erows, cost),
"by_category": {c: _group_stats(rs, cost) for c, rs in sorted(cats.items())},
"by_dataset": {d: _group_stats(rs, cost) for d, rs in sorted(dsets.items())},
}
return report
# ---------- 渲染 ----------
def _pct_str(v: float | None) -> str:
return f"{v * 100:.2f}%" if v is not None else ""
def _num(v: float | None, fmt: str = "{:.2f}") -> str:
return fmt.format(v) if v is not None else ""
def render_markdown(report: dict) -> str:
hw = report["hardware"]
lines = [
"# ASR 模型评估报告",
"",
f"- 生成时间:{report['generated_at']}",
f"- 硬件:{hw['platform']} · {hw['machine']} · {hw['cpu_count']} vCPU · {hw['total_mem_gb']} GB",
"",
"## 总览(各引擎全量)",
"",
"| 引擎 | 本地 | 样本 | 错误 | MER | CER | WER | RTF | 首包 p50 | 定稿 p50 | 峰值内存MB | 模型大小MB | 成本 |",
"|---|---|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|",
]
for eng, e in report["engines"].items():
o = e["overall"]
lines.append(
"| {eng} | {loc} | {n} | {err} | {mer} | {cer} | {wer} | {rtf} | {fp} | {fin} | {mem} | {sz} | {cost} |".format(
eng=eng,
loc="" if e["is_local"] else "",
n=o["samples"],
err=o["errors"],
mer=_pct_str(o["mer"]),
cer=_pct_str(o["cer"]),
wer=_pct_str(o["wer"]),
rtf=_num(o["rtf_mean"]),
fp=_num(o["first_partial_p50"], "{:.2f}s") if o["first_partial_p50"] is not None else "",
fin=_num(o["finalize_p50"], "{:.2f}s") if o["finalize_p50"] is not None else "",
mem=_num(o["peak_rss_mb_mean"], "{:.0f}"),
sz=_num(e["model_size_mb"], "{:.0f}"),
cost=_num(o["cost_total"], "¥{:.4f}") if o["cost_total"] is not None else "",
)
)
# 中英混说专项
lines += ["", "## 中英混说(code-switch)专项 MER", "", "| 引擎 | 样本 | MER | CER | WER |", "|---|--:|--:|--:|--:|"]
for eng, e in report["engines"].items():
cs = e["by_category"].get("code-switch")
if not cs:
continue
lines.append(
f"| {eng} | {cs['samples']} | {_pct_str(cs['mer'])} | {_pct_str(cs['cer'])} | {_pct_str(cs['wer'])} |"
)
# 按数据集
lines += ["", "## 按数据集 MER", "", "| 引擎 | 数据集 | 样本 | MER | CER | WER |", "|---|---|--:|--:|--:|--:|"]
for eng, e in report["engines"].items():
for ds, g in e["by_dataset"].items():
lines.append(
f"| {eng} | {ds} | {g['samples']} | {_pct_str(g['mer'])} | {_pct_str(g['cer'])} | {_pct_str(g['wer'])} |"
)
lines.append("")
lines.append(
"> 注:流式(gummy)与离线(本地)延迟口径不同——gummy 的 RTF/延迟含 2x 推流与网络,"
"非纯算力;本地引擎为整段离线处理。资源(内存/CPU)仅本地引擎测量,含 Python 进程基底。"
)
return "\n".join(lines)
_HTML_TMPL = """<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
<title>ASR 评估报告</title>
<style>
body{{font-family:-apple-system,"PingFang SC",sans-serif;max-width:1100px;margin:32px auto;padding:0 20px;color:#1B1E26;background:#F6F7FA}}
h1{{font-size:24px}} h2{{font-size:18px;margin-top:32px;border-left:3px solid #4F6EF7;padding-left:10px}}
table{{border-collapse:collapse;width:100%;background:#fff;border:1px solid #E4E6EB;border-radius:10px;overflow:hidden;margin:12px 0;font-size:13px}}
th,td{{padding:8px 10px;border-bottom:1px solid #F0F1F4;text-align:right}}
th{{background:#FCFCFD;color:#5A6072;font-weight:600;text-transform:uppercase;font-size:11px;letter-spacing:.04em}}
td:first-child,th:first-child{{text-align:left;font-weight:600}}
.meta{{color:#5A6072;font-size:13px}} .note{{color:#7A8090;font-size:12px;margin-top:8px}}
tr:hover td{{background:#F6F7FA}}
</style></head><body>
<h1>ASR 模型评估报告</h1>
<p class="meta">生成时间 {generated_at} · {hw}</p>
{body}
<p class="note">流式(gummy)与离线(本地)延迟口径不同;资源仅本地引擎测量,含 Python 进程基底。</p>
</body></html>"""
def render_html(report: dict) -> str:
hw = report["hardware"]
def table(headers, rows):
h = "".join(f"<th>{x}</th>" for x in headers)
body = ""
for row in rows:
body += "<tr>" + "".join(f"<td>{x}</td>" for x in row) + "</tr>"
return f"<table><thead><tr>{h}</tr></thead><tbody>{body}</tbody></table>"
parts = ["<h2>总览(各引擎全量)</h2>"]
rows = []
for eng, e in report["engines"].items():
o = e["overall"]
rows.append([
eng, "" if e["is_local"] else "", o["samples"], o["errors"],
_pct_str(o["mer"]), _pct_str(o["cer"]), _pct_str(o["wer"]),
_num(o["rtf_mean"]),
_num(o["first_partial_p50"], "{:.2f}s") if o["first_partial_p50"] is not None else "",
_num(o["peak_rss_mb_mean"], "{:.0f}"), _num(e["model_size_mb"], "{:.0f}"),
_num(o["cost_total"], "¥{:.4f}") if o["cost_total"] is not None else "",
])
parts.append(table(
["引擎", "本地", "样本", "错误", "MER", "CER", "WER", "RTF", "首包p50", "峰值内存MB", "模型MB", "成本"], rows
))
parts.append("<h2>中英混说专项 MER</h2>")
cs_rows = []
for eng, e in report["engines"].items():
cs = e["by_category"].get("code-switch")
if cs:
cs_rows.append([eng, cs["samples"], _pct_str(cs["mer"]), _pct_str(cs["cer"]), _pct_str(cs["wer"])])
parts.append(table(["引擎", "样本", "MER", "CER", "WER"], cs_rows))
parts.append("<h2>按数据集</h2>")
ds_rows = []
for eng, e in report["engines"].items():
for ds, g in e["by_dataset"].items():
ds_rows.append([eng, ds, g["samples"], _pct_str(g["mer"]), _pct_str(g["cer"]), _pct_str(g["wer"])])
parts.append(table(["引擎", "数据集", "样本", "MER", "CER", "WER"], ds_rows))
return _HTML_TMPL.format(
generated_at=report["generated_at"],
hw=f"{hw['platform']} · {hw['cpu_count']} vCPU · {hw['total_mem_gb']}GB",
body="\n".join(parts),
)
def write_reports(report: dict, out_dir: str) -> dict:
os.makedirs(out_dir, exist_ok=True)
paths = {
"json": os.path.join(out_dir, "report.json"),
"md": os.path.join(out_dir, "report.md"),
"html": os.path.join(out_dir, "report.html"),
}
with open(paths["json"], "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(paths["md"], "w", encoding="utf-8") as f:
f.write(render_markdown(report))
with open(paths["html"], "w", encoding="utf-8") as f:
f.write(render_html(report))
return paths
+74
View File
@@ -0,0 +1,74 @@
# dudu ASR 评估配置。改这里即可调整跑哪些引擎/数据集、采样多少。
# 跑法:rbw get dashscope-api-key | python -m asr_eval run --config config.yaml
output_dir: results
audio_cache: datasets/_audio_cache
sample_limit: null # null=全量;整数=每个数据集采样上限(冒烟用小值)
# ---------------- 数据集 ----------------
# 公共集统一走 type=hf(差异全在配置里);自定义集 type=custom 指向 JSONL manifest。
datasets:
- name: ascend # 中英混说金标准(headline
type: hf
hf_id: CAiRE/ASCEND
split: test
text_field: transcription
lang: zh-en
domain: general
- name: aishell1 # 纯普通话
type: hf
hf_id: AISHELL/AISHELL-1
split: test
text_field: text
lang: zh
domain: general
trust_remote_code: true
- name: librispeech # 纯英文
type: hf
hf_id: openslr/librispeech_asr
hf_config: clean
split: test
text_field: text
lang: en
domain: general
trust_remote_code: true
- name: custom # 你的自录/业务场景音频
type: custom
manifest: manifests/custom.example.jsonl
# ---------------- 引擎 ----------------
# 云端 gummy 与本地开源横向对比。本地引擎需先装 extraspip install '.[whisper]' 或 '.[funasr]'
engines:
- name: gummy
type: gummy
model: gummy-realtime-v1
realtime_factor: 2.0 # 推流倍速(同 gummycheck);影响延迟口径,不影响准确率
cost_per_min: 0.09 # ⚠️占位:按 dudu 实付 DashScope 单价改(此处暂用售价 ¥9/100min)
enabled: true
- name: whisper-small
type: whisper
model_size: small
device: cpu
compute_type: int8
enabled: true
- name: whisper-large-v3
type: whisper
model_size: large-v3
device: cpu
compute_type: int8
enabled: false # 体积大(~1.5GB),按需开
- name: sensevoice
type: sensevoice # 多语,中英混说强
device: cpu
enabled: false
- name: paraformer-zh
type: funasr # 纯中 SOTA,英文/混说较弱
device: cpu
enabled: false
+6
View File
@@ -0,0 +1,6 @@
# 自定义测试集示例。每行一个 JSON(# 开头的注释行会被忽略)。
# audio 路径相对本 manifest 文件所在目录解析;lang 可省略(按文本自动猜)。
# 把你自录/业务场景的 wav 放进来,与公共集同管线评估。
{"id": "cs-001", "audio": "clips/weekly-report.wav", "text": "帮我把这份 weekly report 整理一下,重点突出本周的 milestone", "lang": "zh-en", "domain": "tech"}
{"id": "cs-002", "audio": "clips/deploy.wav", "text": "把这个 service 部署到 staging 环境然后跑一遍 e2e test", "lang": "zh-en", "domain": "tech"}
{"id": "zh-001", "audio": "clips/zhoubao.wav", "text": "帮我把这份周报整理一下重点突出本周的进展", "lang": "zh", "domain": "general"}
+3
View File
@@ -0,0 +1,3 @@
{"id": "smoke-a", "audio": "clips/smoke_a.wav", "text": "帮我把这份周报整理一下,重点突出本周的进展", "lang": "zh", "domain": "general"}
{"id": "smoke-b", "audio": "clips/smoke_b.wav", "text": "今天下午三点开会,记得带上笔记本电脑", "lang": "zh", "domain": "general"}
{"id": "smoke-c", "audio": "clips/smoke_c.wav", "text": "请把这个文件发送到我的邮箱", "lang": "zh", "domain": "general"}
+34
View File
@@ -0,0 +1,34 @@
[project]
name = "dudu-asr-eval"
version = "0.1.0"
description = "dudu ASR 模型评估框架:横向对比云端 gummy 与本地开源模型(准确率/速度/资源)"
requires-python = ">=3.10"
# 核心依赖:评估管线本身(不含本地推理引擎,按需装 extras)
dependencies = [
"jiwer>=3.0", # CER/WER/MER 编辑距离
"soundfile>=0.12", # 读音频
"soxr>=0.3", # 重采样到 16k
"numpy>=1.24",
"psutil>=5.9", # 资源探针(cpu/mem
"pyyaml>=6.0", # config.yaml
"websocket-client>=1.6", # gummy 云端 WS(对照 gummy.go 移植)
"opencc>=1.1", # 繁→简归一化(缺失时自动降级)
"jinja2>=3.1", # HTML 报告
"rich>=13.0", # 进度/表格
"datasets>=2.18", # 公共集(ASCEND/AISHELL/LibriSpeech
"huggingface_hub>=0.20",
]
[project.optional-dependencies]
# 本地开源引擎按需安装,避免核心管线被重依赖拖累
whisper = ["faster-whisper>=1.0"] # CTranslate2CPU-first
funasr = ["funasr>=1.0", "torch>=2.0", "torchaudio>=2.0"] # SenseVoice / Paraformer
all = ["faster-whisper>=1.0", "funasr>=1.0", "torch>=2.0", "torchaudio>=2.0"]
[project.scripts]
asr-eval = "asr_eval.cli:main"
[tool.setuptools.packages.find]
where = ["."]
include = ["asr_eval*"]