TeslasuitDocumentation
Frameworks

Step 7 — Recording and LSL outlets

Goal: publish all session data as LSL outlets so LabRecorder can record a synchronised XDF file. You'll use: LSLStreamer, lsl_enabled=True, LabRecorder Builds on: Step 6 Anchored example: examples/atomic/lsl_streaming.py


What you're adding#

You don't write any new strategy code in this step. Instead, you flip a launch flag (lsl_enabled=True) and the framework starts publishing 7 LSL outlets — every piece of data the framework sees, plus your ControlMessage and the UtilityMessage. Any LSL consumer (LabRecorder, MATLAB, Python via pylsl) can pick them up.

The standard recording workflow is:

  1. Start LabRecorder.
  2. Click Update — see the 7 streams appear.
  3. Tick the streams you want.
  4. Pick an output folder. Click Start.
  5. Run your application; the trial happens.
  6. Click Stop. LabRecorder writes a single XDF file with everything timestamped on a shared clock.

Code#

# app/main.py
from fes_framework.orchestrator import launch
from app.engine import CalibratedParamEngine
from app.strategy import StanceQuadStrategy

if __name__ == "__main__":
    launch(
        StanceQuadStrategy,
        engine_class=CalibratedParamEngine,
        lsl_enabled=True,                  # <— this is the entire change
        external_input_streams=["ForcePlate_GRF"],
        external_input_timeout=5.0,
    )

That's it. The strategy doesn't change. The engine doesn't change. The framework now publishes:

Stream nameChannelsContent
TS_Biomechanics29Joint angles in degrees
TS_API_StepDetector2Left/right foot contact
TS_EMSParameters80EMS params for 20 muscles × 4 fields
AppData_ControlMessagevariesYour ControlMessage subclass fields
AppData_UtilityMessage6System state flags
TS_BonePosition140Skeleton bone positions and rotations
TS_RawData280Raw IMU sensor readings

(If a PPG sensor is detected, HeartRate, HRV, and RawPPG outlets are added too.)


Walkthrough#

lsl_enabled=True. The default is False (GDPR — see Design principle 7). Flipping it on creates the 7 outlets at engine startup.

No code change in the strategy. The strategy doesn't know it's being recorded. The framework reads the same dataclasses your strategy writes (ems_output, joints, etc.) and pushes them as LSL samples after every process().

Channel count for AppData_ControlMessage is your subclass's field count. The framework introspects MyControlMessage at startup and lays the channels out in declaration order. Add or remove fields and the outlet adapts.

Source ID. Each outlet uses a {source_id}_<suffix> pattern. The default source_id is fine for one application instance. If you run multiple applications in parallel, set distinct source IDs to keep the recordings separate (advanced; passed to ClosedLoopEngine directly, not via launch()).


Setting up LabRecorder#

Download from https://github.com/labstreaminglayer/App-LabRecorder/releases.

Install, run, and:

  1. Click Update. The 7 streams appear under "Streams." If you don't see them, check your firewall (LSL uses UDP multicast).
  2. Tick the streams you want. For most analyses, all of them.
  3. Set "Folder" to your data directory and "Template" to a meaningful filename pattern (e.g. sub-{sub}_run-{run}.xdf).
  4. Click Start before the trial.
  5. Click Stop after.

LabRecorder writes one .xdf file per recording, with all selected streams in a single time-aligned bundle.


Loading XDF files in Python#

import pyxdf
streams, header = pyxdf.load_xdf("path/to/sub-01_run-01.xdf")
for stream in streams:
    print(stream["info"]["name"], stream["time_series"].shape)

pyxdf is on PyPI (pip install pyxdf). Each stream entry has:

  • info — metadata (name, channel labels, sample rate)
  • time_series — numpy array, shape (n_samples, n_channels)
  • time_stamps — numpy array of LSL timestamps

The timestamps are aligned across all streams (LSL's strong guarantee), so you can stack force-plate samples next to suit biomechanics next to your control message without resampling.


What gets recorded#

You can verify all channels are populated:

import pylsl

streams = pylsl.resolve_streams(wait_time=2.0)
for s in streams:
    print(f"{s.name():25s}  {s.type():15s}  {s.channel_count()} ch")

Expected output while your app is running with lsl_enabled=True:

TS_Biomechanics            Biomechanical    29 ch
TS_API_StepDetector        StepDetection     2 ch
TS_EMSParameters           EMSParameters    80 ch
AppData_ControlMessage     ControlMessage    4 ch
AppData_UtilityMessage     UtilityMessage    6 ch
TS_BonePosition            BonePosition    140 ch
TS_RawData                 RawData         280 ch

(External inlets you registered in Step 6 are not republished as outlets — they're recorded directly by LabRecorder under their original stream name.)


Bandwidth considerations#

The full 7-stream set is about 400 KB/s at 100 Hz. Most of that is TS_RawData and TS_BonePosition. If you're recording over a slow network drive, consider:

  • Disabling TS_RawData and TS_BonePosition in LabRecorder when you don't need them.
  • Recording locally first, copying afterwards.

The framework itself doesn't drop samples under normal lab conditions — pylsl handles the backpressure transparently.


When you don't want LSL#

GDPR or institutional review concerns may require not publishing any data on the network. Just leave lsl_enabled=False (the default). The strategy still runs, the suit still streams, the control loop still works — there's just nothing on the LSL bus.


Verify#

Start LabRecorder, click Update, see the 7 streams. Click Start, run your app for ~10 seconds, stop it (Ctrl-C). Click Stop in LabRecorder. Open the XDF file:

import pyxdf
streams, _ = pyxdf.load_xdf("recording.xdf")
print({s["info"]["name"][0]: s["time_series"].shape for s in streams})

You should see something like:

{
  "TS_Biomechanics":         (1023, 29),
  "TS_API_StepDetector":     (1023, 2),
  "TS_EMSParameters":        (1023, 80),
  "AppData_ControlMessage":  (1023, 4),
  "AppData_UtilityMessage":  (1023, 6),
  "TS_BonePosition":         (1023, 140),
  "TS_RawData":              (1023, 280),
}

About 100 samples per second, all the same length, all timestamped on the same clock.


What you've learned#

  • lsl_enabled=True opts in to 7 framework outlets at 100 Hz.
  • LabRecorder is the standard recording tool. XDF is the format.
  • pyxdf loads XDF in Python; MATLAB has its own loader.
  • Recording costs you nothing in code — only the launch flag.

Next#

Step 8 — Adding a GUI. You'll add a PyQt5 operator interface that shows live data and lets the operator tune parameters.