7,832
社区成员
发帖
与我相关
我的任务
分享本文记录一个在 QCS8550 开发板上跑通的端侧语音助手:科大讯飞 M260C 环形麦克风负责采集语音,Sherpa-ONNX 在 CPU 上完成中文识别与语音合成,Qwen3-VL-8B 通过 QNN248 在 NPU 上常驻运行。整条交互链不调用云端 ASR、LLM 或 TTS 接口。诚然这个链路还有很多问题,比如说识别不够精准,会出现断尾,语音处理过慢等,希望能得到大家的指导。
一条可以连续使用的本地语音链路:
实际终端交互类似这样:
=== M260C 本地语音助手已就绪 ===
音频输入:M260C / 16000 Hz
识别器:Sherpa-ONNX 本地模型
对话模型:Qwen3-VL-8B / QCS8550 NPU
语音播报:Sherpa-ONNX 本地中英双语
转发模式:人工确认(回车/y 发送,n 跳过,其他文字作为修正版)
录音中......
语音识别中......
识别结果:介绍一下端侧人工智能的优势
转发给模型?[Y/n/直接输入修改]
Qwen:……
语音播报中……
回答完毕
本项目做了简单的异构分工:
| 模块 | 执行位置 | 本项目实现 |
|---|---|---|
| 音频采集 | Linux 音频服务/CPU | PulseAudio parec |
| ASR | CPU | Sherpa-ONNX Online Transducer |
| LLM | QCS8550 NPU | Qwen3-VL-8B、AidGen/QNN248 |
| TTS | CPU | Sherpa-ONNX VITS |
| 流程协调 | CPU | Python 子进程、线程、队列和事件 |
Qualcomm 官方资料说明,QAIRT/QNN 可以面向 CPU、GPU、NPU 等不同执行单元;在这个项目中,使用 QNN248/NPU 的是 Qwen,ASR 与 TTS 运行在 CPU 上。Qualcomm On-Device AI Development
完整数据流如下:
┌──────────────────────┐
│ 科大讯飞 M260C 六麦阵列 │
└──────────┬───────────┘
│ USB Audio
▼
┌──────────────────────┐
│ PulseAudio / parec │
│ 16 kHz / Mono / S16LE │
└──────────┬───────────┘
▼
┌──────────────────────┐
│ Sherpa-ONNX ASR │ CPU
│ 最终整句文本 │
└──────────┬───────────┘
│ 自动转发或人工修正
▼
┌──────────────────────┐
│ Qwen3-VL-8B │ QCS8550 NPU
│ AidGen / QNN248 │
└──────────┬───────────┘
▼
┌──────────────────────┐
│ Markdown/代码清洗 │
└──────────┬───────────┘
▼
┌──────────────────────┐
│ Sherpa-ONNX VITS TTS │ CPU
└──────────┬───────────┘
▼
┌──────────────────────┐
│ PulseAudio / paplay │ → USB 扬声器
└──────────────────────┘
Sherpa-ONNX 官方文档明确支持完全本地的语音识别,并提供流式 ASR、非流式 ASR 和离线 TTS 等接口,适合 ARM64 Linux 上的嵌入式组合。Sherpa-ONNX 文档
项目保持为两个主要入口,没有引入 Web 服务或额外框架:
m260c-asr/
├── m260c_asr.py # 独立 ASR 与音频采集
├── m260c-chat # ASR、Qwen、TTS 编排
├── test_m260c_chat.py # 最小关键行为检查
├── model/ # ASR 模型
└── vits-melo-tts-zh_en/ # TTS 模型
本次环境包括:
Python 和音频工具的最小准备示例:
sudo apt install pulseaudio-utils
python3 -m venv .venv
. .venv/bin/activate
python3 -m pip install numpy sherpa-onnx
模型文件需要分别按照其来源和许可证准备。ASR 目录包含:
model/
├── encoder.int8.onnx
├── decoder.int8.onnx
├── joiner.int8.onnx
└── tokens.txt
TTS 目录至少包含:
vits-melo-tts-zh_en/
├── model.onnx
├── lexicon.txt
├── tokens.txt
├── date.fst
└── number.fst
笔者在本文不展开 Qwen3-VL-8B 的模型部署过程。语音助手只要求有一个能够“加载一次、循环读取标准输入、连续输出回答”的本地聊天入口。当前项目通过 CHAT 常量调用之前部署模型时已经验证的 chat.sh,迁移到其他环境时应改成自己的实际路径。
出现录音问题时,不要一开始就安装或重装驱动。先分层检查:
cat /proc/asound/cards
arecord -l
pactl list short sources
如果 /proc/asound/cards 和 arecord -l 能看到 M260C,说明 USB Audio 和 ALSA 枚举已经工作;如果 pactl list short sources 也能看到对应 source,则 PulseAudio 已经接管它。
M260C 在不同资料包或固件版本中可能显示为不同名称。当前实现匹配这些已见过的标识:
M260C_NAMES = (
"xfmdpv",
"xfm-dp",
"l6microphone",
"listengo",
"circular_6-microphone",
)
程序优先查找 PulseAudio source;系统没有 PulseAudio 或没有匹配 source 时,才回退到 ALSA:
PulseAudio source 找到 → 使用 parec
│
└─ 未找到 → 从 /proc/asound/cards 匹配 ALSA 设备
│
└─ 仍未找到 → 要求 --device 手动指定
这比写死 card 0 或 card 2 更可靠,因为 USB 声卡编号会在插拔和启动后发生变化。
本项目容易被误判成“缺驱动”的错误是:
Device or resource busy
不是所有 Linux 音频问题都来自 PulseAudio,也不能看到 busy 就直接下结论。本项目有以下证据链:
plughw 直接打开 ALSA 设备时返回忙;原来的访问方式相当于:
桌面程序 ─┐
浏览器 ─┼→ PulseAudio → ALSA → M260C
ASR 程序 ─┘ 另一路直接打开 plughw,尝试独占同一设备
修复后,ASR 也通过 PulseAudio 读取:
parec \
--device=<M260C_SOURCE> \
--raw \
--format=s16le \
--rate=16000 \
--channels=1
Python 端将 parec 作为子进程,从标准输出读取定长 PCM:
self.process = subprocess.Popen(
[
"parec",
f"--device={source}",
"--raw",
"--format=s16le",
"--rate=16000",
"--channels=1",
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
PulseAudio 官方排障文档同样建议用 fuser /dev/snd/* 查找占用声卡的程序,并用 paplay 区分 PulseAudio 路径与更底层的硬件问题。PulseAudio Troubleshooting
建议的判断顺序是:
# 1. 驱动/枚举层
cat /proc/asound/cards
# 2. PulseAudio 是否已接管
pactl list short sources
# 3. 谁正在使用声音设备
fuser -v /dev/snd/*
# 4. 用 PulseAudio 路径直接验证
parec --device=<SOURCE> --file-format=wav /tmp/m260c.wav
只有在设备已经枚举、直接 ALSA 报忙、PulseAudio source 存在且 parec 能工作时,才能把问题归因到这类访问冲突。
最初版本虽然可以录音,但实际使用会遇到两个问题:
如果把音频块设得很大,按键边界的误差还会进一步增加。最终使用的参数是:
| 参数 | 当前值 | 作用 |
|---|---|---|
| 采样率 | 16 kHz | 匹配 ASR 输入 |
| 音频块 | 640 samples / 40 ms | 降低按键边界误差 |
| 预录缓冲 | 1600 samples / 100 ms | 保留按键前的起始语音 |
| 松键尾录 | 5 blocks / 200 ms | 保留松键后的尾音 |
时间关系如下:
按下空格 松开空格
│ │
背景环形缓存 ──────┼────── 正式录音 ───────────────┼── 尾部保护 ─→ 整句识别
100 ms 200 ms
未录音时,程序持续把最近的音频块放入固定长度 deque。收到 start 后,先把最后 100 ms 加入本轮录音;收到 stop 后,再读取 5 个音频块,然后一次性解码完整语句。
CHUNK_SAMPLES = 640 # 40 ms @ 16 kHz
PRE_ROLL_SAMPLES = 1600 # 100 ms
POST_ROLL_BLOCKS = 5 # 200 ms
这里仍然复用 Sherpa-ONNX Online Transducer,但按键模式只发送最终整句,不把连续变化的中间识别结果交给大模型。
ASR 使用三个 INT8 ONNX 文件组成的 Transducer:encoder、decoder 和 joiner。核心配置如下:
recognizer = sherpa_onnx.OnlineRecognizer.from_transducer(
tokens="model/tokens.txt",
encoder="model/encoder.int8.onnx",
decoder="model/decoder.int8.onnx",
joiner="model/joiner.int8.onnx",
num_threads=2,
provider="cpu",
sample_rate=16000,
feature_dim=80,
decoding_method="greedy_search",
)
先独立验证 ASR,避免把音频设备问题和 Qwen/NPU 问题混在一起:
# 识别已有 WAV
.venv/bin/python m260c_asr.py test-audio/funasr-zh.wav
# 使用自动发现的 M260C
.venv/bin/python m260c_asr.py
# 输入信号偏小时尝试增益 2
.venv/bin/python m260c_asr.py --gain 2
# 必要时手动指定 ALSA 设备
.venv/bin/python m260c_asr.py \
--device plughw:CARD=L6Microphone,DEV=0
--gain 是数字增益
模型不适合采用“每识别一句就启动一次”的方式:
一句语音 → 加载数 GB 模型 → 回答 → 退出 → 下一句重新加载
这会把模型加载时间和内存分配成本重复放大,也无法自然保留上下文。项目采用一个常驻聊天进程:
model = subprocess.Popen(
[CHAT],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
启动阶段同时拉起 Qwen 和 ASR,但只有满足以下条件后才开放录音:
之后每轮只向模型标准输入写入一行文本,通过标准输出读取流式回答。/reset 清理上下文,退出时优先发送 /quit,超时才终止进程。
STARTING
│ ASR、Qwen、TTS 就绪
▼
READY
│ 按下空格
▼
RECORDING
│ 松开空格
▼
RECOGNIZING
│ 得到最终文本
▼
CONFIRMING(可选)
│ 发送
▼
QWEN_ANSWERING
│ 回答结束
▼
TTS_PLAYING
│ 播放结束
└────────────→ READY
当前实现用几个 threading.Event 表达关键状态:
model_ready:模型完成加载;model_idle:模型正在等待下一条输入;can_record:允许开始新录音;asr_ready:ASR 与输入设备已就绪;model_failed:检测到 QNN 图执行失败。基本规则很简单:
can_record;/reset 必须等模型重新回到空闲状态。这些规则也防止本机扬声器播出的 Qwen 回答马上被 M260C 再次识别,形成自问自答循环。
自动模式直接把识别结果发给 Qwen:
./m260c-chat
也可以开启确认模式:
./m260c-chat --confirm
确认规则如下:
| 输入 | 行为 |
|---|---|
回车、y、yes | 发送原识别文本 |
n、no | 跳过本轮 |
| 其他文字 | 用输入内容替换识别结果后发送 |
/reset | 清空 Qwen 上下文 |
例如 ASR 把“高通端侧人工智能”识别错时,无需重新说一遍,可以直接输入修正后的完整句子。
Qwen 的回答可能包含 Markdown、代码、表格和链接。直接交给 TTS 会把符号、URL 甚至代码逐字朗读,因此先做最小清洗:
TTS 使用 Sherpa-ONNX Offline VITS:
config = sherpa_onnx.OfflineTtsConfig(
model=sherpa_onnx.OfflineTtsModelConfig(
vits=sherpa_onnx.OfflineTtsVitsModelConfig(
model="vits-melo-tts-zh_en/model.onnx",
lexicon="vits-melo-tts-zh_en/lexicon.txt",
tokens="vits-melo-tts-zh_en/tokens.txt",
),
num_threads=4,
),
)
生成的浮点采样被裁剪并转换成 16 位 PCM,然后交给 paplay:
paplay \
--raw \
--device=@DEFAULT_SINK@ \
--format=s16le \
--rate=<TTS_SAMPLE_RATE> \
--channels=1
这里也踩过与录音相似的问题:录音已经通过 PulseAudio 共享,播放却仍用 aplay/plughw 直接访问 ALSA,导致“设备或资源忙”。统一改成 paplay 后,TTS、桌面声音和系统音量控制由同一个声音服务协调。
Sherpa-ONNX 对词典外词汇会输出 OOV 提示。当前实现只在一次合成调用期间隐藏这类底层提示,真正的模型配置错误、空音频或播放失败仍然上报,不会全局吞掉标准错误。
先分别确认 ASR 和 Qwen 能独立运行,再启动完整链路。
cd m260c-asr
# 查看参数
./m260c-chat --help
# 自动发送识别结果并播报
./m260c-chat
# 识别后人工确认或修改
./m260c-chat --confirm
# 输入信号偏小时尝试数字增益
./m260c-chat --confirm --gain 2
# 只显示回答,不做 TTS
./m260c-chat --no-tts
交互命令:
/reset 清空 Qwen 上下文
Ctrl+C 停止 ASR、TTS 与模型进程
由于笔者是转发画面到笔记本的,当前空格键监听通过 X11 XGrabKey 实现,适用于本机 Ubuntu桌面环境。在纯终端、Wayland 或无图形会话中,应把这一层替换成终端按键、GPIO 按键或其他输入事件,ASR 和模型管线本身不需要重写。
建议按层验证:
pactl list short sources
parec --device=<SOURCE> --file-format=wav /tmp/m260c.wav
确认文件有正常波形、采样没有持续为零,再测试 ASR。
.venv/bin/python m260c_asr.py test-audio/funasr-zh.wav
.venv/bin/python m260c_asr.py
分别验证文件输入和实时输入。
先用系统声音确认 PulseAudio sink:
pactl list short sinks
paplay /usr/share/sounds/freedesktop/stereo/complete.oga
再运行完整助手,避免把扬声器或 sink 问题误判成 TTS 模型问题。
项目保留了一个不依赖完整推理的最小检查:
.venv/bin/python test_m260c_chat.py
覆盖:
paplay 参数;