| name | pyodide-mne |
| description | How BrainWaves runs MNE-Python analysis in-browser via Pyodide (WASM) inside a web worker. Use when adding/changing analysis, adding a Python package, debugging Pyodide load failures (esp. prod-only), routing plots back to the UI, or anything touching src/renderer/utils/webworker or InstallMNE.mjs. Covers the JS↔worker↔Python message protocol, plot routing, package install, and the pyodide:// protocol. |
Pyodide + MNE in BrainWaves
MNE-Python runs as WASM inside a web worker, driven from redux-observable epics. You almost never touch Python execution directly — you postMessage a Python string and receive results on the worker's message event.
The pieces
src/renderer/utils/webworker/webworker.js — the worker. Boots Pyodide, then self.onmessage runs pyodide.runPythonAsync(data) and posts back { results, plotKey, error }. Do not edit webworker/src/ — it's generated by InstallPyodide.mjs / InstallMNE.mjs.
src/renderer/utils/webworker/index.ts — the JS API. Each export wraps a Python string in worker.postMessage({ data, plotKey?, csvArray? }). This is where you add/edit analysis calls.
webworker/utils.py — the actual analysis (load_data, get_raw_epochs, plotting). Testable against native MNE (see tests/analysis/); keep one implementation so app and tests don't drift.
webworker/patches.py — monkeypatches applied at boot (apply_patches()).
src/renderer/epics/pyodideEpics.ts — orchestrates: launch worker, send commands, receive messages, dispatch Redux actions.
The message protocol (the whole contract)
Outbound (JS → worker): worker.postMessage({ data: "<python>", plotKey?, csvArray? }).
data is a Python string (or \n-joined lines). Its last expression is the return value.
csvArray rides alongside as a global the Python reads (load_data() pulls js.csvArray).
- Append
; to a statement to suppress a return you don't want marshalled back.
Inbound (worker → JS, on the message event): { results, plotKey, error }. pyodideMessageEpic switches on plotKey:
'ready' → SetWorkerReady
'topo' | 'psd' | 'erp' → Set*Plot with results (an SVG string) wrapped as a MIME bundle
error set → toast + ReceiveError
- else →
ReceiveMessage
Adding an analysis step
- Write/extend the function in
utils.py so it's importable and native-MNE testable (no js.* globals in the core; factor those to the boundary, like load_data(csv_strings=...)).
- Add a thin wrapper in
webworker/index.ts that postMessages the call.
- Drive it from an epic in
pyodideEpics.ts (tap(() => wrapperFn(worker)) then mergeMap(() => EMPTY) for fire-and-forget; results arrive async on the message event).
- Add a test under
tests/analysis/ (runs real MNE via pytest, CI in analysis.yml).
Plots are async and fire-and-forget
worker.postMessage() returns undefined — you cannot await a plot. Pattern:
- Epic:
tap(() => plotX(worker)) then mergeMap(() => EMPTY) — emit nothing.
- The Python renders with
agg (NOT WebAgg — it needs js.document and throws in a worker), fig.savefig(buf, format="svg"), returns the SVG string with a plotKey.
- The worker echoes
plotKey; pyodideMessageEpic routes it to the right Set*Plot action; PyodidePlotWidget renders the MIME bundle.
Always plt.close(_fig) after saving — leaked figures accumulate in the long-lived worker.
Adding a Python package
Edit internals/scripts/InstallMNE.mjs (runs on postinstall), not the worker. Pyodide binary packages come from the Pyodide CDN; pure-Python wheels from PyPI → written to webworker/src/packages/ with a manifest.json. Pure-Python transitive deps are NOT auto-resolved — list them explicitly (jinja2, markupsafe, decorator, requests + certifi/charset-normalizer/idna/urllib3 are already there because MNE/pooch/matplotlib need them at import). CDN version derives from node_modules/pyodide/package.json, not the lock file's dev label.
Loading gotchas (mostly prod-only — see .llms/learnings.md for full detail)
- Assets serve over a custom
pyodide:// Electron protocol (registered in src/main/index.ts), because Vite's SPA fallback returns index.html for worker fetch()s. Dev root webworker/src/; prod root resourcesPath/pyodide/ (the extraResources dest name must match the protocol handler).
- Prod needs both
indexURL and packageBaseUrl set to the protocol base — import.meta.url resolution fails once Vite bundles pyodide.mjs away from its siblings.
- Worker must be
type: 'module'; checkIntegrity: false (lock hashes ≠ CDN wheels); optimizeDeps.exclude: ['pyodide'].
micropip rejects custom schemes — wheels are JS-fetched via the protocol, written to the emscripten FS, installed via emfs:///tmp/....
If Pyodide works in npm run dev but breaks in npm run package, it's almost always asset paths / protocol dest-name / indexURL.