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:
- Start LabRecorder.
- Click Update — see the 7 streams appear.
- Tick the streams you want.
- Pick an output folder. Click Start.
- Run your application; the trial happens.
- 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 name | Channels | Content |
|---|---|---|
TS_Biomechanics | 29 | Joint angles in degrees |
TS_API_StepDetector | 2 | Left/right foot contact |
TS_EMSParameters | 80 | EMS params for 20 muscles × 4 fields |
AppData_ControlMessage | varies | Your ControlMessage subclass fields |
AppData_UtilityMessage | 6 | System state flags |
TS_BonePosition | 140 | Skeleton bone positions and rotations |
TS_RawData | 280 | Raw 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:
- Click Update. The 7 streams appear under "Streams." If you don't see them, check your firewall (LSL uses UDP multicast).
- Tick the streams you want. For most analyses, all of them.
- Set "Folder" to your data directory and "Template" to a
meaningful filename pattern (e.g.
sub-{sub}_run-{run}.xdf). - Click Start before the trial.
- 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_RawDataandTS_BonePositionin 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=Trueopts in to 7 framework outlets at 100 Hz.- LabRecorder is the standard recording tool. XDF is the format.
pyxdfloads 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.
