流式 ASR 从入门到实践:Zipformer2 + ONNX 全链路详解
一篇面向初学者的语音识别教程。以工业界常见的 Zipformer2 Transducer 流式评测流水线为例,从「声音是什么」讲到 FBank 特征、Encoder/Decoder/Joiner 解码、流式推理与模型评测,并附带可运行的实验代码。
导读
如果你刚接触语音识别(ASR),很可能会被一堆术语淹没:波形、FBank、Transducer、blank、Token、cache……
建议: 先扫一眼下一节 「零、核心概念先搞懂」,再读正文会轻松很多。
本文用一条完整的工程链路把它们串起来:
WAV 音频
│
▼
前处理(读文件、重采样)
│
▼
FBank 特征(80 维)
│
▼
滑动窗口(45 帧,步长 32)
│
▼
ONNX 推理
├── Encoder ──┐
│ ├── Joiner ──► Token 序列
└── Decoder ──┘
│
▼
后处理(ID → 文字)
│
▼
识别文本
│
▼
评测指标
分工: WAV → Python(FBank + 分窗)→ ONNX(Encoder / Decoder / Joiner)→ Token → Python(转文本 + 评测)
Python 负责前后处理;中间「声音 → 文字」的核心推理,由三个 ONNX 模型共同完成。
零、核心概念先搞懂
正文里会反复出现下面这些词。这里先用大白话讲清楚「是什么、解决什么问题、出现在哪一步」。后文遇到时可回查本节。
ASR(Automatic Speech Recognition)
是什么: 自动语音识别,让机器把人说的话变成文字。
解决什么: 人机交互、字幕生成、语音输入等都需要「听 → 写」。
波形(waveform)
是什么: 麦克风录下来的一串采样数字,描述声音随时间的高低变化。
打个比方: 像心电图折线,横轴是时间,纵轴是振幅;还没有任何「字」的信息。
出现在: 流水线最开头,读取 WAV 文件之后。
FBank(Filter Bank,滤波器组特征)
是什么: 从波形里提取的一种声学特征。把每小段声音(约 25 ms)变成 80 个数字,表示「这段声音在各频率上的能量强弱」。
全称: Filter Bank = 一组滤波器;Mel FBank = 按人耳听感排列的滤波器组。
为什么需要: 原始波形有两个硬伤——太长、对音量太敏感。FBank 更短、更稳,模型更好学。
怎么理解「太长」?
- 1 秒语音 = 16000 个采样点,相邻两个点往往数值很接近(声音变化是连续的),大量数字其实在重复类似信息。
- 模型要从中找「说的是哪个字」,等于在一条极长的折线里挖规律,搜索空间巨大、训练困难。
- FBank 把 1 秒压成约 100 帧 × 80 维,每帧只概括 25 ms 的「频率能量分布」,丢掉大量与识别无关的细枝末节。
怎么理解「音量大一点,波形就变很多」?
- 波形记录的是振幅(声音有多「响」)。同一个人说同一个「播」字:
- 离麦克风近 → 数值可能是
[800, -600, 900, ...] - 离麦克风远 → 数值可能是
[80, -60, 90, ...]
- 离麦克风近 → 数值可能是
- 两串数字整体成比例放大或缩小,形状相似,但每个点的绝对值差很多。若直接把波形喂给模型,它很容易把「音量差异」误当成「内容差异」——同一句「播放音乐」,大声和小声在模型眼里像两种完全不同的输入。
- 识别任务关心的是发音结构(有哪些频率、怎么变化),而不是录音音量。所以需要一种对「整体变大变小」不那么敏感的特征。
FBank 如何缓解?
- FFT + Mel 滤波器组:看的是各频段的能量比例,不只盯原始振幅。
- 对数变换(log):能量从线性刻度变成对数刻度——大声时 +10 倍能量,log 上只是加一个常数,不会像波形那样整体乘 10。人耳听响度也是对数感知的(分贝)。
- 分帧:每帧单独算,进一步去掉采样点之间的冗余。
可以把它理解成声音的「压缩摘要」——不是文字,但是「对识别有用、对音量不那么敏感」的描述。
打个比方: 波形像按原尺寸扫描的整页照片,像素极多且亮暗随扫描仪增益变;FBank 像提取的「颜色/纹理统计」,尺寸小,且主要描述「有什么颜色、各占多少」,而不是「每个像素有多亮」。
出现在: 前处理阶段;Encoder 的输入就是 FBank,不是波形。
典型形状: 1 秒音频 → 约 100 帧 × 每帧 80 维 → (100, 80)。
帧 / 分帧 / 分窗
分帧: 把长音频切成很多短片段(如每段 25 ms),逐段算 FBank。因为人声在几十毫秒内相对稳定,一段一段分析才合理。
分窗(滑动窗口): 流式推理时,每次取连续多帧(如 45 帧 = 450 ms)一起送给 Encoder,然后窗口向前滑(如每次滑 32 帧)。模型需要看一小段上下文,不能一帧一帧孤立看。
别搞混: 分帧是「怎么算特征」;分窗是「怎么喂模型」。
Token
是什么: 模型输出和词表里的最小符号单位,通常对应一个汉字、一个词片段或子词。每个 Token 有一个整数 ID。
打个比方: Token 像「字典里的条目编号」——模型先输出 67、123 这样的数字,再查 tokens.txt 变成「播」「放」。
出现在: Joiner 预测 → 贪婪解码 → tokens_to_text 转文字。
blank(空白符,ID 通常为 0)
是什么: Transducer 架构里的一个特殊 Token,表示「当前这一小步不输出任何字」。
为什么需要: 1 秒语音可能对应 4 个字,但 Encoder 在时间轴上要走很多步。不是每一步都该吐一个字——大部分步应该说「我还没到新字呢」,这就是 blank。
打个比方: 像打字时光标在原地等——不是删字,是占位、对齐:声音还在播,但还没到下一个字的开头发音。
例子: 识别「你好」时,10 个时间步里可能 8 步是 blank,2 步分别输出「你」「好」。
出现在: 贪婪解码循环里;max_idx == 0 时不往结果里加字。
Transducer(也叫 RNN-T)
是什么: 一种端到端 ASR 架构,由 Encoder + Decoder + Joiner 三部分组成。
解决什么: 同时做「听声音」和「猜文字」,并且适合流式——音频来一块,就能解一块,不必等整句录完。
和 CTC 的简单区别(了解即可): CTC 也有 blank,但 Transducer 多了 Decoder,能利用「已经识别出的前文」帮助猜下一个字(如区分同音字)。
Encoder / Decoder / Joiner
| 名字 | 干什么 | 通俗类比 |
|---|---|---|
| Encoder | 读 FBank,提取声学表示 | 「听」——这段声音像什么音 |
| Decoder | 读已输出的 Token 历史,提取语言表示 | 「想」——前面说了啥,下文该连贯 |
| Joiner | 把 Encoder 和 Decoder 的输出加起来(再映射),预测下一个 Token | 「拍板」——综合听+想,选出最可能的字 |
三者通常是三个独立的 ONNX 文件。
ONNX
是什么: Open Neural Network Exchange,一种通用的模型文件格式(.onnx)。
为什么用: 训练可能在 PyTorch,部署可能在 C++ / 嵌入式;导出成 ONNX 后,Python 与生产端可以用同一套权重,结果才对得上。
出现在: 推理阶段;onnxruntime 负责加载和运行。
贪婪解码(Greedy Search)
是什么: 解码策略的一种——每个时间步只选概率最大的那个 Token,不做回溯、不保留多条候选。
优点: 简单、快,适合流式实时场景。
缺点: 偶尔选错就一路错下去;更复杂的有 beam search(保留多条候选),但更慢。
出现在: greedy_search 函数;argmax(joiner_out) 那一步。
Cache(流式缓存)
是什么: Encoder 内部保存的历史中间结果(Attention 的 key/value、卷积的 padding 等)。
为什么需要: 流式场景下窗口在滑动,重叠部分不能每轮都重算;带着 cache 往前滑,只算新增的那几帧,延迟和算力才可控。
打个比方: 像看视频时「记住上一段剧情」,新片段来了接着看,不用从第一集重看。
Zipformer2
是什么: 一种高效的 Transformer 变体,常用作 Transducer 的 Encoder 骨干网络。
特点: 比传统 Transformer 更适合长音频流式识别;工业界(如 Icefall 工具链)广泛使用。
流式 ASR vs 离线 ASR
| 离线 | 流式 | |
|---|---|---|
| 输入 | 整段录好的音频 | 边说边来的音频块 |
| 延迟 | 高 | 低 |
| 典型场景 | 会议录音转写 | 语音助手、实时字幕 |
WER(Word Error Rate,词错误率)
是什么: 衡量识别整句准不准的指标,看有多少词被替换、删除、多识别。
公式: WER = (替换 + 删除 + 插入) / 参考词数
和子串匹配的区别: WER 要求整句对齐;子串匹配只关心「关键词有没有出现」,适合快捷指令场景。
VAD / 静音分段
VAD(Voice Activity Detection): 检测「有没有人在说话」。
本文流水线里的简化做法: 不是直接算音频能量,而是看「多久没输出新 Token」——超过 1 秒没新字,就认为一句话说完了,切句。
概念在流水线里怎么走(串一遍)
波形 → FBank(80维特征) → Encoder(听)
↓
历史Token → Decoder(想) → Joiner(拍板) → Token(含 blank)
↓
去掉 blank、查表 → 文字 → WER / 子串匹配
一、声音在计算机里是什么
1.1 从物理信号到数字序列
声音是空气振动。麦克风把振动转为电信号,计算机通过采样和量化得到离散数字:
- 采样:固定间隔测量振幅。语音识别常用 16000 Hz,即每秒 16000 个采样点。
- 量化:把振幅映射为整数。常用 16-bit,范围约 -32768 ~ +32767。
1 秒音频在内存里大致是:
[324, -128, 512, -256, ...] ← 共 16000 个数,称为 waveform(波形)
张量形状通常为 (1, num_samples),其中 num_samples = 时长(秒) × 16000。
为何是 16 kHz? 人声主要能量在 0–8 kHz。由奈奎斯特采样定理,采样率需不低于最高频率的 2 倍;16 kHz 是 ASR 领域广泛采用的平衡点。
1.2 语音的层级结构:从发音到文字
人说话是一股连续的气流振动;文字却是离散的符号(字、词)。ASR 要做的,就是在两者之间架一座桥。
1.2.1 四个层级分别是什么
| 层级 | 示例 | 说明 | 大致时长(参考) |
|---|---|---|---|
| 音素(Phoneme) | b、p、m、a、zh、i | 最小发音单位,再分就没法独立成音了 | 几十毫秒 |
| 音节(Syllable) | ba、ma、da、kong | 一个或多个音素拼成,中文常以字对应一个音节 | 约 200–300 ms |
| 词(Word) | 播放、音乐 | 语言里有意义的最小单位(中文有时字=词) | 不定 |
| 句子(Sentence) | 播放音乐 | 一次完整表达 | 1 秒~数秒 |
音素最接近「怎么发音」。比如「爸」可以粗分为 b + a 两个音素——嘴唇怎么闭、怎么打开,对应波形里不同的共振模式。
音节是耳朵更容易感知的一拍。普通话大约 400 多个常用音节;很多 ASR 系统内部并不显式输出音节,但声学上语音仍是一拍一拍过去的。
词才进入「语义」。「播放」是一个动作概念,「音乐」是一个对象——模型最终要落到文字,多半是在词或字这一层。
句子是用户真正说出来的东西。评测时常见的标注就是整句:播放音乐。
1.2.2 用一句话串起来
以「播放音乐」为例,概念上可以这么理解:
句子:播放音乐
│
├── 词:播放 + 词:音乐
│ │ │
│ 音节:bo-fang 音节:yin-yue
│ │ │
│ 音素:b-o ... 音素:y-in ...
│
└── 波形:连续的一整段振动(几千到几万个采样点)
说话时,这些层级在时间上重叠、连续;文字却是一个一个分开的。这就是 ASR 的核心难点。
1.2.3 「连续波形 → 离散文字」到底是什么意思
连续,指波形没有天然的「缝」:
- 1 秒音频 = 16000 个数字,数字之间平滑变化
- 你听不到「第 500 个采样点是『播』,第 501 个是『放』」——边界是模糊的
- 语速、连读、口音都会让同一个字的波形长得不一样
离散,指文字是有限符号表里的条目:
- 中文可用几千~几万个字/词
- 模型输出的是 Token ID(整数),再查表变成「播」「放」「音」「乐」
- 评测对比的也是离散字符串:
播放音乐
所以 ASR 的任务可以概括成一句话:
输入: 一段连续波形(记作 x(t),t 表示时间)
输出: 一个离散符号序列 w1, w2, …, wn(每个 wi 是一个字或词)
目标: 找到最可能对应这段声音的文字
用更直白的话说:左边是成千上万个采样数字,右边是一串有限的字/词 ID——模型就在做这件匹配的事。
1.2.4 传统 ASR vs 端到端:层级还在,但「谁来做」变了
早期系统会把层级拆成多个模块,逐级转换:
波形 → 音素序列 → 音节/词 → 句子
↑ ↑
声学模型 语言模型 + 词典
每个模块各管一层,需要发音词典、语言专家,维护成本高。
Zipformer2 Transducer 这类端到端模型不再显式输出「音素串」,但逻辑上仍在做同一件事:
波形 → FBank 特征 → Encoder 声学编码 → Joiner 逐帧预测 Token → 「播放音乐」
- 中间没有人工定义的「音素层」
- 模型自己从数据里学「什么样的声音对应什么字」
- 对外表现仍是:声音进,文字出
可以把它想成:音素、音节仍存在于声学规律里,只是被神经网络「打包」学进了 Encoder,不再单独打印出来。
1.2.5 和本文后续章节的关系
| 概念 | 在流水线里对应什么 |
|---|---|
| 连续波形 | waveform,原始 WAV |
| 短时声学片段 | FBank 每帧 25 ms,相当于粗粒度的「声音切片」 |
| 离散符号 | Token ID,tokens.txt 里的 0、45、123… |
| 最终文字 | tokens_to_text 拼出来的「播放音乐」 |
| 对齐问题 | Transducer 的 blank:很多帧不输出字,只在合适时刻吐出一个 Token |
blank 和层级的关系: 说「播放」两个字,Encoder 可能走过 10 个时间步,其中大部分步输出 blank(「这一步还不出字」),少数步才输出「播」「放」。这就是在没有显式音素标注的情况下,用 blank 解决「长波形、短文字」的对齐。
1.2.6 小结
- 音素 → 音节 → 词 → 句子 是从「怎么发声」到「说什么意思」的抽象 ladder
- ASR 本质是 连续信号 → 离散文字,难点在于边界模糊、同字异形的波形、噪声干扰
- 现代端到端模型一步到位预测 Token,但理解这套层级,有助于读懂 FBank、blank、流式切句等设计
下一节会讲:既然波形这么「原始」,为什么不直接送进模型,而要先把 FBank 特征提取出来。
二、FBank 特征:为什么模型不直接吃波形
FBank 是什么? 见「零、核心概念」;本节讲它怎么算、参数含义。
2.1 原始波形的三个问题
- 信息密度低 — 1 秒 16000 个数,有效语音信息占比小
- 鲁棒性差 — 对音量、语速、音色、噪声敏感
- 不符合听觉机制 — 人耳按频率感知,而非按时间看振幅
因此需要特征提取:把波形变为更紧凑、更稳定的表示。
2.1.1 深入理解:为什么说波形「太长、对音量敏感」
上面三点里,新手最容易卡在第 2 点。拆开讲:
(1)太长 — 数字多,且大量重复
1 秒 @ 16kHz → 16000 个采样点
同 1 秒 FBank → 约 100 帧 × 80 维 = 8000 个数(且每帧已是摘要)
波形相邻采样点高度相关(连续信号),模型要从 16000 个高度相似的数里学「字是什么」,效率低。FBank 先按频率压缩,再去冗余,输入更紧凑。
(2)对音量敏感 — 同一句话,波形数值可以差一个数量级
波形本质是振幅随时间的变化。录音音量(麦克风距离、增益、说话轻重)会直接乘在整个波形上:
小声说「播」: ... 50, -40, 60, -30 ...
大声说「播」: ... 500, -400, 600, -300 ...
↑ 形状类似,但整体 ×10
若模型学的是「看到 500 就是某音素,看到 50 是另一回事」,那就学偏了——我们关心的是发音,不是响度。
(3)FBank + log 为什么更稳
| 阶段 | 对音量的反应 |
|---|---|
| 原始波形 | 整体等比例放大/缩小,数值线性变化 |
| 能量(FFT 后) | 仍随音量平方变化 |
| log 能量(FBank 最后一步) | 大声 ≈ 小声 + 一个偏移,模式更接近 |
所以工程上几乎不会把 raw waveform 直接送进 ASR 模型,而是先做 FBank(或 MFCC、Spectrogram 等同类特征)——它们提取的是「频谱结构」,并对响度做压缩,更贴近「听内容,不听大小声」。
(4)还有其他敏感因素(了解即可)
除音量外,波形还对语速(同样字说快说慢,时间轴拉伸)、音色(不同人、不同麦克风)敏感。FBank 不能解决全部问题,但比 raw waveform 好得多;再配合大数据训练,模型才学得动。
2.2 FBank 提取的六个步骤
再强调一遍: FBank 不是文字,是「每 25 ms 声音的频率能量摘要」,每段 80 个数。下面是从波形算出这 80 个数的流水线。
fbank_opts = kaldifeat.FbankOptions()
fbank_opts.frame_length_ms = 25 # 帧长 25 ms
fbank_opts.frame_shift_ms = 10 # 帧移 10 ms
fbank_opts.num_bins = 80 # 80 个 Mel 滤波器
fbank_opts.high_freq = -400 # 最高频率 = sr/2 - 400
fbank_opts.dither = 0
fbank_opts.snip_edges = True
fbank_opts.energy_floor = 0
fbank_opts.use_log_fbank = True
| 步骤 | 作用 | 结果 |
|---|---|---|
| 预加重 | 提升高频,y[n]=x[n]-0.97·x[n-1] |
平衡频谱 |
| 分帧 | 25 ms 窗、10 ms 步 | 1 秒 ≈ 100 帧 |
| 加窗 | 汉明窗 | 减少频谱泄漏 |
| FFT | 时域 → 频域 | 每帧 ~257 个频点 |
| Mel 滤波器组 | 80 个三角滤波器 | 257 → 80 维 |
| 对数变换 | log 能量 | 对音量变化更鲁棒 |
Mel 频率转换(可选深入):Mel(f) = 2595 × log10(1 + f/700),低频细、高频粗,模拟人耳感知。
输出形状: 1 秒 16 kHz 音频 → 约 (100, 80)。
2.3 三种常见 FBank 实现
| 后端 | 特点 | 适用 |
|---|---|---|
| kaldifeat | 与 Kaldi C++ 一致,支持 CUDA | 生产对齐、正式评测 |
| python_speech_features | 纯 CPU | 本地学习 |
| torchaudio | PyTorch 生态 | 快速实验 |
正式评测若需与 C++ 端 bitwise 对齐,应使用 kaldifeat。
2.4 分帧 vs 分窗:两个「窗」别搞混
分帧(frame): 25 ms 帧长、10 ms 帧移,生成 FBank 时间序列。
分窗(window,流式推理用):
帧: [f1][f2][f3][f4][f5][f6][f7][f8]...
窗口: |←-------- 45 帧 = 450 ms --------→|
步长 32 帧(320 ms)
|←-------- 45 帧 --------→|
WINDOW_LEN = 45:每次送入 Encoder 的上下文长度WINDOW_SHIFT = 32:滑动步长
Zipformer2 通常需要约 450 ms 上下文才能稳定编码。
三、ASR 模型:Zipformer2 Transducer
Transducer / Encoder / Decoder / Joiner / blank 是什么? 见「零、核心概念」;本节讲它们如何配合工作。
3.1 架构一览
| 代际 | 方案 | 特点 |
|---|---|---|
| 传统 | GMM-HMM + N-gram LM + 发音词典 | 组件多、维护成本高 |
| 端到端 | CTC / Attention / Transducer | 单一模型,语音 → 文字 |
Transducer(RNN-T)由三个子网络组成,天然适合流式识别:
FBank 特征 ──► Encoder ──┐
├──► Joiner ──► 下一 Token 概率
历史 Token ──► Decoder ──┘
3.2 三个 ONNX 模型各做什么
| 组件 | 角色 | 典型输入 | 典型输出 |
|---|---|---|---|
| Encoder | 「听」— 声学编码 | (1, 45, 80) |
(1, 8, 512) |
| Decoder | 「想」— 语言上下文 | 最后 2 个 token | 语言向量 |
| Joiner | 「决策」— 融合预测 | 声学 + 语言向量 | vocab_size logits |
Encoder 对时间维下采样 4 倍:45 帧 FBank → 8 个输出步(enc_tout = decode_chunk_len // 4)。
三个文件通常命名为 encoder.onnx、decoder.onnx、joiner.onnx。
3.3 Blank Token(空白符)
详见「零、核心概念 → blank」;这里展开「它在对齐里具体怎么工作」。
核心问题: 声音在时间轴上很长,文字很短。说「播放音乐」4 个字,Encoder 可能在时间轴上走 20~30 个小步——不能每步都硬吐一个字。
blank 的答案: 引入一个特殊符号,ID = 0,意思是 「这一步:声音在继续,但还没有新字要输出」。
时间轴示意:
Encoder 时间步: 1 2 3 4 5 6 7 8 9 10
Joiner 输出: blank blank blank 播 blank blank 放 blank blank ...
最终保留: 播 放
- 输出 blank → 不写入最终句子(但 Decoder 仍会用当前状态准备下一步)
- 输出「播」「放」等非零 ID → 追加到识别结果
和 <sos> / <eos> 的区别:
| 符号 | 常见 ID | 含义 |
|---|---|---|
| blank | 0 | 对齐用,「本步无输出」,转文字时丢弃 |
| sos | 1 | Start of sentence,句首标记(训练用) |
| eos | 2 | End of sentence,句末标记(训练用) |
评测转文字时,通常 ID < 3 的全部跳过,用户只看到「播放音乐」这样的纯文本。
例子: 识别「你好」——10 个 Encoder 步里,可能 8 步 blank,2 步分别输出「你」「好」。
3.4 贪婪解码(Greedy Search)
贪婪解码是什么? 见「零、核心概念 → 贪婪解码」。
流式推理常用贪婪策略:每步只取概率最大的 token,不做多条候选回溯。
hyp = [0, 0]
decoder_input = hyp[-2:]
decoder_out = decoder.run(decoder_input)
for t in range(enc_tout):
enc_step = encoder_out[:, t, :]
logits = joiner.run(enc_step, decoder_out)
token_id = argmax(logits)
if token_id != 0:
hyp.append(token_id)
decoder_input = [hyp[-2], hyp[-1]]
decoder_out = decoder.run(decoder_input)
要点:context_size = 2 时,Decoder 始终看最后两个 token;初始 hyp = [0, 0] 保证长度足够。
3.5 流式 Encoder 的 Cache
Cache 是什么? 见「零、核心概念 → Cache」。
流式 ASR 不能每来一段音频就从头重算。Zipformer2 Encoder 在每一层维护状态缓存,例如:
| Cache | 用途 |
|---|---|
| cached_key / cached_val | Attention 历史 |
| cached_nonlin_attn | 非线性 Attention 历史 |
| cached_conv1 / cached_conv2 | 卷积 padding 历史 |
| embed_states | Embedding 模块状态 |
| processed_lens | 已处理帧计数 |
每次推理传入旧 cache,输出 new_* cache 供下一步使用——这是流式低延迟的关键。
四、流式解码:四大机制
4.1 离线 vs 流式
| 离线 ASR | 流式 ASR | |
|---|---|---|
| 输入 | 完整音频 | 增量音频流 |
| 延迟 | 较高 | 低(常见 ~300 ms 量级) |
| 场景 | 录音转写 | 语音助手、快捷指令、实时字幕 |
4.2 机制一:滑动窗口
- 窗口长 45 帧(450 ms),步长 32 帧(320 ms)
- 相邻窗口重叠 13 帧,由 cache 避免重复计算
4.3 机制二:静音分段
用简单状态机切句:若连续 1000 ms 没有新 token 输出,认为一句话结束,保存当前假设并重置 Decoder 状态。
IDLE ──(检测到新词)──→ IN_UTTERANCE
IN_UTTERANCE ──(静音 > 1s)──→ IDLE,输出一句
IN_UTTERANCE ──(又检测到新词)──→ 重置静音计时
4.4 机制三:尾帧填充
在音频末尾补 30 帧零(300 ms),避免缺少右侧上下文导致尾字漏识别。
4.5 机制四:Token 转文本
Token 是什么? 见「零、核心概念 → Token」。
模型内部只认识整数 ID。词表文件 tokens.txt 建立「文字 ↔ ID」映射,示例:
<blank> 0
<sos> 1
<eos> 2
播 45
放 123
转换规则通常包括:
- 跳过 ID < 3 的特殊符号
- 查表映射为字符或子词
- 未知 ID →
<unk>
五、Python 评测流水线长什么样
典型的 Zipformer2 离线批量评测脚本(用于验证与 C++ 部署一致性)包含以下模块:
| 模块 | 职责 |
|---|---|
OnnxModel |
加载 3 个 ONNX,管理 Encoder cache |
| FBank 提取 | kaldifeat / 备选后端 |
decode_wav_streaming |
滑动窗口 + 静音切句 |
greedy_search |
Transducer 贪婪解码 |
tokens_to_text |
ID → 字符串 |
main |
遍历测试集、统计指标、导出报告 |
5.1 推荐代码阅读顺序
main— 入口与评测逻辑tokens_to_text— 后处理- FBank 提取 — 前处理
decode_wav_streaming— 流式主循环greedy_search— 解码核心OnnxModel— 模型与 cache
5.2 测试数据格式
project/
├── encoder.onnx
├── decoder.onnx
├── joiner.onnx
├── tokens.txt
├── eval.py
└── test_data/
├── 001.wav
├── 001.txt # 标注:"播放音乐"
├── 002.wav
└── 002.txt
流程:读 wav → 流式解码 → 转文本 → 与 txt 标注对比 → 输出报告。
5.3 调试建议
在 pipeline 中打印形状,快速定位问题:
print("waveform:", waveform.shape) # (1, N)
print("fbank:", fbank.shape) # (T, 80)
print("encoder_out:", encoder_out.shape) # (1, 8, D)
print("hyp:", hyp) # [0, 0, 45, 123, ...]
六、怎么评测 ASR 好不好
6.1 关键词场景:子串匹配
语音助手或 IoT 快捷指令常关心「关键词有没有被识别」,而非整句完全一致:
correct = any(label in sent for sent in hypotheses)
| 标注 | 识别结果 | 判定 |
|---|---|---|
| 播放音乐 | 请播放音乐 | 正确 |
| 播放音乐 | 播放因乐 | 错误 |
前缀「请」「帮我」不影响实际触发,因此子串匹配更贴近这类场景。
6.2 通用场景:WER(词错误率)
转写、字幕等场景常用 WER:
WER = (S + D + I) / N × 100%
- S Substitutions 替换
- D Deletions 删除
- I Insertions 插入
- N 参考文本词数
例:参考「播放音乐」,识别「请播放因乐」→ WER = 100%。
6.3 常见错误类型
- 同音字混淆
- 相似词混淆(播放 / 暂停)
- 噪声、口音
- 静音切分不当
七、动手实践
7.1 环境准备
onnxruntime-gpu # ONNX + CUDA
kaldifeat # 生产级 FBank(推荐)
torch
openpyxl # 可选,导出 Excel 报告
# 学习用备选(无需 GPU)
torchaudio
python_speech_features
音频格式建议:16 kHz、mono、16-bit WAV;标注 txt 为 UTF-8 纯文本。
7.2 实验一:最小 FBank(5 分钟)
无需完整 ASR 模型,先理解「波形 → 特征」:
import torchaudio
import torch
wav_path = "test.wav"
waveform, sr = torchaudio.load(wav_path)
if sr != 16000:
waveform = torchaudio.transforms.Resample(sr, 16000)(waveform)
mel = torchaudio.transforms.MelSpectrogram(
sample_rate=16000,
n_fft=512,
win_length=400, # 25 ms
hop_length=160, # 10 ms
n_mels=80,
)(waveform)
log_mel = torch.log(mel.clamp(min=1e-10))
print("shape:", log_mel.shape) # 1 秒音频 → 帧数约 100
7.3 实验二:用 Netron 看 ONNX
安装 Netron,打开 encoder.onnx,确认:
- 输入是否包含
x与各类cached_* - 输出是否有
encoder_out与new_cached_* decoder.onnx/joiner.onnx的输入名是否与推理代码一致
7.4 实验三:跑通评测脚本
- 准备 1 条 wav + 对应 txt
- 跑通推理,核对识别文本
- 改
POST_SILENCE_MS、WINDOW_SHIFT,观察切句与准确率变化 - 若有 C++ 部署端,对比同一音频两端输出是否一致
7.5 进一步学习
附录 A:术语速查(一句话版)
| 术语 | 一句话解释 |
|---|---|
| ASR | 语音转文字 |
| waveform | 原始采样数字序列,声音的「折线图」 |
| FBank | 每 25 ms 声音变成 80 个数,描述频率能量;模型的声学输入 |
| 分帧 | 把长音频切成短段再算特征 |
| 分窗 | 把多帧 FBank 打包成一块送给 Encoder |
| Token | 词表里的符号单元,用整数 ID 表示 |
| blank | 特殊 Token(通常 ID=0),表示「本步不输出字」,用于声音与文字对齐 |
| sos / eos | 句首 / 句末标记,转文本时一般跳过 |
| Transducer | Encoder + Decoder + Joiner 的端到端 ASR 架构 |
| Encoder | 读 FBank,「听」声音 |
| Decoder | 读已输出 Token 历史,「想」上下文 |
| Joiner | 融合听+想,预测下一 Token |
| ONNX | 跨平台模型格式,便于 Python 与 C++ 共用同一模型 |
| Zipformer2 | 常用的流式 Encoder 骨干网络 |
| greedy search | 每步只选概率最大的 Token,简单快速 |
| cache | Encoder 的历史中间状态,流式推理必用 |
| logits | Joiner 输出的各 Token 得分(未归一化概率) |
| vocab | 词表,所有可能出现的 Token 集合 |
| 流式 ASR | 边说边识别,低延迟 |
| 离线 ASR | 整段录完再识别,可多用全局上下文 |
| WER | 词错误率,衡量整句转写质量 |
| VAD | 检测是否在说话;本文用「多久无新 Token」近似切句 |
| 子串匹配 | 标注文本是否出现在识别结果里,适合关键词场景 |
更详细的比喻和流水线位置,见正文 「零、核心概念先搞懂」。
附录 B:最小 Transducer 解码骨架
以下代码展示 Joiner 贪婪解码的核心结构,便于理解第 3.4 节(需自行补全 FBank 与 Encoder 流式逻辑):
import numpy as np
BLANK = 0
CONTEXT_SIZE = 2
def load_tokens(path):
table = {}
with open(path, encoding="utf-8") as f:
for line in f:
parts = line.strip().split()
if len(parts) >= 2:
table[int(parts[-1])] = " ".join(parts[:-1])
return table
def tokens_to_text(hyp, token_table):
return "".join(
token_table.get(t, "<unk>")
for t in hyp if t >= 3
)
def greedy_search(encoder_out, decoder_sess, joiner_sess):
hyp = [0] * CONTEXT_SIZE
dec_in = np.array([hyp[-CONTEXT_SIZE:]], dtype=np.int64)
dec_out = decoder_sess.run(None, {"y": dec_in})[0]
for t in range(encoder_out.shape[1]):
enc = encoder_out[:, t:t+1, :]
logits = joiner_sess.run(None, {
"encoder_out": enc,
"decoder_out": dec_out,
})[0]
tid = int(np.argmax(logits))
if tid != BLANK:
hyp.append(tid)
dec_in = np.array([hyp[-CONTEXT_SIZE:]], dtype=np.int64)
dec_out = decoder_sess.run(None, {"y": dec_in})[0]
return hyp
附录 C:自测题
- 16 kHz 下,3 秒 mono 音频大约多少个采样点?
- FBank 为何通常用 80 维?
- blank token 解决什么问题?
WINDOW_LEN=45、WINDOW_SHIFT=32分别表示什么?重叠多少帧?- 子串匹配与 WER 分别适合什么场景?
- 为什么流式 Encoder 需要 cache?
- 约 48000。
- 在感知质量与计算量之间折中;80 个 Mel 滤波器已能较好表征语音。
- 对齐语音与文字长度;标记「本步不输出字符」。
- 45 = 每次编码上下文帧数;32 = 滑动步长;重叠 13 帧。
- 子串匹配适合关键词触发;WER 适合整句转写质量。
- 保存历史状态,避免重复计算,实现低延迟增量推理。
全文完。希望这份教程能帮你建立 ASR 的全局地图;细节参数因模型训练配置而异,部署时请以实际 ONNX metadata 为准。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)