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.
Metadata
Release files for mudra-lsl 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| mudra_lsl-0.1.0.tar.gz | 144.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mudra_lsl-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 169.0 kB
Release files / mudra_lsl-0.1.0.tar.gz
| Download URL | mudra_lsl-0.1.0.tar.gz |
|---|---|
| Size | 144.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
83d6d7ea62f484d96c5602e0ff82d144057ab1a1b044734c1983a1a531ba6562
|
|
BLAKE2b-256 checksum How to use checksums |
603bf3ef73e242ac6b8c2137b53679b4adaac3a91c38cd9195fc105d562cc495
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 10, 2026.
Transparency logRelease files / mudra_lsl-0.1.0-py3-none-any.whl
| Download URL | mudra_lsl-0.1.0-py3-none-any.whl |
|---|---|
| Size | 24.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
34989d9324e4f287c49fc9a24ad0bd2b7ba6f65b786f0c3f34bf16ac4658fe47
|
|
BLAKE2b-256 checksum How to use checksums |
aee3ebef48a11fef83efb939016c915e7241e86e0a8145c8c25671da4eb0e36a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 10, 2026.
Transparency log