diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 674e90f1..daa90e2f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -6,10 +6,43 @@ on:
- 'v*'
jobs:
+ # Gate. A tag must not publish anything until the same checks CI runs on every
+ # push have passed on the tagged commit. Runs once rather than per-OS — test.yml
+ # already covers the cross-OS matrix — but nothing is built or uploaded until
+ # this job is green.
+ verify:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out Git repository
+ uses: actions/checkout@v4
+
+ - name: Install Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Test
+ run: npm test
+
+ - name: Typecheck
+ run: npm run typecheck
+
+ - name: Lint
+ run: npm run lint
+
release:
+ needs: verify
runs-on: ${{ matrix.os }}
strategy:
+ # Stop the other platforms as soon as one fails, so a broken tag uploads
+ # as few half-good artifacts as possible.
+ fail-fast: true
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
@@ -24,11 +57,20 @@ jobs:
cache: 'npm'
- name: Install dependencies
- run: npm install
+ run: npm ci
+
+ - name: Build
+ run: npm run build
+
+ # Between build and publish: refuse to ship a build whose Pyodide payload
+ # is missing. That failure is invisible in dev and breaks analysis entirely
+ # in the packaged app.
+ - name: Verify build artifacts
+ run: npm run test:build
- - name: Build and publish
+ - name: Publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
USERNAME: ${{ secrets.USERNAME }}
PASSWORD: ${{ secrets.PASSWORD }}
- run: npm run package-ci
+ run: npx electron-builder build --publish always
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 994d35eb..c5d5745a 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -24,8 +24,10 @@ jobs:
node-version: 22
cache: 'npm'
+ # npm ci, not npm install: installs exactly the locked tree, so the commit
+ # CI passes on is the commit that gets packaged.
- name: Install dependencies
- run: npm install
+ run: npm ci
- name: Test
run: npm test
@@ -38,3 +40,8 @@ jobs:
- name: Build
run: npm run build
+
+ # Must follow Build — asserts out/ and the Pyodide payload are actually
+ # on disk where the packaged app's protocol handler will look for them.
+ - name: Verify build artifacts
+ run: npm run test:build
diff --git a/.llms/learnings.md b/.llms/learnings.md
index 9443e174..b1eca16b 100644
--- a/.llms/learnings.md
+++ b/.llms/learnings.md
@@ -9,6 +9,16 @@ Format: brief heading + explanation + (optional) relevant file paths.
+## Comment style: keep them on definitions, not inside logic
+
+This repo prefers minimal comments. Write them on **function, prop, or data-structure
+definitions** (docstrings / JSDoc, a one-liner over a type field, an interface member) —
+they describe intent and behavior that outlives the implementation. Avoid comments
+*inside* function bodies that narrate the process step-by-step, restate the code, or
+tag "Phase N"/PR history; that knowledge belongs in the definition's summary, the
+relevant skill (e.g. the pyodide skill), or git history. A component's top-of-file
+comment should concisely state what it is and does — not a diagram of every branch.
+
## Markers: device-agnostic injection via the EEGDriver interface
Marker injection used to be Muse-only and lived in the UI (`RunComponent.eventCallback`
@@ -173,8 +183,3 @@ Build note: with `module: ESNext` source but CommonJS main output, a guarded `re
Experiments emit `this.data.correct_response` as a real boolean (`true`/`false`) and `response_given` as `'yes'`/`'no'` (see `src/renderer/utils/labjs/functions.ts`). But all consumers in `src/renderer/utils/behavior/compute.js` read data **after** it's been written to CSV and re-parsed, so every value is a **string**. That's why existing code gates on `row.correct_response === 'true'` and `row.response_given === 'yes'`, and parses numbers with `parseFloat`.
Trap: a new metric written naively (`row.correct_response === true`, or arithmetic on an unparsed string) will silently return `false`/`0`/`NaN` for post-CSV data — and may *work* on pre-CSV in-memory data, so it passes a quick test and fails in production. Always compare against the string `'true'`/`'yes'` and `parseFloat` before doing math.
-
-## Pre-existing TypeScript errors (do not treat as regressions)
-
-- `src/renderer/epics/experimentEpics.ts` (lines 170, 205) — RxJS operator type mismatch
-- `src/renderer/routes.tsx` (lines 15-17) — Redux container component prop types
diff --git a/TODOS.md b/TODOS.md
index 80fad40f..4ca8ee16 100644
--- a/TODOS.md
+++ b/TODOS.md
@@ -6,6 +6,8 @@ Deferred and in-flight work. Keep this current — when something ships, delete
- [ ] **Lesson content from Steve Azeka** — owner: Dano. Set a date for the first sample lesson. Fallback: if no lesson by that date, Dano authors one throwaway stub to unblock the lesson surface.
- [ ] **In-app lesson surface v1** — static markdown render only (no editing, no interactivity). Build against a dummy lesson; tune to real content once a sample lands. Confirm markdown is a rich-enough format with Azeka before building.
+- [ ] **First release dry-run (`v1.0.0-rc.1`)** — the Release workflow has *never executed*; no tag has ever been pushed. It is now gated (verify job: test/typecheck/lint; artifact check between build and publish) and publishes as a **draft**, so a tag no longer auto-updates installed clients until someone publishes the draft by hand. Still unproven end to end: push an RC tag, watch all three OS legs build, download each artifact, launch it, and confirm the app opens and Pyodide reaches `ready`. Do this before any demo depends on a released build.
+- [ ] **Packaged-app launch smoke test** — `tests/build.check.ts` asserts the build output and Pyodide payload are present on disk, but nothing *launches* the packaged app. The gap is a real Electron smoke test (spawn the built app under `xvfb` on Linux, assert the window opens and the worker posts `ready`) — that is what would catch a prod-only Pyodide regression automatically instead of during a demo.
- [ ] **Cross-platform LSL packaging verification** — `macOS arm64` is done + self-contained (see Done recently). Still needs each build machine: **macOS x64** (`patchDeps` only symlinks on arm64, so an Intel build uses node-labstreaminglayer's original x86_64 dylib — `otool -L` the packaged liblsl to confirm self-contained; the `afterPack` hook bundles whatever external deps it finds, arch-agnostic), **Windows x64** (bundled `lsl_amd64.dll` is a self-contained PE — confirm koffi loads it from the packaged app), **Linux/Ubuntu** (bundled `.so` is x86_64 ELF — `ldd` it + Web-Bluetooth smoke test, `--enable-experimental-web-platform-features` already set).
## Deferred (post-launch)
@@ -15,12 +17,14 @@ Deferred and in-flight work. Keep this current — when something ships, delete
- [ ] External / generic LSL stream support (any EEG device).
- [ ] Lab.js cleanup — remove jspsych, type lab.js data. Not on the content critical path; users never see it.
- [ ] Lesson surface beyond markdown — block-based programming, embedded notebooks (the CLAUDE.md extensibility horizon).
+- [ ] **Epoch reviewer Phase 3 — onboarding layer.** Plain-language explanations of epochs + each artifact type, a **guided mode** (step through auto-flagged epochs with "why we flagged this," student confirms/overrides), channel legend tied to head position (Muse 10-20), student-facing tone. Builds on the Phase 0-2 reviewer (PRs #223/#224/#225). **Open question OQ3 (onboarding depth) is still unresolved** — how much curriculum (tooltips only vs. a real walkthrough), guided-mode-as-default? This is product-shaped, not architecture — take it through `/office-hours` or `/plan-ceo-review` before forging. Note it overlaps the "in-app lesson surface" critical-path work above; sequence deliberately.
+- [ ] **Epoch reviewer Phase 4 — polish & generalize.** N-channel devices (Neurosity 8-ch, external LSL 32-64-ch) — the renderer already windows/downsamples but needs real testing at scale and possibly the WebGL swap behind `drawEpochs` (OQ1 left that a later swap). Accessibility, keyboard-first flow, performance. Also OQ7 (keep a read-only static-SVG fallback for environments where the interactive UI can't run?) is still open.
## Known issues / tech debt
- [ ] **(Optional) Full Pyodide worker RPC** — the analysis/Clean pipeline crash is now **fixed** (harvested from PR #194): a `dataKey` routing pattern parallel to `plotKey` — the worker echoes `dataKey` + PyProxy-converted results, and `pyodideMessageEpic` routes `epochsInfo`→`SetEpochInfo` / `channelInfo`→`SetChannelInfo`; the info epics are fire-and-forget. This unblocks the pipeline without the bigger refactor. The deeper latent issue remains, though: `worker.postMessage` returns `undefined` on *post*, so the `await`s in `webworker/index.ts` are no-ops and cross-message sequencing still relies on worker FIFO. A true `runPython(worker, code, ctx?)` RPC — `Map` + one `message` listener, worker echoes `id` — would let epics `await` real results and delete the `plotKey`/`dataKey` switch entirely. Only worth doing if the FIFO sequencing ever actually bites; not urgent now.
-- [ ] Pyodide-fidelity smoke test — analysis pipeline is tested against native MNE, not yet under Pyodide/WASM (see `.llms/learnings.md`).
-- [ ] Pre-existing TypeScript errors (not regressions): `experimentEpics.ts` (RxJS operator types), `routes.tsx` (Redux container prop types).
+- [ ] Pyodide-fidelity smoke test — analysis pipeline is tested against native MNE, not yet under Pyodide/WASM (see `.llms/learnings.md`). **In progress:** the epoch-review Phase 0 adds a *narrow* Pyodide test for the `get_epochs_arrays` float32 buffer path (byteLength, decode-vs-native, transfer detaches source); the full-pipeline Pyodide job remains deferred.
+- [ ] **Epoch reviewer Phase 2 polish (from PR #225 review).** Three non-blocking behaviors flagged during the Phase 2 forge: (1) `get_epochs_arrays`/`suggest_rejections` use `pick_types(eeg=True)` whose MNE default is `exclude='bads'`, so after a Clean that flags a bad channel the re-fetched reviewer omits that channel from the display (saved `.fif` is unaffected) — decide whether to keep bad channels visible-but-greyed (`exclude=[]`) instead of vanishing; (2) re-running "Auto-flag" with the same threshold re-adds suggestions the user had manually unclicked (additive union merge in `CleanComponent.componentDidUpdate`); (3) the auto-flag threshold `` has no min guard (0 µV flags everything). All in `src/renderer/components/CleanComponent/` + `webworker/utils.py`.
## Done recently
diff --git a/docs/device-connectivity.md b/docs/device-connectivity.md
index c072b2b8..60ba16fa 100644
--- a/docs/device-connectivity.md
+++ b/docs/device-connectivity.md
@@ -1,6 +1,10 @@
# Device Connectivity
-How BrainWaves discovers and connects to EEG devices (currently: Muse only).
+How BrainWaves discovers and connects to EEG devices. Supported devices are Muse
+and Neurosity (first-party drivers) plus external LSL inlet streams; the flow
+diagrams below trace the **Muse** path specifically, since it is the most involved
+(Web Bluetooth device selection). The driver interface itself is device-agnostic —
+see the `redux-observable-epochs` skill for the `EEGDriver` contract.
---
@@ -12,7 +16,7 @@ Device connectivity spans three layers:
|---|---|---|
| **UI** | `CollectComponent/`, `EEGExplorationComponent` | Trigger search, display state, handle user selection |
| **Epics** | `epics/deviceEpics.ts` | Orchestrate async device lifecycle via RxJS |
-| **Driver** | `utils/eeg/muse.ts` | Web Bluetooth API calls via `muse-js` |
+| **Driver** | `utils/eeg/index.ts` (registry) → `muse.ts`, `neurosity.ts`, `lslInlet.ts` | Per-device acquisition behind the shared `EEGDriver` interface (`utils/eeg/types.ts`) |
All device state lives in Redux (`reducers/deviceReducer.ts`). Epics react to dispatched actions and fire new actions as side effects.
diff --git a/docs/epoch-review-ui-plan.md b/docs/epoch-review-ui-plan.md
deleted file mode 100644
index 20a0ee6e..00000000
--- a/docs/epoch-review-ui-plan.md
+++ /dev/null
@@ -1,251 +0,0 @@
-# Interactive Epoch Review UI — Preliminary Plan
-
-**Status:** Preliminary / discussion. Written to seed a planning loop — vision and
-context first, deep technical decisions deliberately left open (flagged as Open
-Questions). Not yet an implementation spec.
-
-**Owner:** Dano · **Drafted:** 2026-07-06
-
----
-
-## 1. Vision
-
-Replace MNE's native Matplotlib "clean epochs" GUI — which **cannot run in the
-Pyodide/WASM port** — with a first-class, fully interactive React experience for
-reviewing and cleaning epoched EEG.
-
-This is not just a feature port. It is a chance to make epoch review **an
-onboarding experience to EEG signal analysis** as much as a tool. The target
-user is a student who may be seeing epoched EEG for the first time. The UI should
-teach *what an epoch is, what artifacts look like, and why we reject them* while
-they do the work — in the app's lighthearted, student-friendly voice (per
-`.llms/CLAUDE.md`). It must also be a genuinely capable tool an experienced user
-trusts. Both, not either.
-
-It should be **fully featured and interactive** — at least at parity with what
-MNE's GUI offered, and ideally beyond it (guided artifact detection, live ERP
-feedback, explanations) because we control the whole surface.
-
----
-
-## 2. Why this exists — the WASM gap
-
-BrainWaves runs its analysis (MNE-Python) **in-browser via Pyodide/WASM inside a
-web worker** (see the `pyodide-mne` skill and `docs/pyodide-in-electron-vite.md`).
-That environment has hard constraints:
-
-- The worker uses Matplotlib's **`agg` backend** — headless, buffer-based. There
- is **no GUI event loop and no window**. WebAgg (Matplotlib's browser backend)
- does not work in a worker context at all.
-- Every *static* plot we render today (PSD, topo map, ERP) works by having Python
- `savefig()` an **SVG string**, tagging it with a `plotKey`, and shipping that
- string back over the worker message channel to render in `PyodidePlotWidget`.
-- `epochs.plot()` is the **only** analysis call that tried to be *interactive*.
- It returns a live Matplotlib Figure object, which (a) has no GUI to display in,
- and (b) can't even cross the worker boundary — a `PyProxy`/Figure isn't
- `structuredClone`-able, so `postMessage` throws `DataCloneError`.
-
-**This is not an MNE-version problem.** We are already on **MNE 1.12.1** (current).
-The recent errors (`plot_psd` → `compute_psd`, `events=None` → `events=False`) are
-just the code catching up to the modern MNE API; bumping MNE won't remove them.
-The interactive-GUI problem is architectural: **native Matplotlib GUIs don't exist
-in WASM, and no MNE version changes that.** The only path to interactive epoch
-review is to build the UI ourselves — which is what this plan is for.
-
----
-
-## 3. What MNE's Matplotlib GUI did (the experience to replicate)
-
-`epochs.plot()` opened an interactive browser. Reference behavior:
-
-**Layout**
-- Epochs laid out **side by side horizontally**; channels **stacked vertically**.
-- Vertical divider lines between epochs; epoch index labels along the bottom.
-- A configurable window of `n_epochs` visible at once (our call used 6).
-- Epochs colored by **event/condition** (our numeric marker codes → conditions),
- with a legend.
-- Event markers drawn as vertical lines within each epoch.
-
-**Navigation**
-- Scroll through epochs (←/→ / Page keys) and channels (↑/↓).
-- Amplitude **scaling** up/down (`scalings='auto'` set the initial scale).
-- Butterfly mode, DC-removal toggle, annotation toggle, help overlay.
-
-**The core action — rejection**
-- **Click an epoch** → toggle it "bad" (visually greyed). On window close, the
- marked epochs are **dropped** (`epochs.drop(indices)`).
-- **Click a channel label** → mark the channel bad (`epochs.info['bads']`),
- excluding it from downstream analysis.
-
-**Result**
-- On close, the Epochs object is mutated: bad epochs dropped, bad channels
- flagged. That cleaned object is what gets saved (`-cleaned-epo.fif`) and fed to
- topo/PSD/ERP.
-
-**Adjacent MNE capability we should exploit**
-- MNE can **auto-flag** epochs via peak-to-peak `reject` thresholds, recording
- *why* each was dropped in the **`drop_log`**. This is gold for onboarding:
- "we flagged these as likely blinks — here's the trace, do you agree?"
-
----
-
-## 4. What "cleaning" actually means (and a caveat)
-
-Cleaning = **removing artifact-contaminated epochs and bad channels** so the
-average (ERP) reflects brain activity, not blinks/muscle/movement. Common
-artifacts a student should learn to recognize:
-
-- **Eye blinks** — large, slow frontal deflections (AF7/AF8 on Muse).
-- **Muscle/EMG** — high-frequency bursts.
-- **Movement/drift** — slow baseline wander.
-- **Electrode pop** — sudden step in one channel.
-
-**Caveat that raises the stakes:** filtering already happens at load (`filterIIR`
-1–30 Hz). So *without* interactive rejection, today's "Clean Data" does no real
-cleaning — it just re-saves every epoch. The interactive review **is** the
-feature; there's no meaningful non-interactive fallback. (A read-only static SVG
-of the epochs is possible as a stopgap, but it can't clean anything.)
-
----
-
-## 5. Design goals
-
-1. **Onboarding-first.** A newcomer should leave understanding epochs and
- artifacts. Explanations, a guided mode, plain-language cues — not jargon walls.
-2. **Fully featured & interactive.** Parity-plus with MNE's GUI: reject epochs,
- flag channels, scroll, scale, zoom, condition coloring.
-3. **A tool experts trust.** Auto-suggestions must be overridable; nothing hidden
- or magic; the resulting cleaned data must be exactly what MNE would produce.
-4. **Live feedback.** Ideally, show the **ERP updating as you reject** — the
- single most powerful teaching moment (see the signal get cleaner).
-5. **Hackable & extensible.** Fits the CLAUDE.md horizon (block-based programming,
- embedded notebooks). Don't leak MNE/Pyodide abstractions into the UI.
-6. **Device-agnostic.** Works for 4-ch Muse today, N-ch (Neurosity, external LSL)
- tomorrow.
-
----
-
-## 6. Replicating it in React — the pieces
-
-Four concerns. Each has real design choices (deferred to the planning loop).
-
-### 6a. Data path: Python → renderer
-We stop shipping a *GUI object* and instead ship the **raw numbers**, then render
-them ourselves. Needed from Python per "clean" request:
-- Epoch data array: `epochs.get_data()` → shape `(n_epochs, n_channels, n_times)`.
-- Metadata: `ch_names`, `sfreq`, `times`, per-epoch condition (from the marker
- registry), and the **auto-reject flags + `drop_log`** for suggestions.
-
-**Transport is the crux** (and the lesson from this whole debugging session):
-don't JSON-serialize float arrays, and don't return a `PyProxy`. Get the numpy
-buffer as a **`Float32Array` / `ArrayBuffer`** and send it as a **transferable**
-over `postMessage` (zero-copy). Small metadata rides as JSON alongside. This
-sidesteps the serialization wall entirely — buffers cross cleanly; Figures don't.
-
-### 6b. Rendering
-Muse is tiny (4 ch × ~256 samples × dozens of epochs), but we must not design for
-4 channels — Neurosity (8) and external LSL devices (32–64) are on the roadmap.
-- **Canvas 2D** is the pragmatic default (smooth for thousands of points).
-- **WebGL** if channel/epoch counts get large.
-- **SVG/DOM overlay** on top for interaction targets (epoch columns, channel
- labels, tooltips, selection highlights) — cheap hit-testing and accessibility.
-- Likely a **hybrid**: canvas traces + DOM/SVG interaction layer.
-
-### 6c. Interaction model (parity-plus with MNE)
-- Click/tap an epoch → toggle reject (clear visual state).
-- Click a channel label → toggle bad channel.
-- Scroll/scrub epochs and channels; amplitude scaling; horizontal zoom.
-- Condition coloring + legend.
-- **Beyond MNE:** hover tooltips ("this looks like a blink"), a "reject all
- flagged" / "review flagged only" mode, keyboard-first flow, undo.
-
-### 6d. Apply path: renderer → Python
-- Collect rejected epoch indices + bad channels → dispatch an action → epic posts
- to the worker → Python `epochs.drop(indices)` + set `info['bads']` → save
- `-cleaned-epo.fif` via the existing MEMFS/`saveEpochs` path.
-- The result must be **bit-identical to what MNE's GUI would have produced** — the
- UI changes, the science does not.
-
-### 6e. The onboarding layer (the differentiator)
-- Plain-language explanations of epochs and each artifact type.
-- **Guided mode** (default for newcomers): step through auto-flagged epochs with
- "why we flagged this," student confirms/overrides → teaches artifact spotting.
-- Channel legend tied to head position (Muse 10-20: TP9/AF7/AF8/TP10).
-- **Live ERP preview** pane that updates as epochs are rejected.
-- Tone: encouraging, lighthearted, student-facing.
-
----
-
-## 7. Architecture fit & constraints
-
-- **Worker protocol** (`pyodide-mne` skill): today's plots use fire-and-forget
- `postMessage` + `plotKey`/`dataKey` reply routing through `pyodideMessageEpic`.
- A data-heavy request/response (fetch epoch arrays, get result back, then let the
- user act) pushes toward finally doing the **`runPython` RPC** already tracked in
- `TODOS.md` ("Optional Full Pyodide worker RPC"). This feature is the strongest
- reason yet to build it — worth deciding early.
-- **New Python helpers** (`webworker/utils.py`): `get_epochs_arrays(epochs)` →
- buffer + metadata; `apply_rejection(epochs, drop_indices, bad_channels)`. Keep
- them native-testable (the `tests/analysis/` pattern) so cleaning logic is
- verified against real MNE in CI.
-- **New epic(s)** in `pyodideEpics.ts` + **new actions** (fetch/set epoch data,
- apply rejection). Reuse `buildMarkerRegistry` for condition labels/colors.
-- **New React component** (`EpochReviewer` or similar) replacing the plot area in
- `CleanComponent`. Styling: shadcn/ui + Tailwind, brand teal, student tone.
-- **Constraints to respect:** headless `agg` in the worker; no large-array JSON;
- transferables for buffers; keep main/renderer/worker separation clean; don't
- leak Pyodide/MNE types into React.
-
----
-
-## 8. Open questions (for the planning loop)
-
-1. **Rendering tech** — Canvas 2D now, or WebGL up front for future high-channel
- devices?
-2. **RPC first?** — Do we build the `runPython` request/response RPC as a
- prerequisite (cleaner data fetch), or bolt this onto the current fire-and-forget
- `dataKey` pattern?
-3. **Onboarding depth** — Is guided mode the default? How much curriculum
- (tooltips only vs. a real walkthrough)?
-4. **Auto-rejection** — Expose peak-to-peak thresholds to the user, or keep them
- as invisible "suggestions"? What defaults?
-5. **Bad channels on Muse** — dropping 1 of 4 channels is drastic; do we support
- channel rejection for low-channel devices, or epochs-only there?
-6. **Live ERP preview** — in-scope for v1 (big teaching win, more compute) or a
- fast-follow?
-7. **Static fallback** — keep a read-only SVG epochs view for environments where
- the interactive UI can't run, or all-in on the React UI?
-
----
-
-## 9. Rough phases (to be firmed up in planning)
-
-- **Phase 0 — Transport & read-only render.** Ship epoch arrays + metadata across
- the worker boundary (transferable buffers); render static traces in React.
- Proves the data path and rendering choice.
-- **Phase 1 — Core interaction.** Click-to-reject epochs, scroll/scale/zoom, apply
- → `epochs.drop` → save `-cleaned-epo.fif`. Reaches functional parity with the
- *essential* MNE workflow.
-- **Phase 2 — Full parity.** Bad-channel flagging, condition coloring/legend,
- auto-flag suggestions from `drop_log`/peak-to-peak.
-- **Phase 3 — Onboarding layer.** Explanations, guided mode, artifact tutorials,
- live ERP preview.
-- **Phase 4 — Polish & generalize.** N-channel devices (Neurosity/LSL),
- accessibility, keyboard flow, performance.
-
----
-
-## 10. Context references
-
-- **Skills:** `pyodide-mne` (worker↔Python protocol, plot routing, `pyodide://`),
- `redux-observable-epochs` (epic anatomy, numeric marker-code contract).
-- **Docs:** `docs/pyodide-in-electron-vite.md`, `docs/user-flow.md`.
-- **Learnings** (`.llms/learnings.md`): agg backend / WebAgg-in-worker limits;
- plot-result routing pattern; PyProxy serialization; marker registry / numeric
- event codes; the analysis pipeline testability pattern.
-- **Code today:** `src/renderer/utils/webworker/{index.ts,webworker.js,utils.py}`
- (worker + Python), `src/renderer/epics/pyodideEpics.ts` (epics),
- `src/renderer/components/CleanComponent/`, `PyodidePlotWidget.tsx` (existing
- static-plot render path), `src/renderer/utils/eeg/markerRegistry.ts`.
-- **Related TODO:** `TODOS.md` → "Optional Full Pyodide worker RPC" (this feature
- is the strongest motivation to build it).
diff --git a/docs/lsl-implementation-plan.md b/docs/lsl-implementation-plan.md
deleted file mode 100644
index 186961fa..00000000
--- a/docs/lsl-implementation-plan.md
+++ /dev/null
@@ -1,477 +0,0 @@
-# LSL Integration Plan — BrainWaves
-
-## Executive Summary
-
-This document describes the architecture for adding Lab Streaming Layer (LSL) support to BrainWaves. The design supports connectivity to multiple device types (Muse, Neurosity, and arbitrary third-party LSL devices), real-time EEG visualization, and stimulus marker emission from lab.js experiments — all through a unified data pipeline.
-
-This plan is grounded in the actual codebase (`device-lsl` branch). It supersedes the original research-agent draft, which was written without source access.
-
----
-
-## Current State (device-lsl branch)
-
-The Muse Web Bluetooth connectivity issues documented in `docs/device-connectivity.md` are **already fixed** on this branch:
-- `select-bluetooth-device` handler registered in `src/main/index.ts:459` (auto-selects first Muse)
-- `cachedDevice` pattern in `src/renderer/utils/eeg/muse.ts:36` (avoids redundant `requestDevice` call)
-- `bluetooth:cancelSearch` IPC implemented in both preload and main
-
-What does NOT yet exist: any LSL plumbing. Everything below is net-new work.
-
----
-
-## Architecture Overview
-
-```
-┌──────────────────────────── Renderer ──────────────────────────────┐
-│ │
-│ muse.ts / future neurosity.ts Redux + RxJS Epics │
-│ ┌───────────────────────────┐ ┌──────────────────────┐ │
-│ │ getMuse / connectToMuse │──raw──►│ deviceEpics.ts │ │
-│ │ createRawMuseObservable() │ │ → rawObservable │ │
-│ └───────────────────────────┘ │ → signalQuality │ │
-│ │ → epochBatcher epic │──┐ │
-│ └──────────────────────┘ │ │
-│ │ │
-│ RunComponent.tsx │ │
-│ ┌───────────────────────────┐ │ │
-│ │ injectMuseMarker() (existing, keep) │ │
-│ │ window.electronAPI │ │ │
-│ │ .sendLSLMarker() (new) │────────────────────────────────┐ │ │
-│ └───────────────────────────┘ ipc: lsl:sendMarker │ │ │
-│ │ │ │
-│ ipc: lsl:sendEpoch ◄──┘ │ │
-│ │ │
-│ ConnectModal / future LSL stream browser │ │
-│ ipc: lsl:discoverStreams (invoke) │ │
-│ ipc: lsl:subscribeStream │ │
-│ ipc: lsl:unsubscribeStream │ │
-│ ipc: lsl:inletData│
-│ (main→renderer)│
-└──────────────────────────────────────────────────────────────────┴─┘
- │
-┌──────────────────────────── Main Process ──────────────────────── ▼─┐
-│ │
-│ src/main/index.ts │
-│ imports LSLOutletManager, LSLInletManager │
-│ │
-│ src/main/lsl/outlets.ts src/main/lsl/inlets.ts │
-│ ┌───────────────────────┐ ┌───────────────────────┐ │
-│ │ LSLOutletManager │ │ LSLInletManager │ │
-│ │ per-device EEG outlet│ │ resolveStreams() │ │
-│ │ marker outlet │ │ create/poll inlets │ │
-│ │ (irregular, string) │ │ forward via IPC │ │
-│ └───────────────────────┘ └───────────────────────┘ │
-│ │
-│ ◄──── LSL network (UDP multicast) ────► │
-└──────────────────────────────────────────────────────────────────────┘
-```
-
----
-
-## Key Decisions and Rationale
-
-### 1. BLE acquisition stays in the renderer via Web Bluetooth
-
-Keep muse-js and @neurosity/sdk in the renderer. Do not migrate to noble/bleat in main.
-
-- Web Bluetooth is actively maintained by Chromium; noble is effectively abandoned
-- Electron ships Chromium, so Web Bluetooth works on macOS, Windows, and Linux with no native build deps
-- The Neurosity SDK targets Web Bluetooth for its BLE transport; noble is not supported
-- IPC overhead with epoch batching (~8–16 messages/sec) is negligible
-- Noble requires platform-specific system libraries and `electron-rebuild` for each target
-
-### 2. LSL runs exclusively in the main process
-
-All LSL outlet/inlet operations happen in `src/main/lsl/` using `node-labstreaminglayer`.
-
-- LSL bindings use native liblsl via Node FFI; sandboxed renderers cannot load native modules
-- Centralized LSL in main creates a single lifecycle management point
-- `node-labstreaminglayer` (EdgeBCI) is the most complete Node binding — supports outlets AND inlets
-
-### 3. Neurosity's built-in device-side LSL is not used
-
-We manage our own outlets for all devices.
-
-- The Crown's embedded LSL is marked experimental with timing variability in their own docs
-- Running both device-side and app-side LSL causes duplicate streams in LabRecorder
-- Our outlet manager ensures consistent stream metadata and naming across all device types
-
-### 4. Existing muse.ts and epics are modified, not replaced
-
-No new "MuseAdapter class". Instead:
-- `src/renderer/utils/eeg/muse.ts` gains an epoch-batching utility function
-- A new epic in `deviceEpics.ts` subscribes to `rawObservable` and pipes batched epochs over IPC
-- The existing connect/search/signal-quality flow is unchanged
-
----
-
-## IPC Channels
-
-All channels are registered in `src/preload/index.ts` via `contextBridge.exposeInMainWorld('electronAPI', {...})` and handled in `src/main/index.ts` via `ipcMain.handle` / `ipcMain.on`.
-
-| Channel | Direction | Payload | Rate |
-|---|---|---|---|
-| `lsl:sendEpoch` | renderer → main | `LSLEpoch` | ~8–16 msg/sec per device |
-| `lsl:sendMarker` | renderer → main | `LSLMarker` | Event-driven |
-| `lsl:inletData` | main → renderer | `LSLInletEpoch` | ~16–60 msg/sec per stream |
-| `lsl:discoverStreams` | renderer → main (invoke) | — | On demand |
-| `lsl:subscribeStream` | renderer → main | `{ uid: string }` | Per subscription |
-| `lsl:unsubscribeStream` | renderer → main | `{ uid: string }` | Per teardown |
-| `lsl:inletDisconnected` | main → renderer | `{ uid: string }` | On loss |
-| `lsl:outletStatus` | main → renderer | `{ deviceId, status }` | On outlet change |
-
----
-
-## Shared Types
-
-Create **`src/shared/lslTypes.ts`** (new file). These types are imported by both `src/main/lsl/` and `src/renderer/`.
-
-To enable this, add a `@shared` alias to **both** the `main` and `renderer` Vite config blocks in `vite.config.ts`:
-
-```ts
-// vite.config.ts — add to main.resolve.alias AND renderer.resolve.alias
-'@shared': path.resolve(__dirname, 'src/shared'),
-```
-
-```typescript
-// src/shared/lslTypes.ts
-
-export interface LSLEpoch {
- deviceId: string;
- deviceType: 'muse' | 'neurosity';
- samples: number[][]; // [sampleIndex][channelIndex], µV
- timestamps: number[]; // one per sample (ms, performance.now())
- channelNames: string[];
- sampleRate: number;
-}
-
-export interface LSLMarker {
- label: string; // e.g. 'stimulus_onset', '1', '2'
- rendererTimestamp: number; // performance.now() at event time
-}
-
-export interface DiscoveredStream {
- uid: string;
- name: string;
- type: string; // 'EEG', 'Markers', etc.
- channelCount: number;
- sampleRate: number;
- sourceId: string;
-}
-
-export interface LSLInletEpoch {
- uid: string;
- samples: number[][];
- timestamps: number[];
-}
-```
-
----
-
-## Constants and Enums
-
-Update **`src/renderer/constants/constants.ts`**:
-
-```ts
-export enum DEVICES {
- NONE = 'NONE',
- MUSE = 'MUSE',
- NEUROSITY = 'NEUROSITY', // add in Phase 2
- LSL = 'LSL', // add in Phase 3 (external inlet)
- GANGLION = 'GANGLION',
-}
-```
-
----
-
-## Component Specifications
-
-### Epoch Batcher (Renderer — `src/renderer/utils/eeg/lslBridge.ts`, new file)
-
-A thin helper module that:
-- Exports `batchSamplesToEpoch(rawObservable, deviceId, deviceType, channelNames, sampleRate)` — returns a new Observable that buffers N samples (`bufferCount(32)`) into `LSLEpoch` objects
-- Exports `sendEpoch(epoch: LSLEpoch)` — calls `window.electronAPI.sendLSLEpoch(epoch)`
-- Exports `sendMarker(marker: LSLMarker)` — calls `window.electronAPI.sendLSLMarker(marker)`
-
-The buffer size of 32 gives ~125ms latency at 256 Hz and ~8 IPC messages/sec — negligible overhead.
-
-### New epic in `deviceEpics.ts`
-
-Add `lslForwardEpic` that:
-1. Filters on `DeviceActions.SetRawObservable`
-2. Gets device metadata from `state$.value.device.connectedDevice`
-3. Pipes `rawObservable` through `batchSamplesToEpoch(...)`
-4. Uses `tap(sendEpoch)` to forward each epoch over IPC
-5. Completes on `DeviceActions.Cleanup`
-
-This runs alongside — not instead of — the existing `setRawObservableEpic` and `setSignalQualityObservableEpic`.
-
-### Marker Bridge (Renderer — `src/renderer/components/CollectComponent/RunComponent.tsx`)
-
-`RunComponent.tsx` already calls `injectMuseMarker(event, time)` inside a callback. In Phase 4:
-- **Keep** the `injectMuseMarker` call (keeps marker-in-raw-EEG behavior for CSV recording)
-- **Add** `sendMarker({ label: event, rendererTimestamp: performance.now() })` alongside it
-- This makes the marker system device-agnostic — no change required to muse.ts
-
-### LSL Outlet Manager (Main — `src/main/lsl/outlets.ts`, new file)
-
-```ts
-import { StreamInfo, StreamOutlet, cf_int32 } from 'node-labstreaminglayer';
-
-class LSLOutletManager {
- private outlets = new Map();
- private markerOutlet: StreamOutlet | null = null;
-
- createDeviceOutlet(deviceId: string, channelNames: string[], sampleRate: number) { ... }
- pushEpoch(deviceId: string, epoch: LSLEpoch) { ... } // calls outlet.pushChunk()
- destroyDeviceOutlet(deviceId: string) { ... }
-
- createMarkerOutlet() { ... } // name='ExperimentMarkers', type='Markers', channels=1, IRREGULAR_RATE, string format
- pushMarker(label: string) { ... } // calls markerOutlet.pushSample([label])
-
- destroyAll() { ... }
-}
-
-export const lslOutlets = new LSLOutletManager();
-```
-
-Imported by `src/main/index.ts`. IPC handlers call `lslOutlets.createDeviceOutlet(...)` on `lsl:outletCreate` and `lslOutlets.pushEpoch(...)` on `lsl:sendEpoch`.
-
-### LSL Inlet Manager (Main — `src/main/lsl/inlets.ts`, new file)
-
-```ts
-class LSLInletManager {
- private inlets = new Map();
-
- async discoverStreams(): Promise { ... } // resolveStreams(1.0)
- subscribeStream(uid: string, onData: (epoch: LSLInletEpoch) => void) { ... }
- unsubscribeStream(uid: string) { ... }
- destroyAll() { ... }
-}
-```
-
-The poll loop calls `inlet.pullChunk(timeout=0.0)` at ~60 Hz per subscription and invokes `onData`. `onData` sends `lsl:inletData` via `mainWindow.webContents.send(...)`.
-
----
-
-## Build Configuration Changes
-
-### `vite.config.ts`
-
-1. Add `@shared` alias to both `main.resolve.alias` and `renderer.resolve.alias`
-2. Native modules in main are automatically externalized by electron-vite — no special config needed for `node-labstreaminglayer`
-
-### `package.json` (electron-builder section)
-
-Add `asarUnpack` for native `.node` files — they cannot be loaded from inside an ASAR archive:
-
-```json
-"build": {
- "asarUnpack": ["**/*.node"],
- ...
-}
-```
-
-`node-labstreaminglayer` ships prebuilt liblsl binaries in its `material/liblsl-release/` directory. These get included via the existing `"node_modules/**/*"` entry in `files`. Test packaging early (Phase 1) to confirm binary resolution works.
-
-### `postinstall` / `electron-rebuild`
-
-`electron-builder install-app-deps` (already in `postinstall`) handles rebuilding native modules for Electron's Node ABI. No changes needed to the script.
-
----
-
-## Pre-existing Bug to Fix in Phase 1
-
-**`src/renderer/epics/experimentEpics.ts:79`** hardcodes `MUSE_CHANNELS`:
-
-```ts
-writeHeader(streamId, MUSE_CHANNELS); // BUG: wrong for Neurosity or LSL inlets
-```
-
-Change to:
-
-```ts
-writeHeader(streamId, state$.value.device.connectedDevice?.channels ?? MUSE_CHANNELS);
-```
-
----
-
-## Implementation Phases
-
-### Phase 1: Muse → LSL Outlet
-
-**Goal:** Muse EEG data flows through the full pipeline and appears as a stream in LabRecorder.
-
-**Prerequisite:** Muse Web Bluetooth fixes are already merged on `device-lsl`. ✓
-
-**Steps:**
-
-1. **Install `node-labstreaminglayer`**
- ```bash
- npm install node-labstreaminglayer
- npm run postinstall # runs electron-builder install-app-deps to rebuild native module
- ```
- Verify the package loads in the main process: add a quick `require('node-labstreaminglayer')` test in `src/main/index.ts` and run `npm run dev`.
-
-2. **Add `@shared` alias to `vite.config.ts`** (both `main` and `renderer` blocks).
-
-3. **Create `src/shared/lslTypes.ts`** with `LSLEpoch`, `LSLMarker`, `DiscoveredStream`, `LSLInletEpoch`.
-
-4. **Create `src/main/lsl/outlets.ts`** with `LSLOutletManager`. Wire the `lsl:sendEpoch` IPC handler in `src/main/index.ts`.
-
-5. **Create `src/renderer/utils/eeg/lslBridge.ts`** with `batchSamplesToEpoch` and `sendEpoch`.
-
-6. **Add `lslForwardEpic` to `src/renderer/epics/deviceEpics.ts`**. Register it in `combineEpics` in `src/renderer/epics/index.ts`.
-
-7. **Add LSL IPC methods to `src/preload/index.ts`**:
- ```ts
- sendLSLEpoch: (epoch: LSLEpoch) => ipcRenderer.send('lsl:sendEpoch', epoch),
- sendLSLMarker: (marker: LSLMarker) => ipcRenderer.send('lsl:sendMarker', marker),
- discoverLSLStreams: () => ipcRenderer.invoke('lsl:discoverStreams'),
- ```
- Also add TypeScript declarations for the new methods (the existing `window.electronAPI` object is not yet typed — add a `src/renderer/types/electron.d.ts` declaration file).
-
-8. **Fix the `MUSE_CHANNELS` hardcoding** in `experimentEpics.ts`.
-
-9. **Add `asarUnpack: ["**/*.node"]`** to `package.json` build config.
-
-10. **Test:** connect a Muse, run LabRecorder on the same machine, confirm the EEG stream appears with correct channel count and sample rate.
-
----
-
-### Phase 2: Neurosity SDK
-
-**Goal:** Neurosity Crown connects and streams to its own LSL outlet alongside Muse.
-
-**Steps:**
-
-1. **Install `@neurosity/sdk`**
- ```bash
- npm install @neurosity/sdk
- ```
- Note: Neurosity SDK uses Web Bluetooth — no native build step needed.
-
-2. **Add `NEUROSITY = 'NEUROSITY'` to `DEVICES` enum** in `constants.ts`.
-
-3. **Create `src/renderer/utils/eeg/neurosity.ts`** mirroring the interface of `muse.ts`:
- - `getNeurosity()` — initiates Web Bluetooth scan for Crown
- - `connectToNeurosity(device)` → returns `DeviceInfo { name, samplingRate: 256, channels: [...] }`
- - `createRawNeurosityObservable()` — wraps `neurosity.brainwaves('raw')`, maps Crown epoch format to the same `EEGData` shape as `createRawMuseObservable()`
- - `disconnectFromNeurosity()`
-
-4. **Update `deviceEpics.ts`** to route based on `deviceType` (Muse vs Neurosity) when calling connect/disconnect/raw observable functions. The existing epic shape stays the same — just add conditionals.
-
-5. **`lslForwardEpic` already handles Neurosity** because it reads `deviceType` from Redux state and passes it through to `LSLEpoch`. The outlet manager creates a separate outlet per `deviceId`.
-
-6. **Test:** simultaneous Muse + Neurosity streams visible in LabRecorder.
-
----
-
-### Phase 3: LSL Inlet Manager + External Device Visualization
-
-**Goal:** Users can discover and visualize any LSL stream on the local network (OpenBCI, g.tec, BrainFlow, pylsl test scripts), even without a BLE device.
-
-**Steps:**
-
-1. **Create `src/main/lsl/inlets.ts`** with `LSLInletManager` (discover, subscribe, poll, forward).
-
-2. **Wire inlet IPC handlers** in `src/main/index.ts`:
- - `ipcMain.handle('lsl:discoverStreams', ...)` → returns `DiscoveredStream[]`
- - `ipcMain.on('lsl:subscribeStream', ...)` → starts poll loop, sends `lsl:inletData`
- - `ipcMain.on('lsl:unsubscribeStream', ...)` → stops poll loop
-
-3. **Add inlet IPC to preload** (`subscribeLSLStream`, `unsubscribeLSLStream`, `onLSLInletData`).
-
-4. **Build a stream discovery UI** — add a new tab or section in `ConnectModal.tsx` for "External LSL Device". It calls `discoverLSLStreams()`, shows results, and lets the user subscribe.
-
-5. **Add `LSL = 'LSL'` to `DEVICES` enum** and add a new Redux action `SetLSLInletStream` that stores the `DiscoveredStream` info in `deviceReducer` as the `connectedDevice`.
-
-6. **Wire inlet data to `rawObservable`** — when an inlet is subscribed, create an RxJS Subject in the renderer that emits `EEGData` for each `lsl:inletData` message, then dispatch `SetRawObservable` with it. Signal quality viz will work automatically.
-
-7. **Test with BrainFlow or `pylsl`** test sender script.
-
----
-
-### Phase 4: Stimulus Markers via LSL
-
-**Goal:** lab.js experiment events appear as a dedicated Markers stream in LabRecorder, aligned with the EEG stream.
-
-**Steps:**
-
-1. **Create the marker outlet** in `LSLOutletManager`:
- - `StreamInfo`: name `'BrainWavesMarkers'`, type `'Markers'`, 1 channel, `IRREGULAR_RATE`, format `string`
- - Create on app startup (not per-device)
-
-2. **Wire `lsl:sendMarker` IPC handler** in `src/main/index.ts` → calls `lslOutlets.pushMarker(label)`.
-
-3. **Update `RunComponent.tsx`** to call `window.electronAPI.sendLSLMarker({ label: event, rendererTimestamp: performance.now() })` alongside the existing `injectMuseMarker(event, time)` call.
- - Keep `injectMuseMarker` — it embeds markers in the raw EEG CSV, which the existing Pyodide analysis pipeline depends on.
-
-4. **Implement clock sync** (optional, needed if sub-5ms precision required):
- - Periodically send a round-trip IPC ping: renderer records `t0 = performance.now()`, main records `lsl_local_clock()`, renderer records `t1`. Offset ≈ `lsl_local_clock() - (t0 + t1) / 2`.
- - Store offset in a ref; pass it in `LSLMarker` so main can correct the LSL timestamp.
- - For most ERP paradigms, raw IPC jitter (1–5ms) is acceptable and this step can be deferred.
-
-5. **Test:** run Stroop or N170 experiment with LabRecorder, load XDF in MNE Python, verify marker latencies align with EEG epochs.
-
----
-
-### Phase 5: Production Hardening
-
-- **Backpressure for high-density inlets**: for 64+ channel streams at 1kHz+, decimate in main before forwarding to renderer. Full-rate stays on LSL network for LabRecorder.
-- **Graceful error handling**: BLE disconnects, LSL network loss, inlet timeouts, `node-labstreaminglayer` FFI errors.
-- **Platform testing**: macOS arm64, macOS x64, Windows x64. Confirm liblsl binary path resolves correctly post-packaging.
-- **Electron packaging verification**: `npm run package`, install the `.dmg`/`.exe`, run with LabRecorder.
-- **Linux Web Bluetooth**: `--enable-experimental-web-platform-features` is already set in `src/main/index.ts:23`. Verify BLE works end-to-end on Ubuntu.
-
----
-
-## Risks and Mitigations
-
-| Risk | Severity | Mitigation |
-|---|---|---|
-| `node-labstreaminglayer` is low-traffic (~7 downloads/week) with possible undiscovered Electron-specific bugs | Medium | Pin version. Test Phase 1 against real hardware before building further. If FFI proves unstable, fallback: Python sidecar process using `pylsl` with a WebSocket bridge. |
-| liblsl binary path breaks after electron-builder packaging (ASAR) | High | Add `asarUnpack: ["**/*.node"]` in Phase 1. Test packaged build early — don't leave this for Phase 5. |
-| IPC marker jitter exceeds tolerance for ERP analysis | Low | Document typical jitter (1–5ms). Add clock sync in Phase 4 if needed. |
-| `@neurosity/sdk` Web Bluetooth API changes or breaks | Medium | SDK is MIT; fork if needed. Crown BLE protocol is documented. |
-| High-channel-count LSL inlets (64ch, 1kHz) overwhelm renderer | Medium | Decimate in main process in Phase 5. |
-| iOS / mobile pivot requires native BLE | Low (deferred) | Adapter pattern in `muse.ts` / `neurosity.ts` isolates BLE. Add native adapter without touching LSL/viz/marker code. |
-
----
-
-## File Inventory
-
-### New files to create
-
-| File | Purpose |
-|---|---|
-| `src/shared/lslTypes.ts` | Shared IPC payload types |
-| `src/main/lsl/outlets.ts` | `LSLOutletManager` class |
-| `src/main/lsl/inlets.ts` | `LSLInletManager` class (Phase 3) |
-| `src/renderer/utils/eeg/lslBridge.ts` | Epoch batcher + IPC send helpers |
-| `src/renderer/utils/eeg/neurosity.ts` | Neurosity device driver (Phase 2) |
-| `src/renderer/types/electron.d.ts` | TypeScript declarations for `window.electronAPI` |
-
-### Files to modify
-
-| File | Change |
-|---|---|
-| `vite.config.ts` | Add `@shared` alias to `main` and `renderer` blocks |
-| `package.json` | Add `asarUnpack: ["**/*.node"]` to build config |
-| `src/preload/index.ts` | Add LSL IPC methods (`sendLSLEpoch`, `sendLSLMarker`, `discoverLSLStreams`, etc.) |
-| `src/main/index.ts` | Import and initialize `LSLOutletManager`; register IPC handlers |
-| `src/renderer/constants/constants.ts` | Add `NEUROSITY` and `LSL` to `DEVICES` enum |
-| `src/renderer/epics/deviceEpics.ts` | Add `lslForwardEpic`; route Neurosity in Phase 2 |
-| `src/renderer/epics/index.ts` | Register `lslForwardEpic` in `combineEpics` |
-| `src/renderer/epics/experimentEpics.ts` | Fix `MUSE_CHANNELS` hardcoding (line 79) |
-| `src/renderer/components/CollectComponent/RunComponent.tsx` | Add `sendLSLMarker` call alongside `injectMuseMarker` (Phase 4) |
-
-Test plan
-
- - x Connect a real Muse; confirm stream appears in LabRecorder with correct channel names and sample rate (still could interrogatee file for correctness. path is '/Users/dano/Documents/CurrentStudy/sub-P001/ses-S001/eeg/sub-P001_ses-S001_task-Default_run-001_eeg.xdf')
- - Connect a Neurosity Crown; confirm stream appears in LabRecorder
- - Run lab.js experiment; confirm markers are recorded synchronized with EEG
- - Discover and subscribe to an external LSL stream; confirm live visualization
- - Disconnect Muse mid-session; confirm toast + clean teardown
- - npm run package on macOS arm64, macOS x64, Windows x64; confirm liblsl loads from the packaged build
- - Ubuntu smoke test for Web Bluetooth (--enable-experimental-web-platform-features is already set)
\ No newline at end of file
diff --git a/package.json b/package.json
index fa3d7ba0..55b6aa9b 100644
--- a/package.json
+++ b/package.json
@@ -22,9 +22,10 @@
"lint-fix": "npm run lint -- --fix",
"postlint-fix": "prettier --single-quote --write '**/*.{ts,tsx,js,jsx,json,html,css,less,scss,yml}'",
"test": "vitest run",
+ "test:build": "vitest run --config vitest.build.config.ts",
"device-integration": "vitest run src/main/lsl src/renderer/utils/eeg",
"test:watch": "vitest",
- "test-all": "npm run lint && npm run typecheck && npm run build && npm test",
+ "test-all": "npm run lint && npm run typecheck && npm run build && npm test && npm run test:build",
"tsc": "tsc --noEmit",
"typecheck": "tsc --noEmit"
},
@@ -85,7 +86,8 @@
"provider": "github",
"owner": "makebrainwaves",
"repo": "brainwaves",
- "private": false
+ "private": false,
+ "releaseType": "draft"
},
"extraResources": [
{
diff --git a/src/main/index.ts b/src/main/index.ts
index c4313dde..a17d1a9b 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -25,6 +25,10 @@ import log from 'electron-log';
import { is, optimizer } from '@electron-toolkit/utils';
import MenuBuilder from './menu';
import { FILE_TYPES } from '../renderer/constants/constants';
+import {
+ PYODIDE_SOURCE_DIR,
+ PYODIDE_RESOURCE_DIR,
+} from '../shared/pyodideAssets';
import { lslOutlets } from './lsl/outlets';
import { lslInlets } from './lsl/inlets';
import { isLSLAvailable } from './lsl/native';
@@ -160,6 +164,13 @@ ipcMain.handle('shell:showItemInFolder', (_event, fullPath) =>
shell.showItemInFolder(fullPath)
);
+// Open a workspace folder. Resolve the absolute path here — shell.openPath (and
+// showItemInFolder) silently no-op on a relative path, which is why the old
+// renderer-side path.join('BrainWaves_Workspaces', title) did nothing.
+ipcMain.handle('shell:openWorkspaceDir', (_event, title: string) =>
+ shell.openPath(getWorkspaceDir(title))
+);
+
ipcMain.handle('shell:moveItemToTrash', (_event, fullPath) =>
shell.trashItem(fullPath)
);
@@ -326,6 +337,28 @@ ipcMain.handle(
}
);
+// Writes the cleaned epochs .fif from the Pyodide worker's in-memory MEMFS to
+// host disk (the worker filesystem cannot reach host paths on its own). Analyze's
+// `readWorkspaceCleanedEEGData` scans for the `epo.fif` suffix this produces.
+ipcMain.handle(
+ 'fs:writeCleanedEpochs',
+ (_event, title: string, subject: string, rawData: ArrayBuffer) => {
+ const dir = path.join(getWorkspaceDir(title), 'Data', subject, 'EEG');
+ mkdirPathSync(dir);
+ const buffer = Buffer.from(rawData);
+ return new Promise((resolve, reject) => {
+ fs.writeFile(
+ path.join(dir, `${subject}-cleaned-epo.fif`),
+ buffer,
+ (err) => {
+ if (err) reject(err);
+ else resolve();
+ }
+ );
+ });
+ }
+);
+
ipcMain.handle('fs:deleteWorkspaceDir', (_event, title) =>
shell.trashItem(path.join(workspaces, title))
);
@@ -710,11 +743,9 @@ app.on('before-quit', () => {
app.whenReady().then(async () => {
// Serve pyodide:// assets (whl files, manifest.json, etc.) directly from the
// filesystem via Electron's protocol API — no network socket required.
- // In dev: files are in src/renderer/utils/webworker/src/
- // In prod: files are copied to resources/pyodide/ by extraResources (package.json)
const pyodideRoot = is.dev
- ? path.join(app.getAppPath(), 'src/renderer/utils/webworker/src')
- : path.join(process.resourcesPath, 'pyodide');
+ ? path.join(app.getAppPath(), PYODIDE_SOURCE_DIR)
+ : path.join(process.resourcesPath, PYODIDE_RESOURCE_DIR);
protocol.handle('pyodide', (request) => {
const { pathname } = new URL(request.url);
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 2330df60..fa29f35a 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -52,6 +52,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
moveItemToTrash: (fullPath: string) =>
ipcRenderer.invoke('shell:moveItemToTrash', fullPath),
+ openWorkspaceDir: (title: string): Promise =>
+ ipcRenderer.invoke('shell:openWorkspaceDir', title),
+
// ------------------------------------------------------------------
// Filesystem — workspace management
// ------------------------------------------------------------------
@@ -117,6 +120,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
): Promise =>
ipcRenderer.invoke('fs:storePyodideImagePng', title, imageTitle, rawData),
+ writeCleanedEpochs: (
+ title: string,
+ subject: string,
+ rawData: ArrayBuffer
+ ): Promise =>
+ ipcRenderer.invoke('fs:writeCleanedEpochs', title, subject, rawData),
+
deleteWorkspaceDir: (title: string): Promise =>
ipcRenderer.invoke('fs:deleteWorkspaceDir', title),
diff --git a/src/renderer/actions/deviceActions.ts b/src/renderer/actions/deviceActions.ts
index 69edf14c..7c2c451a 100644
--- a/src/renderer/actions/deviceActions.ts
+++ b/src/renderer/actions/deviceActions.ts
@@ -41,7 +41,7 @@ export const DeviceActions = {
Cleanup: createAction('CLEANUP'),
DeviceLost: createAction('DEVICE_LOST'),
- // External LSL inlet streams (Phase 3)
+ // External LSL inlet streams
DiscoverLSLStreams: createAction(
'DISCOVER_LSL_STREAMS'
),
diff --git a/src/renderer/actions/pyodideActions.ts b/src/renderer/actions/pyodideActions.ts
index a9d6a1ce..f27905d8 100644
--- a/src/renderer/actions/pyodideActions.ts
+++ b/src/renderer/actions/pyodideActions.ts
@@ -2,6 +2,23 @@ import { createAction } from '@reduxjs/toolkit';
import { ActionType } from 'typesafe-actions';
import { PYODIDE_VARIABLE_NAMES } from '../constants/constants';
+// Metadata shipped alongside the raw epoch buffer (dataKey 'epochArrays').
+export interface EpochArraysMeta {
+ n_epochs: number;
+ n_channels: number;
+ n_times: number;
+ ch_names: string[];
+ times: number[];
+ event_codes: number[];
+}
+
+// Auto-flag: a single artifact suggestion returned by Python's
+// suggest_rejections(epochs, threshold_uv) — one epoch/channel over threshold.
+export interface SuggestedRejection {
+ index: number;
+ reason: string;
+}
+
// -------------------------------------------------------------------------
// Actions
@@ -17,7 +34,10 @@ export const PyodideActions = {
LoadPSD: createAction('LOAD_PSD'),
LoadERP: createAction('LOAD_ERP'),
LoadTopo: createAction('LOAD_TOPO'),
- CleanEpochs: createAction('CLEAN_EPOCHS'),
+ CleanEpochs: createAction<
+ { dropIndices: number[]; badChannels: string[] },
+ 'CLEAN_EPOCHS'
+ >('CLEAN_EPOCHS'),
GetEpochsInfo: createAction(
'GET_EPOCHS_INFO'
),
@@ -26,6 +46,10 @@ export const PyodideActions = {
SetEpochInfo: createAction('SET_EPOCH_INFO'), // Pyodide WASM runtime result — shape is dynamic
// eslint-disable-next-line @typescript-eslint/no-explicit-any
SetChannelInfo: createAction('SET_CHANNEL_INFO'), // Pyodide WASM runtime result — shape is dynamic
+ SetEpochArrays: createAction<
+ { buffer: ArrayBuffer; meta: EpochArraysMeta },
+ 'SET_EPOCH_ARRAYS'
+ >('SET_EPOCH_ARRAYS'),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
SetPSDPlot: createAction('SET_PSD_PLOT'), // Pyodide WASM runtime result — shape is dynamic
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -37,6 +61,13 @@ export const PyodideActions = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ReceiveError: createAction('RECEIVE_ERROR'), // Worker error event — shape is dynamic
SetWorkerReady: createAction('SET_WORKER_READY'),
+ GetSuggestedRejections: createAction(
+ 'GET_SUGGESTED_REJECTIONS'
+ ),
+ SetSuggestedRejections: createAction<
+ SuggestedRejection[],
+ 'SET_SUGGESTED_REJECTIONS'
+ >('SET_SUGGESTED_REJECTIONS'),
} as const;
export type PyodideActionType = ActionType<
diff --git a/src/renderer/components/AnalyzeComponent.tsx b/src/renderer/components/AnalyzeComponent.tsx
index c486dfaf..69ea8e96 100644
--- a/src/renderer/components/AnalyzeComponent.tsx
+++ b/src/renderer/components/AnalyzeComponent.tsx
@@ -1,4 +1,5 @@
import React, { Component } from 'react';
+import { Link } from 'react-router-dom';
import { Button } from './ui/button';
import { isNil } from 'lodash';
import Plot from 'react-plotly.js';
@@ -7,6 +8,7 @@ import {
DEVICES,
MUSE_CHANNELS,
EXPERIMENTS,
+ SCREENS,
} from '../constants/constants';
import {
readWorkspaceCleanedEEGData,
@@ -376,22 +378,36 @@ export default class Analyze extends Component {
EEG differs between electrodes
- Insert the USB receiver into a USB port on your computer. Ensure
- that the LED on the receiver is continously lit or flickering
- rapidly. If it is blinking slowly or not illuminated, remove and
- reinsert the receiver
-