Stream raw sEMG from a Mudra Link wristband to Lab Streaming Layer (LSL).
Project description
mudra-lsl
Stream raw surface-EMG (sEMG) from a Mudra Link wristband (Wearable Devices Ltd.) to Lab Streaming Layer (LSL).
mudra-lsl connects to the band over Bluetooth Low Energy, decodes its raw sEMG
stream with the published mudraka engine,
and republishes it as an LSL outlet — so any LSL-aware tool (LabRecorder, BCI
pipelines, MNE-Python, real-time visualisers) can consume it.
It is a thin, well-behaved LSL bridge, not a general-purpose Mudra SDK. The one thing it does, it aims to do correctly.
Why this exists. The Mudra Link's raw EMG is easy to decode (that is
mudraka's job) but there is no off-the-shelf way to get it onto LSL, the lingua franca of biosignal recording and BCI research.mudra-lslfills exactly that gap.
Status
v1 — EMG only. This release streams the 3-channel sEMG signal and nothing else. IMU, gestures, pressure, navigation, and battery all exist on the device but are intentionally out of scope for now — see the roadmap.
Features
- 🔌 Scan for and connect to a Mudra Link over BLE (
bleak). - 🧠 Decode raw sEMG via
mudraka(the same engine used elsewhere in the Mudra tooling family). - 📡 Publish a single 3-channel
float32EMG outlet over LSL (mne-lsl), with proper channel labels, types, and units baked into the stream metadata. - 🆔 Stable
source_idderived from the device's hardware serial number, so recordings can be tied to a specific physical band across sessions and platforms. - ♻️ Automatic reconnection with backoff, plus a
--forgetescape hatch for the macOS/CoreBluetooth "stale link" trap. - 🧪 Offline, device-free tests that replay synthetic BLE frames through the real decode + publish path.
Requirements
- Python ≥ 3.11 (required by
mne-lsl). - A Mudra Link wristband, powered on and in BLE range.
- A working Bluetooth adapter. On Linux,
bleakuses BlueZ; on macOS, CoreBluetooth; on Windows, WinRT.
Installation
pip install mudra-lsl
Or, with uv:
uv add mudra-lsl
Quick start
Scan for nearby bands:
mudra-lsl scan
Start streaming (scans, connects to the single match, publishes the outlet):
mudra-lsl stream -v
Connect to a specific device and skip scanning:
mudra-lsl stream --address <ADDRESS-OR-UUID> -v
Press Ctrl-C to stop; the SNC stream is disabled and the band disconnected
cleanly on exit.
As a library
import mudra_lsl
# Blocks until Ctrl-C (or `duration` seconds, if given).
mudra_lsl.stream()
# Connect directly, run for 10 seconds:
mudra_lsl.stream(address="XX:XX:XX:XX:XX:XX", duration=10.0)
For embedding in your own asyncio application:
import asyncio
from mudra_lsl import MudraLslApp
async def main():
app = MudraLslApp(stream_name="MudraEMG")
await app.run()
asyncio.run(main())
The LSL stream
| Property | Value |
|---|---|
| Stream name | MudraEMG (configurable with --stream-name) |
| Stream type | EMG |
| Channels | 3 — ulnar, median, radial (fixed order) |
| Channel type | emg |
| Channel unit | microvolts |
| Sample format | float32 |
| Sampling rate | 834 Hz (read from mudraka, not hardcoded) |
source_id |
device hardware serial (see below) |
Extra metadata is attached to the stream's desc() for reproducibility:
manufacturer, device_model, firmware_version, and a scale_note.
⚠️ The amplitude scale is provisional
Values are published in microvolts using
mudraka's scale of 0.035 µV/count. This figure is provisional and unverified — it is not vendor-calibrated. Treat the signal as a relative amplitude, not an absolute physical measurement, until a proper calibration exists. This caveat is carried in the stream'sscale_notemetadata as well as here.
Why source_id is the serial number, not the BLE address
LSL consumers use source_id to decide "is this the same physical device as
last time." The BLE address is a poor key for that: on macOS bleak surfaces a
CoreBluetooth-assigned UUID rather than the hardware MAC, and even on
Linux/Windows the address can change (privacy-mode randomisation, adapter
swaps). The device's own serial number is stable across platforms and
reconnects, so mudra-lsl reads it once at connect time (GATT 0x2a25 /
0x2a27) and uses it as the source_id. If the serial can't be read, it falls
back to the BLE address and logs a warning.
Consuming the stream
Any LSL client works. For example, record to XDF with LabRecorder, or pull it into Python:
from mne_lsl.lsl import resolve_streams, StreamInlet
stream = resolve_streams(name="MudraEMG")[0]
inlet = StreamInlet(stream)
samples, timestamps = inlet.pull_chunk()
See examples/ for runnable scripts, including exporting a short
recording to an MNE Raw object.
How it works
Mudra Link ──BLE notify (0xfff4)──▶ mudra_lsl.app
│ feed(frame, recv_time)
▼
mudraka.Stream (ring buffer, decode)
│ pull_uv_into(cursor, buf)
▼
EmgPublisher ──push_chunk──▶ LSL outlet
app.pyowns the BLE connection and onemudraka.Stream. Every 0xfff4 notification is fed to the stream verbatim; on a timer, each publisher drains new samples and pushes them.publishers/emg.pyowns its read cursor, the LSL outlet, and the channel-major → sample-major transpose that LSL requires.- The
StreamPublisherseam (publishers/base.py) is the extension point: adding IMU (Phase 2) or discrete-event Markers (Phase 3) is a new publisher class, not a rewrite.
Timestamps are left to LSL's push_chunk auto-timestamping (stamped at push
time and back-filled at the nominal rate), matching the muse-lsl /
OpenBCI_LSL convention. See docs/adr/ for the reasoning.
Roadmap
mudra-lsl aims to eventually cover everything the Mudra Link can communicate.
The path there is deliberately incremental:
- Phase 1 (this release): EMG only. A correct, well-tested single-stream bridge.
- Phase 2: IMU. Once
mudrakagains an IMU decoder, add a second publisher. - Phase 3: discrete events. Gesture / pressure / navigation / button as an LSL irregular-rate Markers stream; possibly battery/status as a low-rate auxiliary stream.
Capabilities we'd like from mudraka to enable later phases are tracked in
docs/mudraka-wishlist.md.
Non-goals
- No IMU / gesture / pressure / navigation / battery streaming (yet — see roadmap).
- One band per process. Bilateral EMG means running two
mudra-lslprocesses, each with its ownsource_id-disambiguated outlet — matchingmuse-lsl's model. - Not a Mudra SDK. Decode is delegated to
mudraka; this project only bridges to LSL.
Development
uv sync # create the venv and install everything
uv run pytest # run the offline test suite
uv run ruff check . # lint
The default test suite needs no BLE hardware or LSL network: it replays
synthetic sEMG frames through the real mudraka decode path and asserts on what
gets pushed to a fake outlet. Tests that need a real band are marked device
and skipped by default (run with uv run pytest -m device).
Acknowledgements
mudraka— the sEMG decode engine this bridge is built on.mne-lsl— the LSL binding used for the outlet.bleak— cross-platform BLE.muse-lslandOpenBCI_LSL— reference designs for wearable-to-LSL bridges.
License
Apache License 2.0 — see LICENSE. This matches mudraka's own
license, so there is no cross-license friction within the Mudra tooling family.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file mudra_lsl-0.1.0.tar.gz.
File metadata
- Download URL: mudra_lsl-0.1.0.tar.gz
- Upload date:
- Size: 144.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
83d6d7ea62f484d96c5602e0ff82d144057ab1a1b044734c1983a1a531ba6562
|
|
| MD5 |
80a77cb9e7dccd7670fa095741ea552a
|
|
| BLAKE2b-256 |
603bf3ef73e242ac6b8c2137b53679b4adaac3a91c38cd9195fc105d562cc495
|
Provenance
The following attestation bundles were made for mudra_lsl-0.1.0.tar.gz:
Publisher:
release.yml on ttktjmt/mudra-lsl
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mudra_lsl-0.1.0.tar.gz -
Subject digest:
83d6d7ea62f484d96c5602e0ff82d144057ab1a1b044734c1983a1a531ba6562 - Sigstore transparency entry: 2138159627
- Sigstore integration time:
-
Permalink:
ttktjmt/mudra-lsl@c92297296c9dc37542832cf764c986ec0f57f1d8 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/ttktjmt
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c92297296c9dc37542832cf764c986ec0f57f1d8 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file mudra_lsl-0.1.0-py3-none-any.whl.
File metadata
- Download URL: mudra_lsl-0.1.0-py3-none-any.whl
- Upload date:
- Size: 24.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
34989d9324e4f287c49fc9a24ad0bd2b7ba6f65b786f0c3f34bf16ac4658fe47
|
|
| MD5 |
1b3caf43005c21a1b6d7d0d52f031107
|
|
| BLAKE2b-256 |
aee3ebef48a11fef83efb939016c915e7241e86e0a8145c8c25671da4eb0e36a
|
Provenance
The following attestation bundles were made for mudra_lsl-0.1.0-py3-none-any.whl:
Publisher:
release.yml on ttktjmt/mudra-lsl
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mudra_lsl-0.1.0-py3-none-any.whl -
Subject digest:
34989d9324e4f287c49fc9a24ad0bd2b7ba6f65b786f0c3f34bf16ac4658fe47 - Sigstore transparency entry: 2138159635
- Sigstore integration time:
-
Permalink:
ttktjmt/mudra-lsl@c92297296c9dc37542832cf764c986ec0f57f1d8 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/ttktjmt
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c92297296c9dc37542832cf764c986ec0f57f1d8 -
Trigger Event:
workflow_dispatch
-
Statement type: