A standalone PyQt6 desktop application for decoding memory reactivation from EEG in real time. It trains a subject-specific decoder offline from a recording, then runs that decoder live against the incoming EEG stream to read out reactivation as it happens.
This README has three parts:
- Getting Started: install and run.
- User Guide: what the app works with, how to configure an experiment, and how to operate it.
- Developer Guide: architecture, hardware, and testing.
- Python 3.10+ (3.11 recommended)
- Windows is required for the live LSL stream path
(
tools/lslproxy/LSLProxy.exe). Phase 1 and the full test suite work on Windows, macOS, Linux, and WSL.
cd online_decoder
python -m venv .venvActivate the venv:
- Windows PowerShell:
.venv\Scripts\Activate.ps1(one-time only, if PowerShell blocks the script:Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser) - macOS / Linux / WSL:
source .venv/bin/activate
Then:
pip install -r requirements-dev.txtrequirements-dev.txt transitively includes requirements.txt (via
the first-line -r reference), so this single install covers both the
app's runtime deps and the tooling needed to run tests + debug scripts.
For a strict production-runtime-only install, use
pip install -r requirements.txt instead, but you won't be able to
run pytest or the scripts/ helpers.
# Windows PowerShell
$env:PYTHONPATH = "src"
python -m frontend.main# macOS / Linux / WSL
PYTHONPATH=src python -m frontend.mainThe app is not tied to a single experiment. A study is compatible as long as it is structured so decoders can be trained offline and then run live:
- A functional-localizer (training) phase. The classes you want to decode must be presented as tagged stimuli in a dedicated phase, so Phase 1 can train one decoder per class from that recording. In the example above, animate vs. inanimate images are shown and labeled during the localizer. The trained decoders are then read out live during encoding and retrieval.
- 64 EEG channels + 1 trigger channel, recorded at 1000 Hz.
- Parallel-port triggers. Events are emitted as parallel-port codes, which land in the recording's markers offline and in the trigger channel live.
- A BrainVision recording (
.vhdr+.vmrk+.eeg) of the localizer for offline training. - A decodable contrast. Each decoding target must be expressible as a positive-vs-negative grouping of trigger labels (see Configuration).
Everything experiment-specific (stimuli, trigger codes, decode targets) is
declared in experiment_config.yaml, so adapting the app to a new compatible
study needs no code changes.
A single file, experiment_config.yaml, describes the experiment. It is the
only thing you edit to adapt the app to a new compatible study: stimuli, trigger
codes, and decode targets all live here, so no code changes are needed. You pick
the file on the app's Settings screen.
The file is validated on load against the Pydantic schema in
src/backend/core/config_models.py, and
unknown keys are rejected. It holds only the experiment-specific settings below.
The preprocessing parameters are fixed.
A minimal, complete config:
experiment_info:
name: My_Study # free-text label for the run
random_state: 42 # seed for ICA, CV splits, and training (top level only)
markers_mapping: # every trigger code the experiment emits, code -> name
events:
- {id: 11, name: red}
- {id: 12, name: green}
- {id: 13, name: yellow}
decoders:
model: LDA # LDA | Logistic | SVM
params: # model-dependent, validated per model (see config_models.py)
solver: lsqr
shrinkage: auto
scale_method: standard # standard | median | null
cv:
k: 5 # cross-validation folds (minimum 2)
tasks: # one binary decoder is trained per task
- name: red decoder
pos_labels: [red]
neg_labels: [green, yellow]
- name: yellow decoder
pos_labels: [yellow]
neg_labels: [green, red]The keys:
experiment_info.name: a label for the run.random_state: one integer seed, set at the top level only (setting it underdecodersis an error). Drives the ICA fit, CV splits, and training.markers_mapping.events: the trigger-to-event mapping. Each entry maps a parallel-portidto aname. Names are what the decoders refer to.decoders:model:LDA,Logistic, orSVM.params: model-specific hyperparameters, validated against the allowed keys for the chosen model (unknown keys are rejected). See_VALID_PARAMS_BY_MODELand the defaults inconfig_models.pyfor the authoritative list.scale_method:standard,median, ornull(no scaling).cv.k: number of cross-validation folds (minimum 2).tasks: one entry per decoder. Each names apos_labelsgroup and aneg_labelsgroup. The two must not overlap, and every label must be a name frommarkers_mapping.events(or an interval, below).
Optional: intervals. A class can also be defined by the span between two
markers, rather than a single stimulus. Inside every [start, stop] occurrence,
epoch-sized windows are tiled and labeled, and that name becomes usable as a task
label, for example a resting baseline to contrast against stimuli:
intervals:
- name: rest
start: trial_start # both must be names mapped in markers_mapping.events
stop: trial_endThe app opens on a welcome screen with two entry points: Start New Training, which runs the full offline pipeline from a config and recording, or Open Live from Existing Output, which jumps straight to live inference from a folder a previous run already trained into.
The Application User Manual is a step-by-step, screenshot-driven guide to operating the app end to end. It covers:
- what you need before starting
- the Phase 1 training sequence: settings, data loading, preprocessing (with the interactive bad-channel and ICA reviews), evaluation and timepoint selection, and training
- the Phase 2 live screen: selecting a stream, and reading the probabilities, decisions, frozen events, and latency, plus the controls
- the output files a run produces and how to use them
The app is a PyQt6 frontend over a decoding backend, running in two phases: offline decoder training and live inference.
For the details:
- Backend architecture: the
AppSessionentry point, the two phases, the artifact, the live loop, and the decision layer. - Frontend architecture: the application window, the three screens, the Phase 1 views, and the Phase 2 live screen.
- Replacing the decoder: how to swap the models, or the whole decoding approach, while keeping the rest of the app.
- Logging conventions.
Live inference reads EEG from a NeurOne amplifier, bridged onto an LSL stream the app consumes.
See the Hardware guide for the full signal chain, setup, stream contract, and trigger decoding.
scripts/ holds command-line helpers for development and lab work. The useful
groups:
- Replay a recording as a live LSL stream:
replay_vhdr_to_lsl.py,replay_xdf_to_lsl.py. - Inspect and smoke-test the live path:
characterize_lsl.py,smoke_test_lsl_receiver.py,smoke_stream_worker.py,inspect_xdf.py. - Set up dev data and fixtures:
demo_seed_debug_snapshots.py,create_test_eeg.py,split_subject_by_phase.py.
Fast path for iterating on UI screens without sitting through ~5 min of real preprocessing each time. One-time seed from a real recording, then drive the whole pipeline with Ctrl+→.
python -m scripts.demo_seed_debug_snapshots --data <path/to/subject>
python -m frontend.debug.mainSee src/frontend/debug/README.md for the full walkthrough mechanics and the seeded snapshot profiles.
The suite lives under tests/, organized to mirror the source: core/ for
config and session-path validation, offline_phase/ for the training pipeline
(preprocessor, evaluator, trainer, orchestrator), online_phase/ for the live
path (LSL receiver, online preprocessor, inference, decision engine, session
logger, stream worker), and frontend/ for headless Qt tests of the screens,
views, and widgets.
Run it with:
pytest tests/Two things need extra setup. The Qt tests rely on pytest-qt (the qtbot
fixture), which the dev requirements provide. And an LSL integration test is
skipped unless RUN_LSL_INTEGRATION=1 is set with a real stream present.

