#移远产品体验官 Quectel Pi M2 实战:本地离线中文语音唤醒
环境:Quectel Pi M2(RK3576)/ Debian 13 (trixie) aarch64 / Python 3.13.5
目标:在开发板上跑通完全离线的中文唤醒词检测 —— 说「你好小智」即被本地识别并触发
前言
上一篇把录音和蓝牙放音两条音频链路打通了。这一步往上走一层:让板子「听懂」第一句指令——唤醒词。
唤醒词检测(KWS, Keyword Spotting)是离线语音助手的入口:设备 7×24 小时只听几个固定的词,命中后才唤醒后面的 ASR / LLM / TTS。比起一上来就常驻跑大模型,KWS 模型极小、常驻代价低,是端侧最务实的第一层。
本文用 sherpa-onnx + 中文 zipformer KWS 模型,在一台 Quectel Pi M2 上完成部署与测试,包含原理、完整命令、可复制的推理脚本,以及若干踩坑记录。
一、原理:唤醒词是怎么被「听」出来的
1.1 一句话概括
sherpa-onnx 用的是 icefall 训练的小型流式 Zipformer Transducer 模型,本质上就是一个体积很小的语音识别模型;为了实现唤醒功能,在解码端加了约束:只允许预先定义好的那几串音(关键词)被解出来。
模型结构就是标准的 transducer 三件套:
| 文件 | 作用 |
|---|---|
encoder-*.onnx | 声学编码器,把音频帧编码成隐层表示(本模型 12M,int8 版 4.6M) |
decoder-*.onnx | 预测器,给出当前预测的 token 历史 |
joiner-*.onnx | 把两者拼起来,输出下一个 token 的概率 |
tokens.txt | 建模单元词表 |
1.2 关键点:建模单元是「拼音」
这个模型不是按汉字建模,而是按**拼音(声母 + 韵母)**建模。看 tokens.txt 就知道:
<blk> 0
<sos/eos> 1
<unk> 2
A 3
B 4
S 5
I 6
f 7
ù 8
ǔ 9
zh 10
y 11
ī 12
...
所以「小爱同学」这个唤醒词,喂给模型的是这一串 token:
x iǎo ài t óng x ué @小爱同学
1.3 唤醒词文件格式(keywords.txt)
每行一个唤醒词,格式是:拼音 token 序列(空格分隔) + @ + 中文显示文本。
模型自带 keywords.txt 示例:
n ǐ h ǎo j ūn g ē @你好军哥
d àn g ē d àn g ē @蛋哥蛋哥
x iǎo ài t óng x ué @小爱同学
n ǐ h ǎo w èn w èn @你好问问
x iǎo y ì x iǎo y ì @小艺小艺
x iǎo m ǐ x iǎo m ǐ @小米小米
l ín m ěi l ì @林美丽
n ǐ h ǎo x ī x ī @你好西西
唤醒词数量不限、可自定义,代价是每个词要单独调参数。
1.4 两个调参旋钮
keywords_score(默认 1.0):关键词的加权分数,越大越「执着」;keywords_threshold(默认 0.25):命中阈值,调低更容易唤醒(也更容易误唤醒),调高更严格。
二、部署过程
2.1 环境现状
python3 -V # Python 3.13.5
python3 -m pip -V # No module named pip ← 板端没有 pip
Debian 13 既没有预装 python3-pip,也把 ensurepip 阉割掉了(No module named ensurepip),标准做法是:
python3 -m venv --without-pip /userdata/rkllm/venv # 先建一个不带 pip 的 venv
curl -sSL https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py
/userdata/rkllm/venv/bin/python /tmp/get-pip.py # 往 venv 里装 pip
装完得到 pip 26.2.1。
说明:把环境全部放在
/userdata下,不碰系统目录,避免污染 rootfs、也方便日后整目录迁移。
2.2 安装 sherpa-onnx
关键坑:版本必须锁。 当时 PyPI 上 sherpa-onnx 已经出到 1.13.x,但 aarch64 的 manylinux 轮子只发布到 1.10.46,更新的版本在 aarch64 + cp313 下没有可用 wheel:
# 查一下有哪些 cp313 + aarch64 轮子
curl -s https://pypi.org/simple/sherpa-onnx/ \
| grep -o "sherpa_onnx-1\.[0-9.]*-cp313-cp313-manylinux_2_17_aarch64[^<]*whl" \
| sort -u -V | tail
所以直接锁安装:
/userdata/rkllm/venv/bin/python -m pip install \
-i https://mirrors.aliyun.com/pypi/simple/ \
"sherpa-onnx==1.10.46" numpy soundfile
结果:
Successfully installed cffi-2.1.1 numpy-2.5.4 pycparser-3.11 sherpa-onnx-1.10.46 soundfile-0.14.0 typing-extensions-4.16.0
校验一下 Python API 是否带关键词检测:
/userdata/rkllm/venv/bin/python -c \
"import sherpa_onnx as s; print(hasattr(s,'KeywordSpotter'))" # True
2.3 下载中文唤醒模型
模型在 GitHub sherpa-onnx 仓库的 kws-models release 里(HuggingFace 镜像上部分路径会 404,直接用 GitHub release 更稳):
MODEL=sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01
curl -L --retry 3 -o /userdata/rkllm/kws.tar.bz2 \
"https://github.com/k2-fsa/sherpa-onnx/releases/download/kws-models/${MODEL}.tar.bz2"
cd /userdata/rkllm && tar xjf kws.tar.bz2
模型信息(README):
- 训练数据:WenetSpeech L(~10000 小时)
- 模型大小:约 3.3M 参数,zipformer
- 建模单元:拼音(声母 + 韵母)
- 支持自定义唤醒词,数量不限
解压后目录:
README.md configuration.json tokens.txt keywords.txt keywords_raw.txt
encoder/decoder/joiner-epoch-12-avg-2-chunk-16-left-64.onnx (+ .int8.onnx)
encoder/decoder/joiner-epoch-99-avg-1-chunk-16-left-64.onnx (+ .int8.onnx)
test_wavs/
三、离线验证:先用测试音频确认模型能「听懂」
3.1 坑:test_wavs 配的是另一套关键词
模型自带的 keywords.txt(你好军哥/小爱同学…)不是 test_wavs 的标注。test_wavs/ 目录里另有一份 test_keywords.txt:
w én s ēn t è k ǎ s uǒ @文森特卡索
zh ōu w àng j ūn @周望军
zh ū l ì n án @朱丽楠
j iǎng y ǒu b ó @蒋友伯
n ǚ ér @女儿
f ǎ g uó @法国
j iàn m iàn h uì @见面会
l uò sh í @落实
跑测试音频时必须把这套当关键词文件,否则会全部「无命中」,白折腾半天。
3.2 推理脚本 kws_test.py
"""sherpa-onnx 中文关键词唤醒(KWS)离线测试。"""
import glob, os, sys, wave
import numpy as np
import sherpa_onnx
MODEL = "/userdata/rkllm/sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01"
EPOCH = "epoch-12-avg-2-chunk-16-left-64"
SR = 16000
def load_wav(path):
with wave.open(path, "rb") as f:
sr, ch, data = f.getframerate(), f.getnchannels(), f.readframes(f.getnframes())
x = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
if ch > 1:
x = x.reshape(-1, ch).mean(axis=1)
if sr != SR:
raise SystemExit("采样率不是 %d: %s (%d)" % (SR, path, sr))
return x
def main():
kws = sherpa_onnx.KeywordSpotter(
tokens=os.path.join(MODEL, "tokens.txt"),
encoder=os.path.join(MODEL, "encoder-%s.onnx" % EPOCH),
decoder=os.path.join(MODEL, "decoder-%s.onnx" % EPOCH),
joiner=os.path.join(MODEL, "joiner-%s.onnx" % EPOCH),
num_threads=4,
keywords_file=os.environ.get("KWS_KEYWORDS", os.path.join(MODEL, "keywords.txt")),
keywords_score=float(os.environ.get("KWS_SCORE", "1.0")),
keywords_threshold=float(os.environ.get("KWS_THRESHOLD", "0.25")),
provider="cpu",
)
wavs = sys.argv[1:] or sorted(glob.glob(os.path.join(MODEL, "test_wavs", "*.wav")))
for wav in wavs:
stream = kws.create_stream()
stream.accept_waveform(SR, load_wav(wav))
stream.input_finished()
hits = []
while kws.is_ready(stream):
kws.decode_stream(stream)
r = kws.get_result(stream)
if r:
hits.append(r)
print("%-28s -> %s" % (os.path.basename(wav), hits if hits else "无命中"))
if __name__ == "__main__":
main()
用测试音频 + 对应关键词文件跑:
KWS_KEYWORDS=$MODEL/test_wavs/test_keywords.txt \
/userdata/rkllm/venv/bin/python /userdata/rkllm/kws_test.py
实测结果:
3.wav -> ['文森特卡索', '法国']
4.wav -> ['蒋友伯', '女儿']
5.wav -> ['周望军', '落实']
6.wav -> ['朱丽楠', '见面会']
0.wav -> 无命中
1.wav -> 无命中
2.wav -> 无命中
7 段音频里,8 个关键词全部命中;另外 3 段是纯背景音频,零误报。7 段音频(含模型加载)总耗时约 8 秒,CPU 上跑毫无压力。
四、自定义唤醒词 + 实时麦克风监听
4.1 定义自己的唤醒词
新建 /userdata/rkllm/keywords_my.txt:
n ǐ h ǎo x iǎo zh ì @你好小智
x iǎo zh ì x iǎo zh ì @小智小智
注意:每个拼音 token 都必须是
tokens.txt里存在的,否则加载直接报错。写之前可以先查一下:
grep -c "^n " tokens.txt、grep -c "^iǎo " tokens.txt…
4.2 实时监听脚本 live_kws.py
用 arecord 从板载麦克风拉 16k 单声道 PCM,每 0.1 秒喂一段给 KWS,命中即打印:
"""实时麦克风中文唤醒词监听(sherpa-onnx KWS)。"""
import os, subprocess
import numpy as np
import sherpa_onnx
MODEL = "/userdata/rkllm/sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01"
EPOCH = "epoch-12-avg-2-chunk-16-left-64"
SR = 16000
KEYWORDS = os.environ.get("KWS_KEYWORDS", "/userdata/rkllm/keywords_my.txt")
THRESHOLD = float(os.environ.get("KWS_THRESHOLD", "0.25"))
kws = sherpa_onnx.KeywordSpotter(
tokens=os.path.join(MODEL, "tokens.txt"),
encoder=os.path.join(MODEL, "encoder-%s.onnx" % EPOCH),
decoder=os.path.join(MODEL, "decoder-%s.onnx" % EPOCH),
joiner=os.path.join(MODEL, "joiner-%s.onnx" % EPOCH),
num_threads=2,
keywords_file=KEYWORDS,
keywords_score=1.0,
keywords_threshold=THRESHOLD,
provider="cpu",
)
proc = subprocess.Popen(
["arecord", "-D", "default", "-f", "S16_LE", "-r", str(SR),
"-c", "1", "-t", "raw", "-q"],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
)
print("唤醒词文件: %s (阈值 %.2f)" % (KEYWORDS, THRESHOLD))
print("开始监听麦克风,请说唤醒词… (Ctrl-C 退出)", flush=True)
stream = kws.create_stream()
try:
while True:
data = proc.stdout.read(3200) # 0.1 秒 @16k 单声道
if not data:
print("录音流中断", flush=True)
break
samples = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
stream.accept_waveform(SR, samples)
while kws.is_ready(stream):
kws.decode_stream(stream)
r = kws.get_result(stream)
if r:
print(">>> 唤醒命中: %s" % r, flush=True)
except KeyboardInterrupt:
print("\n已退出")
finally:
proc.terminate()
4.3 实测
/userdata/rkllm/venv/bin/python /userdata/rkllm/live_kws.py
唤醒词文件: /userdata/rkllm/keywords_my.txt (阈值 0.25)
开始监听麦克风,请说唤醒词… (Ctrl-C 退出)
>>> 唤醒命中: 你好小智
>>> 唤醒命中: 小智小智
^C
已退出
对着板载麦克风说「你好小智」「小智小智」,两个唤醒词都被实时命中,整条「麦克风 → 本地模型 → 唤醒」链路打通。
五、排查与调参速查
| 现象 | 原因 / 处理 |
|---|---|
No module named pip / ensurepip | Debian 阉割了 pip,用 venv --without-pip + get-pip.py |
| 装 sherpa-onnx 报找不到匹配版本 | 新版无 aarch64 轮子,锁 1.10.46 |
| 模型下载 404 | 别用 HF 镜像路径,走 GitHub release kws-models |
| 测试音频全部「无命中」 | 用错了关键词文件,test_wavs 要配 test_keywords.txt |
| 加载唤醒词即报错 | 某个拼音 token 不在 tokens.txt 里 |
| 老误唤醒 | 调高 keywords_threshold |
| 喊了没反应 | 调低阈值;并检查麦克风增益(见上一篇音频测试) |
| 声音太小/太爆 | 调 ES8389 的 ADC 采集增益,ASR/KWS 建议 50%~60% 并关 ALC |
六、总结
- 原理:KWS = 一个微型流式 Zipformer transducer「语音识别模型」+ 解码端关键词约束;建模单元是拼音,唤醒词用「拼音 token + @中文」描述,数量和内容完全自定义。
- 部署:Debian 13 无 pip 要靠
venv --without-pip+get-pip.py;sherpa-onnx 在 aarch64 上要锁 1.10.46;模型从 GitHubkws-modelsrelease 拿。 - 验证:先用自带
test_wavs+test_keywords.txt离线确认(8 个关键词全中、背景零误报,7 段音频约 8 秒),再上实时麦克风。 - 实时:
live_kws.py用arecord拉流 + 流式 KWS,实测「你好小智 / 小智小智」均实时命中。 - 下一步:唤醒之后就该接本地 ASR(把说完整句转文字)与 LLM,真正把「离线大脑」串起来。
本文为 Quectel Pi M2 离线语音助手系列实践第二篇:本地中文语音唤醒。
更多推荐




所有评论(0)