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

Select Clean Datasets

- - {eegFilePath.text} - - ))} - - {this.renderEpochLabels()} + {this.state.eegFilePaths.map((eegFilePath) => ( + + ))} + + {this.renderEpochLabels()} + + ) : ( +
+

+ No cleaned data yet — clean a recording first, then it'll + show up here to analyze. +

+ +
+ )}
; + // Toggle a single ABSOLUTE epoch index in/out of the rejected set. + onToggleEpoch: (index: number) => void; + // Channel names the student has flagged as "bad" (controlled by the parent). + badChannels: Set; + // Toggle a single channel name in/out of the bad-channel set. + onToggleChannel: (name: string) => void; + // Optional map from numeric event code to a human-readable condition label. + codeToLabel?: Record; +} + +// Logical canvas size (scaled up for devicePixelRatio at draw time). +const CANVAS_WIDTH = 640; +const CANVAS_HEIGHT = 320; + +// How many epochs we draw per page. Prev/Next pages by this amount. +const VISIBLE_EPOCHS = 8; + +// Gutter reserved on the left for channel labels (logical px). +const LABEL_GUTTER = 64; +// Gutter reserved at the bottom for epoch index labels (logical px). +const BOTTOM_GUTTER = 20; + +const REJECTED_TRACE_COLOR = 'rgba(120, 120, 120, 0.5)'; +const REJECTED_FILL_COLOR = 'rgba(120, 120, 120, 0.15)'; +const BAD_CHANNEL_FILL_COLOR = 'rgba(200, 60, 60, 0.10)'; + +export default function EpochReviewer({ + epochArrays, + rejected, + onToggleEpoch, + badChannels, + onToggleChannel, + codeToLabel, +}: Props): JSX.Element { + const canvasRef = useRef(null); + // First epoch of the current page (absolute index). + const [startEpoch, setStartEpoch] = useState(0); + + const meta = epochArrays?.meta ?? null; + + // Page size is constant so column widths stay stable across pages. + const perPage = meta + ? Math.min(meta.n_epochs, VISIBLE_EPOCHS) + : VISIBLE_EPOCHS; + const maxStart = meta ? Math.max(0, meta.n_epochs - perPage) : 0; + const clampedStart = Math.min(startEpoch, maxStart); + const visibleCount = meta + ? Math.min(perPage, meta.n_epochs - clampedStart) + : 0; + // Deterministic per-condition coloring: position in the sorted unique codes. + const uniqueSortedCodes = useMemo( + () => [...new Set(meta?.event_codes ?? [])].sort((a, b) => a - b), + [meta] + ); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !epochArrays || !meta || meta.n_epochs === 0) { + return; + } + + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + // Scale the backing store for crisp lines on HiDPI displays, but keep + // drawing in logical coordinates. + const dpr = window.devicePixelRatio || 1; + canvas.width = CANVAS_WIDTH * dpr; + canvas.height = CANVAS_HEIGHT * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + + const { buffer } = epochArrays; + const { n_epochs, n_channels, n_times, event_codes, ch_names } = meta; + + const plotWidth = CANVAS_WIDTH - LABEL_GUTTER; + const plotHeight = CANVAS_HEIGHT - BOTTOM_GUTTER; + const colWidth = plotWidth / perPage; + const laneHeight = plotHeight / n_channels; + + // Translucent grey wash over rejected columns (drawn first, under traces). + for (let c = 0; c < visibleCount; c += 1) { + const absolute = clampedStart + c; + if (rejected.has(absolute)) { + ctx.fillStyle = REJECTED_FILL_COLOR; + ctx.fillRect(LABEL_GUTTER + c * colWidth, 0, colWidth, plotHeight); + } + } + + // Translucent red wash over flagged bad-channel lanes — spans every epoch + // column (drawn under traces) so bad channels read at a glance. + for (let ch = 0; ch < n_channels; ch += 1) { + if (badChannels.has(ch_names[ch])) { + ctx.fillStyle = BAD_CHANNEL_FILL_COLOR; + ctx.fillRect( + LABEL_GUTTER, + ch * laneHeight, + CANVAS_WIDTH - LABEL_GUTTER, + laneHeight + ); + } + } + + // Faint lane dividers (channels). + ctx.strokeStyle = 'rgba(0, 0, 0, 0.08)'; + ctx.lineWidth = 1; + for (let ch = 1; ch < n_channels; ch += 1) { + const y = Math.round(ch * laneHeight) + 0.5; + ctx.beginPath(); + ctx.moveTo(LABEL_GUTTER, y); + ctx.lineTo(CANVAS_WIDTH, y); + ctx.stroke(); + } + + // Vertical dividers between epochs. + ctx.strokeStyle = 'rgba(0, 0, 0, 0.15)'; + for (let e = 0; e <= visibleCount; e += 1) { + const x = Math.round(LABEL_GUTTER + e * colWidth) + 0.5; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, plotHeight); + ctx.stroke(); + } + + const cols = Math.max(1, Math.floor(colWidth)); + + for (let c = 0; c < visibleCount; c += 1) { + const absolute = clampedStart + c; + const colLeft = LABEL_GUTTER + c * colWidth; + const isRejected = rejected.has(absolute); + const code = event_codes[absolute]; + const traceColor = isRejected + ? REJECTED_TRACE_COLOR + : cssColorForIndex(Math.max(0, uniqueSortedCodes.indexOf(code))); + ctx.strokeStyle = traceColor; + ctx.lineWidth = 1; + + for (let ch = 0; ch < n_channels; ch += 1) { + const laneTop = ch * laneHeight; + const laneCenter = laneTop + laneHeight / 2; + const series = epochChannelSeries(buffer, meta, absolute, ch); + + // Per-lane autoscaling centered on the trace's mean: the full + // [min, max] range fills the lane, i.e. deviation from + // the mean. y is clamped to the lane so magnified traces don't bleed + // into neighboring channels. + let min = Infinity; + let max = -Infinity; + let sum = 0; + for (let i = 0; i < series.length; i += 1) { + const v = series[i]; + if (v < min) { + min = v; + } + if (v > max) { + max = v; + } + sum += v; + } + const mean = series.length > 0 ? sum / series.length : 0; + const pad = laneHeight * 0.1; + const usableHeight = laneHeight - 2 * pad; + const range = max - min || 1; + const scale = usableHeight / range; + const laneLo = laneTop + pad; + const laneHi = laneTop + laneHeight - pad; + const toY = (v: number): number => { + const y = laneCenter - (v - mean) * scale; + return y < laneLo ? laneLo : y > laneHi ? laneHi : y; + }; + + if (n_times > cols) { + // More samples than pixels: draw a vertical min→max line per column + // so sharp transients survive downsampling. + const buckets = downsampleMinMax(series, cols); + ctx.beginPath(); + for (let col = 0; col < buckets.length; col += 1) { + const x = colLeft + (col * colWidth) / buckets.length; + const [lo, hi] = buckets[col]; + ctx.moveTo(x, toY(hi)); + ctx.lineTo(x, toY(lo)); + } + ctx.stroke(); + } else { + // Fewer samples than pixels: a normal polyline reads best. + ctx.beginPath(); + for (let i = 0; i < series.length; i += 1) { + const x = + colLeft + + (series.length <= 1 ? 0 : (i / (series.length - 1)) * colWidth); + const y = toY(series[i]); + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + ctx.stroke(); + } + } + } + }, [epochArrays, meta, rejected, clampedStart, perPage, badChannels, visibleCount, uniqueSortedCodes]); + + // Empty state — friendly, brand-styled, student-facing. + if (!epochArrays || !meta || meta.n_epochs === 0) { + return ( +
+ Load a dataset to see your epochs here 🧠 +
+ ); + } + + const laneHeight = (CANVAS_HEIGHT - BOTTOM_GUTTER) / meta.n_channels; + const colWidth = (CANVAS_WIDTH - LABEL_GUTTER) / perPage; + const plotHeight = CANVAS_HEIGHT - BOTTOM_GUTTER; + const firstShown = clampedStart + 1; + const lastShown = clampedStart + visibleCount; + + return ( +
+
+

Epochs

+
+ + +
+
+ +
+ + + {/* Channel labels (left gutter) double as click-to-flag bad-channel + toggles. A flagged channel renders struck-through + red and its lane + is washed red across every epoch column (see canvas draw). */} + {meta.ch_names.map((name, ch) => { + const isBad = badChannels.has(name); + return ( + + ); + })} + + {/* Transparent click targets — one per visible epoch column. Clicking + toggles that ABSOLUTE epoch index in/out of the rejected set. */} + {Array.from({ length: visibleCount }, (_, c) => { + const absolute = clampedStart + c; + const isRejected = rejected.has(absolute); + return ( +
+ + {/* Condition legend: one swatch + human-readable label per unique code. */} + {uniqueSortedCodes.length > 0 && ( +
+ {uniqueSortedCodes.map((code) => ( + + + {codeToLabel?.[code] ?? `Condition ${code}`} + + ))} +
+ )} + +

+ showing {firstShown}–{lastShown} of {meta.n_epochs} epochs + {rejected.size > 0 && ` · ${rejected.size} marked for rejection`} +

+
+ ); +} diff --git a/src/renderer/components/CleanComponent/LiveErpPane.tsx b/src/renderer/components/CleanComponent/LiveErpPane.tsx new file mode 100644 index 00000000..8845300f --- /dev/null +++ b/src/renderer/components/CleanComponent/LiveErpPane.tsx @@ -0,0 +1,258 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import type { EpochArraysMeta } from '../../actions'; +import { cssColorForIndex } from '../../utils/eeg/conditionPalette'; +import { meanTrace } from './epochArrays'; + +// --------------------------------------------------------------------------- +// Live ERP pane — the "watch it clean up" payoff. Averages the INCLUDED epochs +// (all epochs NOT in `rejected`), grouped by condition, and draws one mean line +// per condition for the selected channel. Recomputes instantly whenever the +// student toggles an epoch, so the averaged waveform visibly cleans up. +// +// Live ERP channel: [ Pz ▾ ] +// ┌──────────────────────────────────────────────┐ +// │ ╱‾‾‾╲ │ +// │ ‾‾‾╲__╱‾‾‾╱ ╲___ ← one line / condition │ +// │ ─────────┊────────────────── (zero baseline) │ +// │ 0ms │ +// └──────────────────────────────────────────────┘ +// ■ Condition 1 (42) ■ Condition 2 (39) +// Averaged over 81 epochs — reject noisy ones to clean it up +// --------------------------------------------------------------------------- + +interface Props { + epochArrays: { buffer: ArrayBuffer; meta: EpochArraysMeta } | null; + // ABSOLUTE epoch indices marked for rejection (excluded from the average). + rejected: Set; + // Optional map from numeric event code to a human-readable condition label. + codeToLabel?: Record; +} + +const CANVAS_WIDTH = 640; +const CANVAS_HEIGHT = 240; +const PAD_LEFT = 8; +const PAD_RIGHT = 8; +const PAD_TOP = 12; +const PAD_BOTTOM = 12; + +export default function LiveErpPane({ + epochArrays, + rejected, + codeToLabel, +}: Props): JSX.Element { + const canvasRef = useRef(null); + const [channel, setChannel] = useState(0); + + const meta = epochArrays?.meta ?? null; + + // Included epoch indices grouped by condition code. Recomputed whenever the + // dataset or the rejected set changes. + const { groups, uniqueSortedCodes } = useMemo(() => { + if (!meta || meta.n_epochs === 0) { + return { + groups: new Map(), + uniqueSortedCodes: [] as number[], + }; + } + const byCode = new Map(); + for (let i = 0; i < meta.n_epochs; i += 1) { + if (rejected.has(i)) { + continue; + } + const code = meta.event_codes[i]; + const list = byCode.get(code); + if (list) { + list.push(i); + } else { + byCode.set(code, [i]); + } + } + return { + groups: byCode, + uniqueSortedCodes: [...new Set(meta.event_codes)].sort((a, b) => a - b), + }; + }, [meta, rejected]); + + const includedCount = [...groups.values()].reduce((n, g) => n + g.length, 0); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !epochArrays || !meta || meta.n_epochs === 0) { + return; + } + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + const dpr = window.devicePixelRatio || 1; + canvas.width = CANVAS_WIDTH * dpr; + canvas.height = CANVAS_HEIGHT * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + + const { buffer } = epochArrays; + const { n_times, times } = meta; + const plotLeft = PAD_LEFT; + const plotRight = CANVAS_WIDTH - PAD_RIGHT; + const plotTop = PAD_TOP; + const plotBottom = CANVAS_HEIGHT - PAD_BOTTOM; + const plotWidth = plotRight - plotLeft; + const plotHeight = plotBottom - plotTop; + + // Compute one mean trace per condition, tracking a shared Y range so all + // conditions are drawn on the same amplitude scale. + const traces: Array<{ code: number; series: Float32Array }> = []; + let min = Infinity; + let max = -Infinity; + for (const code of uniqueSortedCodes) { + const indices = groups.get(code); + if (!indices || indices.length === 0) { + continue; + } + const series = meanTrace(buffer, meta, indices, channel); + for (let t = 0; t < series.length; t += 1) { + if (series[t] < min) { + min = series[t]; + } + if (series[t] > max) { + max = series[t]; + } + } + traces.push({ code, series }); + } + + if (!Number.isFinite(min) || !Number.isFinite(max)) { + min = -1; + max = 1; + } + const range = max - min || 1; + const yPad = range * 0.1; + const yLo = min - yPad; + const yHi = max + yPad; + const toY = (v: number): number => + plotBottom - ((v - yLo) / (yHi - yLo)) * plotHeight; + const toX = (i: number): number => + plotLeft + (n_times <= 1 ? 0 : (i / (n_times - 1)) * plotWidth); + + // Framing. + ctx.strokeStyle = 'rgba(0, 0, 0, 0.12)'; + ctx.lineWidth = 1; + ctx.strokeRect(plotLeft + 0.5, plotTop + 0.5, plotWidth, plotHeight); + + // Horizontal zero-amplitude baseline (if 0 is inside range). + if (yLo <= 0 && yHi >= 0) { + const y = Math.round(toY(0)) + 0.5; + ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)'; + ctx.beginPath(); + ctx.moveTo(plotLeft, y); + ctx.lineTo(plotRight, y); + ctx.stroke(); + } + + // Vertical stimulus-onset line at t = 0 (where times crosses zero). + if (times.length === n_times && n_times > 1) { + const zeroIdx = times.findIndex((t) => t >= 0); + if (zeroIdx >= 0) { + const x = Math.round(toX(zeroIdx)) + 0.5; + ctx.strokeStyle = 'rgba(0, 0, 0, 0.25)'; + ctx.setLineDash([4, 3]); + ctx.beginPath(); + ctx.moveTo(x, plotTop); + ctx.lineTo(x, plotBottom); + ctx.stroke(); + ctx.setLineDash([]); + } + } + + // One mean line per condition. + ctx.lineWidth = 2; + for (const { code, series } of traces) { + ctx.strokeStyle = cssColorForIndex( + Math.max(0, uniqueSortedCodes.indexOf(code)) + ); + ctx.beginPath(); + for (let i = 0; i < series.length; i += 1) { + const x = toX(i); + const y = toY(series[i]); + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + ctx.stroke(); + } + }, [epochArrays, meta, rejected, channel, groups, uniqueSortedCodes]); + + // Empty state — brand-styled, mirrors EpochReviewer's placeholder. + if (!epochArrays || !meta || meta.n_epochs === 0) { + return ( +
+

Live ERP

+
+ Load a dataset to watch the ERP build 📈 +
+
+ ); + } + + return ( +
+
+

Live ERP

+ +
+ +
+ +
+ + {/* Legend: one swatch + condition code + included count. */} +
+ {uniqueSortedCodes.map((code) => { + const count = groups.get(code)?.length ?? 0; + return ( + + + {codeToLabel?.[code] ?? `Condition ${code}`} ({count}) + + ); + })} +
+ +

+ Averaged over {includedCount} epochs — reject noisy ones to clean it up +

+
+ ); +} diff --git a/src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx b/src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx new file mode 100644 index 00000000..e4abb5ab --- /dev/null +++ b/src/renderer/components/CleanComponent/__tests__/EpochReviewer.test.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import EpochReviewer from '../EpochReviewer'; +import type { EpochArraysMeta } from '../../../actions'; + +// jsdom does not implement .getContext; stub it so the draw effect is a +// no-op (this test exercises the DOM overlay, not the canvas pixels). +beforeAll(() => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue( + null as unknown as CanvasRenderingContext2D + ); +}); + +// Minimal 3-epoch / 2-channel / 4-sample dataset. Buffer is C-order +// [epoch][channel][time]; exact values don't matter for the click test. +function makeEpochArrays(): { buffer: ArrayBuffer; meta: EpochArraysMeta } { + const nEpochs = 3; + const nChannels = 2; + const nTimes = 4; + const data = new Float32Array(nEpochs * nChannels * nTimes); + for (let i = 0; i < data.length; i += 1) { + data[i] = Math.sin(i); + } + const meta: EpochArraysMeta = { + n_epochs: nEpochs, + n_channels: nChannels, + n_times: nTimes, + ch_names: ['Fp1', 'Fp2'], + times: [-0.1, 0, 0.1, 0.2], + event_codes: [1, 2, 1], + }; + return { buffer: data.buffer, meta }; +} + +describe('EpochReviewer', () => { + it('calls onToggleEpoch with the absolute index when a column overlay is clicked', () => { + const onToggleEpoch = vi.fn(); + render( + + ); + + // The overlay for epoch 1 is labelled "Reject epoch 1". + const target = screen.getByLabelText('Reject epoch 1'); + fireEvent.click(target); + + expect(onToggleEpoch).toHaveBeenCalledTimes(1); + expect(onToggleEpoch).toHaveBeenCalledWith(1); + }); + + it('labels an already-rejected column as Restore', () => { + const onToggleEpoch = vi.fn(); + render( + + ); + + expect(screen.getByLabelText('Restore epoch 0')).toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/CleanComponent/__tests__/epochArrays.test.ts b/src/renderer/components/CleanComponent/__tests__/epochArrays.test.ts new file mode 100644 index 00000000..941b57cf --- /dev/null +++ b/src/renderer/components/CleanComponent/__tests__/epochArrays.test.ts @@ -0,0 +1,153 @@ +/** + * Tests for the pure epoch-array helpers backing the (read-only) EpochReviewer. + * These decode the C-order [epoch][channel][time] Float32 buffer, downsample + * traces while preserving spikes, and map event codes to a stable condition + * color index. + */ +import { describe, it, expect } from 'vitest'; +import type { EpochArraysMeta } from '../../../actions'; +import { + downsampleMinMax, + epochChannelSeries, + meanTrace, +} from '../epochArrays'; + +const makeMeta = (over: Partial = {}): EpochArraysMeta => ({ + n_epochs: 2, + n_channels: 2, + n_times: 3, + ch_names: ['A', 'B'], + times: [0, 0.01, 0.02], + event_codes: [1, 2], + ...over, +}); + +describe('epochChannelSeries', () => { + it('slices the correct 3 values for each (epoch, channel)', () => { + const meta = makeMeta(); + // C-order [epoch][channel][time]: e0c0, e0c1, e1c0, e1c1 + const data = Float32Array.from([ + 1, + 2, + 3, // epoch 0, channel 0 + 4, + 5, + 6, // epoch 0, channel 1 + 7, + 8, + 9, // epoch 1, channel 0 + 10, + 11, + 12, // epoch 1, channel 1 + ]); + const { buffer } = data; + + expect(Array.from(epochChannelSeries(buffer, meta, 0, 0))).toEqual([ + 1, 2, 3, + ]); + expect(Array.from(epochChannelSeries(buffer, meta, 0, 1))).toEqual([ + 4, 5, 6, + ]); + expect(Array.from(epochChannelSeries(buffer, meta, 1, 0))).toEqual([ + 7, 8, 9, + ]); + expect(Array.from(epochChannelSeries(buffer, meta, 1, 1))).toEqual([ + 10, 11, 12, + ]); + }); + + it('returns a zero-copy view onto the underlying buffer', () => { + const meta = makeMeta(); + const data = Float32Array.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); + const view = epochChannelSeries(data.buffer, meta, 1, 1); + expect(view.buffer).toBe(data.buffer); + }); +}); + +describe('downsampleMinMax', () => { + it('preserves a sharp spike as the max of its bucket', () => { + // Flat baseline with one large spike partway through. + const series = [0, 0, 0, 0, 99, 0, 0, 0]; + const out = downsampleMinMax(series, 2); + // The spike must appear as a max in one of the buckets. + const maxes = out.map(([, hi]) => hi); + expect(Math.max(...maxes)).toBe(99); + expect(out.length).toBeLessThanOrEqual(2); + }); + + it('returns [] for an empty series', () => { + expect(downsampleMinMax([], 5)).toEqual([]); + }); + + it('returns [] for width <= 0', () => { + expect(downsampleMinMax([1, 2, 3], 0)).toEqual([]); + expect(downsampleMinMax([1, 2, 3], -4)).toEqual([]); + }); + + it('never emits more columns than requested width', () => { + const series = Array.from({ length: 100 }, (_, i) => i); + const out = downsampleMinMax(series, 7); + expect(out.length).toBeLessThanOrEqual(7); + // Each entry is a [min, max] pair with min <= max. + out.forEach(([lo, hi]) => expect(lo).toBeLessThanOrEqual(hi)); + }); +}); + +describe('meanTrace', () => { + // n_epochs=3, n_channels=2, n_times=2 in C-order [epoch][channel][time]. + // channel 0 across epochs = [2,4], [4,8], [6,12] -> mean over all 3 = [4, 8]. + // channel 1 across epochs = [1,1], [3,3], [5,5] -> mean over all 3 = [3, 3]. + const makeErpFixture = () => { + const meta = makeMeta({ + n_epochs: 3, + n_channels: 2, + n_times: 2, + times: [0, 0.01], + event_codes: [1, 2, 1], + }); + const data = Float32Array.from([ + 2, + 4, // epoch 0, channel 0 + 1, + 1, // epoch 0, channel 1 + 4, + 8, // epoch 1, channel 0 + 3, + 3, // epoch 1, channel 1 + 6, + 12, // epoch 2, channel 0 + 5, + 5, // epoch 2, channel 1 + ]); + return { meta, buffer: data.buffer }; + }; + + it('averages a channel over all epochs (exact mean)', () => { + const { meta, buffer } = makeErpFixture(); + expect(Array.from(meanTrace(buffer, meta, [0, 1, 2], 0))).toEqual([4, 8]); + }); + + it('averages the correct subset of epochs', () => { + const { meta, buffer } = makeErpFixture(); + // mean of epoch 0 ([2,4]) and epoch 2 ([6,12]) = [4, 8]. + expect(Array.from(meanTrace(buffer, meta, [0, 2], 0))).toEqual([4, 8]); + // mean of epoch 0 ([2,4]) and epoch 1 ([4,8]) = [3, 6]. + expect(Array.from(meanTrace(buffer, meta, [0, 1], 0))).toEqual([3, 6]); + }); + + it('returns exactly that epoch series for a single-epoch selection', () => { + const { meta, buffer } = makeErpFixture(); + const single = Array.from(meanTrace(buffer, meta, [1], 0)); + const series = Array.from(epochChannelSeries(buffer, meta, 1, 0)); + expect(single).toEqual(series); + expect(single).toEqual([4, 8]); + }); + + it('reads the requested channel (channel 1 differs from channel 0)', () => { + const { meta, buffer } = makeErpFixture(); + expect(Array.from(meanTrace(buffer, meta, [0, 1, 2], 1))).toEqual([3, 3]); + expect(Array.from(meanTrace(buffer, meta, [0, 1, 2], 0))).not.toEqual([ + 3, 3, + ]); + }); +}); diff --git a/src/renderer/components/CleanComponent/epochArrays.ts b/src/renderer/components/CleanComponent/epochArrays.ts new file mode 100644 index 00000000..2dcbb44e --- /dev/null +++ b/src/renderer/components/CleanComponent/epochArrays.ts @@ -0,0 +1,76 @@ +import type { EpochArraysMeta } from '../../actions'; + +// Buffer is C-order [epoch][channel][time]. Return the time series for one +// (epoch, channel) as a zero-copy subarray view. +export function epochChannelSeries( + buffer: ArrayBuffer, + meta: EpochArraysMeta, + epoch: number, + channel: number +): Float32Array { + const floats = new Float32Array(buffer); + const stride = meta.n_channels * meta.n_times; + const start = epoch * stride + channel * meta.n_times; + return floats.subarray(start, start + meta.n_times); +} + +// Downsample to `width` columns, preserving spikes by keeping the min AND max +// within each column (so a sharp transient is never averaged away). Returns +// [min, max] per column. Callers only downsample, i.e. width < series length. +export function downsampleMinMax( + series: ArrayLike, + width: number +): Array<[number, number]> { + const { length } = series; + if (width <= 0 || length === 0) { + return []; + } + + // Split the series into `width` contiguous buckets. Bucket boundaries are + // computed with floating math so samples distribute as evenly as possible, + // and every bucket is non-empty (guaranteed because width < length). + const out: Array<[number, number]> = new Array(width); + for (let col = 0; col < width; col += 1) { + const start = Math.floor((col * length) / width); + const end = Math.floor(((col + 1) * length) / width); + let min = series[start]; + let max = series[start]; + for (let i = start + 1; i < end; i += 1) { + const v = series[i]; + if (v < min) { + min = v; + } + if (v > max) { + max = v; + } + } + out[col] = [min, max]; + } + return out; +} + +// Mean waveform for one channel, averaged over the given epoch indices — the +// client-side ERP primitive for the live preview (recomputed on every reject +// toggle; equivalent to np.mean over those epochs for that channel). The epochs +// are already baseline-corrected + filtered at epoching time, so a plain mean +// matches MNE's evoked average to float32 display precision. `epochIndices` +// must be non-empty — the caller skips empty condition groups, and an empty +// selection would divide by zero. +export function meanTrace( + buffer: ArrayBuffer, + meta: EpochArraysMeta, + epochIndices: number[], + channel: number +): Float32Array { + const out = new Float32Array(meta.n_times); + for (const e of epochIndices) { + const series = epochChannelSeries(buffer, meta, e, channel); + for (let t = 0; t < meta.n_times; t += 1) { + out[t] += series[t]; + } + } + for (let t = 0; t < meta.n_times; t += 1) { + out[t] /= epochIndices.length; + } + return out; +} diff --git a/src/renderer/components/CleanComponent/index.tsx b/src/renderer/components/CleanComponent/index.tsx index 6e2e8f9d..a38c1b92 100644 --- a/src/renderer/components/CleanComponent/index.tsx +++ b/src/renderer/components/CleanComponent/index.tsx @@ -1,12 +1,31 @@ import React, { Component } from 'react'; import path from 'pathe'; import { Link } from 'react-router-dom'; -import { isNil, isString } from 'lodash'; +import { isNil, isString, memoize } from 'lodash'; import { Button } from '../ui/button'; -import { EXPERIMENTS, DEVICES } from '../../constants/constants'; +import { + EXPERIMENTS, + DEVICES, + PTP_THRESHOLD, +} from '../../constants/constants'; +import { ExperimentParameters } from '../../constants/interfaces'; +import { buildMarkerRegistry } from '../../utils/eeg/markerRegistry'; import { readWorkspaceRawEEGData } from '../../utils/filesystem/storage'; import CleanSidebar from './CleanSidebar'; -import { PyodideActions, ExperimentActions } from '../../actions'; +import EpochReviewer from './EpochReviewer'; +import LiveErpPane from './LiveErpPane'; +import { + PyodideActions, + ExperimentActions, + EpochArraysMeta, + SuggestedRejection, +} from '../../actions'; + +// Memoized by stimuli reference so we don't rebuild the registry every render. +const codeToLabelFor = memoize( + (stimuli: ExperimentParameters['stimuli']) => + buildMarkerRegistry(stimuli).codeToLabel +); export interface Props { type?: EXPERIMENTS; @@ -15,10 +34,13 @@ export interface Props { epochsInfo: Array<{ [key: string]: number | string; }>; + epochArrays: { buffer: ArrayBuffer; meta: EpochArraysMeta } | null; PyodideActions: typeof PyodideActions; ExperimentActions: typeof ExperimentActions; subject: string; session: number; + params: ExperimentParameters | null; + suggestedRejections: SuggestedRejection[]; } interface DropdownOption { @@ -28,11 +50,21 @@ interface DropdownOption { } interface State { + // Which screen is showing: dataset picker vs. the interactive editor. + view: 'select' | 'review'; subjects: Array; eegFilePaths: Array; selectedSubject: string; selectedFilePaths: Array; isSidebarVisible: boolean; + // ABSOLUTE epoch indices the student has marked for rejection. + rejectedEpochs: Set; + // Channel names the student has flagged as bad (dropped across all epochs). + badChannels: Set; + // Peak-to-peak threshold (µV) used by the auto-flag request. + autoFlagThreshold: number; + // Whether the auto-flag threshold settings panel is open. + showAutoFlagSettings: boolean; } export default class Clean extends Component { @@ -41,16 +73,26 @@ export default class Clean extends Component { constructor(props: Props) { super(props); this.state = { + view: 'select', subjects: [], eegFilePaths: [{ key: '', text: '', value: '' }], selectedFilePaths: [], selectedSubject: props.subject, isSidebarVisible: false, + rejectedEpochs: new Set(), + badChannels: new Set(), + autoFlagThreshold: PTP_THRESHOLD.default, + showAutoFlagSettings: false, }; this.handleRecordingChange = this.handleRecordingChange.bind(this); this.handleLoadData = this.handleLoadData.bind(this); this.handleSidebarToggle = this.handleSidebarToggle.bind(this); this.handleSubjectChange = this.handleSubjectChange.bind(this); + this.handleToggleEpoch = this.handleToggleEpoch.bind(this); + this.handleToggleChannel = this.handleToggleChannel.bind(this); + this.handleAutoFlag = this.handleAutoFlag.bind(this); + this.handleCleanData = this.handleCleanData.bind(this); + this.handleThresholdChange = this.handleThresholdChange.bind(this); this.icons = props.type === EXPERIMENTS.N170 ? ['😊', '🏠', '✕', '📖'] @@ -96,29 +138,122 @@ export default class Clean extends Component { handleLoadData() { this.props.ExperimentActions.SetSubject(this.state.selectedSubject); this.props.PyodideActions.LoadEpochs(this.state.selectedFilePaths); + // Launch the editor; a fresh dataset invalidates any previously selected + // epoch indices and bad-channel selections. + this.setState({ + view: 'review', + rejectedEpochs: new Set(), + badChannels: new Set(), + }); + } + + componentDidUpdate(prevProps: Props) { + if (prevProps.suggestedRejections !== this.props.suggestedRejections) { + const suggested = this.props.suggestedRejections; + if (suggested.length > 0) { + this.setState((prev) => { + const next = new Set(prev.rejectedEpochs); + for (const s of suggested) next.add(s.index); + return { rejectedEpochs: next }; + }); + } + } + } + + handleToggleEpoch(index: number) { + this.setState((prev) => { + const next = new Set(prev.rejectedEpochs); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return { rejectedEpochs: next }; + }); + } + + handleToggleChannel(name: string) { + const next = new Set(this.state.badChannels); + const adding = !next.has(name); + if (adding) { + next.add(name); + } else { + next.delete(name); + } + this.setState({ badChannels: next }); + + // Dropping >1 of a 4-channel (Muse) recording loses a lot of signal — + // informational only; they can still proceed. + if (adding && next.size > 1 && this.props.epochArrays?.meta.n_channels === 4) { + window.electronAPI.showMessageBox({ + buttons: ['Got it'], + message: + "You've marked more than one bad channel on a 4-channel recording. " + + 'That removes a big chunk of your data — if the signal is really this ' + + 'noisy, consider collecting another dataset.', + }); + } + } + + handleAutoFlag() { + this.props.PyodideActions.GetSuggestedRejections( + this.state.autoFlagThreshold + ); + } + + async handleCleanData() { + const total = this.props.epochArrays?.meta.n_epochs ?? 0; + const nDropped = this.state.rejectedEpochs.size; + // Rejecting every epoch produces an empty dataset that can't be analyzed + // (and previously wrote a degenerate .fif with no error). Warn first. + if (total > 0 && nDropped >= total) { + const response = await window.electronAPI.showMessageBox({ + buttons: ['Cancel', 'Reject all anyway'], + message: `This will reject all ${total} epochs, leaving nothing to analyze. Are you sure?`, + }); + if (response.response !== 1) { + return; + } + } + this.props.PyodideActions.CleanEpochs({ + dropIndices: Array.from(this.state.rejectedEpochs), + badChannels: Array.from(this.state.badChannels), + }); + // After Clean, raw_epochs is re-fetched with fewer epochs, so the old + // absolute indices no longer apply. + this.setState({ + rejectedEpochs: new Set(), + badChannels: new Set(), + }); + } + + handleThresholdChange(e: React.ChangeEvent) { + const parsed = parseFloat(e.target.value); + if (!Number.isNaN(parsed)) { + this.setState({ autoFlagThreshold: parsed }); + } } handleSidebarToggle() { this.setState({ isSidebarVisible: !this.state.isSidebarVisible }); } - renderEpochLabels() { - if ( - !isNil(this.props.epochsInfo) && - this.state.selectedFilePaths.length >= 1 - ) { - return ( -
- {this.props.epochsInfo.map((infoObj, index) => ( -
- {this.icons[index]} {infoObj.name} -

{infoObj.value}

-
- ))} -
- ); + renderStats() { + const { epochsInfo } = this.props; + if (isNil(epochsInfo) || epochsInfo.length === 0) { + return null; } - return
; + return ( +
+ {epochsInfo.map((infoObj, index) => ( + + {this.icons[index]} + {infoObj.name}:{' '} + {infoObj.value} + + ))} +
+ ); } renderAnalyzeButton() { @@ -135,6 +270,177 @@ export default class Clean extends Component { return null; } + renderSelect(filteredFilePaths: DropdownOption[]) { + return ( +
+

Clean

+

Select & Clean

+

+ Ready to clean some data? Pick a subject and one or more EEG + recordings, then launch the editor. +

+

Select Subject

+ +

Select Recordings

+ + +
+ ); + } + + renderReview( + codeToLabel: Record, + suggestedRejections: SuggestedRejection[] + ) { + const hasEpochs = !isNil(this.props.epochArrays); + const nRecordings = this.state.selectedFilePaths.length; + return ( + <> +
+ +

Clean

+ + {this.state.selectedSubject} · {nRecordings} recording + {nRecordings === 1 ? '' : 's'} + +
+ +
+ + + +
{this.renderAnalyzeButton()}
+
+ + {this.state.showAutoFlagSettings && ( +
+ +
+ More flags + + Fewer flags +
+

+ Flag epochs whose peak-to-peak amplitude exceeds{' '} + + {this.state.autoFlagThreshold} µV + + . +

+
+ )} + {suggestedRejections.length > 0 && ( +
+

+ Flagged {suggestedRejections.length}{' '} + {suggestedRejections.length === 1 ? 'epoch' : 'epochs'} +

+
    + {suggestedRejections.slice(0, 3).map((s, i) => ( +
  • {s.reason}
  • + ))} +
+
+ )} + +
{this.renderStats()}
+ + {hasEpochs ? ( +
+ + +
+ ) : ( +
+ Loading your epochs… 🧠 +
+ )} + + ); + } + render() { const filteredFilePaths = this.state.eegFilePaths.filter((filepath) => { const strVal = filepath.value; @@ -144,6 +450,9 @@ export default class Clean extends Component { return this.state.selectedSubject === subjectFromFilepath; }); + const codeToLabel = codeToLabelFor(this.props.params?.stimuli); + const { suggestedRejections } = this.props; + return (
{this.state.isSidebarVisible && ( @@ -151,65 +460,10 @@ export default class Clean extends Component {
)} -
-
-

Clean

-
-
-
-

Select & Clean

-

- Ready to clean some data? Select a subject and one or more EEG - recordings, then launch the editor -

-

Select Subject

- -

Select Recordings

- -
- - -
-
-
- {this.renderEpochLabels()} - {this.renderAnalyzeButton()} -
-
+
+ {this.state.view === 'select' + ? this.renderSelect(filteredFilePaths) + : this.renderReview(codeToLabel, suggestedRejections)}
); diff --git a/src/renderer/components/CollectComponent/ConnectModal.tsx b/src/renderer/components/CollectComponent/ConnectModal.tsx index e4aaea01..1fbfca1f 100644 --- a/src/renderer/components/CollectComponent/ConnectModal.tsx +++ b/src/renderer/components/CollectComponent/ConnectModal.tsx @@ -42,7 +42,6 @@ interface State { enum INSTRUCTION_PROGRESS { SEARCHING, TURN_ON, - COMPUTER_CONNECTABILITY, } export default class ConnectModal extends Component { @@ -242,44 +241,6 @@ export default class ConnectModal extends Component { Back )} - -
- - ); - } - if ( - this.state.instructionProgress === - INSTRUCTION_PROGRESS.COMPUTER_CONNECTABILITY - ) { - return ( - <> -

Insert the USB Receiver

-

- 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 -

-
- -
- Subject ID: {subject} -
-
- Group Name: {group} -
-
- Session Number: {session} -
-
- -
+ {!isRunning && hasFinished && ( +
+

Recording complete 🎉

+

+ Saved {subject}'s data. Ready to clean and analyze it? +

+
+ +
)} + {!isRunning && !hasFinished && ( +
+ + +
+

Ready to run

+ +
+
+ +
+ Subject ID: {subject} +
+
+ Group Name: {group} +
+
+ Session Number: {session} +
+
+ +
+
+
+
+ )} + {isRunning && (
{ @@ -35,13 +37,14 @@ export default class Home extends Component { super(props); this.state = { isConnectModalOpen: false, + isHelpVisible: false, }; this.handleConnectModalClose = this.handleConnectModalClose.bind(this); this.handleStartConnect = this.handleStartConnect.bind(this); this.handleStopConnect = this.handleStopConnect.bind(this); } - componentDidUpdate = (prevProps: Props, prevState: State) => { + componentDidUpdate = (_prevProps: Props, prevState: State) => { if ( this.props.connectionStatus === CONNECTION_STATUS.CONNECTED && prevState.isConnectModalOpen @@ -122,6 +125,19 @@ export default class Home extends Component { />
)} + {this.state.isHelpVisible ? ( +
+ this.setState({ isHelpVisible: false })} + /> +
+ ) : ( +
+ this.setState({ isHelpVisible: true })} + /> +
+ )}
); } diff --git a/src/renderer/constants/constants.ts b/src/renderer/constants/constants.ts index f2e32f8d..a697f4a8 100644 --- a/src/renderer/constants/constants.ts +++ b/src/renderer/constants/constants.ts @@ -9,6 +9,19 @@ export enum EXPERIMENTS { // SSVEP = 'Steady-state Visual Evoked Potential', } +/** + * Slider bounds for the Clean auto-flag peak-to-peak threshold, in microvolts. + * Students set sensitivity on a slider instead of typing a raw µV number. The + * range is deliberately generous around real Muse ptp (tens of µV; artifacts + * well over 100), and is shared by every device. + */ +export const PTP_THRESHOLD = { + min: 20, + max: 400, + step: 10, + default: 100, +}; + export const SCREENS = { HOME: { route: '/', title: 'HOME', order: 0 }, BANK: { route: '/home', title: 'HOME', order: 0 }, diff --git a/src/renderer/containers/CleanContainer.ts b/src/renderer/containers/CleanContainer.ts index 33cd55af..8d03efab 100644 --- a/src/renderer/containers/CleanContainer.ts +++ b/src/renderer/containers/CleanContainer.ts @@ -11,6 +11,7 @@ function mapStateToProps(state: RootState) { subject: state.experiment.subject, group: state.experiment.group, session: state.experiment.session, + params: state.experiment.params, deviceType: state.device.deviceType, ...state.pyodide, }; diff --git a/src/renderer/epics/pyodideEpics.ts b/src/renderer/epics/pyodideEpics.ts index 5d5df451..afbdfd40 100644 --- a/src/renderer/epics/pyodideEpics.ts +++ b/src/renderer/epics/pyodideEpics.ts @@ -1,11 +1,15 @@ import { combineEpics, Epic } from 'redux-observable'; -import { EMPTY, fromEvent, Observable, of } from 'rxjs'; -import { map, mergeMap, tap, pluck, filter } from 'rxjs/operators'; +import { EMPTY, from, fromEvent, Observable, of } from 'rxjs'; +import { map, mergeMap, tap, pluck, filter, catchError } from 'rxjs/operators'; import { toast } from 'react-toastify'; import { isActionOf } from '../utils/redux'; -import { PyodideActions, PyodideActionType } from '../actions'; +import { + PyodideActions, + PyodideActionType, + EpochArraysMeta, + SuggestedRejection, +} from '../actions'; import { RootState } from '../reducers'; -import { getWorkspaceDir } from '../utils/filesystem/storage'; import { buildMarkerRegistry } from '../utils/eeg/markerRegistry'; import { loadCSV, @@ -15,7 +19,9 @@ import { epochEvents, requestEpochsInfo, requestChannelInfo, - cleanEpochsPlot, + requestEpochArrays, + requestSuggestRejections, + applyRejection, plotPSD, plotERP, plotTopoMap, @@ -77,7 +83,7 @@ const pyodideMessageEpic: Epic< PyodideActionType, PyodideActionType, RootState -> = (action$) => +> = (action$, state$) => action$.pipe( filter(isActionOf(PyodideActions.SetPyodideWorker)), pluck('payload'), @@ -107,6 +113,45 @@ const pyodideMessageEpic: Epic< // results is an array of channel-name strings return of(PyodideActions.SetChannelInfo(results as string[])); } + if (dataKey === 'epochArrays') { + return of( + PyodideActions.SetEpochArrays({ + buffer: e.data.buffer as ArrayBuffer, + meta: results as EpochArraysMeta, + }) + ); + } + if (dataKey === 'suggestedRejections') { + return of( + PyodideActions.SetSuggestedRejections(results as SuggestedRejection[]) + ); + } + if (dataKey === 'savedEpochs') { + const savedEpochsBuffer = e.data.buffer as ArrayBuffer | undefined; + // Surface a dropped/empty save instead of writing nothing silently — + // that path left the Analyze picker mysteriously empty with no error. + if (!savedEpochsBuffer || savedEpochsBuffer.byteLength === 0) { + toast.error( + 'Could not save cleaned data — the recording came back empty. Nothing was written.' + ); + return EMPTY; + } + const { title, subject } = state$.value.experiment; + return from( + window.electronAPI.writeCleanedEpochs( + title, + subject, + savedEpochsBuffer + ) + ).pipe( + tap(() => toast.success('Cleaned data saved')), + mergeMap(() => EMPTY), + catchError((err) => { + toast.error(`Failed to save cleaned data: ${err?.message ?? err}`); + return EMPTY; + }) + ); + } // Route plot results to the appropriate Redux state slot. // results is an SVG string returned from Python. @@ -153,6 +198,8 @@ const loadEpochsEpic: Epic = ( } // Result returns asynchronously via pyodideMessageEpic → SetEpochInfo. requestEpochsInfo(worker, PYODIDE_VARIABLE_NAMES.RAW_EPOCHS); + // Fetch epoch arrays for the interactive reviewer (dataKey 'epochArrays'). + requestEpochArrays(worker, PYODIDE_VARIABLE_NAMES.RAW_EPOCHS); }), mergeMap(() => EMPTY) ); @@ -187,16 +234,22 @@ const cleanEpochsEpic: Epic = ( ) => action$.pipe( filter(isActionOf(PyodideActions.CleanEpochs)), - mergeMap(async () => { - await cleanEpochsPlot(state$.value.pyodide.worker!); - const dir = await getWorkspaceDir(state$.value.experiment.title); - return saveEpochs( - state$.value.pyodide.worker!, - dir, - state$.value.experiment.subject + pluck('payload'), + // Worker runs these FIFO: drop/flag -> save cleaned .fif -> re-fetch arrays. + tap(({ dropIndices, badChannels }) => { + const worker = state$.value.pyodide.worker!; + applyRejection( + worker, + PYODIDE_VARIABLE_NAMES.RAW_EPOCHS, + dropIndices, + badChannels ); + saveEpochs(worker, state$.value.experiment.subject); + requestEpochArrays(worker, PYODIDE_VARIABLE_NAMES.RAW_EPOCHS); }), - map(() => PyodideActions.GetEpochsInfo(PYODIDE_VARIABLE_NAMES.RAW_EPOCHS)) + map(() => + PyodideActions.GetEpochsInfo(PYODIDE_VARIABLE_NAMES.RAW_EPOCHS) + ) ); const getEpochsInfoEpic: Epic< @@ -224,6 +277,24 @@ const getChannelInfoEpic: Epic< mergeMap(() => EMPTY) ); +const getSuggestedRejectionsEpic: Epic< + PyodideActionType, + PyodideActionType, + RootState +> = (action$, state$) => + action$.pipe( + filter(isActionOf(PyodideActions.GetSuggestedRejections)), + pluck('payload'), + tap((threshold) => + requestSuggestRejections( + state$.value.pyodide.worker!, + PYODIDE_VARIABLE_NAMES.RAW_EPOCHS, + threshold + ) + ), + mergeMap(() => EMPTY) + ); + const loadPSDEpic: Epic = ( action$, state$ @@ -275,6 +346,7 @@ export default combineEpics( cleanEpochsEpic, getEpochsInfoEpic, getChannelInfoEpic, + getSuggestedRejectionsEpic, loadPSDEpic, loadTopoEpic, loadERPEpic diff --git a/src/renderer/reducers/experimentReducer.ts b/src/renderer/reducers/experimentReducer.ts index 1a1f4a3f..70088466 100644 --- a/src/renderer/reducers/experimentReducer.ts +++ b/src/renderer/reducers/experimentReducer.ts @@ -35,7 +35,8 @@ const initialState: ExperimentStateType = { group: '', session: 1, isRunning: false, - isEEGEnabled: false, + // EEG-on is the app's whole point; opt out for behavior-only runs, not in. + isEEGEnabled: true, dateModified: null, }; diff --git a/src/renderer/reducers/pyodideReducer.ts b/src/renderer/reducers/pyodideReducer.ts index 536afb25..e43e55ea 100644 --- a/src/renderer/reducers/pyodideReducer.ts +++ b/src/renderer/reducers/pyodideReducer.ts @@ -1,5 +1,10 @@ import { createReducer } from '@reduxjs/toolkit'; -import { PyodideActions, ExperimentActions } from '../actions'; +import { + PyodideActions, + ExperimentActions, + EpochArraysMeta, + SuggestedRejection, +} from '../actions'; export interface PyodideStateType { readonly epochsInfo: Array<{ @@ -24,6 +29,8 @@ export interface PyodideStateType { } | null | undefined; + readonly epochArrays: { buffer: ArrayBuffer; meta: EpochArraysMeta } | null; + readonly suggestedRejections: SuggestedRejection[]; readonly worker: Worker | null; readonly isWorkerReady: boolean; } @@ -34,6 +41,8 @@ const initialState: PyodideStateType = { psdPlot: null, topoPlot: null, erpPlot: null, + epochArrays: null, + suggestedRejections: [], worker: null, isWorkerReady: false, }; @@ -76,14 +85,24 @@ export default createReducer(initialState, (builder) => erpPlot: action.payload, }; }) + .addCase(PyodideActions.SetEpochArrays, (state, action) => { + // New epoch arrays → any prior auto-flag suggestions are stale. + return { ...state, epochArrays: action.payload, suggestedRejections: [] }; + }) + .addCase(PyodideActions.SetSuggestedRejections, (state, action) => ({ + ...state, + suggestedRejections: action.payload, + })) .addCase(PyodideActions.SetWorkerReady, (state) => { return { ...state, isWorkerReady: true }; }) - .addCase(ExperimentActions.ExperimentCleanup, (state, action) => { + .addCase(ExperimentActions.ExperimentCleanup, (state) => { return { ...state, epochsInfo: [], channelInfo: [], + epochArrays: null, + suggestedRejections: [], psdPlot: null, topoPlot: null, erpPlot: null, diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index c97e4e76..c2fc4265 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -65,6 +65,11 @@ declare global { imageTitle: string, rawData: ArrayBuffer ) => Promise; + writeCleanedEpochs: ( + title: string, + subject: string, + rawData: ArrayBuffer + ) => Promise; deleteWorkspaceDir: (title: string) => Promise; readImages: (dir: string) => Promise; getImages: (params: unknown) => Promise; diff --git a/src/renderer/utils/eeg/__tests__/markerRegistry.test.ts b/src/renderer/utils/eeg/__tests__/markerRegistry.test.ts index 49aef16c..86343207 100644 --- a/src/renderer/utils/eeg/__tests__/markerRegistry.test.ts +++ b/src/renderer/utils/eeg/__tests__/markerRegistry.test.ts @@ -11,6 +11,11 @@ import { EVENTS } from '../../../constants/constants'; import type { Stimulus } from '../../../constants/interfaces'; const stim = (title: string, type: EVENTS): Stimulus => ({ title, type }); +const stimC = (title: string, type: EVENTS, condition: string): Stimulus => ({ + title, + type, + condition, +}); describe('buildMarkerRegistry', () => { it('maps event_id values to the numeric codes written to the CSV', () => { @@ -43,6 +48,27 @@ describe('buildMarkerRegistry', () => { } }); + it('uses the experiment condition name as the display label when present', () => { + const { eventId, codeToLabel } = buildMarkerRegistry([ + stimC('Face1', EVENTS.STIMULUS_1, 'Face'), + stimC('Face2', EVENTS.STIMULUS_1, 'Face'), + stimC('House1', EVENTS.STIMULUS_2, 'House'), + ]); + // Codes are unchanged (they still drive CSV + MNE); only labels are human. + expect(eventId).toEqual({ Face: 1, House: 2 }); + expect(codeToLabel).toEqual({ 1: 'Face', 2: 'House' }); + }); + + it('falls back to the neutral label if two codes share a condition name', () => { + const { codeToLabel } = buildMarkerRegistry([ + stimC('A', EVENTS.STIMULUS_1, 'Same'), + stimC('B', EVENTS.STIMULUS_2, 'Same'), + ]); + // Never collapse two codes under one label — the second gets STIMULUS_2. + expect(codeToLabel[1]).toBe('Same'); + expect(codeToLabel[2]).toBe('STIMULUS_2'); + }); + it('returns empty maps when there are no stimuli', () => { expect(buildMarkerRegistry([])).toEqual({ codeToLabel: {}, eventId: {} }); expect(buildMarkerRegistry()).toEqual({ codeToLabel: {}, eventId: {} }); diff --git a/src/renderer/utils/eeg/conditionPalette.ts b/src/renderer/utils/eeg/conditionPalette.ts new file mode 100644 index 00000000..64e9800b --- /dev/null +++ b/src/renderer/utils/eeg/conditionPalette.ts @@ -0,0 +1,22 @@ +// Canonical condition color palette — single source of truth for BOTH the React +// EpochReviewer (via cssColorForIndex) and the Python matplotlib plots +// (utils.py plot_topo/plot_conditions), which receive CONDITION_PALETTE_RGB +// JSON-injected by the worker. RGB components are 0..1 floats to match MNE/ +// matplotlib's convention. Keep these values in sync across the WASM boundary +// by keeping this file the ONE place they are defined. +export const CONDITION_PALETTE_RGB: ReadonlyArray< + readonly [number, number, number] +> = [ + [0.86, 0.37, 0.34], + [0.34, 0.86, 0.37], + [0.37, 0.34, 0.86], + [0.86, 0.72, 0.34], +]; + +// CSS rgb() string for the i-th condition (cycles via modulo). Used by the +// canvas renderer to color epoch traces by their condition. +export function cssColorForIndex(index: number): string { + const [r, g, b] = + CONDITION_PALETTE_RGB[index % CONDITION_PALETTE_RGB.length]; + return `rgb(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)})`; +} diff --git a/src/renderer/utils/eeg/markerRegistry.ts b/src/renderer/utils/eeg/markerRegistry.ts index f82954e5..48caeb2e 100644 --- a/src/renderer/utils/eeg/markerRegistry.ts +++ b/src/renderer/utils/eeg/markerRegistry.ts @@ -42,21 +42,42 @@ export interface MarkerRegistry { * Build the registry for an experiment from the distinct numeric event codes * present in its stimuli. Multiple stimuli may share a code (e.g. every "Face" * image is STIMULUS_1) — they collapse to one condition entry, which is exactly - * what MNE wants. Unknown codes fall back to an `EVENT_` label so nothing is - * silently lost. + * what MNE wants. + * + * The display label prefers the experiment's own condition name + * (`stimulus.condition`, e.g. "Face"/"House") so the Clean counts, legend, and + * ERP read the real conditions instead of the generic STIMULUS_n. It falls back + * to the neutral STIMULUS_n label, then `EVENT_`, so nothing is silently + * lost. Labels are display-only — the numeric codes still drive the CSV and + * MNE's event_id (eventId VALUES are the codes). A condition name is only used + * if it's unique across codes; two codes are never collapsed under one label + * (that would silently merge conditions in analysis). */ export const buildMarkerRegistry = ( stimuli: Stimulus[] = [] ): MarkerRegistry => { - const codes = new Set(); + // First distinct condition name seen for each code (codes may repeat across + // many stimuli that all share the same condition). + const conditionForCode = new Map(); for (const stimulus of stimuli) { - if (typeof stimulus.type === 'number') codes.add(stimulus.type); + if (typeof stimulus.type !== 'number') continue; + if (!conditionForCode.has(stimulus.type) && stimulus.condition) { + conditionForCode.set(stimulus.type, stimulus.condition); + } else if (!conditionForCode.has(stimulus.type)) { + conditionForCode.set(stimulus.type, ''); + } } const codeToLabel: Record = {}; const eventId: Record = {}; - for (const code of codes) { - const label = CODE_TO_LABEL[code] ?? `EVENT_${code}`; + const usedLabels = new Set(); + for (const [code, condition] of conditionForCode) { + const neutral = CODE_TO_LABEL[code] ?? `EVENT_${code}`; + // Use the condition name only if it's non-empty and not already claimed by + // another code; otherwise fall back to the guaranteed-unique neutral label. + const label = + condition && !usedLabels.has(condition) ? condition : neutral; + usedLabels.add(label); codeToLabel[code] = label; eventId[label] = code; } diff --git a/src/renderer/utils/filesystem/storage.ts b/src/renderer/utils/filesystem/storage.ts index 57894eab..da835037 100644 --- a/src/renderer/utils/filesystem/storage.ts +++ b/src/renderer/utils/filesystem/storage.ts @@ -27,7 +27,7 @@ export const createWorkspaceDir = (title: string): Promise => api().createWorkspaceDir(title); export const openWorkspaceDir = (title: string): Promise => - api().showItemInFolder(path.join('BrainWaves_Workspaces', title)); + api().openWorkspaceDir(title); // ----------------------------------------------------------------------------------------------- // Storing diff --git a/src/renderer/utils/webworker/functions.ts b/src/renderer/utils/webworker/functions.ts index eb149770..e078cfd3 100644 --- a/src/renderer/utils/webworker/functions.ts +++ b/src/renderer/utils/webworker/functions.ts @@ -1,8 +1,5 @@ // ------------------------------------------- // Helper & utility functions -export const parseSingleQuoteJSON = (string: string) => - JSON.parse(string.replace(/'/g, '"')); - export const formatFilePath = (filePath: string) => `"${filePath.replace(/\\/g, '/')}"`; diff --git a/src/renderer/utils/webworker/index.ts b/src/renderer/utils/webworker/index.ts index bb1a212b..c6d153db 100644 --- a/src/renderer/utils/webworker/index.ts +++ b/src/renderer/utils/webworker/index.ts @@ -1,7 +1,7 @@ -import { formatFilePath } from './functions'; import path from 'pathe'; import patchesPy from './patches.py?raw'; import utilsPy from './utils.py?raw'; +import { CONDITION_PALETTE_RGB } from '../eeg/conditionPalette'; // --------------------------------- // This file contains the JS functions that allow the app to access python-wasm through pyodide @@ -51,7 +51,7 @@ export const loadCleanedEpochs = ( worker.postMessage({ fsFiles, data: [ - `clean_epochs = concatenate_epochs([read_epochs(file) for file in ${JSON.stringify(memfsPaths)}])`, + `clean_epochs = load_clean_epochs(${JSON.stringify(memfsPaths)})`, `conditions = OrderedDict({key: [value] for (key, value) in clean_epochs.event_id.items()})`, ].join('\n'), }); @@ -127,42 +127,57 @@ export const requestChannelInfo = (worker: Worker) => { }); }; -// ----------------------------- -// Plot functions +// Fetch epoch data arrays for the interactive reviewer. get_epochs_arrays writes +// a float32 buffer to a MEMFS path and returns metadata; the worker reads the +// buffer back (readFileAfter) and posts it zero-copy on dataKey 'epochArrays'. +export const requestEpochArrays = (worker: Worker, variableName: string) => { + const outPath = '/tmp/epoch_arrays.f32'; + worker.postMessage({ + data: `get_epochs_arrays(${variableName}, "${outPath}")`, + dataKey: 'epochArrays', + readFileAfter: outPath, + }); +}; -export const cleanEpochsPlot = async (worker: Worker) => { - await worker.postMessage({ - // MNE 1.x validates `events` as bool|ndarray — events=None raises TypeError; - // False is the "no events overlaid" default. Also close the returned Figure - // so the worker doesn't try to structuredClone a PyProxy back (this plot is - // not routed to the UI; wiring it to a plotKey would be a separate feature). - data: [ - `_fig = raw_epochs.plot(scalings='auto', n_epochs=6, title="Clean Data", events=False)`, - `plt.close(_fig)`, - ].join('\n'), +export const requestSuggestRejections = ( + worker: Worker, + variableName: string, + thresholdUv: number +) => { + worker.postMessage({ + data: `suggest_rejections(${variableName}, ${thresholdUv})`, + dataKey: 'suggestedRejections', }); }; -export const plotPSD = async (worker: Worker) => { +// Apply the user's rejection to raw_epochs in Python: drop the marked epoch +// indices + set bad channels, mutating in place. Trailing ';' suppresses the +// return value — apply_rejection returns the Epochs object, which cannot cross +// postMessage (PyProxy is not structured-cloneable). Fire-and-forget; the epic +// then triggers saveEpochs + requestEpochArrays, which the worker runs in order. +export const applyRejection = ( + worker: Worker, + variableName: string, + dropIndices: number[], + badChannels: string[] +) => { worker.postMessage({ - plotKey: 'psd', data: [ - 'import io', - '_fig = raw.compute_psd(fmin=1, fmax=30).plot(show=False)', - '_buf = io.BytesIO()', - '_fig.savefig(_buf, format="svg", bbox_inches="tight")', - 'plt.close(_fig)', - '_buf.getvalue().decode()', + `apply_rejection(${variableName}, ${JSON.stringify(dropIndices)}, ${JSON.stringify(badChannels)})`, + 'None', ].join('\n'), }); }; -export const plotTopoMap = async (worker: Worker) => { +// ----------------------------- +// Plot functions + +export const plotPSD = async (worker: Worker) => { worker.postMessage({ - plotKey: 'topo', + plotKey: 'psd', data: [ 'import io', - '_fig = plot_topo(clean_epochs, conditions)', + '_fig = raw.compute_psd(fmin=1, fmax=30).plot(show=False)', '_buf = io.BytesIO()', '_fig.savefig(_buf, format="svg", bbox_inches="tight")', 'plt.close(_fig)', @@ -171,16 +186,12 @@ export const plotTopoMap = async (worker: Worker) => { }); }; -export const plotTestPlot = async (worker: Worker | null) => { - if (!worker) return; +export const plotTopoMap = async (worker: Worker) => { worker.postMessage({ plotKey: 'topo', data: [ 'import io', - 'import matplotlib.pyplot as plt', - '_fig, _ax = plt.subplots()', - '_ax.plot([1, 2, 3, 4], [1, 4, 2, 3])', - '_ax.set_title("Test Plot")', + `_fig = plot_topo(clean_epochs, conditions, palette=${JSON.stringify(CONDITION_PALETTE_RGB)})`, '_buf = io.BytesIO()', '_fig.savefig(_buf, format="svg", bbox_inches="tight")', 'plt.close(_fig)', @@ -194,7 +205,7 @@ export const plotERP = async (worker: Worker, channelIndex: number) => { plotKey: 'erp', data: [ 'import io', - `_fig, _ = plot_conditions(clean_epochs, ch_ind=${channelIndex}, conditions=conditions, ci=97.5, n_boot=1000, title='', diff_waveform=None)`, + `_fig, _ = plot_conditions(clean_epochs, ch_ind=${channelIndex}, conditions=conditions, ci=97.5, n_boot=1000, title='', diff_waveform=None, palette=${JSON.stringify(CONDITION_PALETTE_RGB)})`, '_buf = io.BytesIO()', '_fig.savefig(_buf, format="svg", bbox_inches="tight")', 'plt.close(_fig)', @@ -203,19 +214,16 @@ export const plotERP = async (worker: Worker, channelIndex: number) => { }); }; -export const saveEpochs = ( - worker: Worker, - workspaceDir: string, - subject: string -) => +// Cleaned epochs are saved into the worker's in-memory MEMFS, then read back and +// shipped to the renderer (dataKey 'savedEpochs') which writes them to host disk +// via the fs:writeCleanedEpochs IPC bridge — Pyodide's FS can't reach host paths. +export const saveEpochs = (worker: Worker, subject: string) => { + const memfsPath = `/tmp/${subject}-cleaned-epo.fif`; worker.postMessage({ - data: `raw_epochs.save(${formatFilePath( - path.join( - workspaceDir, - 'Data', - subject, - 'EEG', - `${subject}-cleaned-epo.fif` - ) - )})`, + // save() returns the Epochs/filename object — append None so only the MEMFS + // bytes (readFileAfter) cross postMessage, not the Python return value. + data: [`raw_epochs.save("${memfsPath}", overwrite=True)`, 'None'].join('\n'), + dataKey: 'savedEpochs', + readFileAfter: memfsPath, }); +}; diff --git a/src/renderer/utils/webworker/utils.py b/src/renderer/utils/webworker/utils.py index 023053f8..b6ce0c30 100644 --- a/src/renderer/utils/webworker/utils.py +++ b/src/renderer/utils/webworker/utils.py @@ -4,7 +4,8 @@ from matplotlib import pyplot as plt import pandas as pd # maybe we can remove this dependency -from mne import (concatenate_raws, create_info, viz, find_events, Epochs) +from mne import (concatenate_raws, concatenate_epochs, create_info, viz, + find_events, Epochs, pick_types, read_epochs) from mne.io import RawArray from io import StringIO @@ -14,6 +15,7 @@ # sns.set_style('white') + def load_data(sfreq=128., replace_ch_names=None, csv_strings=None): """Load CSV files into a RawArray object. @@ -77,6 +79,13 @@ def load_data(sfreq=128., replace_ch_names=None, csv_strings=None): # get data and exclude Aux channel data = data.values[:, ch_ind].T + # Muse CSV amplitudes are in microvolts, but MNE treats eeg channel + # data as volts. Scale eeg rows uV -> V so peak-to-peak lands in the + # real ~tens-of-uV range; leave the stim/Marker row (numeric event + # codes) untouched. + eeg_mask = np.array([t == 'eeg' for t in ch_types]) + data[eeg_mask] = data[eeg_mask] * 1e-6 + # create MNE object info = create_info(ch_names=ch_names, ch_types=ch_types, sfreq=sfreq) @@ -125,12 +134,19 @@ def get_raw_epochs(raw, event_id, tmin, tmax, baseline=None, reject=None, verbose=False, picks=picks) -def plot_topo(epochs, conditions=OrderedDict()): - # palette = sns.color_palette("hls", len(conditions) + 1) - # temp hack, just pull in the color palette from seaborn - palette = [(0.85999999999999999, 0.37119999999999997, 0.33999999999999997), - (0.33999999999999997, 0.85999999999999999, 0.37119999999999997), - (0.37119999999999997, 0.33999999999999997, 0.85999999999999999)] +def load_clean_epochs(file_paths): + """Load cleaned .fif epoch files from MEMFS paths into one Epochs object. + + Analyze stages host .fif bytes at /tmp/... in the worker MEMFS before calling + this. + """ + return concatenate_epochs( + [read_epochs(path, preload=True, verbose=False) for path in file_paths], + verbose=False, + ) + + +def plot_topo(epochs, conditions, palette): evokeds = [epochs[name].average() for name in (conditions)] evoked_topo = viz.plot_evoked_topo( @@ -151,8 +167,8 @@ def plot_topo(epochs, conditions=OrderedDict()): return evoked_topo -def plot_conditions(epochs, ch_ind=0, conditions=OrderedDict(), ci=97.5, - n_boot=1000, title='', palette=None, diff_waveform=(4, 3)): +def plot_conditions(epochs, palette, ch_ind=0, conditions=OrderedDict(), + ci=97.5, n_boot=1000, title='', diff_waveform=(4, 3)): """Plot Averaged Epochs with ERP conditions. Parameters @@ -197,15 +213,10 @@ def plot_conditions(epochs, ch_ind=0, conditions=OrderedDict(), ci=97.5, if isinstance(conditions, dict): conditions = OrderedDict(conditions) - if palette is None: - palette = [ - (0.86, 0.37, 0.34), - (0.34, 0.86, 0.37), - (0.37, 0.34, 0.86), - (0.86, 0.72, 0.34), - ] - X = epochs.get_data() + # get_data() is volts (load_data scales eeg uV -> V); the y-axis is labeled + # uV, so convert back to microvolts for display. + X = epochs.get_data() * 1e6 times = epochs.times y = pd.Series(epochs.events[:, -1]) fig, ax = plt.subplots() @@ -254,6 +265,53 @@ def plot_conditions(epochs, ch_ind=0, conditions=OrderedDict(), ci=97.5, return fig, ax +def get_epochs_arrays(epochs, out_path): + """Serialize epoch data to a float32 buffer file plus a metadata dict. + + Writes the raw EEG epoch samples (Marker/stim channel excluded) as a flat + little-endian float32 buffer to `out_path` and returns metadata describing + the buffer's shape and per-epoch/per-channel labels. `out_path` is a Pyodide + MEMFS path in-app and a real filesystem path in the native tests. + + # buffer (float32, C-order): epoch0[ch0[t0..tN] ch1[..] ..] epoch1[..] .. + # byte length == n_epochs * n_channels * n_times * 4 + + Parameters + ---------- + epochs : mne.Epochs + The epoched data. The Marker channel (type 'stim') is excluded. + out_path : str + Destination path for the raw float32 buffer. + + Returns + ------- + meta : dict + Buffer metadata (see keys below). + """ + # EEG only — the Marker channel is type 'stim' (set in load_data), so + # pick_types(eeg=True) drops it while keeping the EEG channels in order. + picks = pick_types(epochs.info, eeg=True) + # get_data() is volts (load_data scales eeg uV -> V). This buffer drives the + # epoch viewer, which works in microvolts, so convert back to uV here. + data = epochs.get_data(picks=picks) * 1e6 # (n_epochs, n_channels, n_times) + data = np.ascontiguousarray(data.astype(np.float32)) + + with open(out_path, 'wb') as f: + f.write(data.tobytes()) + + n_epochs, n_channels, n_times = data.shape + ch_names = [epochs.ch_names[i] for i in picks] + + return { + "n_epochs": int(n_epochs), + "n_channels": int(n_channels), + "n_times": int(n_times), + "ch_names": ch_names, + "times": epochs.times.tolist(), + "event_codes": epochs.events[:, -1].tolist(), + } + + def get_epochs_info(epochs): print('Get Epochs Info:') # drop_log_stats() ignores IGNORED/NO_DATA entries, so the percentage is @@ -261,3 +319,53 @@ def get_epochs_info(epochs): return [*[{x: len(epochs[x])} for x in epochs.event_id], {"Drop Percentage": round(epochs.drop_log_stats(), 2)}, {"Total Epochs": len(epochs.events)}] + + +def apply_rejection(epochs, drop_indices, bad_channels): + """Drop the given epoch indices and mark bad channels, mutating epochs in place. + + drop_indices : list[int] -- 0-based indices into the CURRENT epochs (same + order as get_epochs_arrays produced), the epochs the user marked bad. + bad_channels : list[str] -- channel names to add to info['bads']. + + The result is exactly what MNE produces from epochs.drop(...) / info['bads'] — + the science is unchanged; only the UI that chooses the indices is new. + Returns epochs (the same, mutated object). + """ + if bad_channels: + epochs.info['bads'] = list(bad_channels) + if drop_indices: + epochs.drop(list(drop_indices)) + return epochs + + +def suggest_rejections(epochs, threshold_uv): + """Suggest artifact epochs by peak-to-peak amplitude (does NOT drop anything). + + For each epoch, compute the per-channel peak-to-peak (max-min over time) on the + EEG channels only (Marker/stim excluded), take the worst channel, and if it + exceeds threshold_uv microvolts, suggest that epoch. Advisory only — the UI + pre-marks these but the user can override; the real drop goes through + apply_rejection so the saved data stays MNE-exact. + + Returns list[dict] with keys: index (int, 0-based into the CURRENT epochs, + same order get_epochs_arrays produced) and reason (str). MNE data is in + volts; threshold_uv is microvolts. + """ + picks = pick_types(epochs.info, eeg=True) + data = epochs.get_data(picks=picks) # (n_epochs, n_channels, n_times), volts + ch_names = [epochs.ch_names[i] for i in picks] + ptp = data.max(axis=2) - data.min(axis=2) # (n_epochs, n_channels), volts + thresh_v = threshold_uv * 1e-6 + suggestions = [] + for e in range(ptp.shape[0]): + worst = int(ptp[e].argmax()) + peak_v = float(ptp[e, worst]) + if peak_v > thresh_v: + suggestions.append({ + "index": int(e), + "reason": "peak-to-peak {}µV on {}".format( + round(peak_v * 1e6), ch_names[worst] + ), + }) + return suggestions diff --git a/src/renderer/utils/webworker/webworker.js b/src/renderer/utils/webworker/webworker.js index 77d3e7d6..0e5ad98a 100644 --- a/src/renderer/utils/webworker/webworker.js +++ b/src/renderer/utils/webworker/webworker.js @@ -90,7 +90,11 @@ const pyodideReadyPromise = (async () => { return pyodide; })(); -self.onmessage = async (event) => { +// Async onmessage handlers can overlap; chain work so Python globals like +// clean_epochs exist before follow-up commands (GetEpochsInfo, LoadTopo, etc.). +let workChain = Promise.resolve(); + +async function processMessage(event) { // Propagate init failures back to the main thread rather than hanging silently. let pyodide; try { @@ -100,7 +104,8 @@ self.onmessage = async (event) => { return; } - const { data, plotKey, dataKey, fsFiles, ...context } = event.data; + const { data, plotKey, dataKey, fsFiles, readFileAfter, ...context } = + event.data; // Write any files to Pyodide's MEMFS before running Python code, so host OS // paths (e.g. .fif epoch files) can be staged in the WASM virtual filesystem. @@ -124,8 +129,26 @@ self.onmessage = async (event) => { results = results.toJs({ dict_converter: Object.fromEntries }); proxy.destroy(); } - self.postMessage({ results, plotKey, dataKey }); + // Read-back path: after running Python (e.g. epochs.save() to a MEMFS path), + // return the file's bytes to the renderer as a transferable ArrayBuffer so it + // can be written to host disk. Pyodide's MEMFS can't reach the host FS itself. + if (readFileAfter) { + const fileBytes = pyodide.FS.readFile(readFileAfter); // Uint8Array + self.postMessage({ buffer: fileBytes.buffer, results, plotKey, dataKey }, [ + fileBytes.buffer, + ]); + return; + } + // Fire-and-forget commands (no plotKey/dataKey/readFileAfter) must not post + // MNE objects back — they aren't structured-cloneable even after toJs. + if (plotKey || dataKey) { + self.postMessage({ results, plotKey, dataKey }); + } } catch (error) { self.postMessage({ error: error.message, plotKey, dataKey }); } +} + +self.onmessage = (event) => { + workChain = workChain.then(() => processMessage(event)); }; diff --git a/src/shared/pyodideAssets.ts b/src/shared/pyodideAssets.ts new file mode 100644 index 00000000..8043f59c --- /dev/null +++ b/src/shared/pyodideAssets.ts @@ -0,0 +1,13 @@ +/** + * Where the Pyodide payload lives, in dev and in a packaged build. + * + * These must agree with `build.extraResources` in package.json (JSON can't + * import, so that pairing is asserted in tests/build.check.ts). A mismatch is + * invisible in dev and breaks analysis completely in prod — it shipped once. + */ + +/** Source tree location; also `extraResources.from`. */ +export const PYODIDE_SOURCE_DIR = 'src/renderer/utils/webworker/src'; + +/** Folder under `process.resourcesPath` in a packaged app; also `extraResources.to`. */ +export const PYODIDE_RESOURCE_DIR = 'pyodide'; diff --git a/tests/analysis/test_apply_rejection.py b/tests/analysis/test_apply_rejection.py new file mode 100644 index 00000000..6d296f06 --- /dev/null +++ b/tests/analysis/test_apply_rejection.py @@ -0,0 +1,70 @@ +"""Native-MNE tests for the epoch-rejection step (Phase 1 epoch review). + +Verifies `utils.apply_rejection` drops the user-selected epoch indices and marks +bad channels producing a result that is bit-identical to what MNE itself would +produce from the same `epochs.drop(...)` / `info['bads']` operations. The UI that +chooses the indices is new; the science is unchanged. +""" +import numpy as np + +import utils # src/renderer/utils/webworker/utils.py (see conftest) +from synthetic import ( + generate_recording, + TARGET_CODE, + STANDARD_CODE, +) + +EVENT_ID = {"STANDARD": STANDARD_CODE, "TARGET": TARGET_CODE} +TMIN, TMAX = -0.1, 0.8 + + +def _build_epochs(): + csv, _ = generate_recording() + raw = utils.load_data(csv_strings=[csv]) + raw.filter(1, 30, method="iir", verbose=False) + return utils.get_raw_epochs(raw, EVENT_ID, TMIN, TMAX) + + +def test_bit_identical_to_manual_mne(): + epochs = _build_epochs() + assert len(epochs) > 3 + + bad = [epochs.ch_names[0]] + drops = [0, 2] + + manual = epochs.copy() + manual.info["bads"] = list(bad) + manual.drop(list(drops)) + + actual = epochs.copy() + utils.apply_rejection(actual, drops, bad) + + assert np.array_equal(actual.get_data(), manual.get_data()) + assert actual.info["bads"] == manual.info["bads"] + assert len(actual) == len(manual) + + +def test_drop_count(): + epochs = _build_epochs() + n0 = len(epochs.copy()) + assert n0 > 4 + + rejected = utils.apply_rejection(epochs.copy(), [1, 3], []) + assert len(rejected) == n0 - 2 + + +def test_empty_args_are_no_ops(): + epochs = _build_epochs() + before_len = len(epochs) + before_data = epochs.get_data() + + result = utils.apply_rejection(epochs.copy(), [], []) + + assert len(result) == before_len + assert np.array_equal(result.get_data(), before_data) + assert result.info["bads"] == [] + + +def test_returns_the_same_object(): + e = _build_epochs().copy() + assert utils.apply_rejection(e, [], []) is e diff --git a/tests/analysis/test_epoch_arrays.py b/tests/analysis/test_epoch_arrays.py new file mode 100644 index 00000000..7c18df3a --- /dev/null +++ b/tests/analysis/test_epoch_arrays.py @@ -0,0 +1,73 @@ +"""Native-MNE tests for the epoch-array serializer (Phase 0 epoch review). + +Verifies `utils.get_epochs_arrays` writes a float32 buffer matching the frozen +contract consumed by the React canvas renderer: Marker/stim channel excluded, +C-order [epoch][channel][time] layout, and a metadata dict whose shape/labels +match the buffer exactly. +""" +import numpy as np +import mne + +import utils # src/renderer/utils/webworker/utils.py (see conftest) +from synthetic import ( + generate_recording, + TARGET_CODE, + STANDARD_CODE, +) + +EVENT_ID = {"STANDARD": STANDARD_CODE, "TARGET": TARGET_CODE} +TMIN, TMAX = -0.1, 0.8 + + +def _build_epochs(): + csv, _ = generate_recording() + raw = utils.load_data(csv_strings=[csv]) + raw.filter(1, 30, method="iir", verbose=False) + return utils.get_raw_epochs(raw, EVENT_ID, TMIN, TMAX) + + +def test_marker_excluded_and_channel_count_matches(tmp_path): + epochs = _build_epochs() + meta = utils.get_epochs_arrays(epochs, str(tmp_path / "arr.f32")) + + assert meta["n_channels"] == len(meta["ch_names"]) + assert "Marker" not in meta["ch_names"] + + +def test_buffer_matches_epoch_data(tmp_path): + epochs = _build_epochs() + path = str(tmp_path / "arr.f32") + meta = utils.get_epochs_arrays(epochs, path) + + with open(path, "rb") as f: + raw_bytes = f.read() + arr = np.frombuffer(raw_bytes, dtype=np.float32).reshape( + meta["n_epochs"], meta["n_channels"], meta["n_times"] + ) + + # get_epochs_arrays emits microvolts (volts * 1e6) for the epoch viewer. + expected = (epochs.get_data( + picks=mne.pick_types(epochs.info, eeg=True) + ) * 1e6).astype(np.float32) + assert np.allclose(arr, expected, rtol=1e-5, atol=1e-3) + + +def test_metadata_lengths_and_event_codes(tmp_path): + epochs = _build_epochs() + meta = utils.get_epochs_arrays(epochs, str(tmp_path / "arr.f32")) + + assert len(meta["times"]) == meta["n_times"] + assert len(meta["event_codes"]) == meta["n_epochs"] + assert meta["event_codes"] == epochs.events[:, -1].tolist() + + +def test_byte_length_matches_shape(tmp_path): + epochs = _build_epochs() + path = str(tmp_path / "arr.f32") + meta = utils.get_epochs_arrays(epochs, path) + + with open(path, "rb") as f: + n_bytes = len(f.read()) + assert n_bytes == ( + meta["n_epochs"] * meta["n_channels"] * meta["n_times"] * 4 + ) diff --git a/tests/analysis/test_suggest_rejections.py b/tests/analysis/test_suggest_rejections.py new file mode 100644 index 00000000..f0333e81 --- /dev/null +++ b/tests/analysis/test_suggest_rejections.py @@ -0,0 +1,87 @@ +"""Native-MNE tests for the artifact-suggestion step (Phase 2 epoch review). + +Verifies `utils.suggest_rejections` flags epochs whose worst EEG-channel +peak-to-peak amplitude exceeds a microvolt threshold. It is advisory only — it +never drops anything (the real drop goes through `apply_rejection`) — so these +tests only check which epochs it *suggests*, and the shape of each suggestion. +""" +from mne import pick_types + +import utils # src/renderer/utils/webworker/utils.py (see conftest) +from synthetic import ( + generate_recording, + TARGET_CODE, + STANDARD_CODE, +) + +EVENT_ID = {"STANDARD": STANDARD_CODE, "TARGET": TARGET_CODE} +TMIN, TMAX = -0.1, 0.8 + + +def _build_epochs(): + csv, _ = generate_recording() + raw = utils.load_data(csv_strings=[csv]) + raw.filter(1, 30, method="iir", verbose=False) + return utils.get_raw_epochs(raw, EVENT_ID, TMIN, TMAX) + + +def test_threshold_gating(): + epochs = _build_epochs() + assert len(epochs) > 3 + + # Very high threshold: nothing is anomalous. + assert utils.suggest_rejections(epochs, 1e9) == [] + + # Very low threshold: every epoch is flagged. + suggestions = utils.suggest_rejections(epochs, 0) + assert len(suggestions) == len(epochs) + for s in suggestions: + assert set(s.keys()) == {"index", "reason"} + assert isinstance(s["index"], int) + assert 0 <= s["index"] < len(epochs) + assert isinstance(s["reason"], str) and s["reason"] + + +def test_detects_injected_artifact(): + epochs = _build_epochs() + assert len(epochs) > 3 + + ep = epochs.copy() + k = 2 + # A single-sample 5 mV (5000 µV) spike on one EEG channel of one epoch. + # It must be a spike, not a DC offset added to every sample — offsetting + # the whole trace leaves peak-to-peak unchanged and nothing gets flagged. + data = ep._data + data[k, 0, data.shape[2] // 2] += 5e-3 + + # Threshold between the normal ptp and the artifact. + suggestions = utils.suggest_rejections(ep, 1000) + indices = [s["index"] for s in suggestions] + + assert k in indices + + +def test_ptp_in_physiological_range(): + """Regression guard for the µV→V units bug (QA plan 6e). + + load_data must scale Muse µV amplitudes into MNE volts. If it doesn't, + peak-to-peak comes back inflated ~1e6× (tens of millions of "µV"), which + made auto-flag reject every epoch. Assert normal-recording ptp lands in a + physiological band so that regression can't return silently. + """ + epochs = _build_epochs() + # Worst-channel peak-to-peak per epoch, in µV (MNE data is volts). + data = epochs.get_data(picks=pick_types(epochs.info, eeg=True)) + peaks = ((data.max(axis=2) - data.min(axis=2)).max(axis=1) * 1e6).tolist() + assert peaks, "expected at least one epoch" + # Synthetic noise is a few µV; real Muse is tens. A correct scale keeps + # every epoch well under 1000 µV — the units bug put these at ~1e7. + assert all(1.0 < p < 1000.0 for p in peaks), f"ptp out of range: {peaks}" + + +def test_marker_channel_excluded(): + # The Marker/stim channel carries event codes, not EEG; if it were picked + # its ptp would dominate and it would be named in every reason string. + epochs = _build_epochs() + suggestions = utils.suggest_rejections(epochs, 0) + assert all("Marker" not in s["reason"] for s in suggestions) diff --git a/tests/build.check.ts b/tests/build.check.ts new file mode 100644 index 00000000..8f57184d --- /dev/null +++ b/tests/build.check.ts @@ -0,0 +1,95 @@ +import fs from 'fs'; +import path from 'path'; +import { describe, it, expect } from 'vitest'; +import { + PYODIDE_SOURCE_DIR, + PYODIDE_RESOURCE_DIR, +} from '../src/shared/pyodideAssets'; + +/** + * Post-build checks: the bundles and the Pyodide payload are actually on disk + * where the packaged app will look for them. Requires `npm run build` and a + * full `npm install` — run via `npm run test:build`. + * + * Named .check.ts, not .test.ts, so the default vitest glob skips it: running + * these without a build should be a loud ordering error, not a silent skip. + */ + +// process.cwd(), not __dirname — test files are ESM, where __dirname is undefined. +const repoRoot = process.cwd(); +const out = (...p: string[]) => path.join(repoRoot, 'out', ...p); +const webworkerSrc = path.join(repoRoot, PYODIDE_SOURCE_DIR); + +const packageJson = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8') +); + +describe('electron-vite build output', () => { + it.each([ + ['main', out('main/index.js')], + ['preload', out('preload/index.js')], + ['renderer', out('renderer/index.html')], + ])('emits the %s bundle', (_label, filePath) => { + expect( + fs.existsSync(filePath), + `${filePath} missing — run \`npm run build\` first` + ).toBe(true); + }); +}); + +describe('Pyodide payload', () => { + // package.json is JSON and cannot import the shared constants, so this is the + // one place that pairing gets checked. + it('extraResources matches the shared path constants', () => { + const entry = ( + packageJson.build.extraResources as Array<{ from: string; to: string }> + ).find((e) => e.from.includes('webworker')); + const normalize = (p: string) => p.replace(/^\.\//, '').replace(/\/$/, ''); + + expect(entry).toBeDefined(); + expect(normalize(entry!.from)).toBe(PYODIDE_SOURCE_DIR); + expect(normalize(entry!.to)).toBe(PYODIDE_RESOURCE_DIR); + }); + + // Required at runtime: `indexURL` resolves the interpreter and stdlib through + // the pyodide:// handler, so a missing one breaks analysis silently. + it.each([ + 'pyodide.mjs', + 'pyodide.asm.js', + 'pyodide.asm.wasm', + 'python_stdlib.zip', + ])('ships the Pyodide runtime file %s', (filename) => { + const filePath = path.join(webworkerSrc, 'pyodide', filename); + expect( + fs.existsSync(filePath), + `${filePath} missing — run \`npm run install-pyodide\`` + ).toBe(true); + }); + + it('every wheel named in the package manifest exists on disk', () => { + const packagesDir = path.join(webworkerSrc, 'packages'); + const manifestPath = path.join(packagesDir, 'manifest.json'); + + expect( + fs.existsSync(manifestPath), + `${manifestPath} missing — run \`npm run install-pyodide\`` + ).toBe(true); + + const manifest: Record = + JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const entries = Object.entries(manifest); + + // micropip.install() is driven entirely off this manifest; a listed-but- + // absent wheel fails at MNE import time, deep inside the worker. + expect(entries.length).toBeGreaterThan(0); + expect(Object.keys(manifest)).toContain('mne'); + + const missing = entries + .filter( + ([, { filename }]) => !fs.existsSync(path.join(packagesDir, filename)) + ) + .map(([pkg, { filename }]) => `${pkg} -> ${filename}`); + + expect(missing, 'manifest lists wheels that are not on disk').toEqual([]); + }); +}); diff --git a/vitest.build.config.ts b/vitest.build.config.ts new file mode 100644 index 00000000..03a51362 --- /dev/null +++ b/vitest.build.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +// tests/build.check.ts is deliberately not named *.test.ts, so the default +// `npm test` glob never picks it up (running it without a build should be a +// loud error, not a silent skip). A CLI positional is only a filter against +// `include`, so pointing at the file directly finds nothing — this config +// supplies the include. No exclude anywhere; the filename does that job. +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/build.check.ts'], + }, +});