ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Web Bluetooth直连八通道肌电臂环:200Hz实时波形与手势识别

Web Bluetooth直连八通道肌电臂环:200Hz实时波形与手势识别

简介:面向可穿戴设备与前端可视化学习者的 myojs-emg 项目,聚焦 Myo 臂章采集的肌电(EMG)信号展示,借助 JavaScript 与 MyoJS 库把肌肉电活动、手势及姿态数据转换为浏览器中的图表和三维交互示例,适合人机交互、生物信号处理与 Web 开发方向的开发者参考。压缩包共69个文件,约652KB,包含33个 js 源码、12个 md 文档、12个 html 页面以及少量 json、png 等配置与素材,覆盖库源码、示例页面和第三方可视化依赖。已有249人学习下载。包内提供 MyoJS 核心模块、Chart.js 与 Three.js 示例、手势与立方体演示及说明文档,可据此理解实时数据采集、连接管理、姿态四元数与可视化渲染流程,也便于拆解项目目录并二次改造。

1. 戴上臂环攥一拳,浏览器里八条波形同时立起来

戴上八通道肌电臂环,攥一下拳,屏幕上八条波形会同时向上顶一下;手腕外翻,其中三四条明显变强,其余几条几乎不动;手一松,八条线回落到一条细噪声带。myojs-emg 要做的就是把这一幕放进浏览器里——页面打开,JavaScript 直接通过蓝牙连上臂环,把每个通道大约 200Hz 的采样画成实时滚动的波形,顺带支持暂停、回看和录成 CSV。

这套代码面向的不是做量产固件的人,而是手里已经有一只臂环、想做手势识别或康复动作评估,又不想先去啃一遍原生蓝牙 SDK 的开发者;也适合想拿一个真实场景练 Web Bluetooth 加 Canvas 实时渲染的人。它的好处是没有构建工具、没有后端、没有依赖树,起一个本地静态服务就能跑起来,改起来也不用等打包。

2. 项目定位与技术选型:一条 200Hz 肌电流水线是怎么搭起来的

先把这项目当成一条流水线看,而不是一个「画图页面」。「画图页面」这个说法容易让人低估它——从臂环字节流到屏幕上那条线,中间隔着位宽、分帧、缓冲、抽点、重绘五道关卡,每一道都不难,但每一道都能把波形画歪。下面把这条流水线和选型理由拆开讲,后面再看代码就不会觉得东一榔头西一棒子。

2.1 从臂环到屏幕的五段链路

一条肌电采样从设备到屏幕上,实际走的是这样五段:

第一段是连接。浏览器用navigator.bluetooth.requestDevice弹出设备选择框,拿到BluetoothDevice之后连 GATT,找到肌电服务和对应的特征值,调startNotifications()把通知打开。这一段的关键是:只有通知打开之后,数据才会主动推过来,后面的所有工作都建立在characteristicvaluechanged事件上。

第二段是解包。一次通知推过来的是一包字节,里面可能包含若干个 8 字节的采样帧,也可能只有半个帧(因为 BLE 默认 MTU 下单个包只有 20 字节,不一定被 8 整除)。所以解包层必须维护一个「尾巴」,把这一包凑不满的部分留到下一包一起处理。

第三段是缓冲。解析出来的帧不能直接塞进渲染,因为渲染是 60Hz 而采样是 200Hz,节奏对不上。常见做法是开一个定长的环形缓冲区,采样侧只管往里写,渲染侧按自己的节奏读最近 N 秒的数据。

第四段是绘制。八条通道各有各的基线(专业点叫泳道),从环形缓冲区按时间顺序取出样本,映射成画布上的坐标,一条一条lineTo下去。

第五段是离线。录制按钮按下之后,缓冲区里的数据要能落成 CSV,方便丢进别的工具做特征分析。

提示:这五段在代码里是五个独立文件,边界切得很干净。想把臂环换成别的设备,理论上只需要动第二段。

2.2 技术栈:为什么不用现成的图表库

整套东西是原生 JavaScript,没有框架,也没有图表库。选型逻辑很直白:

Web Bluetooth 而不是原生 App。这个项目最核心的价值就是「零安装」。做肌电实验的人经常要在不同机器上演示,装一次原生 SDK、配一次驱动、改一次权限,演示的兴致就没了。浏览器方案的好处是换台电脑、装个支持的浏览器就能跑;代价是只有部分桌面浏览器支持这个 API,移动端基本别指望。

Canvas 2D 而不是 ECharts / Chart.js 这类库。图表库的强项是坐标轴、图例、tooltip、大数据量下的抽稀策略,弱项是「每秒 60 次全量重绘」。它每一帧都要走一遍 diff、计算刻度、更新 DOM 或重建路径,八条 1000 点的折线在 60fps 下会明显吃 CPU。而肌电波形需要的渲染特性其实很少:固定泳道、固定颜色、无坐标轴刻度、无动画缓动。用 Canvas 2D 手写一个clearRect加八次stroke,一帧的开销不到 1ms,还省掉了「怎么把 ECharts 的 dataZoom 关掉」这类玄学调试。

环形缓冲区而不是普通数组。普通数组用push加shift维护固定窗口,看着简单,实际每次shift都要把后面所有元素前移一位,1000 帧乘 8 通道就是 8000 次内存搬移,而且是每秒两百次。血泪经验是:这种写法在演示时看不出问题,跑三分钟之后风扇就开始叫。

Float32Array 存样本而不是原始 int8。原始数据是 8 位有符号整数,理论上一字节就够。但缓冲之后大概率要做滤波、整流、算 RMS,中间过程用浮点算误差小,也省得每一步都想着「这里会不会溢出」。5 秒窗口乘 200Hz 乘 8 通道,也就 8000 个 float,32KB,完全不值得为省内存牺牲精度。

2.3 目录结构:每个文件对应链路的一段

结构上没有花活,js/下按职责切文件,main.js负责把所有零件接起来:

文件职责改动频率
index.html页面骨架、画布、连接/录制/暂停按钮低
css/app.css泳道配色、暗色主题、按钮态低
js/ble.jsrequestDevice、GATT 连接、订阅通知低
js/protocol.js字节流转 8 通道帧,处理半帧拼接中,换设备必改
js/ring.js定长环形缓冲,写入与按序读取低
js/render.jsCanvas 2D 绘制、rAF 节流、抽点高,视觉效果都在这
js/record.js录制开关、CSV 生成与下载中
js/mock.js无设备时的合成信号源中
js/main.js组装:把上面几块串成一条链路高

这份目录划分是有意为之:ble.js只关心「怎么拿到 DataView」,protocol.js只关心「字节怎么变成数字」,render.js只关心「数字怎么变成线」。三者之间靠回调通信,谁也不 import 谁。二次开发时想加滤波,就在protocol.js和ring.js之间插一层;想换配色,只动render.js,不碰协议。

再看几个贯穿全局的常量,它们集中在main.js顶部,改之前先想清楚:

参数典型值说明
CHANNELS8通道数,改了要同步改绘制循环和 CSV 表头
SAMPLE_RATE200采样率 Hz,CSV 的时间轴靠它换算
WINDOW_SECONDS5屏幕上一屏显示多少秒,决定缓冲区容量
GAIN1.0纵向缩放系数,肌电幅值个体差异大,靠它调

3. 核心链路拆解:一帧肌电从蓝牙通知到画布的全程

这一章按数据流动的顺序,把四个关键文件逐个拆开。代码是可以直接抄进自己项目里的那种写法,注释里标了容易出问题的地方。

3.1 连接与订阅:把通知打开才有一切

连接这块最容易写错的地方,是把「弹窗选设备」和「拿服务」当成了同步操作。整条链路全是 Promise,而且第一步必须发生在用户的点击事件里。

// ble.js —— 连接臂环并订阅肌电通知 const EMG_SERVICE = '0000fff0-0000-1000-8000-00805f9b34fb'; // 占位,以设备固件为准 const EMG_CHAR = '0000fff1-0000-1000-8000-00805f9b34fb'; export async function connectEmg(onPacket) { // 1. 选设备。用 namePrefix 过滤,弹窗里只出现目标设备,省得选错 const device = await navigator.bluetooth.requestDevice({ filters: [{ namePrefix: 'EMG' }], optionalServices: [EMG_SERVICE], // 不写这一行,后面 getPrimaryService 必挂 }); // 2. 断线要有人管。臂环休眠、走远、电量低都会触发 device.addEventListener('gattserverdisconnected', () => { console.warn('设备已断开,需要重新走一次连接流程'); }); const server = await device.gatt.connect(); const service = await server.getPrimaryService(EMG_SERVICE); const char = await service.getCharacteristic(EMG_CHAR); // 3. 打开通知,之后每来一包数据都会触发 valuechanged await char.startNotifications(); char.addEventListener('characteristicvaluechanged', (e) => { const view = e.target.value; // 这是一个 DataView,别当成数组用 onPacket(view); // 只做分发,不在这里画图 }); return device; }

这段代码有三个参数值得单独说。filters里的namePrefix是设备广播名的前缀,写错了弹窗里就一个设备都没有,很多人第一反应是「蓝牙坏了」,其实是名字没对上。optionalServices必须把后面要访问的服务 UUID 全部列进去,这是浏览器权限模型的要求——不在白名单里的服务,连上之后也拿不到。onPacket回调只做转发,不做任何解析和绘制,原因是这个回调运行在蓝牙事件线程的节奏上,一旦里面塞了重活,后面的包会被丢掉。

另外注意startNotifications()是异步的,await完之后事件才真正开始推。如果设备固件要求先写一个「启动采集」的命令,得在startNotifications()之前用char.writeValue()发下去,这一步顺序反了,表现就是「连上了,但一包数据都没有」。

3.2 解包:8 位有符号采样与半帧拼接

拿到DataView之后的第一件事是搞清位宽和符号。肌电是交流信号,静息时在零点附近抖,发力时向两侧摆,所以采样值是有符号的,中心在 0。如果按无符号读,静息基线就变成了 128,画出来的线整体上移,满量程时还会被截成方波。

// protocol.js —— 把一包字节拆成 8 通道采样帧 const CHANNELS = 8; const BYTES_PER_SAMPLE = 1; // 每通道 1 字节;10 位打包的设备要改成 3 字节 4 采样 const FRAME_BYTES = CHANNELS * BYTES_PER_SAMPLE; export function createParser(onSamples) { let leftover = new Uint8Array(0); // 上一包凑不满一帧的尾巴 return function parse(view) { const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength); const buf = leftover.length ? concat(leftover, bytes) : bytes; const frames = Math.floor(buf.length / FRAME_BYTES); // 一次分配,批量填。别在循环里 new const out = new Int8Array(frames * CHANNELS); for (let f = 0; f < frames; f++) { const base = f * FRAME_BYTES; for (let c = 0; c < CHANNELS; c++) { // 手工做符号扩展:先左移 24 位再算术右移 24 位 out[f * CHANNELS + c] = (buf[base + c] << 24) >> 24; } } leftover = buf.slice(frames * FRAME_BYTES); // 剩下的留到下一包 if (frames > 0) onSamples(out, frames); }; } function concat(a, b) { const out = new Uint8Array(a.length + b.length); out.set(a, 0); out.set(b, a.length); return out; }

leftover是整个解析器里最重要的一个变量。BLE 单个通知包的长度受 MTU 限制,常见是 20 字节,20 除以 8 余 4,也就是说每个包都会剩下半个帧。不处理这个尾巴,通道映射就会整体错位——这一包的通道 1 剩了 4 个字节,下一包前 4 个字节本来属于通道 5、6、7、8,结果被当成通道 1、2、3、4 读了,画出来的现象就是「某几个通道的信号长到了隔壁泳道」,而且越滚越乱。

onSamples的第二个参数frames是这一批的帧数,不是字节数,调用方算实时采样率的时候要用它:拿一段时间内累计的帧数除以耗时,正常应该稳定在 200 上下,如果只有 60 左右,说明有包被丢了或者解析层卡住了。

3.3 环形缓冲与渲染节流:为什么不能用 push 加 shift

缓冲区的作用是把「采样节奏」和「渲染节奏」解耦。采样是 200Hz 且不均匀(蓝牙包到达有抖动),渲染是 60Hz 且必须均匀。用一个定长环形缓冲,写侧永远覆盖最旧的位置,读侧按自己的节奏取,两边互不阻塞。

// ring.js —— 定长环形缓冲,容量单位是「帧」 export class RingBuffer { constructor(capacityFrames, channels) { this.cap = capacityFrames; this.ch = channels; this.data = new Float32Array(capacityFrames * channels); // 预分配,全程不扩容 this.head = 0; // 下一个写入位置 this.count = 0; // 已写入帧数,上限为 cap } push(frame) { const base = this.head * this.ch; for (let c = 0; c < this.ch; c++) this.data[base + c] = frame[c]; this.head = (this.head + 1) % this.cap; if (this.count < this.cap) this.count++; } // i = 0 表示缓冲区里最旧的一帧 at(i, c) { const idx = (this.head - this.count + i + this.cap) % this.cap; return this.data[idx * this.ch + c]; } }

容量按SAMPLE_RATE * WINDOW_SECONDS算,200 乘 5 等于 1000 帧,乘 8 通道乘 4 字节,正好 32KB,一个Float32Array分配完就再也不动了。head回绕、count封顶这两个变量配合,使得at(i, c)无论缓冲区满没满都能正确取到第 i 帧——没满的时候count小于cap,取到的自然就是有效数据。

渲染侧是一个requestAnimationFrame驱动的循环,每次画一屏。这里必须做抽点:1000 帧要画到 800 像素宽的画布上,一帧画一个点会有一半的点落在同一个像素里,白算。

// render.js —— 每个 rAF 画一屏,八条泳道一次画完 const COLORS = ['#e05c5c', '#e0965c', '#d8c85c', '#7cc46e', '#5cc0c0', '#5c8ce0', '#9a6ee0', '#d06cb0']; export function createRenderer(canvas, ring, gain = 1) { const ctx = canvas.getContext('2d', { alpha: false }); // 不透明,省一次合成 function resize() { const dpr = window.devicePixelRatio || 1; // 高分屏不糊 canvas.width = canvas.clientWidth * dpr; canvas.height = canvas.clientHeight * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } function draw() { const w = canvas.clientWidth; const h = canvas.clientHeight; const laneH = h / ring.ch; // 每条通道占一条泳道 const n = ring.count; const step = Math.max(1, Math.floor(n / w)); // 样本多于像素就抽点 ctx.fillStyle = '#0f1115'; ctx.fillRect(0, 0, w, h); ctx.lineWidth = 1; for (let c = 0; c < ring.ch; c++) { const mid = laneH * c + laneH / 2; ctx.strokeStyle = COLORS[c]; ctx.beginPath(); for (let i = 0; i < n; i += step) { const x = (i / (n - 1)) * w; // int8 满量程 ±128,映射到半条泳道的高度 const y = mid - (ring.at(i, c) * gain * laneH) / (128 * 2); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.stroke(); } requestAnimationFrame(draw); } resize(); requestAnimationFrame(draw); return { resize }; }

getContext里的alpha: false是个小优化,画布不透明就不需要和页面背景做 alpha 混合。step的意义在于把绘制量从「样本数」压到「像素数」,一屏永远只画 800 个点左右,不管缓冲区开多大,帧时间都稳定。gain这个参数之所以单独拎出来,是因为肌电幅值的个体差异很大,同一个人贴的位置稍微挪一点幅值就能差一倍,写死一个系数,换个使用者就得改代码。

3.4 录制与导出:把内存里的数据落成 CSV

可视化只能看,做分析还得有数据。录制这块没什么技术含量,但有一个必须记住的点:URL.createObjectURL出来的地址要revoke,否则每导一次就漏一份内存。

// record.js —— 环形缓冲转 CSV 并触发下载 const CHANNELS = 8; export function toCsv(ring, sampleRate) { const header = ['t_ms']; for (let c = 1; c <= CHANNELS; c++) header.push('ch' + c); const lines = [header.join(',')]; for (let i = 0; i < ring.count; i++) { const row = [(i * 1000 / sampleRate).toFixed(1)]; // 时间轴按采样率换算 for (let c = 0; c < CHANNELS; c++) row.push(ring.at(i, c).toFixed(2)); lines.push(row.join(',')); } return lines.join('\n'); } export function download(filename, text) { const url = URL.createObjectURL(new Blob([text], { type: 'text/csv' })); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); // 不释放,长会话里内存会一直涨 }

时间轴用i * 1000 / sampleRate换算成毫秒,写两位小数就够了,200Hz 下一个样本间隔 5ms。样本值保留两位小数,是因为导出之后大概率要进别的工具做滤波和特征提取,精度留着不碍事,文件也不大——200Hz 乘 8 通道,一秒 1600 个数字,一分钟不到 200KB。

真正要留意的是导出的时机。缓冲区是环形覆盖的,如果你录了 30 秒但WINDOW_SECONDS是 5,那前 25 秒早被冲掉了。所以要么录制时另开一个只增不减的数组,要么在导出前提醒使用者窗口长度。前者更靠谱,代价是长会话要注意内存,我一般是按「录制开始时刻」累计,导完就清空。

4. 跑起来:本地静态服务、mock 数据源与真机联调

项目本身没有构建步骤,但「直接双击 index.html」一定跑不起来,因为file://协议下 Web Bluetooth 会被直接拒绝。这一章按从零到看到波形的顺序走一遍,顺带给出没有设备时的开发方案。

4.1 起一个本地静态服务

任选一种,Python 自带的最省事:

# 在项目根目录执行 python3 -m http.server 8000 # 或者用 Node 生态的静态服务器 npx serve -l 8000

启动后必须用http://localhost:8000或http://127.0.0.1:8000访问,不要用局域网 IP。Web Bluetooth 要求安全上下文,localhost被浏览器视为安全来源,而192.168.x.x不是,用 IP 访问会看到「蓝牙 API 不可用」或者navigator.bluetooth直接是undefined。

注意:如果确实需要别的机器访问(比如演示时手机投屏),得给本地服务配一份自签证书走 HTTPS,别图省事改成关闭浏览器安全策略,那个开关会连带关掉一堆别的东西。

打开页面后先别急着点连接,按 F12 看一眼控制台。干净的启动日志应该只有一两条模块加载信息,如果有Failed to resolve module specifier之类的报错,说明index.html里用了<script type="module">但路径带了./之外的前缀,或者你在本地开了某种代理式的路径重写。

4.2 没有设备怎么开发:mock 数据源

手里没臂环、或者臂环正在充电的时候,样式和渲染逻辑就没法调了。这个项目留了一个 mock 数据源,它生成的信号走的是和真机完全相同的链路——同样的onSamples回调、同样的环形缓冲、同样的渲染。这样做的好处是:等真机接上,唯一变化的就是数据来源,渲染那边一行都不用改。

// mock.js —— 合成一段「攥拳 / 松手」的肌电信号 export function startMock(onSamples, { sampleRate = 200, batchMs = 20 } = {}) { const t0 = performance.now(); let sent = 0; const timer = setInterval(() => { const elapsed = (performance.now() - t0) / 1000; const due = Math.floor(elapsed * sampleRate) - sent; // 这段时间该补多少帧 if (due <= 0) return; const out = new Int8Array(due * 8); for (let f = 0; f < due; f++) { const t = (sent + f) / sampleRate; // 0.5Hz 的方波当包络:1 秒攥拳、1 秒松手,方便肉眼核对 const env = Math.sin(2 * Math.PI * 0.5 * t) > 0 ? 1 : 0.05; for (let c = 0; c < 8; c++) { // 各通道给不同频率,模拟不同肌群的主导频率差异 const carrier = Math.sin(2 * Math.PI * (40 + c * 6) * t); const noise = Math.random() * 2 - 1; // 均匀噪声,模拟静息底噪 out[f * 8 + c] = Math.round(env * 60 * carrier + noise * 12); } } sent += due; onSamples(out, due); }, batchMs); return () => clearInterval(timer); // 返回停止函数,别忘了在切页时调用 }

这段代码有两个设计点。一是「按时间补帧」而不是「每次固定发 N 帧」,setInterval的计时并不精确,机器一忙就会拖后,固定发帧会让模拟的采样率悄悄变成 150Hz 或 180Hz;按已发送量和应有量做差,采样率就能稳定在 200。二是包络用了 0.5Hz 的方波,眼睛能直接对着屏幕数——每秒一次鼓包,鼓包的时候八条线一起变粗,这个视觉反馈用来验证「渲染有没有掉帧」特别好使。

接进主流程只需要一行开关:

// main.js 片段 const params = new URLSearchParams(location.search); if (params.get('mock') === '1') { startMock(onSamples); // 合成信号 } else { connectEmg(onPacket); // 真机,onPacket 内部调 parser }

这样index.html?mock=1是模拟模式,不带参数就是真机模式,调样式的时候连设备都不用开。

4.3 真机联调:三个必须确认的点

模拟跑通之后接真机,出问题的位置基本集中在三处,按顺序确认能省很多时间。

第一,确认服务和特征的 UUID。不同固件版本会改 UUID,代码里写死的是占位值。做法是用手机上的通用 BLE 调试工具连一次设备,把服务列表和特征属性(尤其是notify那一位)截图对一遍,再回填到ble.js的常量里。

第二,确认是否需要「启动命令」。有些固件连上之后默认不推数据,得先往控制特征写一个使能字节。表现是startNotifications成功、valuechanged一次都不触发。

第三,确认位宽和帧长。如果拿到的数据长度永远是 20 字节、画出来通道错位,先怀疑是不是每帧不止 8 字节——有的设备会在每帧后面跟一个时间戳或者序号,帧长变成 10 或 12,解析层的FRAME_BYTES就要跟着改。

现象先查什么大概率原因
弹窗里没有设备广播名与namePrefix前缀写错,或设备已被别的程序占用
连上但无数据是否需要写使能命令固件默认不推流
波形整体上移一截有无符号用了getUint8,基线跑到 128
通道串位FRAME_BYTES与leftover每帧不止 8 字节,或没做半帧拼接
波形一顿一顿主线程是否有重活解析或绘制挤在同一个回调里

5. 避坑与排查:五个把波形画歪的坑

这一章记的都是我在调这个项目时真实撞过的,按发生顺序排。共同点是:现象看起来像硬件问题,实际全是代码问题。

5.1 连接阶段:两个让人怀疑硬件的坑

坑一:点「连接」按钮,什么都不弹。

现象:按钮点了没反应,控制台没有报错,或者报一个SecurityError,弹窗压根不出现。

原因:requestDevice必须在用户手势的调用栈里同步触发。如果前面先await了一个别的东西(比如先fetch一个配置、先await一次旧连接的清理),手势上下文就丢了,浏览器会静默拒绝。

解决:把requestDevice挂到click处理函数的第一行,前面不做任何异步操作。确实需要先清理旧连接,就把清理逻辑放到弹窗之后,或者干脆在disconnected事件里清。

坑二:选完设备,getPrimaryService抛NotFoundError。

现象:设备选择框正常弹出,也选中了,但往下走一步就抛异常,提示找不到服务。

原因:requestDevice的时候没有把目标服务的 UUID 写进optionalServices。浏览器的权限模型是「你申请了什么就只能访问什么」,不在白名单里的服务,连上之后也拿不到句柄。

解决:把所有要访问的服务 UUID 都写进optionalServices数组。顺带一句,如果 UUID 是从别处抄来的,先确认它和固件一致——这个错误和「UUID 写错了」的表现完全一样,只能靠 BLE 调试工具区分。

5.2 数据阶段:波形不对,八成是解包

坑三:连上了,通知也开了,但一包数据都没有。

现象:startNotifications()返回成功,valuechanged一次都不触发,界面上八条线全是平的。

原因:固件要求先写一个「启动采集」的命令,或者需要先订阅一个控制特征。也有些设备在连接后需要一个短暂的稳定期,立刻订阅会被忽略。

解决:在startNotifications()之前往控制特征写使能字节,写完等几十毫秒再订阅。如果不确定命令格式,用 BLE 调试工具手动写一次,看数据是否开始推送,能推就说明命令对。

坑四:波形上下打满,像方波,而且某几个通道的信号跑到隔壁去了。

现象:静息状态下八条线也贴着泳道上下边缘来回撞,看着像满量程;同时通道 1 的信号特征出现在通道 4 上,越往后越乱。

原因:两个错误叠在一起。一是按无符号读,静息基线从 0 变成了 128,乘上缩放系数就顶到边界;二是没做半帧拼接,每个 20 字节的包剩下 4 个字节被丢掉,下一包的通道映射整体偏移。

解决:位宽改成手工符号扩展(b << 24) >> 24,同时把leftover补上。改完先看静息——八条线应该是贴着各自泳道中线的一条细噪声带,宽度不超过泳道的十分之一,这个基线对了,后面的分析才有可能对。

5.3 长跑阶段:不崩,但会慢慢难受

坑五:跑三五分钟开始掉帧,内存曲线一路向上,切到别的标签页再回来波形像被撕开。

现象:刚打开时很顺,几分钟后滚动明显卡顿,任务管理器里这个标签页的内存一直涨;切走再切回来,画面上出现一截长直线或者错位的锯齿。

原因:三条。其一,解析时每包都new Int8Array、渲染时每帧都建临时数组,垃圾回收跟不上产生速度;其二,渲染没做抽点,缓冲区开多大就画多少个点;其三,requestAnimationFrame在后台标签页会被暂停,但蓝牙通知不会停,缓冲区一直在被覆盖,回来时读到的是一段不连续的数据。

解决:解析和渲染的所有数组都预分配、复用;绘制按像素宽度抽点;监听visibilitychange,页面隐藏时暂停录制并记一个丢帧计数,恢复时清屏重画而不是接着上一帧的路径继续lineTo。

提示:判断是不是 GC 引起的卡顿,可以在开发者工具的性能面板录 10 秒,看锯齿状的内存曲线是不是和掉帧的时间点对得上。对得上就是分配太频繁,对不上就往绘制量上查。

6. 二次开发:在波形之上加一层手势识别

能看见波形只是第一步,多数人拿到这套代码,真正想做的都是「识别动作」。这里给一条最省事的改造路线:不动渲染,只在缓冲区之后插一层特征提取,把每个 200ms 窗口的八通道数据压成一个定长向量,再拿去做分类。

滑窗特征提取的代码如下,加在ring.js之后、main.js里定时调用即可:

// features.js —— 200ms 滑窗,8 通道 RMS 加过零率,共 16 维 const CHANNELS = 8; export function extract(ring, sampleRate, winMs = 200) { const n = Math.min(ring.count, Math.round(sampleRate * winMs / 1000)); const feats = new Float32Array(CHANNELS * 2); for (let c = 0; c < CHANNELS; c++) { let sum = 0, zc = 0, prev = 0; for (let i = 0; i < n; i++) { const v = ring.at(ring.count - n + i, c); sum += v * v; if (i > 0 && ((v >= 0) !== (prev >= 0))) zc++; // 符号翻转计一次过零 prev = v; } feats[c] = Math.sqrt(sum / n); // RMS:肌电最稳的强度特征 feats[CHANNELS + c] = zc / n; // 过零率:粗粒度的频谱代理 } return feats; }

RMS 反映的是这块肌肉用了多大力,过零率粗略反映频率成分,两个加起来 16 维,对「攥拳 / 张开 / 外翻 / 静息」这四个动作已经够用。分类器别一上来就上模型,先用最近邻:录四段各 20 个窗口当模板,实时窗口算欧氏距离取最近的,准确率不满意再换。改造顺序建议是先验证特征可分,再谈模型——我见过太多人直接上网络,训了两天发现静息和攥拳的 RMS 分布本来就重叠,问题出在电极位置,不在模型。

改造点动哪个文件注意
加 50Hz 陷波protocol.js之后插一层二阶 biquad 就够,别一次上高阶
换设备ble.js与protocol.js帧长和位宽一定要重测
加速度计融合新增imu.js两路采样率不同,时间戳要对齐
数据外传record.js改成 WebSocket 推流,注意别阻塞绘制

验证方法我一般固定走三步:先录一段静息、一段攥拳,导出 CSV 用任意表格工具算 RMS 均值,两组差距不到三倍就回去调电极位置;然后把阈值分类器跑在回放数据上,看混淆矩阵;最后才上真人实时。跳过前两步直接联调,出了问题根本分不清是算法还是硬件。

这套代码我前后改过几版,最浪费时间的从来不是算法,而是「以为数据是对的」。从那以后我每次接新设备,都强制先把原始字节按十六进制打到控制台,肉眼数一遍帧长和符号位,再往下写。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表