环境: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 / ensurepipDebian 阉割了 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

六、总结

  1. 原理:KWS = 一个微型流式 Zipformer transducer「语音识别模型」+ 解码端关键词约束;建模单元是拼音,唤醒词用「拼音 token + @中文」描述,数量和内容完全自定义。
  2. 部署:Debian 13 无 pip 要靠 venv --without-pip + get-pip.py;sherpa-onnx 在 aarch64 上要锁 1.10.46;模型从 GitHub kws-models release 拿。
  3. 验证:先用自带 test_wavs + test_keywords.txt 离线确认(8 个关键词全中、背景零误报,7 段音频约 8 秒),再上实时麦克风。
  4. 实时:live_kws.py 用 arecord 拉流 + 流式 KWS,实测「你好小智 / 小智小智」均实时命中。
  5. 下一步:唤醒之后就该接本地 ASR(把说完整句转文字)与 LLM,真正把「离线大脑」串起来。

本文为 Quectel Pi M2 离线语音助手系列实践第二篇:本地中文语音唤醒。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐