核心课程 / 自然语言交互应用 / EXP-02
EXP-02

本地流式语音识别:波形怎么变成中文

唤醒之后第一件事是听清。这一页把 sherpa-onnx + zipformer 的识别链拆成六格: 切块、提特征、模型打分、解码挑路、查表成字、端点拼句 —— 你能调的所有参数只在前两块和第四块。

⭐ 4 学时sherpa-onnxzipformer流式识别热词

01目标:把一段音频变成一行中文

给你
4.wav(17.64 s,16 kHz 单声道)
拿到
最终识别结果:嗯总是准时交代的作业…
+ 若干 segment

板上实测,最长的那段音频(17.64 秒)识别成这样:

音频时长: 17.64 秒
最终识别结果: 嗯总是准时交代的作业那用一般现在是没有什么感情色彩的承受一个事实上一句话为什么要用现在进行时它的意思并不是说他现在正在交代他

同一段 8.83 秒的音频,换一个解码方法,结果就变了:

---- greedy_search ----          最终识别结果: 现在是
---- modified_beam_search ----   最终识别结果: 现在时间上

学完你该能做出这三件事

  • 跑通 asr_test.py --audio-file,看到上面那种「识别中 → 最终结果」的输出。
  • 说清 为什么程序要按 100 ms 一块喂给模型,而不是整段一次喂。
  • 解释 同一段音频为什么换解码方法会给出不同文字 —— 变的不是音频,是挑路的方式。
⚠ 重难点 直觉是“结果短就是模型不行”,实际是同一段 8.83 秒音频换解码方法就换结果, 先换参数、换音频再怀疑模型 —— 这个顺序能省掉一半排查时间。

🔑 一句话:ASR 不是“把声音翻译成字”,是“在候选字里挑一条最像话的路径”。

02问题:波形跟文字长得一点都不像

一段 1 秒的 16 kHz 音频,是 16000 个浮点数。你要的是一行「小飞同学」。 中间没有任何一一对应的关系 —— 同一个字,不同人、不同语速,波形完全不同。

疑问 1
波形凭什么变成字?

16000 个数里没有任何“字”的信息,模型到底在算什么?

疑问 2
为什么要切成 100 ms 一块?

整段一次喂不是更省事?切成小块图什么?

疑问 3
同一段音频为什么能识别出两种结果?

音频没变、模型没变,只换了解码方法,文字就变了 —— 谁是“变”的那一方?

先猜一下 三个疑问里,哪个的答案与“延迟”有关、与“准确率”无关?记住你的选择,第 04 节末尾回来对。

🔑 一句话:波形没有字,字是模型加解码器一起“猜”出来的。

03现象:七段音频的真实识别结果

🎬 现象速览 · 30 秒先看效果

一条命令识别一个 wav,终端会先报「音频时长」,然后一边识别一边刷新中间结果, 最后给一段 最终识别结果。下面七行全部是板上真跑的输出。

音频时长最终识别结果
0.wav10.05 s昨天是
1.wav5.10 s叫呃呃什么意思
2.wav4.69 s这平凡的
3.wav(greedy)8.83 s现在是
3.wav(beam)8.83 s现在时间上
4.wav17.64 s嗯总是准时交代的作业那用一般现在是没有什么感情色彩的承受一个事实…
TTS 合成的「小飞同学」1.33 s小飞同学

最后一行值得多看一眼:音频是 EXP-03 的 TTS 合成出来的,喂回来识别,一字不差。 这一「说」一「听」能对上,说明两台模型都在正常工作。

🔍 看什么:三处。

  • 看「识别中:」是否滚动刷新 —— 刷新说明第 2 格在逐块喂,不是等整段。
  • 看 segment 数 —— 大于 1 说明端点切过句,最终结果是拼接出来的。
  • 看同一音频换解码方法后的差异 —— 3.wav 从「现在是」变成「现在时间上」。

怎么跑出来

三个模型的路径要显式给,因为它们不在工具目录下,而在 ~/models 里:

cd ~/ros2_ws/src/xflz_eai_car1/interaction/car1_speech/tools
M=~/models/zipformer
python3 asr_test.py --audio-file $M/test_wavs/4.wav --tokens $M/tokens.txt --encoder $M/encoder.rknn --decoder $M/decoder.rknn --joiner $M/joiner.rknn --provider rknn
音频时长: 17.64 秒 开始识别... 最终识别结果: 嗯总是准时交代的作业那用一般现在是… ← 中间会滚动刷「识别中: …」,那是中间结果,不是最终答案

换解码方法对照,只多一个参数:

python3 asr_test.py --audio-file $M/test_wavs/3.wav --tokens $M/tokens.txt --encoder $M/encoder.rknn --decoder $M/decoder.rknn --joiner $M/joiner.rknn --provider rknn --decoding-method modified_beam_search
最终识别结果: 现在时间上

🔑 一句话:现象里最重要的不是“识别对了”,是同一段音频能出两种结果。

04原理:切块、打分、挑路、拼句

CORE IDEA · 一句话原理

模型不是“听音写字”,它做的是给每一小段声音打一张“像哪个音”的分数表; 解码器再拿着这张分数表,在候选路径里挑一条最像人话的走法。 六格,一次形态变化一格。

② 的三个疑问在 04 的哪几格回答
疑问 1 波形怎么变字步骤 1、3、5
疑问 2 为什么要切块步骤 2、6
疑问 3 为什么结果会变步骤 4
音频进 → 文字出:六次形态变化 1 拿到一段 16 kHz 单声道波形 形态:每秒 16000 个采样点,连续无边界 2 切成 100 ms 一块,逐块喂进去 形态:每块 1600 个采样点(0.1 × 16000) 3 声学模型给每一帧打“像哪个 token”的分 形态:一张分数表(encoder 出特征,decoder + joiner 打分) 4 解码器在分数表上挑一条路 形态:候选 token 序列(greedy 只留 1 条,beam 留多条比总分) 5 token 查表变成汉字,滚动显示中间结果 形态:一行文本(终端里不断刷新的「识别中: …」) 6 端点一到就切段,最后拼成完整结果 形态:segment 列表(切了段也一句不丢)
图 4-1|六格链路。1~2 格决定延迟,4 格决定准不准,5~6 格决定你怎么看到它。

1波形怎么变成“像哪个音”的分数?

吃什么:每块 1600 个采样点。做什么:先压成每秒几十帧的特征(相当于把波形“取样成一串小切片”), 再送进 encoder.rknn,由 decoder + joiner 给每个候选 token 打分。 吐什么:一张分数表,不是文字。

直觉:这一步像听写员只听音节 —— 他能确定“这半秒像 zhè”,但还写不出整句。 整句是下一格拼的。

2为什么非要 100 ms 一块?

吃什么:整段波形。做什么:按 samples_per_chunk = int(0.1 * sample_rate) 切块,逐块 accept_waveform。吐什么:一块块进,一个个中间结果出。

为什么需要它:两个理由,都很实际。① 流式:麦克风的声音是源源不断来的,整段等完才出字,对话就没法接。 ② 内存:17 秒音频一次喂进去,NPU 显存和时延都顶不住。

动参数会怎样:块调大到 300 ms → 首次出字变慢,但每块信息更全;调到 20 ms → 出字快,块太碎容易抖动。 这条在 06 节可以拖。

揭晓答案 1 第 02 节里「与延迟有关、与准确率无关」的是疑问 2:切块就是为了流式和内存,不是为了更准。

3分数表上面到底有什么?

吃什么:特征帧。做什么:模型对每个时间片输出一组分数,其中有一个特殊符号 blank(意思是“这一段没有新字”)。吐什么:每一帧上千个候选分数。

动参数会怎样:blank_penalty 就是这个符号的惩罚项。源码注释写它的作用是 减少漏字;本机默认 0.25(文件头注释提到 0.3)。调大 → 更容易“跳过”某段,可能漏字; 调小 → 更容易挤进多余的字。

4greedy 和 beam 差在哪?

吃什么:分数表。做什么: greedy 每一步只留当前最高分(走一步看一步); modified_beam_search 同时保留多条候选路,最后按总分挑一条,还能给热词加分。 吐什么:一条 token 序列。

实测差别就摆在这儿:同一段 8.83 秒音频,greedy 给 现在是,beam 给 现在时间上。 变的不是音频,是挑路的规则。

揭晓答案 3 疑问 3 的答案是:解码器是“变”的那一方。音频和模型都没动,动的是搜索策略。

5token 怎么变成你能读的汉字?

吃什么:token 序列。做什么:用 tokens.txt 查表,一个 token 对应一个字或一个音节。 吐什么:终端里那行不断刷新的 识别中: …。

注意:中间结果会“推翻重来”。你看到 识别中: 小飞 然后变成 小飞同学, 是模型后面几块补充了信息,不是前一句错了。

6为什么要切段,还补 0.5 秒静音?

吃什么:连续的 token 流。做什么:模型判定一句话说完(endpoint)就把当前结果存进 all_segments 再清空;结尾再补 0.5 秒静音(tail_padding)把最后一句逼出来。 吐什么:segment 列表,最后拼接成 最终识别结果。

一段 8.83 秒音频被切成 88 块,端点处切句,末尾补 0.5 秒静音 …… 每块 100 ms …… 端点(一句话说完) 补 0.5s 静音 段 1:前 300 块 → 中间结果滚动刷新 → 端点触发,存进 all_segments 段 2:继续到结尾 → 补静音逼出最后一段 → 两段拼成最终结果 不补静音会怎样:最后一块信息不够,结尾一两个字容易丢。 不存 segment 会怎样:端点一 reset,前面已经识别的内容白算。 这两条护栏都在 asr_test.py 里,不是框架替你做的。
图 4-2|切块与端点。虚线处是“判定说完”,红框是补进去的静音。

🔑 一句话:第 1~2 格管延迟,第 4 格管准不准,第 5~6 格管你怎么看到它。

05实践:三步跑通并改一个参数

  1. 确认模型三件套 约 5 分钟两个 rknn 加 tokens.txt,缺一个都起不来。
  2. 识别一段官方音频 约 10 分钟看「音频时长 → 识别中 → 最终结果」三段。
  3. 只改一个参数再跑 约 15 分钟换 --decoding-method 对照结果差异。

1确认模型三件套在

zipformer 需要三个文件 + 一个词表,缺一个都起不来。

ls ~/models/zipformer
ls ~/models/zipformer/test_wavs
decoder.rknn encoder.rknn joiner.rknn tokens.txt test_wavs 0.wav 1.wav 2.wav 3.wav 4.wav

2识别一段官方测试音频

cd ~/ros2_ws/src/xflz_eai_car1/interaction/car1_speech/tools
M=~/models/zipformer
python3 asr_test.py --audio-file $M/test_wavs/4.wav --tokens $M/tokens.txt --encoder $M/encoder.rknn --decoder $M/decoder.rknn --joiner $M/joiner.rknn --provider rknn
音频时长: 17.64 秒 开始识别... 最终识别结果: 嗯总是准时交代的作业那用一般现在是… ← 能看到「识别中:」在不断刷新,说明第 2 格在逐块喂

3只改一个参数,再跑一遍

换成 beam search,同一段音频结果会变:

python3 asr_test.py --audio-file $M/test_wavs/3.wav --tokens $M/tokens.txt --encoder $M/encoder.rknn --decoder $M/decoder.rknn --joiner $M/joiner.rknn --provider rknn --decoding-method modified_beam_search
最终识别结果: 现在时间上 ← 不换这个参数时同一段音频给的是「现在是」

命令 → 函数 → 文件 对账表

格命令 / 参数函数或变量文件
1--audio-fileload_wav_file(只收 16 kHz / 16-bit / 单声道)tools/asr_test.py:165
2(默认)samples_per_chunk = int(0.1 * sample_rate)tools/asr_test.py:240
3--encoder/--decoder/--joinercreate_recognizer 装三个 rknn 模型tools/asr_test.py:299
4--decoding-method / --blank-penalty / --hotwords-*decode_stream + 搜索参数tools/asr_test.py:248
5(自动)get_result + tokens.txt 查表tools/asr_test.py:252
6(自动)is_endpoint / all_segments / tail_paddingtools/asr_test.py:251,258,267

踩坑速查

现象原因怎么办
ValueError: 仅支持 16-bit PCM WAVload_wav_file 只认 16-bit PCM、16 kHz、单声道先转格式:ffmpeg -i in.mp3 -ar 16000 -ac 1 -c:a pcm_s16le out.wav
ModuleNotFoundError: pyaudio本机没装 pyaudio,实时麦克风那条路要它只做文件识别就用 --audio-file;要实时再装 pyaudio
结果只有两三个字音频本身内容/端点切得早/解码方式不同先换一段音频,再换 --decoding-method,最后才怀疑模型
加了热词结果没变热词只在 modified_beam_search 下生效,且词必须在候选里加 --decoding-method modified_beam_search,并确认音频里真说了那个词
换 blank_penalty 没变化这段音频本来就没什么可漏的换一段含糊的音频再试,别用干净样本验证参数
🔁 现象呼应 回到第 03 节表格里 3.wav 那两行:greedy 与 beam 的差别,就是步骤 4 说的“挑路规则”。 同一段音频、同一套模型,只有第 4 格变了。

🧪 Vibe Coding 复盘

想要提示词验收判据
控制流「这段文件识别的代码里,一个 wav 从读到出结果经过几次循环?循环体里调了哪几个方法?」必须点到 accept_waveform / decode_stream / is_endpoint
参数地图「把命令行参数按下图分组:影响延迟的、影响准确率的、影响输入格式的」块大小归延迟;decoding/blank/热词归准确率;tokens/encoder 归格式与模型
边界分析「如果去掉 tail_padding,最可能丢哪几个字?为什么?」答“结尾最后一块”的答案,而不是“随便丢”
AI 常见说法源码实际
“识别器一次性处理整段音频”100 ms 一块循环喂,边喂边出中间结果
“端点触发后结果会重置并丢弃”先存进 all_segments 再清空
“热词是给模型加提示”热词只在 beam search 的搜索阶段加分,不改进模型

🔑 一句话:先跑通、再只改一个参数、再对比 —— 一次改两个参数就无法归因。

06交互:块大小与 beam 的取舍

📌 掌握检查:把 beam 从 1 拖到 4,最终结果会变还是不变?先答出来再拖。

台子里的三个旋钮都对应源码里的真参数。左边两个影响「多快出字」和「多准」, 第三个直接换解码器 —— 结果是取实测的两条真实输出。

互动 6-1 · 流式识别调参台
100
0.25
1
beam=1 就是 greedy。把它拖到 4 以上,同一段 3.wav 的结果会变成另一句。

按这三步试,结论自然出来:

  • 块大小 100 → 20:首字延迟从约 200 ms 降到 40 ms,但每块信息少,中间结果更容易跳变。
  • 块大小 100 → 300:延迟上去,稳定性上去。真实对话里 100 ms 是折中点。
  • beam 1 → 4:结果从 现在是 变成 现在时间上 —— 这就是 04 节第 4 格说的“挑路规则”。

注意:这里的结果切换用的是 3.wav 的两次实测输出,其他参数组合只换算延迟,不编造识别内容。

🔑 一句话:块大小换延迟,beam 换结果,两件事不在同一格。

07验证:三道题回扣三个疑问

Q1(原理)声学模型那一步,直接输出的东西是什么?

文字是第 5 格查表 + 第 4 格挑路之后才出现的。

Q2(实践)load_wav_file 报「仅支持 16-bit PCM」时,最该先做什么?

输入格式不对,后面五格都不会执行。

Q3(边界)同一段 3.wav,greedy 给「现在是」、beam 给「现在时间上」,说明什么?

音源与模型都没动,动的是第 4 格。

✅ 目标对账

① 的目标对应题号
跑通文件识别Q2(输入格式)
说清为什么切块Q1 + 步骤 2
解释两种结果的来源Q3

🔑 一句话:三题分别考“第 3 格吐什么 / 第 1 格吃什么 / 第 4 格怎么挑”。

08总结:参数都在第 2 格和第 5 格

知识点关系表

概念一句话在第几格
PCM把声音按固定频率采样成数字,16 kHz 就是每秒 16000 个点1
chunk按 100 ms 切出来的一块音频2
声学模型给每帧打“像哪个 token”的分数3
blank“这一段没有新字”的特殊符号,可被惩罚3~4
greedy每步只留最高分,快但只看眼前4
beam search保留多条候选路比总分,慢一些但更稳4
热词在 beam 搜索时给指定词加分4
endpoint模型判定一句话说完,触发切段6

三个反直觉

  • 直觉是“模型越准越好”,实际是块大小先卡住延迟,延迟不合格再准也用不上。
  • 直觉是“中间结果错了要重跑”,实际是它会自己修正,等最终结果再判断。
  • 直觉是“热词能提高准确率”,实际是它只在 beam 搜索里加分,对 greedy 完全无效。

注意事项

  • 文件识别也必须走 --tokens,否则词表缺失会直接报错。
  • 17 秒的长音频能出 60 多字,说明链路是通的;短结果优先怀疑音频与参数,别急着重装。
  • 要验证参数效果,换一段“不好识”的音频;干净样本上改什么都没差别。

前置依赖

类别具体缺了会怎样
硬件无强制要求(文件识别不需要麦克风)要实时识别才需阵列
模型~/models/zipformer/:encoder/decoder/joiner + tokens.txt加载即报文件不存在
库sherpa-onnx(1.13.4)+ RKNN 运行时导入失败或 --provider rknn 报错
配置--tokens 必须给;--decoding-method 可选缺 tokens 无法查表出字
环境pyaudio 仅在实时麦克风路径需要文件识别不受影响,实时路径会 ImportError

这套东西用在哪儿

  • 产线语音报工:工人说“A3 完成 20 件”,端点切句后直接写进 MES。
  • 会议转写:长音频流式识别,段落靠端点切分,热词补公司名与产品名。
  • 机器人语音指令:唤醒后一句话即一条指令,识别完立刻进意图解析(EXP-05)。

下一站 EXP-03 是反方向:把文字变回声音,然后你就能让这套系统自己跟自己说话。

🔑 记忆点:块大小换延迟,解码方法换结果;找不到问题时,先动这两个,别动模型。