六麦阵列串口唤醒:一句话怎么变成一次回调
对着麦克风说「小飞同学」,终端打出一行 Beam / Angle / Keyword。
这一页要讲清的是:这三个数没有一个是 Python 算的,Python 只做了拆帧、解 JSON、防抖三件事。
01目标:让机器人被叫一声就醒,并报出方向
+ 一帧 a5 … b0 字节
板上真实跑出来的那一行,就是本页的产物(原样复制,未改一个字符):
检测到唤醒! Beam: 5, Angle: 309.0, Keyword: xiao3 fei1 tong2 xue2
它背后那帧 JSON 里其实有 6 个字段,score 和 power 也在:
{"type":"aiui_event","content":{"eventType":4,
"info":"{\"ivw\":{\"beam\":5,\"physical\":5,\"score\":945.0,
\"power\":65128300.0,\"angle\":309.0,
\"keyword\":\"xiao3 fei1 tong2 xue2\"}}"}}
学完你该能做出这三件事
- 说清
beam与angle是阵列固件给的,不是上位机算的 —— 这是本页最容易搞错的一点。 - 跑通
xf_mic_array.py,看到上面那行字,并能在终端里看到握手帧。 - 手算 一帧的校验和,说清「为什么程序丢掉 4 个字节还能继续跑」。
🔑 一句话:这一页学的是「字节怎么变成事件」,不是「怎么算声音方向」。
02问题:它凭什么知道是「你」在左边叫它
手机语音助手、车内语音控制、展厅迎宾机器人 —— 它们都有同一个前置动作: 先被叫醒,才开始干活。不然它会把你说的每句话都当指令。
你喊一句「小飞同学」,机器这边发生了三件互相独立的事。拆开它们,就是本页的三个疑问:
6 只麦克风收到同一句话的时间有先后,凭这个就能定方向。但这一步在哪里算?
串口那头进来的是一串十六进制字节,最后变成 Python 里的一个字典,中间过了几道手?
连着喊两声,日志里只有一条唤醒。是设备没听见,还是程序故意不报?
🔑 一句话:方向 / 协议 / 防抖,是三件不同的事,别混成一件。
03现象:真机跑出 Beam 5 / Angle 309 那行字
接上六麦阵列,跑一条命令,终端会先报「找到哪个串口」,再吐一帧握手字节,然后等你说唤醒词。
说完,一行 检测到唤醒! 打出来。下面是板上的真实输出。
第一段:它先找设备,没找错
正在查找设备 VID:PID = 1a86:55d4 检查设备: /dev/ttyUSB2, HWID: USB VID:PID=1A86:7523 检查设备: /dev/ttyUSB1, HWID: USB VID:PID=1A86:7523 检查设备: /dev/ttyUSB0, HWID: USB VID:PID=1A86:7523 检查设备: /dev/ttyACM1, HWID: USB VID:PID=1A86:55D4 SER=5A6C088698 成功打开设备: /dev/ttyACM1 讯飞麦克风阵列通信已启动
第二段:控制台先收到一帧握手
FRAME type=0x01 len=4 total=12 hex=a5 01 01 04 00 00 00 a5 00 00 00 b0
第三段:说完唤醒词,事件来了
接收到消息: {"content":{"arg1":5,"arg2":0,"eventType":4,
"info":"{\"ivw\":{\"start_ms\":240991230,\"end_ms\":240992460,
\"beam\":5,\"physical\":5,\"score\":945.0,\"power\":65128300.0,
\"angle\":309.0,\"keyword\":\"xiao3 fei1 tong2 xue2\"}}",
"result":"xiao3 fei1 tong2 xue2"},"type":"aiui_event"}
检测到唤醒! Beam: 5, Angle: 309.0, Keyword: xiao3 fei1 tong2 xue2
🔍 看什么:三处。
- 看选中的是哪个串口 —— 日志里
成功打开设备那行才是真正用的口,别按名字猜。 - 看握手帧的第三个字节 ——
01是握手请求,04才是事件,认错就解错。 - 看事件里 beam 与 angle 是否同时出现 —— 两个数一起报,说明它们是同一件事的两种表达。
怎么跑出来
先确认设备在,再进工具目录跑起来。两条命令连着用同一个终端。
ls -l /dev/ttyACM* cd ~/ros2_ws/src/xflz_eai_car1/interaction/car1_speech/tools python3 xf_mic_array.py
想让板子自己出声触发(旁边没人时):阵列本身不能放音,放音设备是另一张声卡 ——
用 aplay -l 找到那个 USB Audio Device,本机实测是 plughw:3,0。
🔑 一句话:现象里已经能看到两条线索 —— type=0x01 和 eventType=4,它们是两个不同环节的门牌号。
04原理:声波到回调的六格链路
阵列负责「听懂你在叫它、并从哪边叫」;你的程序负责「把这件事可靠地送到业务代码手里」。 前者是固件,后者是协议。整条链只有 6 次形态变化,一次一格。
| ② 的三个疑问 | 在 04 的哪几格回答 |
|---|---|
| 疑问 1 方向是谁算的 | 步骤 1~2 |
| 疑问 2 字节怎么变事件 | 步骤 3~5 |
| 疑问 3 为什么只触发一次 | 步骤 6 |
1六只耳朵凭什么靠“时间差”定方向?
吃什么:空气振动。做什么:6 个麦克风排成一圈,同一个声音到达每个麦克风的时间不一样, 离声源近的那只先响。吐什么:6 组波形,两两之间的时间差就是 TDOA(到达时间差)。
直觉:你站在圆桌边说一句话,离你最近的 1 个人先听到,对面那 1 个人最后听到。 6 只麦克风就是桌上这 6 个人。知道谁先听到,就知道了方向,并不需要知道说了什么。
2固件为什么还要给一个 score?
吃什么:TDOA + 唤醒词匹配度。做什么:折成四个数。吐什么:
beam(6 个麦克风里能量最大的那只,0~5)、angle(0~360° 方向角)、
score(唤醒词匹配分)、power(能量)。
动参数会怎样:下发唤醒词时带的 threshold: "900" 就是拿 score 比的。
实测这次是 945.0 —— 刚过线 45 分。把它调到 1000,这次唤醒就不会被报出来。
ivw.get('beam') 这种“取出来”的语句,没有任何计算公式。3四个数怎么变成一串字节?
吃什么:四个数 + 事件类型。做什么:按 AIUI 协议打包。吐什么:帧。帧的样子是固定的: 7 字节头 + N 字节载荷 + 1 字节校验。
动参数会怎样:类型字段决定“这帧是干嘛的”。0x01 握手、0x04 事件、0x05 设置、
0xFF 响应。你把类型认错,就会把握手帧当事件去解 JSON,然后抛异常。
4串口是流不是包,怎么保证不缺不歪?
吃什么:随时可能被切断的字节流。做什么:在缓冲里找 0xA5 → 算长度 → 等够整帧 → 验校验。
吐什么:一帧完整 bytes,或“丢掉一个字节继续找”。
源码里写了三道护栏,对应三种真实翻车:
| 护栏 | 防的是什么 | 做的事 |
|---|---|---|
| 丢同步头前的垃圾 | 上一帧被截断,剩下的尾巴先到 | 找到第一个 0xA5 之前的字节全丢 |
msg_len > 8192 就跳过 | 数据里恰好有个 0xA5,冒充帧头 | 只丢这 1 个字节,从下一个再找 |
| 校验不过就跳过 | 传输受干扰,字节被改 | 同上,只丢 1 字节,不整段清空 |
实测里真出现过一次第一道护栏在工作:
丢弃同步头之前的 4 字节: 00 00 00 b0
5载荷里的 JSON 为什么要拆两层?
吃什么:载荷字节。做什么:先 json.loads 外层,拿到 content.eventType;
确认等于 4 之后,再 json.loads 一次 content.info 这个字符串,才拿到 ivw。
所以是两层 JSON:外层描述“这是个什么事件”,内层才装业务数据。
第一层的门牌号 eventType == 4 就是“唤醒”这件事的编号。
ivw 就取不到。6为什么连喊两声只算一次?
吃什么:一个刚解出来的 ivw。做什么:看距上次唤醒是否超过 3.0 秒,并抢一把非阻塞锁。
吐什么:过一次回调,或者打印「已有唤醒事件正在处理中,忽略本次唤醒」。
为什么需要它:固件在你一句话里可能连报两三次唤醒事件(起音一次、尾音一次)。 不拦,业务代码就会被同一句话打断两次。
动参数会怎样:窗口调小到 0.5 秒 → 同一句话可能触发两次;调到 10 秒 → 你喊第二遍它不理你。 这条边界在 06 节可以拖。
🔑 一句话:1~2 格归固件,3~6 格归你;串口那一根线就是分界线。
05实践:三步跑通并核对每一格
- 确认设备被认到 约 5 分钟看串口、VID:PID、声卡三项,缺一项后面都白跑。
- 跑起来看握手帧 约 10 分钟先看到“通信已启动”,再用探针打印一帧原始字节。
- 说唤醒词核对事件 约 15 分钟喊「小飞同学」,把结论行与 JSON 里的六个字段对上。
1确认设备被系统认到
先看三个东西:串口在不在、USB 是不是阵列、声卡有没有它。
ls -l /dev/ttyACM* lsusb | grep -i 1a86 aplay -l
没权限就加组,别用 sudo 跑整个程序(会改掉配置文件的属主):
sudo usermod -aG dialout $USER
2跑起来,并看到握手帧
cd ~/ros2_ws/src/xflz_eai_car1/interaction/car1_speech/tools python3 xf_mic_array.py
想看每一帧的原始字节,在工具目录下包一层探针(不改源码):
python3 -c "
import time,sys; sys.path.insert(0,'.')
from xf_mic_array import XfMicArray
c=XfMicArray(vendor_id=0x1a86,product_id=0x55d4)
o=c._handle_frame
c._handle_frame=lambda d:(print('FRAME',d[:24].hex(' ')),o(d))[1]
c.start(); time.sleep(20); c.stop()"
3说唤醒词,核对事件帧
对着阵列说「小飞同学」。如果旁边没人,就用另一张声卡把唤醒词播出来 —— 播报用的不是阵列那张卡:
aplay -D plughw:3,0 /tmp/tts_wake.wav
命令 → 函数 → 文件 对账表
这张表把 04 节的六格钉到具体代码位置上:左边是链上第几格,右边是你真正会打开的那个文件。
| 格 | 命令 / 动作 | 函数 | 文件 |
|---|---|---|---|
| 1~2 | 说唤醒词 | 阵列固件(源码里没有) | — |
| 3 | python3 xf_mic_array.py | send_msg(回握手 0xFF) | tools/xf_mic_array.py:40 |
| 3 | 改唤醒词时才走 | set_wake_word(0x05 + threshold 900) | tools/xf_mic_array.py:85 |
| 4 | 同上 | _process_buffer(找 0xA5 / 算长度 / 验校验) | tools/xf_mic_array.py:113 |
| 5 | 同上 | _handle_frame(eventType == 4 → ivw) | tools/xf_mic_array.py:167 |
| 6 | 连喊两声 | 3 秒窗口 + wakeup_lock | tools/xf_mic_array.py:205 |
踩坑速查(全部来自板上实测)
| 现象 | 原因 | 怎么办 |
|---|---|---|
想开 ttyACM0 打不开,日志却用了 ttyACM1 | 程序按 VID:PID 找设备,不按名字;本机 ACM0 是别的串口 | 别看名字,看日志里 成功打开设备 那行 |
| 播了唤醒词,麦克风侧毫无反应 | 阵列那声卡只有采集没有播放;回声消除还会吃掉自己的声音 | 用 aplay -l 找 USB Audio Device(实测 card 3),增益加大到能冲破 AEC |
日志里反复出现 丢弃同步头之前的 N 字节 | 读到半帧,尾巴先到(正常,护栏在工作) | 不用管;持续大量出现才查线缆 |
| 唤醒永远不触发,但设备在收数据 | 跑的是 __main__,它没有调用 set_wake_word,用的是出厂唤醒词 | 要么喊出厂词,要么自己调 set_wake_word 下发 |
| 校验和总对不上 | USB 线过长或供电不足导致误码 | 换短线 / 独立供电 HUB |
a5 01 01 04 00 00 00 a5 00 00 00 b0:
现在你应该能指出 01 是类型、04 00 是长度、b0 是校验 —— 这正是步骤 3~4 的内容。🧪 Vibe Coding 复盘:让 AI 画这张图,你来验收
不要逐行读代码。把下面三组提示词丢给 AI,再拿它的输出和真源码对账。
| 想要 | 提示词 | 验收判据 |
|---|---|---|
| 模块职责 | 「这个文件里哪几个函数负责拆帧、哪几个负责解协议?给出函数名和它读写的变量」 | 必须点到 _process_buffer 与 _handle_frame |
| 控制流 | 「画出从串口 read 到唤醒回调的流程图,标出三个可能提前 return 的分支」 | 三个分支:没找到 0xA5 / 长度不够 / 校验失败 |
| 时序 | 「画出设备与主机的时序图:谁先发握手、谁回什么、事件什么时候来」 | 方向别反:设备先发 0x01 |
| AI 常见说法 | 源码实际 |
|---|---|
| “程序解析数组算出声源角度” | 角度是固件给的,源码只 ivw.get('angle') |
| “上位机主动查询唤醒状态” | 设备推事件(msg_type=0x04),不发查询 |
| “校验失败会清空缓冲重来” | 只丢 1 个字节,保留后面的数据 |
| “唤醒词在代码里写死” | 代码里没有唤醒词字面量,靠 set_wake_word 下发 |
🔑 一句话:三步里每一步都有可核对的输出,任何一步看不到输出就停在那儿别往下走。
06交互:拖三下,看唤醒为什么不触发
📌 掌握检查:把防抖窗口拖到 5 秒,第二声还会被放行吗?先答出来再拖。
下面这个台子复刻的是第 6 格:固件报上来的 score 和 angle 进来,
经过阈值(900)与防抖窗口两道闸,决定业务代码是否被打扰。
三个观察点,按顺序试:
- 把 score 拖到 880:阈值那道闸先拦住,链路在第 2 格就断了。
- score 保持 945,把 防抖窗口拖到 1 秒:连喊两声,两条都放行 —— 中间隔 1.5 秒,超过窗口了。
- 防抖窗口拖回 3 秒(默认):第二声被吞,只看到一行“忽略本次唤醒”。
角度那一格只是让你看清 beam 与 angle 的关系:6 只麦克风把 360° 分成 6 份,一格 60°。
🔑 一句话:分数决定“要不要报”,防抖决定“报了要不要理”,两道闸前后脚。
07验证:三道题回扣三个疑问
Q1(原理)唤醒事件里那个 angle=309.0,是谁算出来的?
源码里只有 ivw.get('angle') —— 是“取”,不是“算”。
Q2(实践)你机器上有 4 个串口,程序为什么偏偏打开了 /dev/ttyACM1?
日志里三个 1A86:7523 被跳过,只有 55D4 那行被选中。
Q3(边界)设备明明收到了第二声唤醒,为什么日志里只有第一次?
第 6 格:距上次超过 3.0 秒才放行,否则打印“忽略本次唤醒”。
✅ 目标对账
| ① 的目标 | 对应题号 |
|---|---|
| 说清 beam/angle 是固件给的 | Q1 |
| 跑通并看到握手帧 | Q2 |
| 手算校验和、解释丢字节 | Q3 + 步骤 4 的三道护栏 |
🔑 一句话:三题分别对回“谁算的 / 谁被选中 / 谁被丢掉”,答错哪题就回那一格。
08总结:方向是硬件算的,防抖是软件加的
知识点关系表
| 概念 | 一句话 | 在第几格 |
|---|---|---|
| TDOA | 同一声音到达各麦克风的时间差,用来定方向 | 1 |
| beam | 6 只麦克风里能量最大的那只,0~5 | 2 |
| angle | 声源方向角,0~360° | 2 |
| score | 唤醒词匹配分,与阈值 900 比 | 2 |
| AIUI 帧 | 0xA5 开头、1 字节校验结尾的报文 | 3 |
| 拆帧 | 在字节流里找帧头、量长度、验校验 | 4 |
| 防抖 | 窗口内重复事件只放第一个 | 6 |
| AEC | 回声消除:会把自己播出来的声音当回声吃掉 | 踩坑 |
三个反直觉
- 直觉是“程序在算方向”,实际是程序只在取固件算好的数。
- 直觉是“校验失败要重来”,实际是只丢 1 个字节接着找 —— 串口是流,丢多了会把真帧一起丢掉。
- 直觉是“唤醒词写在代码里”,实际是靠
set_wake_word下发的,源码里没有那个词。
注意事项
- 阵列声卡通常只采集不放音,播报要用另一张声卡(实测
plughw:3,0)。 - 自播自听会被 AEC 削掉,增益要大到能冲破它;这不是 bug。
- 直接跑
xf_mic_array.py用的是出厂唤醒词,别以为自己写的词生效了。 - 防抖窗口小于 1 秒会误触,大于 10 秒会漏触发。
前置依赖
| 类别 | 具体 | 缺了会怎样 |
|---|---|---|
| 硬件 | 六麦阵列(本机 card 4 XFMDPV0018)+ 供电 USB 线 | 找不到 1a86:55d4,程序直接退出 |
| 库 | pyserial | 导入 uart_device 时即失败 |
| 配置 | 无需配置文件;唤醒词靠 set_wake_word 下发 | 不调用它就只能用出厂唤醒词 |
| 权限 | 当前用户在 dialout 组 | Permission denied,打不开串口 |
| 环境 | 放音声卡(card 3)与终端 | 能收事件但无法自播自听触发 |
这套东西用在哪儿
- 展厅迎宾:讲解员离开后,观众喊唤醒词,机器人转身面向声源再开始讲解 —— 用的就是
angle。 - 车载语音:驾驶员说“你好小飞”才开始下指令,避免把闲聊当命令;重复唤醒由防抖挡掉。
- 老人看护:夜间低功耗常听,一句“救命”即定向唤醒并上报房间位置。
下一站 EXP-02 接的就是“被唤醒之后”:把麦克风里的声音流变成文字。
🔑 记忆点:方向与分数是固件给的,帧与防抖是你加的 —— 这条边界定了,后面每个实验都能对上号。