树莓派吃灰率最高的项目,我猜是智能音箱。硬件有、Linux 也有,可每次想让它干活,都要先连上云端、等响应、再等 TTS 回话,网络一抖整个流程就瘫了。今天分享一个我用 sherpa-onnx 在树莓派 4B 上搭的离线语音助手,从唤醒、识别到语音回复全部在本地跑,不需要网络,不依赖任何云平台,整套代码放在文里,照着抄就能用。
先说实话:这个东西不是 Siri,也不是小爱同学。它是一个“规则明确的本地语音开关”,适合做定时提醒、控制 GPIO 外设、查询本机状态这类轻量任务。但它的核心价值非常清楚:完全离线、隐私不出门、延迟可控。树莓派 4B 这种性能水平的板子,跑 sherpa-onnx 的唤醒词检测、离线识别和本地 TTS 完全够用。文章末尾我会把 5 分钟快速跑通的时间线和完整代码都放出来,项目适合树莓派玩家、嵌入式开发者,也适合毕设想找“软硬结合”题目的同学。
1. 项目定位与整体方案拆解
1.1 sherpa-onnx 是什么,为什么值得选
sherpa-onnx 是 k2 生态出来的一个跨平台推理库,专门跑语音相关模型,支持关键词唤醒(KWS)、语音识别(ASR)、语音合成(TTS)、VAD 等。它的模型大多被转成 ONNX 格式,所以部署起来不挑框架,Python、C++、Android、iOS 都有绑定。对于树莓派这种 ARM 平台来说,直接用 Python API 是最省事的路子。
选 sherpa-onnx 而不是其他语音方案,主要是三个原因:
- 离线推理是原生设计,模型文件在本地,不需要联网鉴权。
- 模型体积控制得比较好,KWS 模型可以做到几 MB 到几十 MB,TTS 和 ASR 模型也有一批适合边缘设备的中小型选择。
- 社区活跃,模型仓库里面中文 ASR、中文 TTS 都有现成的,不用自己训练。
对比一下,如果走云端 API,每次交互都有网络延迟,而且断网就废;如果自己训练模型,数据、算力、时间成本都太高。sherpa-onnx 正好卡在“能离线跑”和“开箱即用”之间,对个人项目和毕设都非常友好。
1.2 树莓派方案的取舍与功能边界
树莓派在这个项目里的角色是“边缘主机”。它内存和算力有限,但足够跑推理,而且带 GPIO、USB、音频口,可以往外接设备。我不建议用树莓派 Pico 或 ESP32 这类单片机来做,因为语音模型以 ONNX 形式加载后需要几十 MB 到几百 MB 内存,单片机根本塞不下。
功能边界先说清楚,免得后面踩坑才发现方向不对。这套离线语音助手能做的事:
- 本地唤醒词触发,比如“你好小智”。
- 唤醒后录制一段语音,离线识别成文字。
- 通过简单规则匹配意图,执行本地命令。
- 用本地 TTS 合成中文语音并播报结果。
- 通过 GPIO 控制 LED、继电器等外设。
它不能做的事也很明显:不能理解复杂对话,不能做知识问答,方言适配要看模型本身。不过这些不影响它的核心价值——它给了一个可靠的、离线的“语音控制入口”,你可以在它上面叠更多自己的自动化逻辑。
2. 环境准备与硬件搭配
2.1 硬件清单与系统镜像选择
先列一份我实际用到的硬件清单,照这个买不会错:
| 部件 | 推荐规格 | 说明 |
|---|---|---|
| 树莓派 | 4B,2GB 起步,4GB 更稳 | 2GB 能跑,但编译和缓存空间紧张 |
| TF 卡 | 32GB 以上,A1 或 A2 速度等级 | 模型文件加起来不到 1GB,但系统更新需要空间 |
| USB 麦克风 | 带降噪的会议麦或桌面麦 | 别买那种几块钱的免驱声卡,底噪太大 |
| 音箱 | 3.5mm 小音箱或 USB 音箱 | 用板载 3.5mm 口音质一般,能听清就行 |
| 电源 | 官方 5V 3A 电源 | 供电不足会导致 USB 麦克风随机掉线 |
系统镜像我用的是 Raspberry Pi OS Lite(64 位),也就是无桌面版。很多人喜欢装完整版桌面,但在树莓派上跑服务型项目,无桌面版省内存、少干扰,开机自启也更干净。如果你手里是 Ubuntu 22.04 或 24.04,也可以跑通,只是音频设备命名和 ALSA 配置会和官方系统略有差异。
第一次开机后先做常规配置:设置时区、开 SSH、更新系统。这里提醒一句,不要一上来就装桌面,后面所有操作都用 SSH 连到板子上,调试效率会高很多。
2.2 音频采集设备与调试
语音助手的输入端是麦克风,输出端是音箱,这两个设备在树莓派上经常出问题。别急着写 Python 代码,先把音频链路调通。
插上 USB 麦克风后,用两条命令确认设备是否被识别:
arecord -l aplay -l如果都能看到设备,说明驱动没问题。接下来要做的,是让树莓派默认使用 USB 麦克风采集、默认音箱播放。可以编辑~/.asoundrc文件,内容类似这样:
pcm.!default { type asym capture.pcm "hw:1,0" playback.pcm "hw:0,0" }注意hw:1,0和hw:0,0要按你arecord -l和aplay -l看到的实际 card 编号来改,不要照抄。写完后用下面两条命令做一轮录音回放测试:
arecord -d 5 -f S16_LE -r 16000 -c 1 test.wav aplay test.wav能听到自己的声音,说明音频链路没问题。这时候可以用alsamixer把麦克风输入音量调高一些,通常建议 70% 到 90%,太高容易破音,太低唤醒词检测不到。这一步很多人忽略,结果后面 KWS 一直不触发,排查半天发现是麦克风增益不够。
2.3 安装 sherpa-onnx 运行时与依赖
树莓派上的 Python 环境建议用虚拟环境管理,避免系统级的 Python 包冲突。下面的命令创建虚拟环境并安装核心依赖:
python3 -m venv ~/vaenv source ~/vaenv/bin/activate pip install --upgrade pip pip install sherpa-onnx sounddevice numpysherpa-onnx是核心推理库,sounddevice负责实时采集麦克风音频,numpy用来处理音频数据。如果pip install sherpa-onnx比较慢,可以先换国内镜像源再装。安装完成后,在 Python 里执行import sherpa_onnx,不报错就说明环境正常。
提示:如果你的树莓派网络环境不太好,也可以从 sherpa-onnx 项目的 Release 页面下载预先编译好的 wheel 包,再用
pip install 下载的文件.whl安装。安装过程中如果提示缺libsndfile、libatlas这类依赖,用apt install补上即可。
3. 完整代码实现与部署流程
3.1 整体工作流与代码结构
整个语音助手的逻辑可以用一句话概括:音频块持续送入唤醒词检测,检测到唤醒后录音 N 秒,再把这段录音送进离线识别器得到文字,经过规则匹配生成回复文本,最后用本地 TTS 合成语音并播放。
这个流程里有三个独立的模型组件:KWS 唤醒词模型、ASR 语音识别模型、TTS 语音合成模型。它们各自独立加载,互不影响。代码结构如下:
offline-voice-assistant/ ├── main.py ├── setup.sh └── va.servicemain.py是主程序,setup.sh是一键部署脚本,va.service是 systemd 服务文件,用来开机自启。下面我把main.py拆开讲。
3.2 唤醒词检测的接入与调参
唤醒词检测用的是 sherpa-onnx 的KeywordSpotter。它做的事情是对输入音频流持续打分,当某个预置关键词的置信度达到阈值时,就返回触发结果。模型文件建议选中文友好或者多语种支持的 KWS 模型,下载时看模型说明,别选纯英文数据的模型,那样对中文唤醒词不敏感。
核心代码段:
import wave import time import numpy as np import sounddevice as sd import sherpa_onnx SAMPLE_RATE = 16000 BLOCK_SIZE = 2048 RECORD_SECONDS = 4 MODEL_DIR = "/home/pi/sherpa-onnx-models" KWS_DIR = f"{MODEL_DIR}/kws" ASR_DIR = f"{MODEL_DIR}/asr" TTS_DIR = f"{MODEL_DIR}/tts" def init_kws(): cfg = sherpa_onnx.KeywordSpotterConfig( model=sherpa_onnx.KeywordSpotterModelConfig( tokens=f"{KWS_DIR}/tokens.txt", keywords=f"{KWS_DIR}/keywords.txt", encoder=f"{KWS_DIR}/encoder.onnx", decoder=f"{KWS_DIR}/decoder.onnx", joiner=f"{KWS_DIR}/joiner.onnx", num_threads=2, ), keywords_file=f"{KWS_DIR}/keywords.txt", max_active_paths=4, ) return sherpa_onnx.KeywordSpotter(cfg)keywords.txt文件每行一个唤醒词,比如:
你好小智 hello xiaozhiKWS 模型的keywords.txt有的版本还支持自定义阈值,比如你好小智:1.5,阈值越高越不容易误触发,但也会更不灵敏。这个值需要根据实际环境调,我的建议是:家里比较安静就默认,环境嘈杂就把阈值往上提一点。
唤醒监听的循环逻辑:
def wait_for_wake_word(spotter): stream = spotter.create_stream() with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, dtype="float32", blocksize=BLOCK_SIZE) as s: while True: block, _ = s.read(BLOCK_SIZE) stream.accept_waveform(block[:, 0], SAMPLE_RATE) while spotter.is_ready(stream): spotter.decode(stream) if spotter.is_detected(stream): result = spotter.get_result(stream) print(f"[唤醒] {result.keyword}") return这里的关键是,音频块要持续送入同一个 stream,不能每块重新建流。KWS 模型的上下文信息保留在 stream 内部,如果频繁重建,前面的音频信息就丢了,唤醒词后半句很容易漏检测。
3.3 语音识别与意图处理的实现
唤醒后进入正式指令采集。我设置的录音时长是 4 秒,录完直接送进离线识别器。如果你习惯说长句,可以改成 5 秒,但录音时间越长,等待识别的延迟也越高。
识别模型我推荐sherpa-onnx-paraformer-zh系列,中文识别效果在边缘设备里表现很好,模型体积也不大。
def init_asr(): return sherpa_onnx.OfflineRecognizer.from_paraformer( model=f"{ASR_DIR}/model.onnx", tokens=f"{ASR_DIR}/tokens.txt", num_threads=2, ) def record_audio(duration): frames = [] with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, dtype="float32", blocksize=BLOCK_SIZE) as s: for _ in range(int(duration * SAMPLE_RATE / BLOCK_SIZE)): block, _ = s.read(BLOCK_SIZE) frames.append(block[:, 0].copy()) return np.concatenate(frames) def recognize(recognizer, audio): stream = recognizer.create_stream() stream.accept_waveform(audio, SAMPLE_RATE) recognizer.decode_stream(stream) return stream.result.text.strip()识别得到文字后,进入意图处理环节。这里不搞复杂 NLP,就用关键词匹配,简单直接。
def build_reply(text): if any(k in text for k in ["关灯", "关闭灯", "把灯关了"]): return "好的,关灯。" if any(k in text for k in ["开灯", "打开灯", "把灯打开"]): return "好的,开灯。" if "时间" in text: now = time.localtime() return f"现在是 {now.tm_hour} 点 {now.tm_min} 分" if any(k in text for k in ["退出", "再见", "休息"]): return "好的,有需要再叫我。" return "我在,不过这个指令我还没有学会。"如果你要接 GPIO,可以在build_reply返回回复文本的同时,把控制动作作为副作用执行。比如用gpiozero控制 LED,代码可以写成:
try: from gpiozero import LED led = LED(17) except ImportError: led = None def execute_action(text): if led is None: return if "开灯" in text: led.on() elif "关灯" in text: led.off()这里先不把 GPIO 的逻辑混进回复生成的函数里,保持两个函数各司其职,后续扩展其他设备时结构更清晰。
3.4 本地语音合成与播报
语音合成用的是 sherpa-onnx 的OfflineTts。中文模型里,sherpa-onnx-vits-zh-hf-fanchen是比较省事的选择,只需要 model 和 tokens 两个文件,不需要额外的词典和 FST 规则。
def init_tts(): cfg = sherpa_onnx.OfflineTtsConfig( model=sherpa_onnx.OfflineTtsModelConfig( vits=sherpa_onnx.OfflineTtsVitsModelConfig( model=f"{TTS_DIR}/model.onnx", tokens=f"{TTS_DIR}/tokens.txt", ), num_threads=2, ), ) return sherpa_onnx.OfflineTts(cfg) def speak(tts, text): result = tts.generate(text, sid=0, speed=1.0) sd.play(result.samples, result.sample_rate) sd.wait()sid=0表示使用模型里的第一个说话人。如果模型支持多说话人,可以改这个下标。speed=1.0是正常语速,树莓派 4B 上生成一句话大约需要几秒,这时候不用太追求速度,稳定更重要。
主循环把这些串起来:
def main(): print("加载模型...") kws = init_kws() asr = init_asr() tts = init_tts() print("语音助手已就绪,说唤醒词试试。") while True: wait_for_wake_word(kws) audio = record_audio(RECORD_SECONDS) text = recognize(asr, audio) print(f"[识别] {text}") if not text: speak(tts, "没有听清,请再说一次。") continue reply = build_reply(text) print(f"[回复] {reply}") speak(tts, reply) if __name__ == "__main__": main()整个循环的逻辑是:监听唤醒词,唤醒后录音识别,处理完再回到监听状态。这是最自然的语音交互方式,比一直开着识别省电,也能避免误触发。
3.5 5分钟跑通与开机自启
标题说 5 分钟搞定,这里我把时间线说明白。假设板子系统已经装好,代码已经拷到树莓派上,那么流程是:建虚拟环境装依赖约 1 分钟,下载模型文件约 2 到 5 分钟(看网络速度),运行 Python 脚本进入监听状态约 10 秒。整个过程在 5 到 8 分钟之间。
为了减少手工操作,我写了一个setup.sh:
#!/bin/bash set -e sudo apt update sudo apt install -y python3-pip python3-venv libsndfile1 libatlas3-base python3 -m venv ~/vaenv source ~/vaenv/bin/activate pip install --upgrade pip pip install sherpa-onnx sounddevice numpy mkdir -p ~/sherpa-onnx-models/{kws,asr,tts} echo "请把训练好的 KWS、ASR、TTS 模型文件放到对应目录" echo "然后运行: python main.py"模型文件需要你从 sherpa-onnx 的模型列表中手动下载。下载哪个模型我在前面的章节已经说过,KWS 挑中文友好的,ASR 用 paraformer-zh,TTS 用 vits-zh-hf-fanchen。放到对应目录后,保持文件路径和 main.py 里的一致。
开机自启用 systemd 服务,新建va.service:
[Unit] Description=Offline Voice Assistant After=network-online.target sound.target [Service] ExecStart=/home/pi/vaenv/bin/python /home/pi/offline-voice-assistant/main.py WorkingDirectory=/home/pi/offline-voice-assistant Restart=always User=pi [Install] WantedBy=multi-user.target然后把服务文件放到/etc/systemd/system/va.service,执行:
sudo systemctl daemon-reload sudo systemctl enable va.service sudo systemctl start va.service这样树莓派上电后会自动启动语音助手。调试时先用journalctl -u va.service -f看日志,不要盲改代码。
4. 常见问题与排查技巧实录
4.1 高频问题速查表
我实际调试过程中踩过的坑,整理成一张表,按现象排,后面再展开讲几个重要的:
| 问题现象 | 常见原因 | 解决办法 |
|---|---|---|
arecord -l没有设备 | USB 麦克风供电不足或没插好 | 换 USB 口,用带供电的 HUB,检查lsusb |
| 唤醒词一直不触发 | 麦克风音量太低或模型不支持中文 | alsamixer调高输入增益,换成中文 KWS 模型 |
| 识别结果乱码 | 模型和 tokens 文件不匹配 | 检查 ASR 模型目录,tokens 必须和模型配套 |
| TTS 没有声音 | 默认输出设备不对或音量是 0 | 用aplay -l查设备,改.asoundrc,调alsamixer |
| CPU 占用接近 100% | 线程数设置过高或模型过大 | 降低num_threads,换更小的 ASR 模型 |
| systemd 启动后进程退出 | 模型路径不对或虚拟环境路径错误 | journalctl -u va.service看日志,确认路径 |
4.2 准确率优化与模型替换
如果你发现识别准确率不够,优先查三件事。第一,麦克风摆放位置和增益,麦克风离嘴越近识别越好,这个比换模型效果更明显。第二,录音时长,4 秒可能截断长句,可以改成 5 秒,但延迟会增加。第三,是否做了增益归一化,sounddevice返回的 float32 数据理论上已经在 [-1, 1] 区间,不需要额外归一化,但如果音频是从 wav 文件读的 int16,要除以 32767 才能送进模型。
模型替换方面,ASR 从 paraformer 换成 zipformer 系列时,代码要改成sherpa_onnx.OfflineRecognizer.from_zipformer(...),同时传入 encoder、decoder、joiner 三个文件。TTS 换成其他 VITS 模型时,注意有些模型还需要 lexicon 和 dict 目录,路径少了会直接报错。
提示:下载模型后不要重命名文件,保持模型目录里的原始文件名。因为很多 ONNX 模型内部的输入输出张量名和文件名无关,但是 sherpa-onnx 的一些工具脚本会依赖默认文件名查找文件,乱改名容易给自己挖坑。
4.3 性能调优与后续扩展
树莓派 4B 跑这套方案,我个人实际观察到的情况是:KWS 空转时 CPU 占用大约 15% 到 25%,ASR 识别 4 秒音频大约需要 1 到 2 秒,TTS 合成一句 10 个字的回复大约需要 1 秒。整体交互延迟在 3 到 4 秒左右,完全可以接受。如果觉得慢,可以先加载模型做一次空推理再进入主循环,让 CPU 频率稳定在高位。
线程数不建议盲目调高。树莓派 4B 是四核,KWS、ASR、TTS 各分配 2 个线程,系统负载已经比较均衡。把线程数调成 4 反而可能因为 CPU 争抢导致延迟上升。
扩展方向上,这套语音助手很适合作为智能家居的本地语音入口。你可以把意图匹配从关键词改成更灵活的规则引擎,比如接入 MQTT 控制家里其他设备;也可以在 GPIO 上接继电器控制风扇、窗帘;甚至可以把 TTS 换成其他音色模型,让回复更有辨识度。如果你手里是树莓派 5,内存和算力都更好,直接加大模型、降低线程数,体验会再上一个台阶。
最后再分享一点个人经验
这个项目我做完之后,最深的感受是:语音助手的难点不在模型推理,而在音频链路和交互细节。模型用 sherpa-onnx 几乎是开箱即用,但麦克风增益、默认声卡配置、唤醒词阈值这些看起来不起眼的地方,才是真正消耗时间的地方。所以我强烈建议你先单独跑通录音回放测试,再逐步接入模型,不要一上来就全链路调试,否则出了问题你根本不知道是设备问题还是代码问题。
另外一个很有用的小技巧:调试阶段不要开着 systemd 服务跑,直接在前台运行 Python 脚本,每个阶段加print打印关键信息。等所有环节都确认正常了,再放到服务里跑。日志是最好的朋友,比反复测试麦克风靠谱得多。等你把这一套流程跑顺,再回头看看整个项目,会发现一个几十块钱的 USB 麦克风加一块树莓派,就能拥有一个永远在线、不依赖外网、随叫随到的本地语音助手,这种掌控感,是云端方案给不了的。