diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml new file mode 100644 index 00000000..14948fb5 --- /dev/null +++ b/.github/workflows/analysis.yml @@ -0,0 +1,39 @@ +name: Analysis (ERP round-trip) + +# Validates that a CSV recorded by the app yields a correct ERP, end to end. +# Runs the app's real analysis code (src/renderer/utils/webworker/utils.py) +# against NATIVE MNE — no Pyodide boot — so it's fast and pins down the +# data-integrity contract: event_id values must equal the numeric codes in the +# CSV Marker column, and the sfreq heuristic must survive timestamp jitter. +# +# Note: the app runs this same Python under Pyodide/WASM. A slower +# Pyodide-fidelity smoke job (TODO) will guard against native/WASM divergence. + +on: + push: + branches: + - '**' + pull_request: + +jobs: + analysis: + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + cache-dependency-path: tests/analysis/requirements.txt + + - name: Install native MNE + test deps + run: pip install -r tests/analysis/requirements.txt + + - name: ERP round-trip validation tests + env: + MPLBACKEND: agg + run: python -m pytest tests/analysis -v diff --git a/.github/workflows/device.yml b/.github/workflows/device.yml new file mode 100644 index 00000000..8bc1daf0 --- /dev/null +++ b/.github/workflows/device.yml @@ -0,0 +1,35 @@ +name: Device & LSL Integration + +# Fast, focused gate for the EEG device + Lab Streaming Layer connectivity +# tests. These mock the native liblsl/Neurosity layers with an in-memory "LSL +# network" (see src/main/lsl/__tests__/fakeLslNetwork.ts), so the job needs no +# liblsl on the runner and can skip the heavy Pyodide postinstall entirely via +# --ignore-scripts. The full cross-OS suite still runs separately in test.yml. + +on: + push: + branches: + - '**' + pull_request: + +jobs: + integration: + 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' + + # --ignore-scripts skips electron-builder app-deps + Pyodide/MNE downloads; + # the connectivity tests mock every native module, so none are required. + - name: Install dependencies (no native/postinstall steps) + run: npm ci --ignore-scripts + + - name: Device & LSL integration tests + run: npm run device-integration diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed9d03f1..674e90f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,4 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} USERNAME: ${{ secrets.USERNAME }} PASSWORD: ${{ secrets.PASSWORD }} - CLIENT_ID: ${{ secrets.CLIENT_ID }} - CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }} - LICENSE_ID: ${{ secrets.LICENSE_ID }} run: npm run package-ci diff --git a/.gitignore b/.gitignore index eb02e035..a6fff003 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,15 @@ out dist .idea -keys.js src/renderer/utils/webworker/src + +# Pyodide runtime + package wheels (downloaded or extracted locally; see docs/pyodide-in-electron-vite.md) +src/renderer/utils/pyodide/src + +# Local git worktrees (not part of the repo tree) +.worktrees/ + +# Python venv for native analysis tests (tests/analysis) +.venv-test/ +.venv/ +__pycache__/ diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index c27d8893..00000000 --- a/.husky/pre-commit +++ /dev/null @@ -1 +0,0 @@ -lint-staged diff --git a/.llms/learnings.md b/.llms/learnings.md index ddf0af50..ec579487 100644 --- a/.llms/learnings.md +++ b/.llms/learnings.md @@ -9,6 +9,58 @@ Format: brief heading + explanation + (optional) relevant file paths. +## Markers: device-agnostic injection via the EEGDriver interface + +Marker injection used to be Muse-only and lived in the UI (`RunComponent.eventCallback` +called `injectMuseMarker` directly). Neurosity recordings therefore had an all-zero +Marker column and could not yield ERPs. Now every first-party backend implements a +shared `EEGDriver` interface (`src/renderer/utils/eeg/types.ts`) with `injectMarker`, +registered in `src/renderer/utils/eeg/index.ts`. `deviceEpics` resolves drivers via +`getDriver(deviceType)` instead of branching on MUSE/NEUROSITY, and the UI calls the +device-agnostic `injectMarker()` dispatcher (delegates to the active driver, set on +connect via `setActiveDriver`). A new device cannot ship without a marker path — the +interface won't compile without it. LSL inlet is intentionally NOT in the registry +(separate external-recorder mode; `injectMarker` no-ops for it). Neurosity has no +native marker stream, so its `injectMarker` attaches the code to the next emitted +sample (one epoch of latency); Muse merges via muse-js eventMarkers + `synchronizeTimestamp`. + +## Marker codes are numeric, and the analysis event_id must match them + +Markers passed to `callbackForEEG` (and into the CSV) are **numeric** EVENTS codes +(`stimulus.type`, e.g. STIMULUS_1 = 1), not strings — see the experiment files' +`callbackForEEG(this.parameters.congruent === 'yes' ? 1 : 2)` etc. The CSV Marker +column carries these codes; MNE `find_events` reads them off the last (`stim`) channel. +The bug: `pyodideEpics.loadEpochsEpic` built the MNE `event_id` map as +`{stimulus.title: arrayIndex}` (0-based), which did not match the 1-based codes in the +data — so code-2 epochs matched no event_id and MNE raised "No matching events". Fixed +by `buildMarkerRegistry` (`src/renderer/utils/eeg/markerRegistry.ts`), the single source +of truth used by BOTH collection (CSV codes + `-events.json` sidecar) and analysis +(event_id). Also a latent bug: `epochEvents` interpolated an undefined `reject` as +`reject = undefined` (Python NameError) — now coerced to `None`. + +## Testing the analysis pipeline against native MNE (no Pyodide) + +`webworker/utils.py` is testable outside Pyodide: `load_data(csv_strings=...)` skips the +`js.csvArray` global, and epoching is factored into `get_raw_epochs(raw, event_id, +tmin, tmax, ...)` which `webworker/index.ts` `epochEvents` also calls — so the in-app +analysis and the tests share one implementation (no drift). `tests/analysis/` runs the +real `utils.py` against native MNE via pytest (`conftest.py` puts the webworker dir on +`sys.path`; `MPLBACKEND=agg` because utils.py imports pyplot at module load). The golden +test (`test_erp_roundtrip.py` + `synthetic.py`) plants a P300-like bump on one condition +and asserts it's recovered after `load_data → filter(1,30,'iir') → get_raw_epochs → +average`, plus sfreq-under-jitter and dropped-sample cases. CI: `.github/workflows/ +analysis.yml` (Python job, `pip install -r tests/analysis/requirements.txt`). The app +runs the same Python under Pyodide/WASM — a Pyodide-fidelity smoke job is still a TODO. + +## Testing device + LSL connectivity without native deps + +Device/LSL connectivity is integration-tested with the native layers fully mocked, so the tests run anywhere (incl. CI on all OSes) with **no liblsl / koffi / SDK installed**: +- `src/main/lsl/__tests__/fakeLslNetwork.ts` — an in-memory fake of `node-labstreaminglayer`: creating a `StreamOutlet` registers its `StreamInfo` on a shared broker; `resolveStreams()` lists live outlets; a `StreamInlet`'s `pullChunk()` reads what its outlet pushed since the last pull. This lets `outlets.ts` → `inlets.ts` be tested as a real round trip. Inject it by `vi.mock('../native')` then `vi.mocked(loadLSL).mockReturnValue(fake.module as unknown as ...)`. `outlets.ts`/`inlets.ts` also import `electron-log`, so `vi.mock('electron-log', () => ({ default: { info/warn/error: vi.fn() } }))` is required. The inlet poll loop is a `setInterval`, so use `vi.useFakeTimers()` + `vi.advanceTimersByTime()`. +- Renderer drivers mock `window.electronAPI` (capture the `onLSLInletData`/`onLSLInletDisconnected` handlers to "push" inlet epochs) and `@neurosity/sdk`. The Neurosity mock's `Neurosity` must be `new`-able — `neurosity.ts` does `new Neurosity(...)` — so define a plain `function Neurosity(){ return client }` inside `vi.hoisted` (arrow fns aren't constructable) and `vi.mock('@neurosity/sdk', () => ({ Neurosity: h.Neurosity }))`. +- `lslBridge.ts` probes availability at module load via a top-level `isLSLAvailable()` promise; to test the gate, set `window.electronAPI.isLSLAvailable` before `vi.resetModules()` + dynamic `await import('../lslBridge')`, then flush a microtask. + +**CI**: `npm run device-integration` (script targets `src/main/lsl src/renderer/utils/eeg`) runs as its own `.github/workflows/integration.yml` job with `npm ci --ignore-scripts` — since every native module is mocked, it skips the slow Pyodide/MNE postinstall and needs no liblsl. The full cross-OS suite still runs these too via `npm test` in `test.yml` (whose `Test` step runs before the lint step). + ## Styling System (post Phase 4 migration) The app uses shadcn/ui + Tailwind CSS. CSS modules have been fully removed. Key conventions: @@ -21,23 +73,23 @@ The app uses shadcn/ui + Tailwind CSS. CSS modules have been fully removed. Key - **Background gradient** used on all main screens: `bg-gradient-to-b from-[#f9f9f9] to-[#f0f0ff]` - **`@radix-ui/react-select`** is installed for the shadcn Select component -## Pyodide Asset Serving — Vite SPA Fallback Problem +## Pyodide Asset Serving — Custom `pyodide://` Protocol -Vite's `historyApiFallback` returns `index.html` for **all** `fetch()` requests from web workers, including `/@fs/` and `publicDir` paths. This breaks Pyodide's package loading entirely. +Vite's `historyApiFallback` returns `index.html` for **all** `fetch()` requests from web workers, breaking Pyodide's package loading. We solved this with a custom Electron protocol scheme registered in `src/main/index.ts` (`protocol.handle('pyodide', ...)`). The web worker uses `pyodide://host` as `PYODIDE_ASSET_BASE` and the handler resolves paths against the local filesystem — no HTTP socket required, works identically in dev and prod. -**Solution (two-part):** -1. A custom Vite middleware in `vite.config.ts` intercepts `/pyodide/` and `/packages/` requests before the SPA fallback and serves them directly from `src/renderer/utils/webworker/src/`. -2. An Electron `http` server on **port 17173** (started in `src/main/index.ts`) serves the same directory. Web workers use `http://127.0.0.1:17173` as `PYODIDE_ASSET_BASE`. This is the authoritative path — web worker `fetch()` calls bypass Vite entirely. +**Filesystem roots resolved by the handler:** +- Dev: `src/renderer/utils/webworker/src/` +- Prod: `process.resourcesPath/pyodide/` — `package.json` `extraResources` copies `webworker/src/` to a folder named `pyodide`. The protocol handler must match this destination name (mismatched once and broke prod entirely). -Port 17173 is hardcoded in both `src/main/index.ts` and `src/renderer/utils/webworker/webworker.js` and in the CSP (`src/renderer/index.html`). +**`indexURL` is required in prod, not just `packageBaseUrl`.** In dev, `pyodide.mjs` is imported via Vite's `?url` from `node_modules/pyodide/`, and the runtime files (`pyodide.asm.wasm`, `python_stdlib.zip`) load via `import.meta.url`-relative fetch — siblings live alongside it in node_modules. In prod, Vite bundles `pyodide.mjs` into `out/renderer/assets/` *without* its siblings, so `import.meta.url` resolution fails. Setting `indexURL: '${PYODIDE_ASSET_BASE}/pyodide/'` routes runtime fetches through the protocol handler. Set both `packageBaseUrl` (for `.whl` files via `loadPackage`) and `indexURL` (for the runtime). **Other Pyodide loading gotchas:** - `pyodide.mjs` must be loaded via dynamic `import()` (not `fetch()`), using a `?url` Vite import — `import()` bypasses the SPA fallback, `fetch()` does not - The lock file is embedded via `?raw` and wrapped in a `Blob` + `createObjectURL` to avoid an HTTP fetch -- Use `packageBaseUrl` (not `indexURL`) to tell Pyodide where to find `.whl` files; `indexURL` is for WASM/stdlib - `checkIntegrity: false` is required — SHA256 hashes in the npm lock file don't match CDN-downloaded wheels - Workers must be created with `type: 'module'` (Pyodide 0.26+ ships `pyodide.mjs` as ESM) - `optimizeDeps.exclude: ['pyodide']` in `vite.config.ts` prevents Vite from pre-bundling it +- `micropip.install()` only accepts `http://`, `https://`, `emfs://`, and relative paths — it rejects custom schemes like `pyodide://`. Workaround: JS-fetch each `.whl` via the protocol handler, write into Pyodide's emscripten FS at `/tmp/`, then install via `emfs:///tmp/...`. ## Pyodide Offline Package Installation (InstallMNE.mjs) @@ -81,6 +133,23 @@ In dev: use `/@fs` (Vite's `/@fs/` serving). In prod: use `file:///Frameworks/lsl.framework/Versions/A/lsl` over the bundled x86_64 dylib on every install/dev run. The patch is a no-op on x86_64 macs and on Linux/Windows (which ship usable `.so`/`.dll` in the same prebuild dir). + +Alternatives evaluated and rejected: `@neurodevs/node-lsl` and `@neurodevs/ndx-native` both require the same Homebrew install (they hard-code `/opt/homebrew/Cellar/lsl/...` paths) and have a much different async/worker-thread API that would force a substantial rewrite. + +## LSL is optional — load the native module lazily, never statically + +`node-labstreaminglayer` `dlopen`s liblsl via koffi **at require time**. A static `import … from 'node-labstreaminglayer'` in the main process therefore runs that dlopen during module evaluation at startup — so a missing/incompatible liblsl (e.g. Apple Silicon without the Homebrew build) crashes the *entire app* on launch, even for Muse-only users who don't need LSL at all. + +LSL is an advanced, opt-in capability: Muse and Neurosity connect via Web Bluetooth and record to CSV without it. So the native module must load lazily and fail soft: +- `src/main/lsl/native.ts` does a guarded `require('node-labstreaminglayer')` in try/catch (memoized), exposing `loadLSL()` (module | null) and `isLSLAvailable()`. `outlets.ts`/`inlets.ts` use **type-only** imports + `loadLSL()` at call time, no-opping when null. +- Feature-detect in the renderer via the `lsl:isAvailable` IPC: `ConnectModal` hides the "External LSL stream" option and `lslBridge` no-ops `sendEpoch`/`sendMarker` when unavailable (avoids IPC spam from first-party devices). + +Build note: with `module: ESNext` source but CommonJS main output, a guarded `require(...)` of an externalized dep type-checks (global `require` from `@types/node`) and stays a `require` in `out/main/index.js` (electron-vite externalizes it) — confirmed lazy, not bundled. Do **not** revert to a static import. ## Pre-existing TypeScript errors (do not treat as regressions) diff --git a/README.md b/README.md index de976720..cd6df058 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,16 @@ > **Note:** `npm install` downloads ~300 MB of Pyodide WASM files on first run. This is expected and only happens once. +### macOS (Apple Silicon) — install liblsl + +The `node-labstreaminglayer` npm package only ships an x86_64 `liblsl.dylib`, so arm64 Macs (M1/M2/M3/M4) need an arm64 build of liblsl from Homebrew. The dev script automatically symlinks the Homebrew binary into `node_modules/` on every install. + +```bash +brew install labstreaminglayer/tap/lsl +``` + +If you skip this step, `npm run dev` will fail at startup with `Failed to load shared library: ... incompatible architecture`. Intel Macs, Linux, and Windows do not need this step — the bundled binaries work as-is. + ## Installing from Source (for developers) 1. Clone the repo: diff --git a/docs/BrainWaves Technical Implementation Plan (2026 Modernization).md b/docs/BrainWaves Technical Implementation Plan (2026 Modernization).md deleted file mode 100644 index 2237e403..00000000 --- a/docs/BrainWaves Technical Implementation Plan (2026 Modernization).md +++ /dev/null @@ -1,104 +0,0 @@ -# BrainWaves: Technical Implementation Plan (2026 Modernization) - -**Author:** Manus AI -**Date:** March 2026 - -This document outlines a step-by-step technical implementation plan for AI agents working on the BrainWaves codebase. The goal is to modernize the stack, resolve deployment blockers, and replace the UI library, while establishing a robust testing harness. - -*Note: The migration to Lab Streaming Layer (LSL) is explicitly excluded from this phase and will be addressed once the application is successfully building and deploying.* - ---- - -## Phase 1: Test Harness & Build Environment Setup - -Before modifying the application logic, we must establish a reliable testing harness using Vitest and ensure the Electron build pipeline is functional. The current codebase uses a legacy Jest configuration (`"test": "cross-env jest --passWithNoTests"`) which must be replaced. - -### Step 1.1: Install and Configure Vitest -* **Action:** Remove Jest dependencies and install Vitest, `@testing-library/react`, `@testing-library/jest-dom`, and `jsdom`. -* **Action:** Create a `vitest.config.ts` file configured for a React/DOM environment. -* **Action:** Update `package.json` scripts to use Vitest (`"test": "vitest run"`, `"test:watch": "vitest"`). -* **Acceptance Criteria:** - * A basic sanity test (`src/renderer/App.test.tsx`) rendering a simple component passes using `npm test`. - * The CI workflow (`.github/workflows/test.yml`) is updated to run `npm test` using Vitest. - -### Step 1.2: Verify Electron Build Pipeline -* **Action:** Run the existing `npm run build` and `npm run package-ci` commands to identify any immediate build failures caused by Node 22 or Vite 7. -* **Action:** Fix any immediate compilation errors in `src/main/index.ts` or `vite.config.ts`. -* **Acceptance Criteria:** - * `npm run build` completes without errors. - * An integration test (`tests/build.test.ts`) is added that programmatically verifies the existence of the `out/main/index.js` and `out/renderer/index.html` files after a build. - ---- - -## Phase 2: Routing and State Synchronization Modernization - -The codebase currently relies on `react-router-dom v5` and the abandoned `connected-react-router` (which syncs router state to Redux). This causes significant issues with modern React 18 and Redux Toolkit. - -### Step 2.1: Remove `connected-react-router` -* **Action:** Uninstall `connected-react-router`. -* **Action:** Remove `routerMiddleware` and `connectRouter` from `src/renderer/store.ts` and `src/renderer/reducers/index.ts`. -* **Action:** Remove `ConnectedRouter` from `src/renderer/containers/Root.tsx` and replace it with a standard `HashRouter` from `react-router-dom`. -* **Acceptance Criteria:** - * The Redux store initializes successfully without the router reducer. - * Unit tests verify that the Redux store can be created with initial state (`tests/store.test.ts`). - -### Step 2.2: Upgrade to React Router v6/v7 -* **Action:** Upgrade `react-router-dom` to the latest stable version. -* **Action:** Refactor `src/renderer/routes.tsx` to use the new `` and `}>` syntax instead of the legacy `` and `component={}` props. -* **Action:** Refactor class components (e.g., `HomeComponent`, `DesignComponent`) that rely on `this.props.history.push`. Convert them to functional components using the `useNavigate` hook, or create a Higher-Order Component (HOC) `withRouter` wrapper if functional conversion is too extensive. -* **Action:** Update `src/renderer/epics/experimentEpics.ts` which currently listens to `@@router/LOCATION_CHANGE`. Refactor the logic to trigger based on specific Redux actions rather than URL changes. -* **Acceptance Criteria:** - * Unit tests (`tests/routing.test.tsx`) verify that navigation between the Home, Design, and Collect screens renders the correct components. - ---- - -## Phase 3: Pyodide and Python Dependency Modernization - -The application uses Pyodide `v0.21.0` (hardcoded in `internals/scripts/InstallPyodide.js`) and relies on deprecated Python libraries for data visualization. - -### Step 3.1: Upgrade Pyodide -* **Action:** Update `internals/scripts/InstallPyodide.js` to download a modern stable release of Pyodide (e.g., `v0.27.0`). -* **Action:** Ensure the `vite.config.ts` `publicDir` setting still correctly serves the updated Pyodide WASM and JS files. -* **Acceptance Criteria:** - * An integration test (`tests/pyodide.test.ts`) successfully instantiates the Pyodide web worker (`src/renderer/utils/pyodide/webworker.js`) and executes a simple `1 + 1` Python command. - -### Step 3.2: Refactor Python Plotting Logic -* **Action:** In `src/renderer/utils/pyodide/utils.py`, locate the usage of `sns.tsplot` (which was removed in Seaborn v0.10.0). -* **Action:** Rewrite the `plot_conditions` function to use `sns.lineplot` or standard `matplotlib.pyplot.plot` with `fill_between` for confidence intervals. -* **Acceptance Criteria:** - * A unit test (`tests/python_utils.test.ts`) loads `utils.py` into the Pyodide worker, passes mock EEG data, and verifies that the plotting function executes without throwing a Python `AttributeError`. - ---- - -## Phase 4: UI Library Replacement (Semantic UI to Shadcn/ui) - -`semantic-ui-react` is abandoned and throws deprecation warnings in React 18. We will replace it with Tailwind CSS and Shadcn/ui components. - -### Step 4.1: Install Tailwind CSS and Shadcn/ui -* **Action:** Install Tailwind CSS, PostCSS, and Autoprefixer. Configure `tailwind.config.js` and `postcss.config.js`. -* **Action:** Initialize Shadcn/ui (`npx shadcn-ui@latest init`) and configure it to output components to `src/renderer/components/ui`. -* **Acceptance Criteria:** - * A unit test verifies that a basic Shadcn/ui `Button` component renders correctly with Tailwind utility classes applied. - -### Step 4.2: Component-by-Component Replacement -* **Action:** Identify the ~26 files importing `semantic-ui-react`. The most heavily used components are `Segment`, `Button`, `Grid`, and `Header`. -* **Action:** Replace `semantic-ui-react` components with their Shadcn/ui equivalents: - * `Button` -> Shadcn `Button` - * `Segment` -> Shadcn `Card` or a simple `div` with Tailwind borders/padding. - * `Grid` -> Tailwind CSS Grid (`grid grid-cols-12 gap-4`). - * `Header` -> Standard HTML `h1`-`h6` tags with Tailwind typography classes. - * `Modal` -> Shadcn `Dialog`. -* **Action:** Remove `semantic-ui-css` from `src/renderer/index.tsx` and uninstall `semantic-ui-react`. -* **Acceptance Criteria:** - * `grep -r "semantic-ui-react" src/` returns zero results. - * Visual regression or DOM snapshot tests (`tests/ui_migration.test.tsx`) for key screens (Home, Design, Collect) pass, ensuring the new components render without crashing. - ---- - -## Phase 5: Final Build and Verification - -### Step 5.1: End-to-End Build Verification -* **Action:** Run the full build pipeline (`npm run package-all`). -* **Acceptance Criteria:** - * The Electron application compiles and packages successfully for the target OS. - * All Vitest suites (Routing, Store, Pyodide, UI) pass with 100% success rate. diff --git a/docs/device-connectivity.md b/docs/device-connectivity.md new file mode 100644 index 00000000..c072b2b8 --- /dev/null +++ b/docs/device-connectivity.md @@ -0,0 +1,213 @@ +# Device Connectivity + +How BrainWaves discovers and connects to EEG devices (currently: Muse only). + +--- + +## Architecture Overview + +Device connectivity spans three layers: + +| Layer | Files | Responsibility | +|---|---|---| +| **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` | + +All device state lives in Redux (`reducers/deviceReducer.ts`). Epics react to dispatched actions and fire new actions as side effects. + +--- + +## Connection Flow + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ PHASE 1: SEARCH │ +│ │ +│ CollectComponent mounts (EEG enabled) │ +│ │ │ +│ ▼ │ +│ handleStartConnect() │ +│ │ Opens ConnectModal │ +│ │ DeviceActions.SetDeviceAvailability(SEARCHING) ──────────────────────┐ │ +│ │ │ │ +│ ▼ (Redux dispatch) │ │ +│ │ │ +│ searchMuseEpic searchTimerEpic │ │ +│ │ filter: SEARCHING │ filter: SEARCHING ◄──────────┘ │ +│ │ map(getMuse) ──► Promise │ timer(3000ms) │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ navigator.bluetooth │ │ +│ │ .requestDevice() │ [if still SEARCHING after 3s] │ +│ │ ┌─────────┴──────────┐ │ SetDeviceAvailability(NONE) │ +│ │ │ │ │ │ +│ │ rejected resolved │ │ +│ │ │ │ │ │ +│ │ return [] return [{id, name}] │ +│ │ │ │ │ +│ │ filtered out DeviceFound([device]) │ +│ │ (silent) │ │ +│ │ ▼ │ +│ │ deviceFoundEpic │ +│ │ Deduplicates by id │ +│ │ SetAvailableDevices([...]) │ +│ │ SetDeviceAvailability(AVAILABLE) │ +└────┼───────────────────────────────────────────────────────────────────────── │ + │ │ +┌────▼──────────────────────────────────────────────────────────────────────────┐ +│ PHASE 2: CONNECT │ +│ │ +│ ConnectModal: user selects device from list, clicks Connect │ +│ │ │ +│ ▼ │ +│ DeviceActions.ConnectToDevice(device) │ +│ │ │ +│ ├──► isConnectingEpic │ +│ │ SetConnectionStatus(CONNECTING) │ +│ │ │ +│ └──► connectEpic │ +│ connectToMuse(device) │ +│ │ navigator.bluetooth.requestDevice() [again, with name filter] │ +│ │ deviceInstance.gatt.connect() │ +│ │ client.connect(gatt) [muse-js MuseClient] │ +│ │ │ +│ ├── success ──► DeviceInfo { name, samplingRate: 256, channels } │ +│ │ SetDeviceType(MUSE) │ +│ │ SetDeviceInfo(deviceInfo) │ +│ │ SetConnectionStatus(CONNECTED) │ +│ │ │ +│ └── failure ──► SetConnectionStatus(DISCONNECTED) │ +└───────────────────────────────────────────────────────────────────────────── │ + │ │ +┌────────────▼──────────────────────────────────────────────────────────────── │ +│ PHASE 3: DATA STREAM │ +│ │ +│ setRawObservableEpic (triggered by SetDeviceInfo) │ +│ createRawMuseObservable() │ +│ client.start() │ +│ client.eegReadings ──► zipSamples() ──► filter NaNs ──► share() │ +│ SetRawObservable(observable) │ +│ │ +│ setSignalQualityObservableEpic (triggered by SetRawObservable) │ +│ createMuseSignalQualityObservable(rawObservable, connectedDevice) │ +│ addInfo → epoch(64 samples) → bandpassFilter(1–50Hz) → addSignalQuality │ +│ → parseMuseSignalQuality() → { channelName: SIGNAL_QUALITY enum } │ +│ SetSignalQualityObservable(observable) │ +└───────────────────────────────────────────────────────────────────────────── │ + │ │ +┌────────────▼────────────────────────────────────────────────────────────────┐ │ +│ PHASE 4: CLEANUP (experiment ends or manual disconnect) │ │ +│ │ │ +│ deviceCleanupEpic (triggered by ExperimentCleanup) │ │ +│ disconnectFromMuse() → client.disconnect() │ │ +│ DeviceActions.Cleanup() → resets deviceReducer to initialState │ │ +└─────────────────────────────────────────────────────────────────────────────┘ │ +``` + +--- + +## Redux State (`deviceReducer`) + +``` +deviceType: DEVICES.MUSE (only supported device) +deviceAvailability: NONE | SEARCHING | AVAILABLE +connectionStatus: NOT_YET_CONNECTED | CONNECTING | CONNECTED | DISCONNECTED +availableDevices: Device[] — list from getMuse() +connectedDevice: DeviceInfo | null — { name, samplingRate, channels } +rawObservable: Observable | null +signalQualityObservable: Observable | null +``` + +--- + +## Known Issues & Bug Analysis + +### Bug: No devices found despite nearby Muse + +**Symptom:** `SetDeviceAvailability(SEARCHING)` fires, 3-second timer elapses, state returns to NONE. No devices listed, no error shown. + +**Root cause: Missing `select-bluetooth-device` handler in Electron main process.** + +Electron 22+ changed how Web Bluetooth works. When `navigator.bluetooth.requestDevice()` is called in the renderer, Electron fires a `select-bluetooth-device` event on `webContents` instead of showing the browser's built-in Bluetooth picker. If no handler is registered in the main process, the Promise **hangs indefinitely** (or rejects silently in some Electron versions), and the epic's error handler catches it and returns `[]`. + +**The app is running Electron 39 — this handler is mandatory.** + +The fix requires registering a handler in `src/main/index.ts` before the window is created: + +```ts +mainWindow.webContents.on('select-bluetooth-device', (event, deviceList, callback) => { + event.preventDefault(); + // Store callback and deviceList in state, send to renderer via IPC + // so the user can pick from the ConnectModal UI. + // OR: auto-select first matching Muse device: + const muse = deviceList.find(d => d.deviceName.startsWith('Muse')); + if (muse) { + callback(muse.deviceId); + } else { + callback(''); // reject — no Muse found + } +}); +``` + +There are two approaches for the UX: + +- **Auto-select** (simpler): in the handler, filter `deviceList` for any device whose name starts with `'Muse'` and immediately call `callback(deviceId)`. The user never sees a picker — it just connects. +- **Show picker in app UI** (better): send the `deviceList` to the renderer via IPC, display them in `ConnectModal`, and invoke the callback with the user's selection. Requires storing the callback reference in main process state between IPC calls. + +### Bug: `connectToMuse` calls `requestDevice` a second time + +`getMuse()` calls `requestDevice()` to scan, returns `[{ id, name }]`. Then when the user clicks Connect, `connectToMuse()` calls `requestDevice()` **again** with a name filter. This means the Bluetooth picker (or `select-bluetooth-device` event) fires twice for a single connection. Once the `select-bluetooth-device` handler is in place, both calls need to be handled. + +The cleaner fix is to cache the `BluetoothDevice` instance returned by the first `requestDevice()` call inside `getMuse()` and reuse it in `connectToMuse()`, skipping the second scan entirely. + +### Bug: Silent failure, no user feedback on search errors + +In `searchMuseEpic`, the error handler returns `[]` and the filter `devices.length >= 1` blocks it from dispatching anything. The user only escapes the "Searching..." state when the 3-second `searchTimerEpic` fires. There is no error message, no indication of what went wrong. + +The comment in the code acknowledges this: `"This error will fire a bit too promiscuously until we fix windows web bluetooth"` — the toast was intentionally silenced. Once the `select-bluetooth-device` handler is in place, errors will be more meaningful and the toast can be re-enabled. + +--- + +## Data Flow (during experiment) + +``` +Muse device (BLE) + │ raw EEG packets (12-sample frames, 256Hz) + ▼ +muse-js MuseClient + │ eegReadings: Observable + │ eventMarkers: Observable<{ timestamp, value }> + ▼ +createRawMuseObservable() + │ zipSamples() — assembles 4-channel samples + │ filter NaNs (Muse 2 artifact) + │ withLatestFrom(markers) — stamps event markers by timestamp + ▼ +rawObservable (SetRawObservable → Redux) + │ + ├──► createMuseSignalQualityObservable() + │ addInfo (256Hz, 4ch) → epoch(64) → bandpassFilter(1–50Hz) + │ → addSignalQuality → parseMuseSignalQuality + │ → SignalQualityData { TP9|AF7|AF8|TP10: GREAT|OK|BAD|DISCONNECTED } + │ (SetSignalQualityObservable → Redux → ViewerComponent) + │ + └──► experimentStartEpic (during experiment) + takeUntil(Stop | Cleanup) + writeEEGData(streamId, sample) → IPC → main process WriteStream → CSV +``` + +--- + +## Files at a Glance + +| File | Role | +|---|---| +| `utils/eeg/muse.ts` | Web Bluetooth + muse-js driver | +| `epics/deviceEpics.ts` | Async device lifecycle (search → connect → stream → cleanup) | +| `reducers/deviceReducer.ts` | Device Redux state | +| `actions/deviceActions.ts` | Action creators | +| `components/CollectComponent/ConnectModal.tsx` | Search/connect UI | +| `components/CollectComponent/index.tsx` | Auto-triggers search on mount | +| `components/EEGExplorationComponent.tsx` | Standalone explore-mode connect UI | +| `main/index.ts` | **Missing: `select-bluetooth-device` handler** | diff --git a/docs/lsl-implementation-plan.md b/docs/lsl-implementation-plan.md new file mode 100644 index 00000000..186961fa --- /dev/null +++ b/docs/lsl-implementation-plan.md @@ -0,0 +1,477 @@ +# 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/docs/migration-summary.md b/docs/migration-summary.md deleted file mode 100644 index 906ebcf3..00000000 --- a/docs/migration-summary.md +++ /dev/null @@ -1,113 +0,0 @@ -# BrainWaves Migration Session Summary - -## Architecture Change: Webpack/Babel/Yarn → Electron-Vite/npm - -The project was migrated from a legacy Electron + Webpack + Babel + Yarn stack to a modern **electron-vite** setup. This is a significant architectural shift: - -| Concern | Before | After | -|---|---|---| -| Build system | Webpack + Babel | **electron-vite** (esbuild + Rollup) | -| Package manager | Yarn | **npm** | -| Module format | CommonJS (`require`) | **ESM** (`import`) | -| Env variables (renderer) | `process.env.*` | **`import.meta.env.VITE_*`** | -| Process split | Single config | **Three explicit targets**: `main`, `preload`, `renderer` | -| Path utilities | `path-browserify` (2019, no ESM) | **`pathe`** (modern, pure ESM) | -| Dev server | Webpack HMR | **Vite HMR** | - -The electron-vite architecture enforces a clean Electron process split: -- **Main** (`src/main/index.ts`) — Node.js process, IPC handlers, file system -- **Preload** (`src/preload/index.ts`) — sandboxed bridge with `contextBridge` -- **Renderer** (`src/renderer/`) — pure browser context, React app - ---- - -## Major Work Done - -### 1. Build System Migration -- Replaced all Webpack config with `vite.config.ts` using `defineConfig` from `electron-vite` -- Converted `require()` calls to ESM `import` statements across the codebase -- Used `git mv` for all file renames to preserve git history -- Set `package.json` `"main"` field to `"./out/main/index.js"` (electron-vite's output convention) - -### 2. Electron API Modernization -- **Replaced deprecated devtools APIs**: `session.getAllExtensions()` / `session.loadExtension()` → `session.extensions.*` (new namespaced API) -- **Fixed preload `process` conflict**: Removed redundant `import process from 'process'` — Electron injects it natively -- **Added dev HTTP cache clearing**: `session.defaultSession.clearCache()` in `app.whenReady()` (dev only) to prevent Electron's persistent HTTP cache from serving stale Vite pre-bundled assets - -### 3. Dependency Upgrades - -| Package | From | To | Reason | -|---|---|---|---| -| `@neurosity/pipes` | v3 | **v5** | Eliminated `dsp.js` which used `this[name]` globals incompatible with strict ESM | -| `rxjs` | v6 | **v7** | Required by pipes v5 | -| `redux-observable` | v1 | **v2-rc** | Required by RxJS v7 | -| `plotly.js` | v1.54 (bundles **d3 v3**) | **v2.35** (uses d3 v6) | Eliminated all `this.document` / `this.navigator` / `this.Element` errors | -| `react-plotly.js` | v2.4 | **v2.6** | Compatibility with plotly.js v2 | -| `d3` (direct) | v5.16 | **v7.9** | Modern pure-ESM version | -| `path-browserify` | v1 (2019, CJS) | **`pathe`** (modern ESM) | Drop-in replacement with active maintenance | - -### 4. Environment Variable Migration -Renderer code cannot access `process.env` in Vite (no Node.js context). All renderer references were migrated: -- `process.env.CLIENT_ID` → `import.meta.env.VITE_CLIENT_ID` -- `process.env.NODE_ENV` → `import.meta.env.MODE` -- Emotiv SDK credentials are loaded from `keys.js` at config time and injected as `process.env.VITE_*` so Vite picks them up natively - -### 5. Content Security Policy (CSP) -Built up the CSP in `src/renderer/index.html` incrementally to allow legitimate sources while remaining secure: -- Added `https://fonts.googleapis.com` to `style-src` (Semantic UI's Google Fonts) -- Added `https://fonts.gstatic.com` to `font-src` (actual font files) -- Added `webpack:` to `connect-src` (source map protocol) -- Added `'self'` to `worker-src` (Vite serves workers as HTTP URLs in dev, not `blob:`) - -### 6. Pyodide / Web Worker Fix -- **Problem**: Vite transforms every `.js` file it serves by injecting `import { createHotContext } from '/@vite/client'`, turning files into ES modules. `importScripts()` in a classic worker cannot execute ES modules — causing a `NetworkError`. -- **Fix**: Configured `publicDir` in the renderer Vite config to point at the pyodide install directory (`src/renderer/utils/pyodide/src/`). Vite serves `publicDir` files verbatim with zero transformation. Updated `webworker.js` to use absolute paths (`/pyodide/pyodide.js`) instead of fragile relative ones. - -### 7. redux-observable v2 API Fix -`action$.ofType()` was removed in redux-observable v2. Updated three call sites in `experimentEpics.ts` to use the pipeable `ofType` operator: - -```ts -// Before (v1): -action$.ofType('@@router/LOCATION_CHANGE').pipe(...) - -// After (v2): -action$.pipe(ofType('@@router/LOCATION_CHANGE'), ...) -``` - -### 8. Browser Compatibility Fixes -- **`cortex.js`**: `global.process` → `typeof process !== 'undefined' && process.env` (no `global` in browser) -- **`muse.ts`**: Removed `import 'hazardous'` — a Node.js-only asar path library that was incorrectly imported in the renderer - ---- - -## Key Roadblocks - -### Electron HTTP Cache vs. Vite Pre-bundle Cache -The trickiest issue of the session. Vite sets `Cache-Control: max-age=31536000, immutable` on -pre-bundled deps. Electron's renderer stores these permanently in -`~/Library/Application Support/BrainWaves/Cache/`. Even after patching files on disk, Electron -kept serving the old cached version because the URL's `v=` hash hadn't changed (Vite keys its -cache hash on the package version, not file content). The solution required both patching the -Vite pre-bundle cache file on disk *and* clearing the Electron session HTTP cache at startup -in dev mode (`session.defaultSession.clearCache()`). - -### plotly.js / d3 v3 `this.xxx` Chain -Three separate globals (`this.document`, `this.Element`, `this.CSSStyleDeclaration`, -`this.navigator`) needed patching before the root cause was identified as d3 v3 being bundled -inside plotly.js v1. In Vite's strict-mode ESM context, bare `this` at the module level is -`undefined`. Upgrading to plotly.js v2 (which uses d3 v6, pure ESM) eliminated all of them at -once. - -### `patchDeps.mjs` Strategy Evolution -The plotly fix went through several iterations before the root cause was found: -1. Vite server middleware to intercept HTTP requests — failed due to middleware ordering -2. esbuild plugin in `optimizeDeps.esbuildOptions` — didn't apply to already-cached bundles -3. Patching the npm source only — Vite doesn't re-bundle when the package version hasn't changed -4. Patching both source and Vite's cached pre-bundle file — worked, but made entirely moot by upgrading plotly.js to v2 - -### Pyodide Worker Loading -The worker's `importScripts()` call appeared to reference a valid URL, but the load silently -failed. The cause was subtle: Vite injects HMR boilerplate (an `import` statement) into every -`.js` file it serves, converting them to ES modules. `importScripts()` in a classic worker -can only execute classic scripts — not ES modules. Moving pyodide to `publicDir` bypassed -Vite's transform pipeline entirely. diff --git a/docs/progress.md b/docs/progress.md deleted file mode 100644 index 46caf528..00000000 --- a/docs/progress.md +++ /dev/null @@ -1,353 +0,0 @@ -# BrainWaves Modernization — Implementation Progress - -Tracking file for executing the [Technical Implementation Plan](./BrainWaves_%20Technical%20Implementation%20Plan%20(2026%20Modernization).md) across sessions. - -**Last updated:** 2026-03-07 -**Overall status:** PHASES 1–4 COMPLETE (Phase 5 pending: npm install + build verification) - ---- - -## Codebase Reconnaissance (completed) - -Key files read and understood before starting implementation: - -- `package.json` — current deps, jest config, scripts -- `src/renderer/store.ts` — uses `connected-react-router` (`routerMiddleware`, `createHashHistory`) -- `src/renderer/reducers/index.ts` — uses `connectRouter` from `connected-react-router` -- `src/renderer/containers/Root.tsx` — uses `ConnectedRouter`, receives `history` prop -- `src/renderer/index.tsx` — imports and passes `history` from store to Root -- `src/renderer/routes.tsx` — React Router v5 `` / `` / custom `PropsRoute` -- `src/renderer/epics/experimentEpics.ts` — `autoSaveEpic` and `navigationCleanupEpic` listen to `@@router/LOCATION_CHANGE` -- `src/renderer/containers/TopNavBarContainer.ts` — maps `state.router.location` to props -- `src/renderer/components/TopNavComponent/index.tsx` — class component, uses `this.props.location.pathname` -- `src/renderer/components/HomeComponent/index.tsx` — class component, calls `this.props.history.push()` -- `src/renderer/components/CollectComponent/index.tsx` — passes `history` prop to `ConnectModal` (unused there) -- `src/renderer/components/CollectComponent/ConnectModal.tsx` — has `history` in Props but does NOT use it -- `src/renderer/components/EEGExplorationComponent.tsx` — passes `history` to `ConnectModal` (unused) -- `src/renderer/actions/experimentActions.ts` — RTK `createAction`, `typesafe-actions` -- `src/renderer/utils/pyodide/utils.py` — uses `sns.tsplot` (removed in seaborn v0.10), seaborn import commented out -- `src/renderer/utils/pyodide/webworker.js` — `importScripts('/pyodide/pyodide.js')`, loads matplotlib/mne/pandas -- `internals/scripts/InstallPyodide.js` — downloads pyodide v0.21.0 tarball -- `vite.config.ts` — `publicDir` serves `src/renderer/utils/pyodide/src/` as static assets -- `.github/workflows/test.yml` — runs `npm run package-ci`, lint, tsc (no unit tests) -- 26 files import `semantic-ui-react` (see list below) - ---- - -## Phase 1: Test Harness & Vitest - -**Status: COMPLETE** - -### Step 1.1 — Install and configure Vitest - -**What to do:** -1. Edit `package.json`: - - Remove from `devDependencies`: `jest`, `@types/jest`, `identity-obj-proxy`, `react-test-renderer` - - Add to `devDependencies`: `vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `jsdom` - - Change `"test": "cross-env jest --passWithNoTests"` → `"test": "vitest run"` - - Add `"test:watch": "vitest"` - - Remove the `"jest": { ... }` config block from package.json -2. Create `vitest.config.ts` (project root) -3. Create `src/test-setup.ts` (imports `@testing-library/jest-dom`) -4. Create `src/renderer/App.test.tsx` (basic sanity render test) -5. Run `npm install` to update node_modules - -**Notes:** -- Vitest needs `jsdom` environment for React component tests -- The vite.config.ts babel plugins (decorators, class-properties) must also be present in vitest.config.ts -- CSS modules: vitest handles them natively with `css.modules` config; no `identity-obj-proxy` needed - -### Step 1.2 — Update CI workflow - -**What to do:** -1. Edit `.github/workflows/test.yml`: add `npm test` step before or after existing steps -2. Create `tests/build.test.ts`: verifies `out/main/index.js` and `out/renderer/index.html` exist after build - ---- - -## Phase 2: Routing Modernization - -**Status: COMPLETE** - -### Step 2.1 — Remove `connected-react-router` - -**Package changes:** -- Remove from `dependencies`: `connected-react-router`, `history` -- Remove from `devDependencies`: `@types/history`, `@types/react-router`, `@types/react-router-dom` -- Remove `overrides["connected-react-router"]` block - -**File changes:** - -| File | Change | -|------|--------| -| `src/renderer/store.ts` | Remove `createHashHistory`, `history` export, `routerMiddleware`, remove `router` from middleware | -| `src/renderer/reducers/index.ts` | Remove `connectRouter`, `History` import, remove `router` from combineReducers, remove `router: any` from RootState | -| `src/renderer/containers/Root.tsx` | Remove `ConnectedRouter`; use `HashRouter` from `react-router-dom`; remove `history` prop | -| `src/renderer/index.tsx` | Remove `history` import; pass only `store` to `` | - -### Step 2.2 — Upgrade to React Router v6 - -**Package changes:** -- Change `react-router` and `react-router-dom`: `"^5.2.0"` → `"^6.x"` (v6 merges the two packages; keep both entries or consolidate) - -**New file:** -- `src/renderer/actions/routerActions.ts` — defines `RouterActions.RouteChanged(pathname: string)` action - -**File changes:** - -| File | Change | -|------|--------| -| `src/renderer/routes.tsx` | Replace `/` with `/}>`. Remove `PropsRoute`. Pass `activeStep` directly as JSX prop on ``. | -| `src/renderer/containers/App.tsx` | Add `NavigationTracker` functional component (uses `useLocation` + `useDispatch` to dispatch `RouterActions.RouteChanged` on location change) | -| `src/renderer/epics/experimentEpics.ts` | Replace both `ofType('@@router/LOCATION_CHANGE')` epics to use `filter(isActionOf(RouterActions.RouteChanged))`. Access `action.payload` as pathname string directly. Remove `pluck('payload', 'pathname')` etc. | -| `src/renderer/components/TopNavComponent/index.tsx` | Convert class → functional component. Replace `this.props.location.pathname` with `useLocation().pathname`. State becomes `useState`. Methods become callbacks. | -| `src/renderer/containers/TopNavBarContainer.ts` | Remove `location: state.router.location` from mapStateToProps | -| `src/renderer/components/HomeComponent/index.tsx` | Change `history: History` prop to `navigate: (path: string) => void`. Replace all `this.props.history.push(X)` with `this.props.navigate(X)`. Remove `import { History } from 'history'`. | -| `src/renderer/containers/HomeContainer.ts` | Wrap exported component with `withNavigate` HOC that injects `useNavigate()` as `navigate` prop | -| `src/renderer/components/CollectComponent/index.tsx` | Remove `history: History` from Props; remove passing `history` to `ConnectModal` | -| `src/renderer/components/EEGExplorationComponent.tsx` | Remove `history: History` from Props; remove passing `history` to `ConnectModal` | -| `src/renderer/components/CollectComponent/ConnectModal.tsx` | Remove `history: History` from Props interface | - -**withNavigate HOC pattern (for HomeContainer.ts):** -```tsx -import React from 'react'; -import { useNavigate } from 'react-router-dom'; - -function withNavigate

void }>( - Component: React.ComponentType

-) { - return function WithNavigate(props: Omit) { - const navigate = useNavigate(); - return ; - }; -} -``` - -**NavigationTracker pattern (for App.tsx):** -```tsx -function NavigationTracker() { - const location = useLocation(); - const dispatch = useDispatch(); - useEffect(() => { - dispatch(RouterActions.RouteChanged(location.pathname)); - }, [location.pathname, dispatch]); - return null; -} -``` - -**Tests to add:** -- `tests/store.test.ts` — verifies store initializes without router reducer -- `tests/routing.test.tsx` — verifies navigation between Home/Design/Collect renders correct components - ---- - -## Phase 3: Pyodide Modernization - -**Status: COMPLETE** - -### Step 3.1 — Upgrade Pyodide version - -**File:** `internals/scripts/InstallPyodide.js` - -Changes: -- `PYODIDE_VERSION`: `'0.21.0'` → `'0.27.0'` -- `TAR_NAME`: `pyodide-build-${PYODIDE_VERSION}.tar.bz2` → `pyodide-${PYODIDE_VERSION}.tar.bz2` -- `TAR_URL`: update to match new naming - -**Note:** The tarball for 0.27.0 extracts to a `pyodide/` subdirectory, which is correct for the webworker's `importScripts('/pyodide/pyodide.js')` call. The old 0.21.0 tar was also `pyodide-build-*` but extracted to a `pyodide/` dir. - -**Caution:** `mne` package availability in pyodide 0.27.0 needs verification. If not in the default package list, `webworker.js` `loadPackage(['matplotlib', 'mne', 'pandas'])` will fail. May need to load `mne` via `micropip.install('mne')` instead. - -**Test to add:** `tests/pyodide.test.ts` - -### Step 3.2 — Fix Python plotting (`utils.py`) - -**File:** `src/renderer/utils/pyodide/utils.py` - -The `plot_conditions` function currently: -- Calls `sns.color_palette(...)` — but `import seaborn as sns` is commented out -- Calls `sns.tsplot(...)` — removed from seaborn in v0.10.0 -- Calls `sns.despine()` — still exists but seaborn not imported - -**Fix — replace `plot_conditions` body:** -1. Replace `sns.color_palette(...)` with a hardcoded palette (consistent with `plot_topo`) -2. Replace `sns.tsplot(...)` with manual bootstrap CI using numpy + `ax.plot()` + `ax.fill_between()` -3. Replace `sns.despine()` with `ax.spines['top'].set_visible(False); ax.spines['right'].set_visible(False)` - -**Bootstrap CI replacement for `sns.tsplot(X[...], time=times, color=color, n_boot=n_boot, ci=ci)`:** -```python -cond_data = X[y.isin(cond), ch_ind] -mean = np.nanmean(cond_data, axis=0) -n_samples = cond_data.shape[0] -boot_means = np.array([ - np.nanmean(cond_data[np.random.randint(0, n_samples, n_samples)], axis=0) - for _ in range(n_boot) -]) -alpha = (100 - ci) / 2 -low = np.percentile(boot_means, alpha, axis=0) -high = np.percentile(boot_means, 100 - alpha, axis=0) -ax.plot(times, mean, color=color) -ax.fill_between(times, low, high, color=color, alpha=0.3) -``` - -**Test to add:** `tests/python_utils.test.ts` - ---- - -## Phase 4: UI Library Replacement (Semantic UI → Tailwind + Shadcn/ui) - -**Status: COMPLETE** - -### Step 4.1 — Install Tailwind CSS and Shadcn/ui - -**Package additions (devDependencies):** -- `tailwindcss`, `postcss`, `autoprefixer` - -**Package additions (dependencies):** -- `@radix-ui/react-dialog` -- `@radix-ui/react-dropdown-menu` -- `@radix-ui/react-slot` -- `class-variance-authority` -- `clsx` -- `tailwind-merge` - -**Package removals (dependencies):** -- `semantic-ui-react` -- `semantic-ui-css` - -**New config files:** -- `tailwind.config.js` -- `postcss.config.js` - -**New UI component files** (in `src/renderer/components/ui/`): -- `utils.ts` — `cn()` helper (`clsx` + `tailwind-merge`) -- `button.tsx` — Shadcn Button (CVA variants) -- `card.tsx` — Shadcn Card (replaces `Segment`) -- `dialog.tsx` — Shadcn Dialog (replaces `Modal`) -- `dropdown-menu.tsx` — Shadcn DropdownMenu (replaces `Dropdown`) -- `table.tsx` — Shadcn Table (replaces `Table`) - -**Update `src/renderer/app.global.css`:** add Tailwind directives (`@tailwind base/components/utilities`) - -**Remove from `src/renderer/index.tsx`:** `import 'semantic-ui-css/semantic.min.css'` - -### Step 4.2 — Component-by-component replacement - -**26 files to update** (grep confirmed): - -| File | Semantic UI components used | Status | -|------|-----------------------------|--------| -| `components/AnalyzeComponent.tsx` | Grid, Icon, Segment, Header, Dropdown, Divider, Button, Checkbox, Sidebar, DropdownProps | NOT STARTED | -| `components/CleanComponent/CleanSidebar.tsx` | (need to read) | NOT STARTED | -| `components/CleanComponent/index.tsx` | Grid, Button, Icon, Segment, Header, Dropdown, Sidebar, SidebarPusher, Divider, DropdownProps | NOT STARTED | -| `components/CollectComponent/ConnectModal.tsx` | Modal, Button, Segment, List, Grid, Divider | NOT STARTED | -| `components/CollectComponent/HelpSidebar.tsx` | (need to read) | NOT STARTED | -| `components/CollectComponent/PreTestComponent.tsx` | (need to read) | NOT STARTED | -| `components/CollectComponent/RunComponent.tsx` | (need to read) | NOT STARTED | -| `components/CollectComponent/index.tsx` | (passes through, may be minimal) | NOT STARTED | -| `components/DesignComponent/CustomDesignComponent.tsx` | (need to read) | NOT STARTED | -| `components/DesignComponent/ParamSlider.tsx` | (need to read) | NOT STARTED | -| `components/DesignComponent/StimuliDesignColumn.tsx` | (need to read) | NOT STARTED | -| `components/DesignComponent/StimuliRow.tsx` | (need to read) | NOT STARTED | -| `components/DesignComponent/index.tsx` | (need to read) | NOT STARTED | -| `components/EEGExplorationComponent.tsx` | Grid, Button, Header, Segment, Image, Divider | NOT STARTED | -| `components/HomeComponent/ExperimentCard.tsx` | (need to read) | NOT STARTED | -| `components/HomeComponent/OverviewComponent.tsx` | (need to read) | NOT STARTED | -| `components/HomeComponent/index.tsx` | Grid, Button, Header, Image, Table | NOT STARTED | -| `components/InputCollect.tsx` | (need to read) | NOT STARTED | -| `components/InputModal.tsx` | (need to read) | NOT STARTED | -| `components/PreviewButtonComponent.tsx` | (need to read) | NOT STARTED | -| `components/PreviewExperimentComponent.tsx` | (need to read) | NOT STARTED | -| `components/PyodidePlotWidget.tsx` | (need to read) | NOT STARTED | -| `components/SecondaryNavComponent/SecondaryNavSegment.tsx` | (need to read) | NOT STARTED | -| `components/SecondaryNavComponent/index.tsx` | (need to read) | NOT STARTED | -| `components/SignalQualityIndicatorComponent.tsx` | (need to read) | NOT STARTED | -| `components/TopNavComponent/PrimaryNavSegment.tsx` | (need to read) | NOT STARTED | -| `components/TopNavComponent/index.tsx` | Grid, Segment, Image, Dropdown | NOT STARTED (also being changed in Phase 2) | - -**Replacement mapping:** -| Semantic UI | Replacement | -|-------------|-------------| -| `` | `

` (Tailwind) | -| `` | `
` or grid row | -| `` | Tailwind `col-span-N` | -| `` | `
` or `` | -| `