Skip to main content

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-lsl fills 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 float32 EMG outlet over LSL (mne-lsl), with proper channel labels, types, and units baked into the stream metadata.
  • 🆔 Stable source_id derived 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 --forget escape 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, bleak uses 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's scale_note metadata 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.py owns the BLE connection and one mudraka.Stream. Every 0xfff4 notification is fed to the stream verbatim; on a timer, each publisher drains new samples and pushes them.
  • publishers/emg.py owns its read cursor, the LSL outlet, and the channel-major → sample-major transpose that LSL requires.
  • The StreamPublisher seam (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:

  1. Phase 1 (this release): EMG only. A correct, well-tested single-stream bridge.
  2. Phase 2: IMU. Once mudraka gains an IMU decoder, add a second publisher.
  3. 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-lsl processes, each with its own source_id-disambiguated outlet — matching muse-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-lsl and OpenBCI_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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

mudra_lsl-0.1.0.tar.gz (144.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

mudra_lsl-0.1.0-py3-none-any.whl (24.3 kB view details)

Uploaded Python 3

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

Hashes for mudra_lsl-0.1.0.tar.gz
Algorithm Hash digest
SHA256 83d6d7ea62f484d96c5602e0ff82d144057ab1a1b044734c1983a1a531ba6562
MD5 80a77cb9e7dccd7670fa095741ea552a
BLAKE2b-256 603bf3ef73e242ac6b8c2137b53679b4adaac3a91c38cd9195fc105d562cc495

See more details on using hashes here.

Provenance

The following attestation bundles were made for mudra_lsl-0.1.0.tar.gz:

Publisher: release.yml on ttktjmt/mudra-lsl

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for mudra_lsl-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 34989d9324e4f287c49fc9a24ad0bd2b7ba6f65b786f0c3f34bf16ac4658fe47
MD5 1b3caf43005c21a1b6d7d0d52f031107
BLAKE2b-256 aee3ebef48a11fef83efb939016c915e7241e86e0a8145c8c25671da4eb0e36a

See more details on using hashes here.

Provenance

The following attestation bundles were made for mudra_lsl-0.1.0-py3-none-any.whl:

Publisher: release.yml on ttktjmt/mudra-lsl

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page