本地流式语音识别:波形怎么变成中文
唤醒之后第一件事是听清。这一页把 sherpa-onnx + zipformer 的识别链拆成六格: 切块、提特征、模型打分、解码挑路、查表成字、端点拼句 —— 你能调的所有参数只在前两块和第四块。
01目标:把一段音频变成一行中文
+ 若干 segment
板上实测,最长的那段音频(17.64 秒)识别成这样:
音频时长: 17.64 秒 最终识别结果: 嗯总是准时交代的作业那用一般现在是没有什么感情色彩的承受一个事实上一句话为什么要用现在进行时它的意思并不是说他现在正在交代他
同一段 8.83 秒的音频,换一个解码方法,结果就变了:
---- greedy_search ---- 最终识别结果: 现在是 ---- modified_beam_search ---- 最终识别结果: 现在时间上
学完你该能做出这三件事
- 跑通
asr_test.py --audio-file,看到上面那种「识别中 → 最终结果」的输出。 - 说清 为什么程序要按 100 ms 一块喂给模型,而不是整段一次喂。
- 解释 同一段音频为什么换解码方法会给出不同文字 —— 变的不是音频,是挑路的方式。
🔑 一句话:ASR 不是“把声音翻译成字”,是“在候选字里挑一条最像话的路径”。
02问题:波形跟文字长得一点都不像
一段 1 秒的 16 kHz 音频,是 16000 个浮点数。你要的是一行「小飞同学」。 中间没有任何一一对应的关系 —— 同一个字,不同人、不同语速,波形完全不同。
16000 个数里没有任何“字”的信息,模型到底在算什么?
整段一次喂不是更省事?切成小块图什么?
音频没变、模型没变,只换了解码方法,文字就变了 —— 谁是“变”的那一方?
🔑 一句话:波形没有字,字是模型加解码器一起“猜”出来的。
03现象:七段音频的真实识别结果
一条命令识别一个 wav,终端会先报「音频时长」,然后一边识别一边刷新中间结果,
最后给一段 最终识别结果。下面七行全部是板上真跑的输出。
| 音频 | 时长 | 最终识别结果 |
|---|---|---|
0.wav | 10.05 s | 昨天是 |
1.wav | 5.10 s | 叫呃呃什么意思 |
2.wav | 4.69 s | 这平凡的 |
3.wav(greedy) | 8.83 s | 现在是 |
3.wav(beam) | 8.83 s | 现在时间上 |
4.wav | 17.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
换解码方法对照,只多一个参数:
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原理:切块、打分、挑路、拼句
模型不是“听音写字”,它做的是给每一小段声音打一张“像哪个音”的分数表; 解码器再拿着这张分数表,在候选路径里挑一条最像人话的走法。 六格,一次形态变化一格。
| ② 的三个疑问 | 在 04 的哪几格回答 |
|---|---|
| 疑问 1 波形怎么变字 | 步骤 1、3、5 |
| 疑问 2 为什么要切块 | 步骤 2、6 |
| 疑问 3 为什么结果会变 | 步骤 4 |
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 节可以拖。
3分数表上面到底有什么?
吃什么:特征帧。做什么:模型对每个时间片输出一组分数,其中有一个特殊符号 blank(意思是“这一段没有新字”)。吐什么:每一帧上千个候选分数。
动参数会怎样:blank_penalty 就是这个符号的惩罚项。源码注释写它的作用是
减少漏字;本机默认 0.25(文件头注释提到 0.3)。调大 → 更容易“跳过”某段,可能漏字;
调小 → 更容易挤进多余的字。
4greedy 和 beam 差在哪?
吃什么:分数表。做什么: greedy 每一步只留当前最高分(走一步看一步); modified_beam_search 同时保留多条候选路,最后按总分挑一条,还能给热词加分。 吐什么:一条 token 序列。
实测差别就摆在这儿:同一段 8.83 秒音频,greedy 给 现在是,beam 给 现在时间上。
变的不是音频,是挑路的规则。
5token 怎么变成你能读的汉字?
吃什么:token 序列。做什么:用 tokens.txt 查表,一个 token 对应一个字或一个音节。
吐什么:终端里那行不断刷新的 识别中: …。
注意:中间结果会“推翻重来”。你看到 识别中: 小飞 然后变成 小飞同学,
是模型后面几块补充了信息,不是前一句错了。
6为什么要切段,还补 0.5 秒静音?
吃什么:连续的 token 流。做什么:模型判定一句话说完(endpoint)就把当前结果存进
all_segments 再清空;结尾再补 0.5 秒静音(tail_padding)把最后一句逼出来。
吐什么:segment 列表,最后拼接成 最终识别结果。
🔑 一句话:第 1~2 格管延迟,第 4 格管准不准,第 5~6 格管你怎么看到它。
05实践:三步跑通并改一个参数
- 确认模型三件套 约 5 分钟两个 rknn 加 tokens.txt,缺一个都起不来。
- 识别一段官方音频 约 10 分钟看「音频时长 → 识别中 → 最终结果」三段。
- 只改一个参数再跑 约 15 分钟换
--decoding-method对照结果差异。
1确认模型三件套在
zipformer 需要三个文件 + 一个词表,缺一个都起不来。
ls ~/models/zipformer ls ~/models/zipformer/test_wavs
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
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-file | load_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/--joiner | create_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_padding | tools/asr_test.py:251,258,267 |
踩坑速查
| 现象 | 原因 | 怎么办 |
|---|---|---|
ValueError: 仅支持 16-bit PCM WAV | load_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 没变化 | 这段音频本来就没什么可漏的 | 换一段含糊的音频再试,别用干净样本验证参数 |
🧪 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,最终结果会变还是不变?先答出来再拖。
台子里的三个旋钮都对应源码里的真参数。左边两个影响「多快出字」和「多准」, 第三个直接换解码器 —— 结果是取实测的两条真实输出。
按这三步试,结论自然出来:
- 块大小 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 是反方向:把文字变回声音,然后你就能让这套系统自己跟自己说话。
🔑 记忆点:块大小换延迟,解码方法换结果;找不到问题时,先动这两个,别动模型。