无线EEG原型链路搭建:BW16+ESP32-CYD实时脑电可视化指南
上个月我在搭一个脑电交互原型的时候脑子里最初的想法特别简单把脑电模块的串口直接插到电脑上用 Processing 画一条实时波形。真正开始测试才发现这根线一旦接上整个可穿戴交互的体验就全完蛋了——人只能绑在桌面前线稍微一抖数据就断更别说想拿去做现场演示或者移动采集。于是我把方案改成了完全无线化脑电模块负责采集BW16 负责低功耗无线节点ESP32-CYD 负责屏幕显示和网页转播三层链路串起来最终从电极到浏览器只用了几十毫秒。这篇文章就是把这条无线 EEG 原型链路的完整搭建过程、踩坑和调试思路写出来给同样想做 EEG 可视化、可穿戴原型或者低成本脑电交互 demo 的朋友做个参考。需要先说清楚的是这个项目不是要做一个医疗级脑电设备而是一条快速原型链路。它解决的核心问题是怎样用便宜、好买、好焊的开发板把脑电模块的原始数据流搬到屏幕上和网页里。如果你正在做脑机接口课程作业、交互设计原型、或者只是想在不花大价钱的情况下验证 EEG 数据的采集和可视化流程这套方案很合适。我也不会替你把所有代码都写完但会把关键的数据流、协议设计、任务结构讲透让不同基础的读者都能照着拼出来。1. 无线 EEG 原型的链路拓扑为什么必须拆成采集节点和显示节点很多人看到 BW16 和 ESP32-CYD 的第一反应会是ESP32 不也能连 WiFi 吗为什么采集端不直接用 ESP32 一块板搞定我在第一次设计时也这么想后来发现对于 EEG 这种低速率、高实时性、而且以后可能要做多通道扩展的信号原型来说“采集”和“显示”这两种任务在物理上拆开收益远大于折腾一块主控。1.1 三层节点各自干什么这条链路里一共有三个角色分工非常明确节点硬件核心职责采集节点BW16RTL8720DN从脑电模块读 UART 原始帧解析成 16bit 样本组包并通过 WiFi 发送显示/中枢节点ESP32-CYD接收无线数据包一边在 TFT 屏幕上画实时波形一边充当 WebSocket 服务器把数据转发给浏览器查看端任意浏览器的网页连接 ESP32 的 WebSocket 端口用 Canvas 画滚动波形并做简单去噪显示数据流方向很简单电极 → 脑电模块 → BW16 → WiFi → ESP32-CYD → 屏幕 浏览器。需要注意的是ESP32-CYD 并不是一个单纯的接收器它还承担了“本地网关”的角色。它把 UDP 包解码并缓冲以后既要在板上 TFT 画出波形又要通过 WebSocket 广播给局域网内的网页客户端。屏幕和网页两端看到的是同一份数据只是渲染方式和用途不同。1.2 为什么把 WiFi 放在中间而不是直接用蓝牙串口脑电模块本身通常有 UART 输出很多模块也支持蓝牙 SPP。为什么最终选择了 WiFi 走中间层第一ESP32-CYD 自带 WiFi而浏览器端的 WebSocket 也是 TCP 协议栈的一部分两边都是天然的网络设备不需要额外转接如果走蓝牙浏览器端还要维护 Web Bluetooth 或者经典蓝牙串口演示时兼容性很容易翻车。第二EEG 原始流的带宽其实非常小。单通道 512Hz 采样、16bit 精度算下来也就是每秒 1KB 左右的量WiFi 上跑这点数据几乎不占带宽却换来了极低的使用门槛。第三采集节点和显示节点之间用 WiFi 隔离以后采集端的功耗和体积可以单独优化。BW16 是双频低功耗方案2.4GHz 和 5GHz 都能连以后做成头戴式模组比拖着 ESP32 方便得多。而且如果你把 BW16 连到 5GHz 频段还能避开 ESP32 常用的 2.4GHz 信道干扰这在做现场 demo 的时候尤其有用。2. 源端选型与 UART 解析脑电模块是如何变成 16bit 数据流的链路的第一公里是让脑电模块输出稳定的数字流。我这次用的是市面上很常见的 TGAM 类单通道 TTL 串口模块它集成了模拟前端、放大、滤波和串口协议主控端只需要在 UART 上做状态机解析就能拿到原始 EEG 信号。当然如果你的模块是 ADS1299 这类 SPI 前端思路还是一样的只是把解析层换成 SPI 驱动而已。2.1 常见模块的接口和输出格式在无线 EEG 原型里源端模块主要分两类类型接口采样率精度适合场景TGAM 类单通道模块TTL UART512Hz原始信号 16bit原型链路、专注度/放松度演示、低成本课程设计ADS1299 类多通道前端SPI250SPS 到 16kSPS 可配24bit8 通道以上的多通道采集、后续做源定位TGAM 类的 UART 配置一般是57600bps、8N1输出里既有原始 EEG 波形数据也包含专注度、放松度这类 eSense 值。原始信号按固定帧格式打包先是两个字节的同步头0xAA 0xAA接着是长度字段然后是 payload最后是对 payload 的校验和。不同厂商的具体 payload 定义有差异但解析思路是一致的——先锁同步头再按长度读取整帧最后校验。2.2 串口解析状态机怎么写我写解析器时是一个标准的有限状态机核心逻辑只有几段uint8_t state 0; uint8_t len 0; uint8_t payload[64]; uint8_t idx 0; while (EEStream.available()) { uint8_t b EEStream.read(); switch (state) { case 0: if (b 0xAA) state 1; break; case 1: if (b 0xAA) state 2; else state 0; break; case 2: len b; idx 0; state 3; break; case 3: payload[idx] b; if (idx len) { state 4; /* 对 payload 做校验和验证 */ } break; case 4: /* 根据 payload 的第一个字节区分数据类型 */ state 0; break; } }这段代码的关键在于不能一上来就按固定字节数读因为 EEG 模块输出不止一种包原始数据包短、eSense 包也短类型都由 payload[0] 标识。我在最初版本里只写了原始数据包解析结果一接上模块就频繁乱帧后来才意识到是忽略了其他包类型导致状态机反复重同步。建议你拿到模块后先用串口助手看 5 分钟原始输出把能出现的帧类型都列出来再写解析器。2.3 原始值到电压的映射模块输出的 16bit 原始值是个有符号整数。它能直接画波形但要说“这个波形是多少微伏”就需要按模块手册里的标定关系换算。很多低成本模块的原始值并不直接等于微伏中间有硬件增益和档位偏移。原型阶段我一般先不严格换算直接用一个线性映射把原始值打到屏幕高度上等进入多通道采样或做 EEG 去噪验证时再做精确标定。注意如果你要拿这套原型做严肃的信号分析建议换成带标准前端放大和可配置增益的模块并至少在模拟前端加一点硬件低通滤波不然高频毛刺和工频干扰会严重影响后续所有环节。3. BW16 采集节点低功耗传感器端怎么做打包和上送BW16 是 Realtek RTL8720DN 方案的开发板支持 2.4GHz/5GHz 双频 WiFi 和 BLE片上资源对跑一个 EEG 采集节点来说非常充裕。它在 Arduino 生态里不算最主流但配置好后用起来和普通 WiFi 板差别不大。采集端要做的事可以概括成一句话把 UART 解析出来的一个个样本攒成一批按固定格式塞进 WiFi UDP 报文发出去。3.1 开发环境配置在 Arduino IDE 里给 BW16 添加板卡支持需要用到 Realtek 的 AmebaD 板级包。在“文件 → 首选项 → 附加开发板管理器网址”里填入对应的 JSON 地址然后在开发板管理器里搜索 AmebaD 并安装即可。装完以后选择 RTL8720DN 对应的板子编译一个最简单的 WiFi 扫描示例能扫描到周围 AP 就说明环境正常。这里有个容易卡住的点BW16 模组厂商较多部分开发板的 UART 引脚排列和默认 Boot 模式不同。我建议第一次烧录前先看板子丝印或原理图确认串口 RX/TX 的映射别把调试串口和数据串口搞混。如果烧录失败多半是模块没有进入下载模式按住板上的 BOOT 键再插 USB 是常见操作。3.2 无线发送协议怎么设计EEG 原始数据单样本只有 2 字节如果每个样本都独立发一个 UDP 包包头的开销反而比数据还大而且对端接收任务会被频繁打断。我选择把32 个样本攒成一个 UDP 报文对应到 512Hz 采样率就是 62.5ms 发一包。这个节奏既不会把延迟拖到肉眼可感又避免了 WiFi 频繁发包带来的抖动。我自定义的报文格式是这样字节偏移长度内容03同步头固定为字符 E/G/G31批次序号0-255 循环41样本数量 count本批最多 3251保留字段暂时写 06count * 2int16 原始 EEG 样本小端序同步头加上序号的作用非常关键。同步头让接收端能快速确认这是不是合格的 EEG 包序号则用来统计丢包率。实测中发现 WiFi 偶发丢包非常正常有了序号至少能在网页端明确知道这一段是断了而不是模块出了问题。3.3 BW16 端代码骨架#include WiFi.h #include WiFiUdp.h const char* ssid your-ap; const char* pass your-pass; const IPAddress receiverIP(192, 168, 1, 50); const uint16_t receiverPort 8888; WiFiUDP udp; uint8_t batchBuf[6 32 * 2]; int16_t sampleBuf[32]; int batchIdx 0; void sendBatch() { batchBuf[0] E; batchBuf[1] G; batchBuf[2] G; batchBuf[3] (uint8_t)seq; batchBuf[4] batchIdx; batchBuf[5] 0; for (int i 0; i batchIdx; i) { batchBuf[6 i * 2] sampleBuf[i] 0xFF; batchBuf[7 i * 2] (sampleBuf[i] 8) 0xFF; } udp.beginPacket(receiverIP, receiverPort); udp.write(batchBuf, 6 batchIdx * 2); udp.endPacket(); batchIdx 0; } void setup() { WiFi.mode(WIFI_STA); WiFi.begin(ssid, pass); while (WiFi.status() ! WL_CONNECTED) delay(100); udp.begin(receiverPort); EEStream.begin(57600); // 脑电模块所在硬件串口 } void loop() { // 不断从串口解析原始样本 int16_t raw parseEEGOneSample(); // 内部做状态机解析 if (raw INVALID) return; sampleBuf[batchIdx] raw; if (batchIdx 32) { sendBatch(); } }这段代码在原型期已经足够跑。要注意的是如果 sendBatch 放在解析循环内部执行遇到 WiFi 拥堵时可能会短暂阻塞 UART 读取导致后续样本被串口缓冲覆盖。更稳的做法是把发送逻辑丢到一个独立标志里主循环读取串口、另一个定时任务负责发送批次。我在实际调试时就是因为发送阻塞丢了几个样本后来改成“攒满 32 个样本后置位, 由单独函数统一发送”才解决。4. ESP32-CYD 中枢一屏一网同一个数据流两路分发ESP32-CYD 是带 2.8 寸 TFT 触摸屏的低成本 ESP32 开发板板子上集成了 ESP32-WROOM-32、ILI9341 驱动芯片、SD 卡槽和一个 XPT2046 触摸控制器。用它做中枢节点有一大好处屏幕和网络都在一块板上不用额外接 LCD也不用担心接线接触不良。这一层的核心工作是把 UDP 包解析成 EEG 样本然后同时喂给屏幕绘制任务和 WebSocket 广播任务。4.1 点亮 TFT 屏幕ESP32-CYD 的屏幕是 SPI 接口常用的绘图库是 TFT_eSPI。在使用前需要修改库的User_Setup.h选对驱动和引脚定义。不同批次的 CYD 虽然外观很像但个别引脚有差异最稳妥的方法是先从官方 ESP32-2432S028R 的示例配置开始自己再对照板子丝印确认。我用的关键引脚映射如下引脚功能14SPI SCK13SPI MOSI12SPI MISO15LCD CS2LCD DC4LCD RST21LCD 背光控制点亮屏幕不需要做什么复杂初始化直接TFT_eSPI tft TFT_eSPI(); tft.begin(); tft.fillScreen(TFT_BLACK);就能看到效果。如果屏幕全白或闪烁先查背光引脚有没有拉高再查 CS/DC/RST 是否定义正确。4.2 双缓冲和接收任务设计数据从 UDP 进入 ESP32 后不能直接画到屏幕原因是屏幕刷新频率和网络到达频率不一定同步。ESP32 有两个核心但 Arduino 的默认写法通常还是一个loop()在跑如果接收、解析、绘制、WebSocket 广播全挤在一个循环里画面会明显卡顿。我采用的方式是双缓冲UDP 接收任务把刚解析好的一个批次放进缓冲区 A同时通知绘制任务可以读取 A当接收任务再次收到新批次时把数据放进缓冲区 B而这时绘制任务可能还在读 A。通过两个缓冲区交替使用接收和绘制互不阻塞。伪代码大致是这样static int16_t bufA[32], bufB[32]; static bool readyA false, readyB false; void loop() { // 1. 接收 int n udp.parsePacket(); if (n 6 readyA false) { udp.read((char*)bufA, n); readyA true; } else if (n 6 readyB false) { udp.read((char*)bufB, n); readyB true; } // 2. 如果 A 准备好了绘制并广播然后释放 A if (readyA) { drawWave(bufA, batchCountA); broadcastBatch(bufA, batchCountA); readyA false; } // 3. 如果 B 准备好了同理 if (readyB) { drawWave(bufB, batchCountB); broadcastBatch(bufB, batchCountB); readyB false; } }这个设计虽然简单但解决了 EEG 原型里最常见的一个问题网络突发到达时屏幕绘制不会把接收卡死接收也不会把屏幕刷新挤掉。如果以后要做多通道或者更平滑的绘制可以把这两个任务拆到 ESP32 的两个核心上用队列通信但现阶段双缓冲已经非常够用。4.3 WebSocket 广播网页端为什么要走这条路屏幕显示是本机行为而浏览器访问需要一种服务器能力。在 ESP32-CYD 上维护一个 WebSocket 服务器是我试过之后觉得最稳的做法。HTTP 轮询也能实现但 512Hz 的数据流如果每 200ms 轮询一次网页端既看不到平滑波形也会频繁触发浏览器请求整体体验很差。WebSocket 是长连接服务端可以把 EEG 批次以二进制帧直接推给所有连接的页面延迟低、代码也简单。在 Arduino 里可以直接用 WebSocketsServer 库初始化大概这样#include WebSocketsServer.h WebSocketsServer webSocket(81); void setup() { webSocket.begin(); } void broadcastBatch(const int16_t* samples, int n) { uint8_t pkt[6 64]; pkt[0] E; pkt[1] G; pkt[2] G; pkt[3] seq; pkt[4] n; pkt[5] 0; memcpy(pkt 6, samples, n * 2); webSocket.broadcastBIN(pkt, 6 n * 2); } void loop() { webSocket.loop(); }广播时给所有浏览器推同一份数据意味着网页端的波形和屏幕上的波形是同步的。我实际测试中一个浏览器页面占用带宽几乎可以忽略所以多个页面同时打开也不会把 ESP32 压垮。5. 网页端实时波形与基础去噪浏览器当多通道查看器ESP32-CYD 的屏幕是给现场演示用的但真正要坐下来分析数据、拍照记录、甚至同时看多个电极波形时还是浏览器最方便。网页端不依赖额外安装软件只需要打开一个 HTML 页面连上 ESP32 的 WebSocket就能实时看到脑波滚动。5.1 WebSocket 客户端和 Canvas 绘制网页端的核心逻辑是收到二进制 ArrayBuffer解析出同步头和样本数然后把 int16 样本推入一个循环数组再用 Canvas 画滚动波形。const ws new WebSocket(ws://esp32-cyd-ip:81/); const points []; ws.binaryType arraybuffer; ws.onmessage (ev) { if (!(ev.data instanceof ArrayBuffer)) return; const dv new DataView(ev.data); if (dv.getUint8(0) ! 0x45) return; if (dv.getUint8(1) ! 0x47) return; if (dv.getUint8(2) ! 0x47) return; const count dv.getUint8(4); for (let i 0; i count; i) { points.push(dv.getInt16(6 i * 2, true)); } while (points.length 1024) points.shift(); drawWave(); }; function drawWave() { ctx.clearRect(0, 0, W, H); ctx.beginPath(); ctx.strokeStyle #00ff88; for (let i 1; i points.length; i) { const x (i / 1024) * W; const y H / 2 - (points[i] / 900) * H * 0.4; if (i 1) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); }这个画法对单通道完全够用。如果后面要接 8 通道可以把 points 变成二维数组每个通道一行Canvas 高度按通道数切分即可。5.2 顺手做一点 EEG 去噪原始 EEG 信号在浏览器里直接画通常会有明显的基线漂移和 50Hz 工频毛刺波形看起来会“跳舞”。我在网页端加了两步简单处理不求严谨但足够让演示效果好看很多。第一步是去掉直流和低频漂移。我用一个极低截止频率的滑动平均作为“基线估计”然后用原始样本减去这个基线slowBaseline 0.999 * slowBaseline 0.001 * sample; filteredSample sample - slowBaseline;这个一阶滤波在视觉效果上相当于高通滤波能抑制呼吸、电极轻微位移带来的基线上下漂移。第二步是做一个移动平均平滑压掉一部分高频毛刺smooth (smooth * 31 filteredSample) / 32;严格来说这只能算是“视觉去噪”不能替代专业 EEG 去噪流程。真正要分析脑电数据还是要靠硬件上的良好接地、右腿驱动、电极阻抗检测以及在 PC 端用更正规的滤波算法做处理。但这套网页端的小处理对原型演示特别有用——观众看到一条干净的绿色波动线远比看到一条疯狂抖动的线更有说服力。5.3 网页端还能做什么FFT 和后续处理方向有了流式样本之后网页端顺手做频谱显示也很容易。把最近的 512 个样本拿来算 FFT画一条功率谱柱状图就能在网页上看到 α 波、β 波的大致能量分布。虽然原型阶段的频谱分辨率不高但作为交互反馈已经足够。如果你打算走多通道脑电源定位的路线例如用最小范数估计做 EEG 源定位那么需要记住一个前提单通道只能看“有没有信号”做不了空间定位。源定位需要 8 通道乃至 64 通道的同步采集。这条链路的通信协议在设计上不限制通道数更换成 ADS1299 之类的多通道前端以后只需要在 BW16 端的打包协议里扩展样本数量字段ESP32-CYD 和网页端几乎不用改。6. 链路调试、延迟测量与丢包处理我把这套原型跑到什么程度链路搭好以后最重要的不是让它“能跑”而是让它“稳定、可预测”。我把整个系统跑了一周测了几个关键指标也处理了几个典型问题。6.1 延迟与节奏测量从脑电模块串口输出一个样本到网页端 Canvas 画出一个点这个端到端延迟取决于三个部分BW16 攒批时间、WiFi 传输时间、ESP32-CYD 双缓冲调度时间。攒批时间固定为 62.5msWiFi 传输在局域网里通常小于 5ms双缓冲调度主要看loop()周期实测通常在 20ms 以内。总体下来从信号发生变化到网页看见变化大约在 80ms 左右。这个延迟对看波形来说完全没问题。如果你觉得不够快可以把攒批数量从 32 降到 16代价是 WiFi 包数量翻倍丢包概率会略微上升。实测中 32 是最舒服的平衡点。6.2 丢包统计和画面毛刺WiFi 不是有线连接丢包是不可避免的。我的做法是在接收端用报文里的序号字段做丢包统计uint8_t miss (seq - lastSeq) - 1; lossCount miss;如果丢包率在 1% 以下波形上几乎看不出来顶多就是有个细细的断线。如果丢包率偏高优先检查 BW16 和 ESP32 是不是在同一个 AP 下、信号强度够不够以及是否和其他 2.4GHz 设备抢信道。BW16 支持 5GHz 频段这个优势在 2.4GHz 干扰严重的现场特别明显建议有条件就直接让它连 5GHz。6.3 一定要实时完整数据时的替代方案如果后续项目从“原型演示”转入“数据采集”丢包是不能接受的。此时传输层应该从 UDP 换成 TCP socket或者直接让 BW16 发数据到跑着 MNE-Python 的电脑上。ESP32-CYD 上的 WebSocket 本身就是 TCP如果 BW16 用 TCP 连接到 ESP32可靠性会高很多缺点是一旦 WiFi 拥塞TCP 的重传会造成延迟尖峰屏幕上的波形会有轻微卡顿。我在实际测试中的建议是原型演示用 UDP正式采集用 TCP 或本地存储。两个模式在代码层面只改一个传输层解析逻辑完全可以共用。6.4 从这套链路里学到的最重要一件事把这套链路跑通以后我最深的体会是EEG 原型的瓶颈根本不在屏幕和网页而在源端信号质量和无线链路的稳定性。BW16 和 ESP32-CYD 这两块板子的组合让我把大量精力从“怎么把线接稳”转移到了“我到底想用脑电信号做什么交互”上。下一步我打算把 ADS1299 的 8 通道版本接到同一套链路协议里网页端顺手加一个 EEG 源定位的示意模块。如果你正在搭类似原型我的建议是先单通道无线链路跑通再往多通道扩展这样能避开非常多不必要的调试痛苦。