Skip to content

SSVEP

A complete, hackable brain-computer interface that reads EEG from the Knight Board, figures out which flickering square you’re looking at, and turns that into a keyboard press. It uses SSVEP (steady-state visually evoked potentials) as the brain signal and TRCA (task-related component analysis) as the classifier.

Full source: github.com/NeuroPawn/ssvep

When you stare at a light that flickers at a fixed frequency ff, neurons in your primary visual cortex (V1), at the back of your head in the occipital lobe, start firing in lockstep with that flicker. This is neural entrainment: large populations of neurons synchronise their activity to the rhythm of the external stimulus. The result is a periodic voltage oscillation on the scalp at ff and its harmonics (2f2f, 3f3f, …) that EEG electrodes can pick up.

If four squares flicker at 6.67, 8.57, 10, and 12 Hz and you look at the 10 Hz square, your occipital EEG develops a clear peak at 10 Hz (and 20 Hz, 30 Hz, …). The BCI just has to answer: which flicker frequency is dominant in the EEG right now? That’s a remarkably robust question to ask of the brain, which is why SSVEP BCIs are fast and need almost no user training.

Key properties the pipeline exploits:

  • The response is strongest over the occipital cortex, so electrode placement matters (see below).
  • The response has a short latency (~120–150 ms) before the cortex “locks on”, so the classifier ignores the first ~150 ms of each window.
  • The response is stereotyped and repeatable for a given person, so a personalised spatial filter (TRCA) can be learned from it.

SSVEP is generated in the visual cortex, so every electrode goes over the occipital / parieto-occipital scalp. Placing electrodes anywhere else just adds noise.

Recommended 8-channel montage (matches ssvep/config.py → ELECTRODE_LABELS):

ChannelPositionNotes
1OzMidline occipital (strongest)
2O1Left occipital
3O2Right occipital
4PO7Left parieto-occipital
5PO8Right parieto-occipital
6PO3Left parieto-occipital (medial)
7PO4Right parieto-occipital (medial)
8POzMidline parieto-occipital

Reference and ground: put the reference on one earlobe/mastoid (A1) and the ground/bias on the other (A2). Part the hair, use conductive gel, and keep impedances low — SSVEP is small (microvolts) and buried under mains hum.

When BrainFlow first connects to the Knight Board, every EEG channel is powered down. If you start streaming right away you get flat lines. Each channel must be:

  1. Turned on with a gain, via chon_{channel}_{gain} — this powers the channel’s amplifier at the given PGA gain (the repo uses 12).
  2. Added to the bias / right-leg-drive (RLD) loop with rldadd_{channel} — this feeds an inverted common-mode signal back into the body to actively cancel mains hum and movement artifacts. It’s the single biggest factor in getting clean SSVEP.

ssvep/board.py sends these two commands for every channel, with short pauses in between because the firmware needs a moment to apply each register write:

board.prepare_session()
board.start_stream(450000)
time.sleep(2)
for ch in range(1, num_channels + 1):
board.config_board(f"chon_{ch}_12") # power channel ON at gain 12
board.config_board(f"rldadd_{ch}") # add channel to bias/RLD loop

4. Reading Data Without Draining the Buffer

Section titled “4. Reading Data Without Draining the Buffer”

BrainFlow stores incoming samples in a fixed-size ring buffer. There are two ways to read it, and the difference matters:

MethodBehaviour
get_board_data()Returns everything and empties the buffer (destructive).
get_current_board_data(n)Returns a copy of the latest n samples and leaves the buffer intact (non-destructive).

The pipeline always uses get_current_board_data(188) (wrapped as KnightBoard.get_latest). Because it’s non-destructive, the board keeps filling the ring buffer continuously in the background — after each flicker period the code simply peeks at the most recent 188 samples (~1.5 s at 125 Hz), the exact window the user was just staring at. Nothing has to coordinate “who owns the buffer”; the stimulus process and the classifier never fight over it, and no samples are ever lost.

SSVEP classification lives and dies by timing. Two things must be exact.

The flicker itself must be frame-accurate. Each square is set white or black once per monitor refresh according to the sign of a sine wave:

colour = white if sin(2 * pi * f * t) >= 0 else black # updated every win.flip()

On a 60 Hz monitor the chosen frequencies land on near-integer frame counts (6.67 Hz ≈ 9 frames, 8.57 Hz ≈ 7, 10 Hz = 6, 12 Hz = 5), so the flicker stays rock-steady. If the monitor drops frames, the real flicker frequency drifts away from the intended one and accuracy collapses.

The EEG window must line up with the flicker. The trial structure is:

|<-- 1.0 s cue -->|<----- 1.5 s flicker ----->| capture + 0.5 s rest |
look here eyes on target grab last 188 samples

At the end of the 1.5 s flicker the recording flag is raised, and the classifier grabs the last 188 samples. TRCA then uses samples [19:144] — i.e. it throws away the first ~150 ms (visual latency, before the cortex entrains) and keeps a clean 1.0 s window.

The exact same filter chain is applied when recording training data and when classifying live data — otherwise the learned spatial filters no longer match the incoming signal. It lives in one place, ssvep/preprocessing.py.

Per channel, in order:

  1. Detrend (constant) — remove the DC offset / slow baseline.
  2. Band-pass 3–48 Hz (2nd-order Butterworth, zero-phase) — keep the SSVEP fundamentals and their first harmonics; drop drift and muscle noise.
  3. Band-stop 49–51 Hz and 59–61 Hz (4th-order, zero-phase) — notch out mains hum. Keeping both notches means the same code works on 50 Hz and 60 Hz power grids.

All filters are zero-phase (forward-backward) so they don’t shift the timing of the response, which matters because the analysis window is only 1 s long.

TRCA (Task-Related Component Analysis) learns, for each target frequency, a spatial filter — a set of weights that combines the 8 electrodes into a single channel that maximises the reproducibility of the SSVEP response across training trials. Intuitively: it finds the electrode mixture where “the part of the signal that repeats every time you look at 10 Hz” is strongest and the random noise cancels out.

The pipeline (ssvep/trca_model.py, built on meegkit):

  1. Filter bank. The signal is split into 6 overlapping sub-bands (config.FILTERBANK) so the classifier can exploit SSVEP harmonics, not just the fundamental. Higher sub-bands carry the 2nd/3rd harmonics.
  2. Fit. For each target, TRCA computes a spatial filter and an averaged template from the training trials.
  3. Predict. A new 1 s window is filtered through every sub-band and every target’s spatial filter, correlated with that target’s template, and the sub-band correlations are combined. The target with the highest score wins.

The pipeline uses the ensemble variant, which shares information across all targets’ filters and is noticeably more accurate. evaluate_trca.py runs leave-one-block-out cross-validation and reports accuracy plus ITR (information transfer rate, bits/min — the standard BCI speed metric).

8. Why the Order You Look at the Stimuli Matters

Section titled “8. Why the Order You Look at the Stimuli Matters”

TRCA is a supervised, personalised method: it only knows what a “10 Hz response” looks like because it was told, by looking at the 10 Hz target when the software recorded a trial labelled 10 Hz. Two consequences follow:

  • Labels must be correct. During collection you’re cued which square to look at, and the captured window is saved under that target’s label. Look at the wrong square and that trial is mislabelled, poisoning the model.
  • Training data must reflect the real world. The classifier only generalises to conditions it has seen. Train sitting still in a dark room, then use the BCI in bright light while moving, and the live EEG won’t match the templates — accuracy drops. Collect data the same way you’ll use it: same electrode placement, posture, lighting, distance to screen, and gaze-shift rhythm.

The repo also randomises the order targets are cued within each block. Always looking at them in a fixed order (top-left, top-right, …) risks the model learning sequence/adaptation artifacts (eye fatigue, expectation) that correlate with the label but have nothing to do with the flicker. Randomising the order — while still recording several blocks so every target appears many times — breaks that confound.

ssvep/ # the reusable, commented library
├── config.py # all tunable parameters (port, freqs, timing, keys)
├── board.py # KnightBoard: connect, configure channels, read buffer
├── preprocessing.py # shared detrend + band-pass + notch filter chain
├── trca_model.py # load data, fit TRCA, cross-validate, predict
├── recording.py # background board-reader process (collect / predict)
└── stimulus.py # PsychoPy window, flicker squares, single-trial routine
collect_training_data.py # SCRIPT 1: record your own labelled SSVEP blocks
realtime_control.py # SCRIPT 2: live TRCA control mapped to keyboard keys
evaluate_trca.py # offline leave-one-block-out cross-validation
trca4stim.ipynb # notebook: visualise harmonics + walk through TRCA
docs/electrode_montage.svg # electrode diagram
utils/freq-checker/ # Arduino sketch to validate flicker timing (LDR)
training_data/ # your recorded block_{block}_{trial}.csv files
Terminal window
git clone https://github.com/NeuroPawn/ssvep.git
cd ssvep
pip install -r requirements.txt

Open ssvep/config.py and set at least:

  • SERIAL_PORT — your board’s COM port (Windows) or /dev/tty… (Linux/macOS).
  • STIMULUS_SCREEN0 for your main monitor, 1 for a second monitor.
  • KEY_MAP — which key each target should press.

Make sure the stimulus monitor runs at 60 Hz.

Terminal window
python collect_training_data.py

Look only at the cued (red-outlined) square each trial and hold your gaze steady during the flash. Do 6+ blocks. Files land in training_data/.

Terminal window
python evaluate_trca.py

Aim for high per-block accuracy. If it’s poor: check electrode contact, redo the board setup, confirm the monitor is a true 60 Hz, and collect more blocks.

Terminal window
python realtime_control.py

Give focus to the app you want to control (a game, a robot teleop window, etc.). Look at a target for ~1.5 s and the mapped key is pressed. Press Escape to stop.