一篇面向初学者的语音识别教程。以工业界常见的 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 如何缓解?

  1. FFT + Mel 滤波器组:看的是各频段的能量比例,不只盯原始振幅。
  2. 对数变换(log):能量从线性刻度变成对数刻度——大声时 +10 倍能量,log 上只是加一个常数,不会像波形那样整体乘 10。人耳听响度也是对数感知的(分贝)。
  3. 分帧:每帧单独算,进一步去掉采样点之间的冗余。

可以把它理解成声音的「压缩摘要」——不是文字,但是「对识别有用、对音量不那么敏感」的描述。

打个比方: 波形像按原尺寸扫描的整页照片,像素极多且亮暗随扫描仪增益变;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. 信息密度低 — 1 秒 16000 个数,有效语音信息占比小
  2. 鲁棒性差 — 对音量、语速、音色、噪声敏感
  3. 不符合听觉机制 — 人耳按频率感知,而非按时间看振幅

因此需要特征提取:把波形变为更紧凑、更稳定的表示。

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.onnxdecoder.onnxjoiner.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

转换规则通常包括:

  1. 跳过 ID < 3 的特殊符号
  2. 查表映射为字符或子词
  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 推荐代码阅读顺序

  1. main — 入口与评测逻辑
  2. tokens_to_text — 后处理
  3. FBank 提取 — 前处理
  4. decode_wav_streaming — 流式主循环
  5. greedy_search — 解码核心
  6. 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_outnew_cached_*
  • decoder.onnx / joiner.onnx 的输入名是否与推理代码一致

7.4 实验三:跑通评测脚本

  1. 准备 1 条 wav + 对应 txt
  2. 跑通推理,核对识别文本
  3. POST_SILENCE_MSWINDOW_SHIFT,观察切句与准确率变化
  4. 若有 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:自测题

  1. 16 kHz 下,3 秒 mono 音频大约多少个采样点?
  2. FBank 为何通常用 80 维?
  3. blank token 解决什么问题?
  4. WINDOW_LEN=45WINDOW_SHIFT=32 分别表示什么?重叠多少帧?
  5. 子串匹配与 WER 分别适合什么场景?
  6. 为什么流式 Encoder 需要 cache?
参考答案
  1. 约 48000。
  2. 在感知质量与计算量之间折中;80 个 Mel 滤波器已能较好表征语音。
  3. 对齐语音与文字长度;标记「本步不输出字符」。
  4. 45 = 每次编码上下文帧数;32 = 滑动步长;重叠 13 帧。
  5. 子串匹配适合关键词触发;WER 适合整句转写质量。
  6. 保存历史状态,避免重复计算,实现低延迟增量推理。

全文完。希望这份教程能帮你建立 ASR 的全局地图;细节参数因模型训练配置而异,部署时请以实际 ONNX metadata 为准。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐