Fast, lightweight recording of the Mudra Band's raw channels to CSV or LSL (Lab Streaming Layer), with exact global nanosecond timestamps.
mudrarecord is a small, fully self-contained tool. It talks to the band directly
over BLE and does one thing well: stream every raw channel out as fast as it
arrives, for as long as you like (until the disk fills up), while the band is
prevented from acting as a mouse or keyboard.
Created by Ondřej Bojar and Claude Opus 4.8 based on the Prodilink connection tool.
- Single CLI tool with four commands:
scan,info,stream-csv,stream-lsl. - Records the band's raw channels as real decoded float values: the three SNC (sEMG) nerve channels with per-packet RMS, and the IMU accelerometer (raw gyroscope bytes available on request).
- Output is primarily decoded floats; the verbatim packet bytes are added only
with
--include-raw-bytes. - Exact global timestamps to the nanosecond (
time.time_ns()captured at BLE packet arrival), in both CSV and LSL. - HID is turned off during recording: the band does not send mouse/keyboard input while you record.
- Streams data out immediately as it arrives; nothing is buffered in memory, so it can run continuously for hours.
- LSL output is consumable by MNE-Python, OpenViBE, and other LSL tools.
- Per-channel
--skip-*flags: skipped channels are never enabled on the device, so the band spends no bandwidth on them and the remaining channels can in principle reach a higher rate. - Note that accelerometer data is sometimes not obtained, it's not quite clear why.
Using the included live-viewer/live-viewer, you can create
mudrarecord stream-csv \
| live-viewer/live-viewer --width=5s \
--group=snc* --group=rms* --group=a* --shared-range-groups=1,2,3
you can get a live plot like this:
- Python 3.10+
- Linux with BlueZ (BLE via
bleak) - A Mudra Band, paired via
bluetoothctl - For LSL output:
pylsl(installs the LSL runtime) — see below
pip install .For development (editable install with test dependencies):
pip install -e ".[dev]"To include LSL output support (stream-lsl):
pip install -e ".[dev,lsl]"Getting a reliable BLE link to the Mudra Band is the most common source of trouble. There are two separate layers and it helps to diagnose them independently:
- OS / BlueZ layer — the operating system must have an active BLE
connection to the band (
bluetoothctlreportsConnected: yes). - mudrarecord layer —
mudrarecord(viableak) opens a GATT client on top of that OS connection, discovers services, and subscribes to notifications.
If layer 1 is not up, layer 2 cannot work. Always confirm the OS-level connection first.
The band's LED blinks orange for about 30–60 seconds after you press its button, then the LED goes dark and the band stops advertising / accepting connections. To connect, press the button so it blinks, and start the connection while it is blinking. If it stops blinking mid-attempt, press it again and retry. Keeping the band awake during the whole connect sequence is often all that is needed.
bluetoothctl devices | grep -i mudra # list known bands + addresses
bluetoothctl info <ADDRESS> | grep -Ei 'Name|Paired|Trusted|Connected'-
Connected: yes→ the OS link is up; go to Step 2. -
Connected: no→ bring the link up (band blinking):bluetoothctl connect <ADDRESS>
Note: the first
connectcall starts a background attempt and returns immediately; subsequent calls may printorg.bluez.Error.Failed: Operation already in progresswhile that attempt is still running. This is normal — wait a few seconds and re-checkbluetoothctl info <ADDRESS>until it showsConnected: yes. A short retry loop works well:for i in $(seq 1 6); do bluetoothctl connect <ADDRESS> sleep 3 bluetoothctl info <ADDRESS> | grep -q 'Connected: yes' && break done
Marking the band trusted helps BlueZ auto-reconnect and reduces drops:
bluetoothctl trust <ADDRESS>With the OS link up, confirm mudrarecord can open the GATT layer:
mudrarecord scan # should list the band (discovery works independently)
mudrarecord info # connects, reads firmware/battery/channel statemudrarecord infoprints device details → the full stack works; you can stream.mudrarecord infofails withFailed to connect ... Operation already in progress→ a BlueZ connect attempt is still pending or stuck. Wait, or reset it (see below).mudrarecord infofails with an empty error message → BlueZ dropped/rejected the connection (band asleep or out of range). Wake the band and retry Step 1.
Tip: pass --address <ADDRESS> to info / stream-* to skip discovery and go
straight to a known band — useful when several Mudra devices are nearby.
A stale bond (for example, an old address that no longer advertises, or a half-broken pairing) is a frequent cause of connections that never complete. The cleanest fix is to remove the pairing and pair again from scratch.
# 1) Remove the existing bond (unpair). This also disconnects it.
bluetoothctl remove <ADDRESS>
# 2) Put the band into pairing mode: press its button so the LED blinks orange.
# 3) Pair + trust + connect again automatically, while it blinks:
bluetoothctl --timeout 30 scan on # find the band; note its ADDRESS
bluetoothctl pair <ADDRESS>
bluetoothctl trust <ADDRESS>
bluetoothctl connect <ADDRESS>Or as a one-liner scripted "fresh start" (replace <ADDRESS>), keeping the band
blinking throughout:
ADDR=<ADDRESS>
bluetoothctl remove "$ADDR" 2>/dev/null; sleep 2
bluetoothctl --timeout 15 scan on >/dev/null
bluetoothctl pair "$ADDR"; bluetoothctl trust "$ADDR"
for i in $(seq 1 8); do
bluetoothctl connect "$ADDR"; sleep 3
bluetoothctl info "$ADDR" | grep -q 'Connected: yes' && break
done
bluetoothctl info "$ADDR" | grep -Ei 'Paired|Trusted|Connected'After a successful re-pair, mudrarecord scan will show the band and
mudrarecord info will connect. Note that a re-pair can change the band's BLE
address; re-run mudrarecord scan (or bluetoothctl devices) to get the current
one.
mudrarecord scan # find nearby bands
mudrarecord info # connect and print device + channel state
# Record only the three SNC (sEMG) nerve channels + RMS to a CSV file:
mudrarecord stream-csv --skip-acc --skip-gyro -o emg.csv
# Record everything (SNC + accelerometer) at the device's max rate:
mudrarecord stream-csv -o recording.csv
# Also keep the verbatim packet bytes (adds a raw_hex column):
mudrarecord stream-csv --include-raw-bytes -o with_raw.csv
# Stream to stdout (pipe it somewhere):
mudrarecord stream-csv -o - | your_consumer
# Publish over LSL for MNE-Python / OpenViBE:
mudrarecord stream-lsl
# Keep 1 of every 4 samples (host-side decimation):
mudrarecord stream-csv --rate 4 -o decimated.csv
# Choose the ADC sample type:
mudrarecord stream-csv --sample-type 24bit -o hires.csvRecording stops on Ctrl+C, on device disconnect, or when the output disk is
full. By default HID stays off after exit; pass --restore-hid to turn
gesture→HID back on when you stop.
| Option | Meaning |
|---|---|
--address ADDR |
Connect to a specific BLE address, skipping discovery. |
--rate max|N |
max (default) emits every packet; N keeps 1 of every N samples. |
--sample-type |
16bit or 24bit ADC sample type (default: leave device unchanged). |
--skip-snc |
Do not enable/record the SNC (sEMG) stream. |
--skip-acc |
Do not record the accelerometer (see IMU packet note below). |
--skip-gyro |
Do not record the gyroscope (see IMU packet note below). |
--restore-hid |
Re-enable gesture→HID output on exit. |
stream-csv additionally has -o/--output (file path or - for stdout) and
--include-raw-bytes (add a verbatim raw_hex column; off by default so the
CSV contains only decoded values).
Accelerometer and gyroscope are delivered together in a single IMU packet, so
the IMU stream is only disabled — freeing BLE bandwidth for the remaining
channels — when both --skip-acc and --skip-gyro are given.
The CSV contains primarily real decoded values (floats). One row per decoded
sample. Columns (the raw_hex column is present only with
--include-raw-bytes):
timestamp_ns, timestamp_iso, stream, [raw_hex,] snc1, snc2, snc3, rms1, rms2, rms3, ax, ay, az
timestamp_ns— global wall-clock time in nanoseconds since the Unix epoch, captured when the BLE packet arrived.timestamp_iso— the same instant as ISO-8601 UTC with nanosecond precision.stream—sncorimu.raw_hex— (only with--include-raw-bytes) the verbatim BLE payload.snc1, snc2, snc3— the three sEMG nerve channels (filled only onsncrows). A clipped / no-contact sample is written asnan.rms1, rms2, rms3— per-packet RMS of each SNC channel (see below).ax, ay, az— decoded int16 accelerometer axes (filled only onimurows).
Columns not relevant to a given row's stream are left empty.
A single SNC BLE packet carries a batch of 18 samples of all three channels
(the channels are interleaved in the packet). mudrarecord linearizes this
into one row per sample. The RMS values are only available once per packet, so
the same rms1/rms2/rms3 triple is repeated on every row that came from that
packet. Likewise each accelerometer sample in an IMU packet becomes its own row.
Diagnostic/status messages are written to stderr, so stdout carries clean
CSV when you use -o -.
Up to two outlets are created as data arrives:
Mudra-SNC— typeEMG, sixfloat32channels labelledsnc1, snc2, snc3, rms1, rms2, rms3(the three nerve signals plus their per-packet RMS).Mudra-IMU— typeMotion, threefloat32channels labelledax, ay, az(accelerometer). Gyroscope is not published over LSL.
Each sample is pushed with its exact wall-clock timestamp (converted to LSL seconds), and channel labels are written into the stream metadata so MNE-Python and OpenViBE display named channels. The nominal sampling rate is advertised as irregular because the band streams at its own device-driven rate.
Example: resolve the streams in MNE-Python via mne_lsl / pylsl, or add an
"LSL Acquisition" box in OpenViBE and select the Mudra-SNC / Mudra-IMU
stream.
These reflect what has been reverse-engineered from the band's raw BLE streams
(observed on firmware 6.0.11.5). They are documented here so the recorded data
is interpreted correctly. Add --include-raw-bytes if you want the verbatim
packet bytes preserved alongside the decoded values.
-
The three SNC (sEMG) channels are the main signal. A 112-byte SNC packet is 56 int16 little-endian values: indices 0..53 are 18 samples of three interleaved channels (
snc1, snc2, snc3— the three nerve electrodes), and the last two values are a non-sensor footer. Deinterleaving byindex % 3yields three smooth oscillating sEMG waveforms. The extreme values-32768and+32767are clip/invalid sentinels the firmware emits when a channel is railed or an electrode has poor contact; mudrarecord reports these asnan. This layout was reverse-engineered (the vendor decoder is closed-source with no Linux build), so treat the exact scaling/units as best-effort. -
RMS is computed, not transmitted. The device does not send RMS values; the vendor SDK derives them. mudrarecord computes, per packet and per channel, the root-mean-square over that packet's valid (non-
nan) samples, and emits it inrms1/rms2/rms3. -
Accelerometer is verified. The IMU packet (type byte
0x04) carries 8 accelerometer samples as int16 little-endian(ax, ay, az)triplets; these decode cleanly and reproducibly and are emitted in theax/ay/azcolumns. -
Gyroscope is not decoded. The same IMU packet also carries a gyroscope block whose byte layout is not reliably understood, so
mudrarecorddoes not turn it into columns. Its raw bytes are available via--include-raw-bytes. Because acc and gyro share one packet, they cannot be recorded independently; the IMU stream is only fully disabled when both--skip-accand--skip-gyroare set. -
No firmware "sample rate" knob. The device pushes BLE notifications at its own native (maximum) rate; there is no documented command to set a target Hz.
--rate maxsimply emits every packet as it arrives. A numeric--rate Nperforms host-side decimation on the output. The one real device-side knob is--sample-type(16-bit vs 24-bit ADC resolution). Skipping channels you do not need (--skip-*) frees BLE bandwidth, which can in principle raise the effective rate of the remaining channels. -
Timestamps are host-side. Global timestamps are taken on the recording host at BLE-packet arrival. They therefore include BLE transport/stack latency and jitter, but provide a consistent global time base across all channels. Samples expanded from the same BLE packet share that packet's arrival timestamp.
-
Why the accelerometer can be "fully missing", and how it is handled. The SNC and IMU streams share one BLE link. If SNC is enabled first it immediately floods the link and the subsequent IMU enable/subscribe is frequently lost, so the accelerometer never starts for that whole session. mudrarecord avoids this by bringing the IMU stream up before SNC. Stale BLE state left over from a previous session, or the band's LED-sleep/wake cycle, can also stall a stream mid-recording. As a safety net a watchdog re-sends the enable command and re-subscribes for any requested stream that produces no packets for ~2 s (it prints a note to stderr when it does). If you still see a missing stream, start from a clean connection (see the fresh-start recipe above).
mudrarecord/
protocol.py - BLE UUIDs, status indices, raw IMU/SNC decoders
commands.py - firmware command byte builders (channels, HID, sample type)
ble.py - BLE connection; timestamps notifications at arrival
status.py - firmware status parser (info + verification)
device.py - MudraBand: connect, info, HID off, channel enable, raw callbacks
recorder.py - CSVSink, LSLSink, decimation, raw decoders
cli.py - scan / info / stream-csv / stream-lsl
pytest tests/ -vThe tests cover the pure-Python decode, timestamp formatting, decimation, and CSV writing; no hardware is required.
MIT
