核心课程 / 自然语言交互应用 / EXP-01
EXP-01 · 第 1 章 · 验证性 · 4 学时

六麦阵列串口唤醒:机器人耳朵的第一课

本实验从"机器人如何知道你在叫它"出发,讲清六麦环形阵列用 TDOA 波束成形"听声辨位"的通俗原理,逐字节拆解讯飞 XFM 阵列的 AIUI 串口帧协议(0xA5 帧头、校验和、小端序),实现 3 秒防抖 + wakeup_lock 的可靠唤醒,并用 Tkinter 界面把波束角度与唤醒事件画出来。

难度 ⭐ · 4 学时 六麦阵列AIUI 串口协议 TDOA 波束pyserial · Tkinter

01问题引入:机器人怎么知道"你在叫它"

你对着智能音箱喊一声"小飞小飞",它灯带一转、应声而答;可要是两个人隔着房间聊天,它却不该随口插嘴。这个"该醒才醒"的问题,拆开是三连问:

1叫没叫?——麦克风时刻都在收音,如何从环境噪声中认出唤醒词
2谁在叫?——房间里多人说话,如何判断声音来自哪个方向
3叫了几遍?——一句话尾音会重复触发,如何避免 1 秒内醒来 5 次
💡 思路本课程用硬件分工解决:讯飞 XFM 六麦环形阵列在单片机内实时跑波束成形 + 唤醒词模型,一旦命中就通过串口吐出一条 ivw 事件帧 —— 工控主机只管读帧、做防抖,完全不用自己算声学算法。这就是"专业的人干专业的事"。

02原理通俗讲解:六只耳朵怎么听声辨位

生活比喻:操场上的"听声辨位"。蒙上眼睛听人喊你,你先意识到"有人叫我",还会下意识转头找方向 —— 单耳判断有无,双耳靠声音到达两耳的时间差判断方向。六麦阵列就是把这对"耳朵"扩成六只:

1拾音 = 顺风耳:6 个麦克风均匀排成一圈,同一句话到达每个麦的时刻略有不同,这个微小时间差就是 TDOA(到达时间差)
2唤醒 = 抠门铃名:芯片提取特征,比对内置的"小飞小飞"声纹模板 —— 类似门铃只认自家那声"叮咚",别人按门铃不开门
3防抖 = 挂门挡:门铃刚响过就挂上门挡(wakeup_lock),3 秒内的重复按铃一律忽略;这就是 3 秒防抖
4上报 = 递纸条:命中后阵列通过串口发一帧 JSON 事件,工控机读到即"机器人醒了"
概念生活类比本实验对应
波束成形把六只耳朵的听到的内容"对表合并",只放大某个方向的声音TDOA 波束 → 波束号 0~5 / 角度 0~360°
唤醒词检测(KWS)门铃只认自家叮咚ivw 事件("小飞小飞"等)
防抖(debounce)挂门挡,短时间内不再响应3 秒冷却 + wakeup_lock
串口协议两人约定好的"电报格式"0xA5 帧头 + 7+N+1 帧结构 + 校验和
🔍 串口是什么串口(UART)就像一条只允许一个字节一个字节排队通过的窄门:发送方按约定格式打包(帧头 → 类型 → 长度 → 数据 → 校验),接收方按同样规则拆包。双方规则必须一字不差,否则满屏乱码 —— 这正是本实验大部分 Bug 的藏身处。

03关联知识点

硬件
讯飞 XFM 六麦环形阵列

6 麦克风环形布局,板载唤醒 / 识别固件,USB 转串口上机:VID=0x1a86、PID=0x55d4(CH340 系列),设备节点 /dev/ttyACM0,波特率 115200。

协议
AIUI 串口帧 7+N+1

帧头 0xA5、类型、长度、N 字节负载、校验和 (~sum+1)&0xFF;整数按小端序(低字节在前)拼装。

事件
ivw 与波束角度

唤醒事件携带波束号(0~5,对应六个麦克风方向)与 0~360° 角度;配合 PINYIN_MAP 把拼音事件映射为命令。

软件
pyserial + 线程 + Tkinter

后台线程读串口入队,GUI 主线程 queue + after(50ms) 轮询刷新 —— GUI 不能阻塞是这条链的铁律。

04实验目标

  • 能用 lsusb / arecord -l 确认阵列的 VID:PID 与声卡编号,找到 /dev/ttyACM0
  • 能默画 AIUI 帧结构,手工算出一帧的校验和,并解释小端序的含义
  • 用 pyserial 打开 115200-8-N-1 串口,边收边解析,实时打印波束号与角度
  • 喊出"小飞小飞"触发 ivw 唤醒事件,程序打印 [WAKEUP] 日志
  • 实现 3 秒防抖 + wakeup_lock,对连续触发的事件只响应第一次
  • (进阶)跑通 xf_mic_array_gui.py,在 Tkinter 界面看到角度指针随声源转动

05实验环境与准备

# ① 硬件:讯飞 XFM 六麦阵列 → USB 接入工控主机
lsusb | grep -i 1a86
Bus 001 Device 005: ID 1a86:55d4 QinHeng CH340 serial converter

# ② 确认设备节点(可能是 ttyACM0 或 ttyUSB0)
ls -l /dev/ttyACM*
crw-rw---- 1 root dialout 188, 0 ... /dev/ttyACM0

# ③ 把当前用户加进 dialout 组(否则 Permission denied)
sudo usermod -aG dialout $USER   # 重新登录后生效

# ④ 依赖与素材
pip3 install pyserial
# 确认 xf_mic_array.py / uv_device.py / 讲义 resources/ 在实验目录就位
💡 准备清单阵列平放桌面、远离音箱(避免自唤醒);关闭可能占用串口的串口调试助手;记录你机器上阵列的真实设备号,后续所有命令都以它为准。

06动画讲解:声波 → 波束成形 → 唤醒事件

下图从左到右完整展示一个唤醒周期:声波抵达六个麦克风(时间差高亮)→ 波束定向算出角度 → ivw 唤醒帧经串口飞向工控主机 → 防抖计时期内忽略重复触发。

① 六麦环形阵列 · TDOA 到达时间差 Δt → 定向 0~360° → ② 波束成形 · 定向 波束号 4 · 角度 ≈ 315° → ③ 串口上报 · 防抖 XFM 阵列 工控主机 read → 解析 A5 … ivw … CS 🔔 [WAKEUP] 小飞小飞 ⚠ 3s 防抖中… 忽略 重复 ivw 事件被 wakeup_lock 拦下
动画 1-1|左:声波依次点亮六个麦克风(时间差即方向);中:波束主瓣对准声源;右:ivw 帧串口入主机,防抖期内重复唤醒被拦截(4 秒循环)

07互动讲解:唤醒词模拟与防抖实验台

选择事件类型点击发送,观察 3 秒防抖 + wakeup_lock 如何过滤"连环唤醒":

唤醒事件模拟器 · Wakeup Debounce Lab
// 待机:system_ts = 0.0s,wakeup_lock = False,随时可唤醒 提示:先点"发送 ivw 唤醒",再立刻点"1 秒后再喊一次"看防抖效果
🧪 实验变式把防抖窗口从 3.0s 改成 0.5s 再试:连续喊唤醒词时日志会变成什么样?防抖太短 = 机器人"一惊一乍",太长 = 迟钝叫不醒 —— 权衡点就在这个数字里。

08实操步骤:读通 XFM 阵列帧数据

1接线自检:lsusb 看到 1a86:55d4,/dev/ttyACM0 存在且当前用户在 dialout 组
2打开串口:115200 波特率、8 数据位、无校验、1 停止位(115200-8-N-1),置 timeout=0.05 非阻塞轮询
3状态机拆帧:找 0xA5 帧头 → 读类型 → 读长度 → 收满 N 字节 → 校验 → 派发事件
4喊"小飞小飞"触发 ivw,打印日志;再跑 3 秒防抖验证连喊只剩一条日志
# —— 核心:帧校验与小端解析(xf_mic_array.py 节选)——
def _checksum(data: bytes) -> int:
    return (~sum(data) + 1) & 0xFF      # 一元反码 +1,即二补码

HDR, BEAM_EVT, IVW_EVT = 0xA5, 0x09, 0x12
buf += ser.read(ser.in_waiting)            # 非阻塞灌入接收缓冲
if buf and buf[0] != HDR: buf = buf[1:]   # 丢弃错位字节,重新找帧头
ptype, plen = buf[1], buf[2]
frame, buf = buf[:7+plen+1], buf[7+plen+1:]  # 7 字节头 + N 载荷 + 1 校验
if _checksum(frame[:7+plen]) != frame[-1]:
    raise ValueError("checksum mismatch")
payload = frame[7:7+plen]
if ptype == BEAM_EVT:                    # 波束事件:小端取角度
    beam = payload[0]; angle = payload[1] | (payload[2] << 8)
    print(f"[BEAM] id={beam} angle={angle}°")
elif ptype == IVW_EVT:                   # 唤醒事件 → 防抖后上报
    now = time.time()
    if not wakeup_lock and now - system_ts > 3.0:
        system_ts, wakeup_lock = now, True
        print(f"[WAKEUP] beam={beam} {angle}°")

# —— 运行与自检 ——
python3 -m xfmicarray.uv_device            # 枚举并直读原始帧
# [BEAM] id=4 angle=315° ...(手指绕阵列走,看角度跟着转)
# [WAKEUP] beam=4 angle=300°(喊"小飞小飞")
python3 xf_mic_array_gui.py                # Tkinter 界面:指针 + 事件日志
⚠️ GUI 铁律串口读取放后台线程,结果进 queue;界面用 after(50ms) 轮询取队列刷新 —— 直接在 Tkinter 主线程 read() 会让窗口卡死,这是讲义标注的头号易错点。

09重点与易错点

🎯 本实验三重点① 7+N+1 帧结构与状态机拆包;② 校验和 (~sum+1)&0xFF 与小端序的亲手演算;③ 3 秒防抖 + wakeup_lock 的状态维护。三者都对,硬件链路才算通。
⚠️ 常见坑现象 → 解法
权限不足Permission denied: /dev/ttyACM0 → usermod -aG dialout $USER 后重新登录
乱码 / 校验失败波特率设成 9600 或按大端解析 → 用 xxd 对照十六进制,确认 115200-8-N-1 与小端序
设备号漂移重插后 ttyACM0 变 ttyACM1 → 用 ls -l /dev/serial/by-id/ 的稳定软链
唤醒不触发阵列被遮挡 / 音箱自唤醒干扰 / 唤醒词不对 → 平放桌面、离音箱 1m+,对照固件唤醒词表
一秒内重复唤醒忘记防抖或 ts 每次事件都刷新 → 冻结首次时间戳,窗口内直接丢帧并打印 DIM 日志
GUI 卡死主线程阻塞读串口 → 后台线程 + queue + after(50) 轮询模式
  • lsusb 可见 1a86:55d4,且能稳定读到 A5 开头的完整帧
  • 手工演算任取 8 字节的校验和,与程序输出一致
  • 绕阵列走动,[BEAM] 角度随方位连续变化(0~360° 回绕正常)
  • 喊唤醒词出现 [WAKEUP];1 秒内连喊两次只出一条日志
  • 能用自己的话向同伴讲清"防抖期 = 挂门挡"的类比

10总结与思考

✅ 完成成果
串口唤醒链路贯通

从 VID/PID 自检到 ivw 事件日志再到 GUI 指针,硬件"耳朵"就位。

🧠 原理收获
TDOA + 帧协议 + 防抖

时间差定向、7+N+1 拆包、校验与小端序,三个抽象落到具体字节。

🛠 技能收获
pyserial 稳定读串口

状态机拆包、非阻塞轮询、线程 + 队列 + after() 的 GUI 协作范式。

➡ 去向
EXP-02 拿到文字

唤醒只是开门,门后的声音还要靠声学模型转成文字。

📝 思考题 1校验和只能发现"传错",发现不了"传慢"。同一房间两台阵列互相串台(A 机收到 B 机主机的配置帧)时,仅靠 0xA5 + 校验和 能防住吗?你会在帧里加什么字段?
📝 思考题 2如果唤醒词要从"小飞小飞"换成"你好小飞",栈上要改哪几层(固件 / 协议 / 主机解析 / 防抖)?哪种改法成本最高?