核心课程 / 自然语言交互应用 / EXP-01
EXP-01

六麦阵列串口唤醒:一句话怎么变成一次回调

对着麦克风说「小飞同学」,终端打出一行 Beam / Angle / Keyword。 这一页要讲清的是:这三个数没有一个是 Python 算的,Python 只做了拆帧、解 JSON、防抖三件事。

⭐ 4 学时串口 UARTAIUI 协议TDOA 声源定位防抖

01目标:让机器人被叫一声就醒,并报出方向

给你
一句「小飞同学」+ 6 个麦克风
拿到
检测到唤醒! Beam: 5, Angle: 309.0
+ 一帧 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 个字节还能继续跑」。
⚠ 重难点 直觉是“程序在算方向”,实际是方向、分数全在阵列固件里算好,Python 只做拆帧、解 JSON、防抖。 把这条边界想明白,后面 ASR、TTS 的边界也一通百通。

🔑 一句话:这一页学的是「字节怎么变成事件」,不是「怎么算声音方向」。

02问题:它凭什么知道是「你」在左边叫它

手机语音助手、车内语音控制、展厅迎宾机器人 —— 它们都有同一个前置动作: 先被叫醒,才开始干活。不然它会把你说的每句话都当指令。

你喊一句「小飞同学」,机器这边发生了三件互相独立的事。拆开它们,就是本页的三个疑问:

疑问 1
方向是谁算的?

6 只麦克风收到同一句话的时间有先后,凭这个就能定方向。但这一步在哪里算?

疑问 2
字节怎么变成事件?

串口那头进来的是一串十六进制字节,最后变成 Python 里的一个字典,中间过了几道手?

疑问 3
为什么只触发一次?

连着喊两声,日志里只有一条唤醒。是设备没听见,还是程序故意不报?

先猜一下 三个疑问里,你猜哪个的答案完全不在这份 Python 源码里?先记住你的选择,看完第 04 节回来对。

🔑 一句话:方向 / 协议 / 防抖,是三件不同的事,别混成一件。

03现象:真机跑出 Beam 5 / Angle 309 那行字

🎬 现象速览 · 30 秒先看效果

接上六麦阵列,跑一条命令,终端会先报「找到哪个串口」,再吐一帧握手字节,然后等你说唤醒词。 说完,一行 检测到唤醒! 打出来。下面是板上的真实输出。

第一段:它先找设备,没找错

正在查找设备 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
讯飞麦克风阵列通信已启动
← 三个 CH340(7523)被跳过,只有阵列(55d4)被选中。你机器上的设备名可能不是 ACM1,属正常。

第二段:控制台先收到一帧握手

FRAME type=0x01 len=4 total=12 hex=a5 01 01 04 00 00 00 a5 00 00 00 b0
type=0x01 是设备主动求握手;程序回 0xFF 之后,链路才算通。

第三段:说完唤醒词,事件来了

接收到消息: {"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
讯飞麦克风阵列通信已启动 ← 然后对着阵列说「小飞同学」,等 1~2 秒

想让板子自己出声触发(旁边没人时):阵列本身不能放音,放音设备是另一张声卡 —— 用 aplay -l 找到那个 USB Audio Device,本机实测是 plughw:3,0。

🔑 一句话:现象里已经能看到两条线索 —— type=0x01 和 eventType=4,它们是两个不同环节的门牌号。

04原理:声波到回调的六格链路

CORE IDEA · 一句话原理

阵列负责「听懂你在叫它、并从哪边叫」;你的程序负责「把这件事可靠地送到业务代码手里」。 前者是固件,后者是协议。整条链只有 6 次形态变化,一次一格。

② 的三个疑问在 04 的哪几格回答
疑问 1 方向是谁算的步骤 1~2
疑问 2 字节怎么变事件步骤 3~5
疑问 3 为什么只触发一次步骤 6
声音进 → 事件出:六次形态变化,六格 1 六只耳朵收到同一句话,但时间不同 形态:6 路波形,两两之间差几百微秒(TDOA) 2 固件按时间差反算方向,并给出置信度 形态:四个数 beam=5 / angle=309.0 / score=945 / power=65128300 3 固件把这四个数包进 AIUI 帧,从串口送出 形态:字节流,以 0xA5 开头、以 1 字节校验结尾 4 你的程序在字节流里找帧头、量长度、验校验 形态:一帧完整 bytes(拆错了就丢掉,继续找下一个 0xA5) 5 载荷是 JSON,取出 eventType 和 ivw 形态:字典 beam=5 / angle=309.0 / keyword=xiao3 fei1 tong2 xue2 6 3 秒内重复的唤醒被丢掉,只放第一次过闸 形态:一次函数回调(或一行“已有事件正在处理,忽略本次”)
图 4-1|六格链路。1~2 格在阵列固件里,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,这次唤醒就不会被报出来。

揭晓答案 1 第 02 节那个「完全不在 Python 源码里」的疑问是疑问 1:方向和分数都是固件算好的, 源码里只有 ivw.get('beam') 这种“取出来”的语句,没有任何计算公式。

3四个数怎么变成一串字节?

吃什么:四个数 + 事件类型。做什么:按 AIUI 协议打包。吐什么:帧。帧的样子是固定的: 7 字节头 + N 字节载荷 + 1 字节校验。

实测那一帧:a5 01 01 04 00 00 00 | a5 00 00 00 | b0 7 字节头 4 字节载荷 1 字节校验 a5 01 01 04 00 00 00 a5 00 00 00 b0 同步用户类型 长度L长度HID LID H 载荷载荷载荷载荷 校验 读法:04 00 → 载荷 4 字节;类型 0x01 → 握手请求;00 00 → 消息 ID 0 校验 = (~sum(前 11 字节) + 1) & 0xFF:sum = 0x150 → 取反加一 → 0xB0 长度与 ID 都是小端:低位字节先到,所以 4 写作 04 00。
图 4-2|用实测握手帧逐字节讲解。学会读这一帧,后面所有事件帧都是同一套头。

动参数会怎样:类型字段决定“这帧是干嘛的”。0x01 握手、0x04 事件、0x05 设置、 0xFF 响应。你把类型认错,就会把握手帧当事件去解 JSON,然后抛异常。

4串口是流不是包,怎么保证不缺不歪?

吃什么:随时可能被切断的字节流。做什么:在缓冲里找 0xA5 → 算长度 → 等够整帧 → 验校验。 吐什么:一帧完整 bytes,或“丢掉一个字节继续找”。

源码里写了三道护栏,对应三种真实翻车:

护栏防的是什么做的事
丢同步头前的垃圾上一帧被截断,剩下的尾巴先到找到第一个 0xA5 之前的字节全丢
msg_len > 8192 就跳过数据里恰好有个 0xA5,冒充帧头只丢这 1 个字节,从下一个再找
校验不过就跳过传输受干扰,字节被改同上,只丢 1 字节,不整段清空

实测里真出现过一次第一道护栏在工作:

丢弃同步头之前的 4 字节: 00 00 00 b0
← 前一次读到的尾巴(正好是上一帧的载荷 3 字节 + 校验 b0),被安全丢掉,程序没崩。

5载荷里的 JSON 为什么要拆两层?

吃什么:载荷字节。做什么:先 json.loads 外层,拿到 content.eventType; 确认等于 4 之后,再 json.loads 一次 content.info 这个字符串,才拿到 ivw。

所以是两层 JSON:外层描述“这是个什么事件”,内层才装业务数据。 第一层的门牌号 eventType == 4 就是“唤醒”这件事的编号。

揭晓答案 2 疑问 2 的答案是「过了两道」:字节流 →(拆帧)→ 一帧 →(两层 JSON)→ 字典。 少拆一层,ivw 就取不到。

6为什么连喊两声只算一次?

吃什么:一个刚解出来的 ivw。做什么:看距上次唤醒是否超过 3.0 秒,并抢一把非阻塞锁。 吐什么:过一次回调,或者打印「已有唤醒事件正在处理中,忽略本次唤醒」。

为什么需要它:固件在你一句话里可能连报两三次唤醒事件(起音一次、尾音一次)。 不拦,业务代码就会被同一句话打断两次。

动参数会怎样:窗口调小到 0.5 秒 → 同一句话可能触发两次;调到 10 秒 → 你喊第二遍它不理你。 这条边界在 06 节可以拖。

揭晓答案 3 疑问 3 不是设备没听见,是软件故意丢掉了第二次。

🔑 一句话:1~2 格归固件,3~6 格归你;串口那一根线就是分界线。

05实践:三步跑通并核对每一格

  1. 确认设备被认到 约 5 分钟看串口、VID:PID、声卡三项,缺一项后面都白跑。
  2. 跑起来看握手帧 约 10 分钟先看到“通信已启动”,再用探针打印一帧原始字节。
  3. 说唤醒词核对事件 约 15 分钟喊「小飞同学」,把结论行与 JSON 里的六个字段对上。

1确认设备被系统认到

先看三个东西:串口在不在、USB 是不是阵列、声卡有没有它。

ls -l /dev/ttyACM*
lsusb | grep -i 1a86
aplay -l
crw-rw---- 1 root dialout 166, 1 ... /dev/ttyACM1 Bus 001 Device 004: ID 1a86:55d4 QinHeng Electronics USB Serial card 4: XFMDPV0018 [XFM-DP-V0.0.18], device 0: USB Audio [USB Audio]

没权限就加组,别用 sudo 跑整个程序(会改掉配置文件的属主):

sudo usermod -aG dialout $USER

2跑起来,并看到握手帧

cd ~/ros2_ws/src/xflz_eai_car1/interaction/car1_speech/tools
python3 xf_mic_array.py
成功打开设备: /dev/ttyACM1 讯飞麦克风阵列通信已启动 程序已启动,按Ctrl+C退出 ← 走到这里说明第 3 格(帧)和第 4 格(拆帧)都通了

想看每一帧的原始字节,在工具目录下包一层探针(不改源码):

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()"
FRAME a5 01 01 04 00 00 00 a5 00 00 00 b0 ← 有了这一行,图 4-2 就能一个字节一个字节对上

3说唤醒词,核对事件帧

对着阵列说「小飞同学」。如果旁边没人,就用另一张声卡把唤醒词播出来 —— 播报用的不是阵列那张卡:

aplay -D plughw:3,0 /tmp/tts_wake.wav
检测到唤醒! Beam: 5, Angle: 309.0, Keyword: xiao3 fei1 tong2 xue2 ← 数字与你站的位置有关,不必和这里一样

命令 → 函数 → 文件 对账表

这张表把 04 节的六格钉到具体代码位置上:左边是链上第几格,右边是你真正会打开的那个文件。

格命令 / 动作函数文件
1~2说唤醒词阵列固件(源码里没有)—
3python3 xf_mic_array.pysend_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_locktools/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
🔁 现象呼应 回到第 03 节第二段那行 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)与防抖窗口两道闸,决定业务代码是否被打扰。

互动 6-1 · 唤醒闸门实验台
945
309
3
调一下 score:低于 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
beam6 只麦克风里能量最大的那只,0~52
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 接的就是“被唤醒之后”:把麦克风里的声音流变成文字。

🔑 记忆点:方向与分数是固件给的,帧与防抖是你加的 —— 这条边界定了,后面每个实验都能对上号。