在 sherpa-onnx 中使用 WeNet 模型:CTC 分支 ONNX 导出与流式/非流式部署全指南
【免费下载链接】sherpa-onnxSpeech-to-text, text-to-speech, speaker diarization, speech enhancement, source separation, and VAD using next-gen Kaldi with onnxruntime without Internet connection. Support embedded systems, Android, iOS, HarmonyOS, Raspberry Pi, RISC-V, RK NPU, Axera NPU, Ascend NPU, x86_64 servers, websocket server/client, support 12 programming languages项目地址: https://gitcode.com/GitHub_Trending/sh/sherpa-onnx
导读
本文围绕仓库 scripts/wenet 目录下的模型导出工具链,系统讲解如何将 WeNet(U2/U2++ 系列 Conformer)训练得到的 PyTorch 模型导出为 ONNX 格式,并在 sherpa-onnx 中完成离线(非流式)与在线(流式)语音识别部署。读完本文,你将掌握 WeNet 模型导出脚本的参数含义、模型输入输出张量约定、int8 动态量化方法,以及导出的 ONNX 模型如何在 sherpa-onnx 的 Python API 与底层 C++ 实现中被加载与运行。
一、脚本概览与核心能力边界
scripts/wenet目录中存放的是从 WeNet 到 sherpa-onnx 的模型转换与验证脚本,文件组成如下:
| 文件 | 作用 |
|---|---|
| README.md | 说明该目录用途与支持范围 |
| export-onnx.py | 导出**非流式(non-streaming)**模型 |
| export-onnx-streaming.py | 导出**流式(streaming)**模型 |
| run.sh | 一键式脚本:安装依赖、下载预训练模型、执行导出与验证 |
| test-onnx.py | 非流式 ONNX 模型的 Python 推理验证 |
| test-onnx-streaming.py | 流式 ONNX 模型的分块推理验证 |
根据 scripts/wenet/README.md 的说明,这套工具链有三个明确的能力边界,理解它们对后续使用至关重要:
- 流式与非流式模型均受支持,即 U2/U2++ 架构中
decoding_chunk_size=-1(非流式)与按 chunk 前向(流式)两种模式都可以导出。 - 只使用 CTC 分支。导出时仅取出编码器(encoder)与 CTC 层,用
ctc.log_softmax(encoder_out)得到帧级后验概率;WeNet 自带的 attention decoder 重打分(rescore)不支持。 - 支持 H、HL、HLG 三种图解码路径。导出的帧级 CTC log-probs 可以配合词表构建 H(纯 CTC 贪心/前缀束搜索)、HL(加词典)、HLG(加语言模型)解码图,在 sherpa-onnx 侧通过 CTC 解码器完成解码。
二、导出前的环境准备
run.sh 中的install_dependencies函数给出了完整的依赖清单,手动操作时可按同样步骤准备:
# 1. 安装 WeNet 本体(导出脚本依赖其中的 init_model 等模块) pip install git+https://github.com/wenet-e2e/wenet.git # 2. 安装 ONNX 导出与推理相关库 pip install onnxruntime onnx pyyaml # 3. 安装 CPU 版 PyTorch(仓库脚本使用的版本组合) pip install torch==2.3.1+cpu torchaudio==2.3.1+cpu -f https://download.pytorch.org/whl/torch_stable.html # 4. 安装 k2 与特征提取库(验证脚本使用) pip install k2==1.24.4.dev20240606+cpu.torch2.3.1 -f https://k2-fsa.github.io/k2/cpu.html pip install soundfile kaldi-native-fbankexport-onnx.py与export-onnx-streaming.py的头部注释还提示:WeNet 源码中的部分子模块(wenet/transducer/search、wenet/e_branchformer、wenet/ctl_model)需要补齐到wenet包目录下,因为导出的模型涉及 e_branchformer 编码器结构。run.sh中通过克隆 WeNet 仓库并将这些目录复制进已安装包的位置来完成该步骤:
wenet_dir=$(dirname $(python3 -c "import wenet; print(wenet.__file__)")) git clone https://github.com/wenet-e2e/wenet cp -av ./wenet/wenet/transducer/search $wenet_dir/transducer cp -a ./wenet/wenet/e_branchformer $wenet_dir cp -a ./wenet/wenet/ctl_model $wenet_dir cp -av ./wenet/wenet/finetune $wenet_dir/此外,run.sh开头执行export PYTHONPATH=/tmp/wenet:$PYTHONPATH,这是为了确保后续从/tmp/wenet中解析 WeNet 相关模块。
三、非流式模型导出:export-onnx.py
3.1 模型封装与输入输出约定
非流式导出脚本将 WeNet 模型的 encoder 与 ctc 两个子模块包装进一个torch.nn.Module:
class OnnxModel(torch.nn.Module): def __init__(self, encoder: torch.nn.Module, ctc: torch.nn.Module): super().__init__() self.encoder = encoder self.ctc = ctc def forward(self, x, x_lens): encoder_out, encoder_out_mask = self.encoder( x, x_lens, decoding_chunk_size=-1, # -1 表示非流式,一次性看到全部输入 num_decoding_left_chunks=-1, ) log_probs = self.ctc.log_softmax(encoder_out) log_probs_lens = encoder_out_mask.int().squeeze(1).sum(1) return log_probs, log_probs_lens关键点在于:
- 输入
x:3 维 float32 张量,形状(N, T, C),其中N为 batch size,T为帧数,C = 80为 fbank 特征维度; - 输入
x_lens:1 维 int64 张量,形状(N,),记录每段有效帧数; - 输出
log_probs:帧级 CTC log 后验,形状(N, T', vocab_size); - 输出
log_probs_lens:对应每段的有效输出帧数,由编码器输出的 mask 求和得到。
3.2 导出参数与动态轴
导出时使用opset_version = 13,并显式声明了动态轴:
torch.onnx.export( onnx_model, (x, x_lens), filename, opset_version=opset_version, input_names=["x", "x_lens"], output_names=["log_probs", "log_probs_lens"], dynamic_axes={ "x": {0: "N", 1: "T"}, "x_lens": {0: "N"}, "log_probs": {0: "N", 1: "T"}, "log_probs_lens": {0: "N"}, }, )即帧维度T与 batch 维度N都是动态的,因此导出的model.onnx可接受任意长度的音频特征输入。脚本还使用了torch.jit.script对模型做脚本化后再导出,以规避部分动态 shape 场景下的导出问题。
3.3 写入元数据(meta data)
导出完成后,add_meta_data会向 ONNX 模型写入model.metadata_props,供 sherpa-onnx 运行时识别模型类型与采样配置:
meta_data = { "model_type": "wenet_ctc", "version": "1", "model_author": "wenet", "comment": "non-streaming", "subsampling_factor": torch_model.encoder.embed.subsampling_rate, "vocab_size": torch_model.ctc.ctc_lo.weight.shape[0], "url": url, # 来源于环境变量 WENET_URL }其中model_type = "wenet_ctc"是 sherpa-onnx 识别该类模型的标志;subsampling_factor来自编码器 embedding 层的下采样率(Conformer 通常为 4);vocab_size直接取 CTC 输出层权重矩阵的行数。
3.4 int8 动态量化
脚本末尾调用 onnxruntime 的quantize_dynamic生成 int8 量化版本:
filename_int8 = "model.int8.onnx" quantize_dynamic( model_input=filename, model_output=filename_int8, op_types_to_quantize=["MatMul"], weight_type=QuantType.QInt8, )只对MatMul算子做权重量化(weight-only),量化类型为 QInt8。这样可以在不依赖校准数据集的情况下显著减小模型体积,适合在嵌入式设备或移动端部署,代价是有轻微精度损失。
四、流式模型导出:export-onnx-streaming.py
4.1 分块前向的模型封装
流式模型的关键是复用 WeNet 编码器的forward_chunk接口,逐 chunk 推理并维护跨 chunk 的缓存:
class OnnxModel(torch.nn.Module): def forward( self, x: torch.Tensor, offset: torch.Tensor, required_cache_size: torch.Tensor, attn_cache: torch.Tensor, conv_cache: torch.Tensor, attn_mask: torch.Tensor, ): encoder_out, next_att_cache, next_conv_cache = self.encoder.forward_chunk( xs=x, offset=offset, required_cache_size=required_cache_size, att_cache=attn_cache, cnn_cache=conv_cache, att_mask=attn_mask, ) log_probs = self.ctc.log_softmax(encoder_out) return log_probs, next_att_cache, next_conv_cache各输入张量的语义(来自脚本 docstring)为:
| 输入 | 形状 | 含义 |
|---|---|---|
x | (N, T, C) | 当前 chunk 的特征,仅支持N == 1 |
offset | 标量 int64 | 当前已处理的总帧偏移 |
required_cache_size | 标量 int64 | 注意力缓存所需的历史帧数 |
attn_cache | (num_blocks, head, required_cache_size, output_size/head*2) | 跨 chunk 的注意力 KV 缓存 |
conv_cache | (num_blocks, N, output_size, cnn_module_kernel-1) | 跨 chunk 的卷积缓存 |
attn_mask | (N, 1, required_cache_size + chunk_size)bool | 因果注意力掩码 |
输出为三元组:当前 chunk 的log_probs(形状(N, T, C))、next_att_cache与next_conv_cache,后两者回填给下一次调用,从而形成状态复用的流式推理闭环。
4.2 chunk 与缓存尺寸的计算
脚本从train.yaml的encoder_conf中读取模型结构参数,并据此推导解码窗口:
head = configs["encoder_conf"]["attention_heads"] num_blocks = configs["encoder_conf"]["num_blocks"] output_size = configs["encoder_conf"]["output_size"] cnn_module_kernel = configs["encoder_conf"].get("cnn_module_kernel", 1) right_context = torch_model.right_context() subsampling_factor = torch_model.encoder.embed.subsampling_rate chunk_size = 16 # 每个 chunk 的编码器帧数 left_chunks = 4 # 左侧历史 chunk 数 decoding_window = (chunk_size - 1) * subsampling_factor + right_context + 1 required_cache_size = chunk_size * left_chunks这里chunk_size = 16、left_chunks = 4与 WeNet 训练时的--chunk-size 16 --num-left-chunks 4对齐;decoding_window表示每个推理步需要送入编码器的 fbank 特征帧数,它与subsampling_factor(4)、right_context共同决定。attn_mask的构造为:前required_cache_size位置置 0(遮蔽),其余位置置 1:
attn_mask = torch.ones(1, 1, required_cache_size + chunk_size, dtype=torch.bool) attn_mask[:, :, :required_cache_size] = 0初始时offset = required_cache_size,attn_cache、conv_cache均以零张量初始化。
4.3 流式模型的动态轴与元数据
流式导出的输入输出名称与动态轴声明如下:
input_names=["x", "offset", "required_cache_size", "attn_cache", "conv_cache", "attn_mask"], output_names=["log_probs", "next_att_cache", "next_conv_cache"], dynamic_axes={ "x": {0: "N", 1: "T"}, "attn_cache": {2: "T"}, "attn_mask": {2: "T"}, "log_probs": {0: "N"}, "new_attn_cache": {2: "T"}, },流式模型的元数据比非流式更丰富,完整保留了部署所需的全部结构参数:
meta_data = { "model_type": "wenet_ctc", "version": "1", "model_author": "wenet", "comment": "streaming", "chunk_size": 16, "left_chunks": 4, "head": head, "num_blocks": num_blocks, "output_size": output_size, "cnn_module_kernel": cnn_module_kernel, "right_context": right_context, "subsampling_factor": subsampling_factor, "vocab_size": torch_model.ctc.ctc_lo.weight.shape[0], }这些字段与 sherpa-onnx 流式推理所需的 chunk 划分、缓存尺寸、注意力维度一一对应。同样,脚本末尾会生成model-streaming.int8.onnx的 int8 动态量化版本。
五、一键导出脚本run.sh支持的预训练模型
run.sh 为六套经典 WeNet 预训练模型提供了完整的"下载 → 解压 → 放置 global_cmvn → 导出 → 验证"流水线:
| 函数 | 模型 | 用途 |
|---|---|---|
aishell | aishell_u2pp_conformer_exp | 中文普通话(aishell-1) |
aishell2 | aishell2_u2pp_conformer_exp | 中文普通话(aishell-2) |
multi_cn | multi_cn_unified_conformer_exp | 中文多方言 |
wenetspeech | wenetspeech_u2pp_conformer_exp | 海量中文(wenetspeech 大模型) |
librispeech | librispeech_u2pp_conformer_exp | 英文(librispeech 960h) |
gigaspeech | gigaspeech_u2pp_conformer_exp | 英文(gigaspeech 超大规模) |
以aishell为例,脚本逻辑为:
wget -q https://huggingface.co/openspeech/wenet-models/resolve/main/aishell_u2pp_conformer_exp.tar.gz tar xvf aishell_u2pp_conformer_exp.tar.gz pushd aishell_u2pp_conformer_exp mkdir -p exp/20210601_u2++_conformer_exp cp global_cmvn ./exp/20210601_u2++_conformer_exp cp ../*.py . # 将导出与测试脚本复制进模型目录 export WENET_URL=... # 记录模型来源,写入 ONNX 元数据 wget -O 0.wav ... # 下载测试音频 ./export-onnx-streaming.py && ./test-onnx-streaming.py # 流式:导出 + 验证 ./export-onnx.py && ./test-onnx.py # 非流式:导出 + 验证 popd脚本约定导出脚本在当前目录下寻找final.pt(checkpoint)、train.yaml(训练配置)与global_cmvn(全局 CMVN 统计),这与 WeNet 官方发布包的目录结构一致。执行整个run.sh会依次完成六套模型的转换,最终输出目录树供检查。
六、导出结果验证:两个 Python 测试脚本
6.1 非流式验证test-onnx.py
该脚本用与 sherpa-onnx 一致的特征提取管线(kaldi-native-fbank)处理测试音频,再送入 ONNX 模型做贪心解码:
- 特征提取:
torchaudio.load读 wav,取单声道;若采样率非 16k 则重采样;音频乘 32768 转为整数刻度后,用knf.OnlineFbank提取 80 维 fbank(dither=0、snip_edges=False,与训练时配置一致)。 - ONNX 推理:创建
ort.InferenceSession,线程配置为inter_op_num_threads=1、intra_op_num_threads=4,provider 为 CPU;按x、x_lens两个输入调用。 - 贪心解码:对
log_probs取argmax(dim=1),用torch.unique_consecutive折叠连续重复的 blank/相同 token,剔除索引 0(WeNet 中通常是 blank),最后通过units.txt将 token id 映射回文本并拼接输出。
log_probs.shape = (1, T', vocab_size) indexes = log_probs.argmax(dim=1) indexes = torch.unique_consecutive(indexes) indexes = indexes[indexes != 0].tolist() text = "".join([id2word[i] for i in indexes])6.2 流式验证test-onnx-streaming.py
流式验证的核心是模拟真实在线识别中的"分块送入 + 缓存回填"循环:
- 读取元数据:从 ONNX 模型读取
left_chunks、num_blocks、chunk_size、head、output_size、cnn_module_kernel、right_context、subsampling_factor,并据此初始化attn_cache、conv_cache与offset; - 分块计算:
chunk_length = (chunk_size - 1) * subsampling_factor + right_context + 1为每步送入的帧数,chunk_shift = chunk_size * subsampling_factor为滑窗步长(相邻 chunk 有重叠的右侧上下文); - 掩码更新:每次调用前按当前
chunk_idx = offset // chunk_size - left_chunks动态调整attn_mask,在序列开头阶段(历史不足时)将多余的缓存位置遮蔽; - 缓存回填:推理后把返回的
new_attn_cache、new_conv_cache写回,并更新offset += log_probs.shape[1]; - 结果合并:每个 chunk 的
argmax结果经去重后累积到 token 列表,最终映射为文本。
测试音频尾部会拼接 50 帧零填充(padding = torch.zeros(50, 80)),以兜底尾部未处理完的残帧。
七、在 sherpa-onnx 中加载 WeNet ONNX 模型
7.1 配置结构:离线与在线两条路径
sherpa-onnx 为 WeNet CTC 模型提供了两条独立的配置结构(对应离线识别器与在线识别器):
- 离线:offline-wenet-ctc-model-config.h 定义的
OfflineWenetCtcModelConfig仅含一个model字段,指向非流式导出的model.onnx; - 在线:online-wenet-ctc-model-config.h 定义的
OnlineWenetCtcModelConfig包含三个字段:
struct OnlineWenetCtcModelConfig { std::string model; int32_t chunk_size = 16; // 对应 WeNet 的 --chunk_size int32_t num_left_chunks = 4; // 对应 WeNet 的 --num_left_chunks };chunk_size与num_left_chunks的默认值恰好与导出脚本中的chunk_size = 16、left_chunks = 4一致,用户在部署时若使用默认导出参数,无需额外修改。在线实现(online-wenet-ctc-model.cc)会读取 ONNX 元数据中的结构参数来初始化缓存,因此导出时写入的 meta data 是流式推理正确性的前提。
7.2 Python API 使用示例
仓库的 online-decode-files.py 直接给出了流式 WeNet CTC 模型的完整调用方式:
curl -SL -O https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-zh-wenet-wenetspeech.tar.bz2 tar xvf sherpa-onnx-zh-wenet-wenetspeech.tar.bz2 rm sherpa-onnx-zh-wenet-wenetspeech.tar.bz2 ./python-api-examples/online-decode-files.py \ --tokens=./sherpa-onnx-zh-wenet-wenetspeech/tokens.txt \ --wenet-ctc=./sherpa-onnx-zh-wenet-wenetspeech/model-streaming.onnx \ ./sherpa-onnx-zh-wenet-wenetspeech/test_wavs/0.wav \ ./sherpa-onnx-zh-wenet-wenetspeech/test_wavs/1.wav \ ./sherpa-onnx-zh-wenet-wenetspeech/test_wavs/8k.wav命令行参数在脚本中对应如下定义:
parser.add_argument("--wenet-ctc", type=str, help="Path to the wenet ctc model") parser.add_argument("--wenet-ctc-chunk-size", type=int, default=16, help="The --chunk-size parameter for streaming WeNet models") parser.add_argument("--wenet-ctc-num-left-chunks", type=int, default=4, help="The --num-left-chunks parameter for streaming WeNet models")内部通过sherpa_onnx.OnlineRecognizer.from_wenet_ctc(model=..., chunk_size=..., num_left_chunks=...)构造在线识别器,并逐帧accept_waveform、decode得到结果。非流式模型则可通过 offline-decode-files.py 中sherpa_onnx.OfflineRecognizer的wenet_ctc配置项加载model.onnx使用。
7.3 解码方式说明
由于导出的是帧级 CTC log-probs,sherpa-onnx 在解码时支持三种图路径:
- H:仅使用词表与 CTC 输出做贪心或束搜索,最轻量;
- HL:在 H 基础上叠加词典(lexicon),约束解码路径为合法词序列;
- HLG:进一步叠加语言模型(G),获得带语言模型先验的解码图。
对应的 C++ 示例可以参考 streaming-zipformer-buffered-tokens-hotwords-c-api.c 与 wenet-ctc-c-api.c 等基于 CTC 的解码示例,Python 侧则可参考 online-zipformer-ctc-hlg-decode-file.py 中 HLG 图的加载方式。
八、从导出到部署的完整链路小结
综合以上内容,WeNet 模型进入 sherpa-onnx 的完整链路可归纳为四步:
- 准备环境与模型:安装 WeNet、onnxruntime、onnx、pyyaml、kaldi-native-fbank 等依赖,下载 WeNet 预训练包(含
final.pt、train.yaml、global_cmvn、units.txt); - 导出:在模型目录下运行
export-onnx.py(得到model.onnx、model.int8.onnx)与export-onnx-streaming.py(得到model-streaming.onnx、model-streaming.int8.onnx),或直接执行run.sh一键完成六套模型的导出与验证; - 验证:运行
test-onnx.py/test-onnx-streaming.py,确认输出文本与预期一致,同时核对 ONNX 元数据(model_type=wenet_ctc、chunk_size、left_chunks等); - 部署:在 sherpa-onnx 中按在线/离线两条路径加载——在线使用
OnlineRecognizer.from_wenet_ctc(对应 online-wenet-ctc-model-config.h,默认chunk_size=16、num_left_chunks=4),离线使用OfflineRecognizer的 wenet_ctc 配置(对应 offline-wenet-ctc-model-config.h),并选择合适的 H/HL/HLG 解码图。
需要注意的是,本文描述的是仓库当前状态下的转换链路:导出脚本仅覆盖 CTC 分支,attention decoder rescore 不在支持范围内;流式模型对 chunk 尺寸与左侧历史 chunk 数有固定约定,若在 WeNet 训练阶段使用了不同的--chunk-size/--num-left-chunks,部署时需通过--wenet-ctc-chunk-size、--wenet-ctc-num-left-chunks保持三者一致,否则会导致推理结果错误。
【免费下载链接】sherpa-onnxSpeech-to-text, text-to-speech, speaker diarization, speech enhancement, source separation, and VAD using next-gen Kaldi with onnxruntime without Internet connection. Support embedded systems, Android, iOS, HarmonyOS, Raspberry Pi, RISC-V, RK NPU, Axera NPU, Ascend NPU, x86_64 servers, websocket server/client, support 12 programming languages项目地址: https://gitcode.com/GitHub_Trending/sh/sherpa-onnx
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考