Bench modeSteps, parts, and safety only. Big type for a phone at the bench.

Lab Streaming Layer and PsychoPy

How every BCI lab synchronizes a recording with the stimuli that caused it. Streams, timestamps, markers, and a stimulus program with millisecond timing, in an afternoon.

AssumesFirmware and SPI for a biopotential chipSpineDecoding / signal processing / ML

You are skimming: the title, the first figure, and the short version. Switch to Read in the header for the full page, or Deep to open every deep dive.

An is defined by an event. To average brain responses to a flash, you have to know, to the millisecond, when each flash happened, in the same clock as the EEG samples. is the system that does this: every device and every program publishes a stream with timestamps from a shared clock, and a recorder captures them all. is the program that shows the flashes and publishes the markers. Together they are the infrastructure of every experiment in Phase 3.

Streams and outlets

An LSL outlet is a named source of data: “MyEEG,” eight channels, 250 Hz, float. Your acquisition code creates one and pushes each sample with a timestamp from local_clock(). Any program on the network can find it and pull samples. A marker outlet is the same thing with one string channel and irregular timing: it pushes “target” or “standard” or “flash_10Hz” at the moment the event happens.

from pylsl import StreamInfo, StreamOutlet, local_clock
info = StreamInfo('MyEEG', 'EEG', 8, 250, 'float32', 'myeeg001')
outlet = StreamOutlet(info)
# in the acquisition loop:
outlet.push_sample(list_of_8_floats)          # timestamp taken automatically

If your board already speaks the OpenBCI protocol, the OpenBCI GUI or can publish it to LSL for you.

Markers from PsychoPy

PsychoPy shows stimuli with frame-accurate timing and lets you run Python at the moment of each frame. At the flip that shows the stimulus, push a marker.

from psychopy import visual, core
from pylsl import StreamInfo, StreamOutlet
win = visual.Window(fullscr=True, color='black')
markers = StreamOutlet(StreamInfo('Markers', 'Markers', 1, 0, 'string', 'mk001'))
square = visual.Rect(win, width=0.3, height=0.3, fillColor='white')
for trial in range(50):
    core.wait(1.0 + 0.5 * random.random())        # jittered inter-trial interval
    square.draw()
    win.callOnFlip(markers.push_sample, ['flash'])  # marker pushed exactly at the frame that shows it
    win.flip()
    core.wait(0.1); win.flip()

callOnFlip is the important line: the marker’s timestamp is taken when the screen actually changes, not when the code asked it to.

Recording

LabRecorder (a small program from the LSL project) lists every stream on the network; tick the ones you want and press record. It writes an XDF file with every stream and its timestamps, corrected for clock offsets between machines. MNE reads XDF through the pyxdf package. Now you have EEG and markers in one file and one clock, and epoching around markers is one line.

Timing reality

Screens refresh at 60 Hz, so a stimulus appears at some point within a 16.7 ms window after the flip; PsychoPy times the flip, not the pixels. Monitors add their own processing delay, tens of milliseconds on some televisions. LSL’s clock synchronization is good to about a millisecond on one machine and a few across a network. For P300 and SSVEP this is fine. For anything that needs sub-millisecond precision, a photodiode on the screen wired to a spare amplifier channel records the true onset; do this once to measure your setup’s delay and then correct for it.

  1. Install pylsl, psychopy, and LabRecorder.
  2. Publish your board’s data as an LSL stream (or BrainFlow’s). Confirm it appears in LabRecorder.
  3. Run the PsychoPy script above. Confirm the Markers stream appears.
  4. Record two minutes of both. Open the XDF in Python, epoch the EEG around each flash, and average. Even without a proper experiment you should see a visual evoked response in occipital channels: a bump 100 ms after the flash.
  5. Tape a photodiode to the screen, feed it to a spare channel, and measure the delay between the marker and the light. Write it down; it is a property of your setup.
Recall
Why is callOnFlip the correct way to send a stimulus marker?
It runs the marker push at the moment the frame that shows the stimulus is actually displayed, so the timestamp matches the physical event rather than when the code requested it.
Recall
How would you measure the true delay between a marker and the stimulus appearing?
Tape a photodiode to the screen, record it on a spare amplifier channel alongside the markers, and measure the offset between the marker timestamp and the light onset.