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:
@@ -0,0 +1,14 @@
|
||||
# 数据集、模型权重、结果、缓存全部不入库(体积大)
|
||||
# 注意:锚定到 eval/ 根(前导 /),否则会误伤 asr_eval/datasets/ 源码包
|
||||
/datasets/
|
||||
/models/
|
||||
/results/
|
||||
*.wav
|
||||
*.mp3
|
||||
*.flac
|
||||
*.aiff
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
.pytest_cache/
|
||||
@@ -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。
|
||||
@@ -0,0 +1,7 @@
|
||||
"""dudu ASR 模型评估框架。
|
||||
|
||||
云端 gummy ↔ 本地开源模型横向对比,覆盖中英混说,产出准确率/速度/资源三类指标。
|
||||
入口:python -m asr_eval run --config config.yaml
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
from asr_eval.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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 单声道 PCM(gummy 推流用)。"""
|
||||
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
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,53 @@
|
||||
"""引擎接口与单次识别结果。
|
||||
|
||||
两类引擎统一到 transcribe(),但计时口径不同:
|
||||
- streaming(gummy):有首包延迟 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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,180 @@
|
||||
"""云端 gummy 引擎:阿里云百炼 DashScope 流式 ASR(gummy-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 音频,按倍速 sleep(2x → 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],
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""本地 faster-whisper 引擎(CTranslate2,CPU-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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 与本地开源横向对比。本地引擎需先装 extras:pip 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
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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"] # CTranslate2,CPU-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*"]
|
||||
Reference in New Issue
Block a user