diff --git a/.gitignore b/.gitignore index eb02e035..04eb471e 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ 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 diff --git a/.llms/learnings.md b/.llms/learnings.md index d93903e9..cbdb570f 100644 --- a/.llms/learnings.md +++ b/.llms/learnings.md @@ -21,23 +21,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) @@ -60,6 +60,14 @@ The CDN version is derived from `node_modules/pyodide/package.json` — **not** **Plot result routing pattern** — `worker.postMessage()` is fire-and-forget (returns `undefined`). Plot epics should use `tap()` to fire the worker message and `mergeMap(() => EMPTY)` to emit nothing. Results come back asynchronously on the worker `message` event. Add a `plotKey` field to each worker message; the worker echoes it back; `pyodideMessageEpic` switches on `plotKey` to dispatch `SetTopoPlot`/`SetPSDPlot`/`SetERPPlot` with a `{ 'image/png': base64string }` MIME bundle. `PyodidePlotWidget` renders this via `@nteract/transforms`. +## liblsl on Apple Silicon + +`node-labstreaminglayer@0.3.0` ships only an **x86_64** `liblsl.dylib` in its `prebuild/` directory — the package has no arm64 build and was last updated 2025-08. Loading it on Apple Silicon throws `Failed to load shared library: ... (mach-o file, but is an incompatible architecture)`. + +**Fix**: install liblsl via Homebrew (`brew install labstreaminglayer/tap/lsl`), then `internals/scripts/patchDeps.mjs` symlinks `/opt/homebrew/Cellar/lsl//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. + ## Pre-existing TypeScript errors (do not treat as regressions) - `src/renderer/epics/experimentEpics.ts` (lines 170, 205) — RxJS operator type mismatch diff --git a/.worktrees/modernization b/.worktrees/modernization new file mode 160000 index 00000000..f88eb4b7 --- /dev/null +++ b/.worktrees/modernization @@ -0,0 +1 @@ +Subproject commit f88eb4b7710bf13ec05ff6f1140cb53c87767a0b 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/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..9ec6ae2c --- /dev/null +++ b/docs/lsl-implementation-plan.md @@ -0,0 +1,467 @@ +# 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) | diff --git a/internals/scripts/patchDeps.mjs b/internals/scripts/patchDeps.mjs index e8d0e411..71e7f1b8 100644 --- a/internals/scripts/patchDeps.mjs +++ b/internals/scripts/patchDeps.mjs @@ -8,9 +8,19 @@ * still wired into `postinstall` and `dev` npm scripts. */ -import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { + existsSync, + lstatSync, + readFileSync, + readlinkSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'fs'; +import { execSync } from 'child_process'; import { dirname, join, resolve } from 'path'; import { fileURLToPath } from 'url'; +import { arch, platform } from 'os'; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = resolve(__dirname, '../..'); @@ -33,5 +43,60 @@ function fixElectronPathTxt() { } } +/** + * node-labstreaminglayer 0.3.0 ships only an x86_64 liblsl.dylib in its + * prebuild/ directory. On Apple Silicon it fails to load with an "incompatible + * architecture" error. Replace it with a symlink to the Homebrew-installed + * arm64 build (`brew install labstreaminglayer/tap/lsl`). + */ +function fixLiblslArm64() { + if (platform() !== 'darwin' || arch() !== 'arm64') return; + + const bundled = join( + root, + 'node_modules/node-labstreaminglayer/prebuild/liblsl.dylib' + ); + if (!existsSync(bundled)) return; + + let brewLib; + try { + const cellar = execSync('brew --cellar lsl', { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + const versionDir = execSync(`ls "${cellar}"`, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) + .trim() + .split('\n') + .filter(Boolean) + .sort() + .pop(); + if (!versionDir) return; + brewLib = join( + cellar, + versionDir, + 'Frameworks/lsl.framework/Versions/A/lsl' + ); + } catch { + console.warn( + '[patchDeps] Apple Silicon detected but liblsl is not installed.\n' + + ' Run: brew install labstreaminglayer/tap/lsl' + ); + return; + } + + if (!existsSync(brewLib)) return; + + const stat = lstatSync(bundled); + if (stat.isSymbolicLink() && readlinkSync(bundled) === brewLib) return; + + unlinkSync(bundled); + symlinkSync(brewLib, bundled); + console.log('[patchDeps] Symlinked arm64 liblsl.dylib →', brewLib); +} + fixElectronPathTxt(); +fixLiblslArm64(); console.log('[patchDeps] Done.'); diff --git a/package-lock.json b/package-lock.json index c69e6a35..10771606 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@electron-toolkit/utils": "^4.0.0", "@fortawesome/fontawesome-free": "^5.13.0", "@neurosity/pipes": "^5.2.1", + "@neurosity/sdk": "^7.1.0", "@radix-ui/react-dialog": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.1.0", "@radix-ui/react-select": "^2.2.6", @@ -33,6 +34,7 @@ "mkdirp": "^1.0.4", "mousetrap": "^1.6.5", "muse-js": "^3.1.0", + "node-labstreaminglayer": "^0.3.0", "papaparse": "^5.5.3", "pathe": "^2.0.3", "plotly.js": "^3.4.0", @@ -2033,6 +2035,617 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@firebase/ai": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-2.11.0.tgz", + "integrity": "sha512-+oqOne/h5J51LezazR+VyzKe3AK455W29JXnb4jOeVvQhC7FymledN5+XE+w5vEcMhRQ6n1f62fdGs4A44X32A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/analytics": { + "version": "0.10.21", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.21.tgz", + "integrity": "sha512-j2y2q65BlgLGB5Pwjhv/Jopw2X/TBTzvAtI5z/DSp56U4wBj7LfhBfzbdCtFPges+Wz0g55GdoawXibOH5jGng==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/installations": "0.6.21", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.27", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.27.tgz", + "integrity": "sha512-ZObpYpAxL6JfgH7GnvlDD0sbzGZ0o4nijV8skatV9ZX49hJtCYbFqaEcPYptT94rgX1KUoKEderC7/fa7hybtw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.21", + "@firebase/analytics-types": "0.8.3", + "@firebase/component": "0.7.2", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.3.tgz", + "integrity": "sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app": { + "version": "0.14.11", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.11.tgz", + "integrity": "sha512-yxADFW35LYkP8oSGobGsYIrI42I+GPCvKTNHx4meT9Yq3C950IVz1eANoBk822I9tbKv1wyv9P4Bv1G5TpucFw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.11.2.tgz", + "integrity": "sha512-jcXQVMHAQ5AEKzVD5C7s5fmAYeFOuN6lAJeNTgZK2B9aLnofWaJt8u1A8Idm8gpsBBYSaY3cVyeH5SWMOVPBLQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.4.2.tgz", + "integrity": "sha512-M91NhxqbSkI0ChkJWy69blC+rPr6HEgaeRllddSaU1pQ/7IiegeCQM9pPDIgvWnwnBSzKhUHpe6ro/jhJ+cvzw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check": "0.11.2", + "@firebase/app-check-types": "0.5.3", + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.3.tgz", + "integrity": "sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-compat": { + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.11.tgz", + "integrity": "sha512-KaACDjXkK5VLpI01vEs592R7/8s5DjFdIXfKoR385ly1SmK3Tu+jMHCIB4MsiY5jsez6v7VlEX/3rJ90dVkHyA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app": "0.14.11", + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-types": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.4.tgz", + "integrity": "sha512-crX9TA5SVYZwLPG7/R16IsH8FLlgkPXjJUVhsVpHVDSqJiq3D/NuFTM5ctxGTExXAOeIn//69tQw47CPerM8MQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/logger": "0.5.0" + } + }, + "node_modules/@firebase/auth": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.13.0.tgz", + "integrity": "sha512-mKkSLNym3UbnnZ06dAmtqzp5EpPGCANGCZDJbkoR135aoUdKG6Aizwcnp29RzsQpwH0nmy5nay17Sfbsh9oY8A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^2.2.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@firebase/auth-compat": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.6.5.tgz", + "integrity": "sha512-IfVsafZ3QiXbsydXTP/XMI0wVYbJLI1rkb8Qqf03/h5FnL+upbbPOb+6Yj3RpcX+Y1iP5Uh18lxTHlXfbiyAow==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth": "1.13.0", + "@firebase/auth-types": "0.13.0", + "@firebase/component": "0.7.2", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-types": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.0.tgz", + "integrity": "sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.2.tgz", + "integrity": "sha512-iyVDGc6Vjx7Rm0cAdccLH/NG6fADsgJak/XW9IA2lPf8AjIlsemOpFGKczYyPHxm4rnKdR8z6sK4+KEC7NwmEg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.6.0.tgz", + "integrity": "sha512-OiugPRcdlhqXF97oR9CjVObILmsWU0dFUS0gXNYEe4bDfpW8pZmQ5GqhIPPtLWbT/0W2lMJJD7VILFMk+xuHPg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.2.tgz", + "integrity": "sha512-lP96CMjMPy/+d1d9qaaHjHHdzdwvEOuyyLq9ehX89e2XMKwS1jHNzYBO+42bdSumuj5ukPbmnFtViZu8YOMT+w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.3.tgz", + "integrity": "sha512-GMyfWjD8mehjg/QpNkY/tl9G/MoeugPeg91n9D0atggxbWuKF/2KhVPHZDH+XmoP0EKYqMWYTtKxBsaBaNKLYQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/database": "1.1.2", + "@firebase/database-types": "1.0.19", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.19", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.19.tgz", + "integrity": "sha512-FqewjUZmV9LqFfuEnmgdcUpiOUz7qwLXxnm/H8BcMFEzQXtd1yyUDm8ex5VRad2nuTE+ahOuCjUAM/cyDncO+g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.4", + "@firebase/util": "1.15.0" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.14.0.tgz", + "integrity": "sha512-bZc6YOjRkMBVA16527tgzi6iN9n//xRB3Mmx/R+Gr6UAP/+xrIKOejQIcn1hh+tCzNT8jO0jI+kWox5J4tB/qQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "@firebase/webchannel-wrapper": "1.0.5", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/firestore-compat": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.4.8.tgz", + "integrity": "sha512-WK9NJRpnosGD2nuyjdr7K+Ht7AxRYJlTF62myI4rRA7ibJOosbecvjacR5oirJ7s1BgNS6qzcBw7n4fD3a5w1w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/firestore": "4.14.0", + "@firebase/firestore-types": "3.0.3", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.3.tgz", + "integrity": "sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/functions": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.13.3.tgz", + "integrity": "sha512-csO7ckK3SSs+NUZW1nms9EK7ckHe/1QOjiP8uAkCYa7ND18s44vjE9g3KxEeIUpyEPqZaX1EhJuFyZjHigAcYw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.2", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.4.3.tgz", + "integrity": "sha512-BxkEwWgx1of0tKaao/r2VR6WBLk/RAiyztatiONPrPE8gkitFkOnOCxf8i9cUyA5hX5RGt5H30uNn25Q6QNEmQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/functions": "0.13.3", + "@firebase/functions-types": "0.6.3", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.3.tgz", + "integrity": "sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/installations": { + "version": "0.6.21", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.21.tgz", + "integrity": "sha512-xGFGTeICJZ5vhrmmDukeczIcFULFXybojML2+QSDFoKj5A7zbGN7KzFGSKNhDkIxpjzsYG9IleJyUebuAcmqWA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/util": "1.15.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.21", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.21.tgz", + "integrity": "sha512-zahIUkaVKbR8zmTeBHkdfaVl6JGWlhVoSjF7CVH33nFqD3SlPEpEEegn2GNT5iAfsVdtlCyJJ9GW4YKjq+RJKQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/installations": "0.6.21", + "@firebase/installations-types": "0.5.3", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.3.tgz", + "integrity": "sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", + "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.12.25", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.25.tgz", + "integrity": "sha512-7RhDwoDHlOK1/ou0/LeubxmjcngsTjDdrY/ssg2vwAVpUuVAhQzQvuCAOYxcX5wNC1zCgQ54AP1vdngBwbCmOQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/installations": "0.6.21", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.15.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.25", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.25.tgz", + "integrity": "sha512-eoOQqGLtRlseTdiemTN44LlHZpltK5gnhq8XVUuLgtIOG+odtDzrz2UoTpcJWSzaJQVxNLb/x9f39tHdDM4N4w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/messaging": "0.12.25", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz", + "integrity": "sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/performance": { + "version": "0.7.11", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.11.tgz", + "integrity": "sha512-V3uAhrz7IYJuji+OgT3qYTGKxpek/TViXti9OSsUJ4AexZ3jQjYH5Yrn7JvBxk8MGiSLsC872hh+BxQiPZsm7g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/installations": "0.6.21", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.24", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.24.tgz", + "integrity": "sha512-YRlejH8wLt7ThWao+HXoKUHUrZKGYq+otxkPS+8nuE5PeN1cBXX7NAJl9ueuUkBwMIrnKdnDqL/voHXxDAAt3g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/performance": "0.7.11", + "@firebase/performance-types": "0.2.3", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.3.tgz", + "integrity": "sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/remote-config": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.8.2.tgz", + "integrity": "sha512-5EXqOThV4upjK9D38d/qOSVwOqRhemlaOFk9vCkMNNALeIlwr+4pLjtLNo4qoY8etQmU/1q4aIATE9N8PFqg0g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/installations": "0.6.21", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.23", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.23.tgz", + "integrity": "sha512-4+KqRRHEUUmKT6tFmnpWATOsaFfmSuBs1jXH8JzVtMLEYqq/WS9IDM92OdefFDSrAA2xGd0WN004z8mKeIIscw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/remote-config": "0.8.2", + "@firebase/remote-config-types": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.5.0.tgz", + "integrity": "sha512-vI3bqLoF14L/GchtgayMiFpZJF+Ao3uR8WCde0XpYNkSokDpAKca2DxvcfeZv7lZUqkUwQPL2wD83d3vQ4vvrg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/storage": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.14.2.tgz", + "integrity": "sha512-o/culaTeJ8GRpKXRJov21rux/n9dRaSOWLebyatFP2sqEdCxQPjVA1H9Z2fzYwQxMIU0JVmC7SPPmU11v7L6vQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.4.2.tgz", + "integrity": "sha512-R+aB38wxCH5zjIO/xu9KznI7fgiPuZAG98uVm1NcidHyyupGgIDLKigGmRGBZMnxibe/m2oxNKoZpfEbUX2aQQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/storage": "0.14.2", + "@firebase/storage-types": "0.8.3", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.3.tgz", + "integrity": "sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.15.0.tgz", + "integrity": "sha512-AmWf3cHAOMbrCPG4xdPKQaj5iHnyYfyLKZxwz+Xf55bqKbpAmcYifB4jQinT2W9XhDRHISOoPyBOariJpCG6FA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.5.tgz", + "integrity": "sha512-+uGNN7rkfn41HLO0vekTFhTxk61eKa8mTpRGLO0QSqlQdKvIoGAvLp3ppdVIWbTGYJWM6Kp0iN+PjMIOcnVqTw==", + "license": "Apache-2.0" + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -2080,6 +2693,37 @@ "node": ">=6" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.15", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", + "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2467,6 +3111,12 @@ "integrity": "sha512-rY7KUpe1nLTk6oBPoRx/Eh9FDgTpxnUQSOrs1fsfs1T7l/pT6UtuYvh1UB32jTxe3l4QgUpE5NMq0mNJXrlQwg==", "license": "MIT" }, + "node_modules/@neurosity/ipk": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@neurosity/ipk/-/ipk-2.13.0.tgz", + "integrity": "sha512-uSRBSqEZQplzuOV/y7mgfPzgc2t8e2qTYnA36VNZD7x0U1PgNONFYHnlyB3ux88bHPLfkMnmp2rz3oHHn/C1Pw==", + "license": "MIT" + }, "node_modules/@neurosity/pipes": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/@neurosity/pipes/-/pipes-5.2.1.tgz", @@ -2478,6 +3128,61 @@ "rxjs": "^7.8.0" } }, + "node_modules/@neurosity/sdk": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@neurosity/sdk/-/sdk-7.1.0.tgz", + "integrity": "sha512-220Ni0F20mprXn1LxevzrqUUqnXEw41Xtxxnliy876w/JMdYMl7Kmgd46QsDLfjAuK91dzLrkjNwTX41/DN3Og==", + "license": "MIT", + "dependencies": { + "@neurosity/ipk": "^2.13.0", + "axios": "^1.15.0", + "buffer": "^6.0.3", + "fast-deep-equal": "^3.1.3", + "firebase": "^12.2.1", + "outliers": "0.0.3", + "rxjs": "^7.8.2", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@neurosity/sdk/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/@neurosity/sdk/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3008,6 +3713,70 @@ "integrity": "sha512-Mdk+vUACbQvjd0m/1JJjOOafmkp/EpmHjISsopEz5Av44CBq7rPC05HHNbYGKVyNUF2zmEoBS/TT0pd0SPFFyw==", "license": "MIT" }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -4450,7 +5219,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/common-tags": { @@ -5222,7 +5990,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5232,7 +5999,6 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", - "dev": true, "license": "MIT", "dependencies": { "@types/color-name": "^1.1.1", @@ -5804,7 +6570,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/at-least-node": { @@ -5880,6 +6645,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -5926,7 +6702,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -6336,7 +7111,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6580,7 +7354,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -6595,7 +7368,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -6660,7 +7432,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -6752,7 +7523,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -7897,7 +8667,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -8191,7 +8960,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -9194,7 +9962,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9204,7 +9971,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9249,7 +10015,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -9262,7 +10027,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -9411,7 +10175,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -10250,6 +11013,18 @@ "reusify": "^1.0.4" } }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -10328,6 +11103,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/firebase": { + "version": "12.12.0", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.12.0.tgz", + "integrity": "sha512-5Ap+pN5iEJUvBlQEZEmLuUm7Gvu6I5xv1jZ5SiSNyw4jrwlHo+4tmZv3OPPoKfN9eo1kBwyyBvi+pWHIPXwfYw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/ai": "2.11.0", + "@firebase/analytics": "0.10.21", + "@firebase/analytics-compat": "0.2.27", + "@firebase/app": "0.14.11", + "@firebase/app-check": "0.11.2", + "@firebase/app-check-compat": "0.4.2", + "@firebase/app-compat": "0.5.11", + "@firebase/app-types": "0.9.4", + "@firebase/auth": "1.13.0", + "@firebase/auth-compat": "0.6.5", + "@firebase/data-connect": "0.6.0", + "@firebase/database": "1.1.2", + "@firebase/database-compat": "2.1.3", + "@firebase/firestore": "4.14.0", + "@firebase/firestore-compat": "0.4.8", + "@firebase/functions": "0.13.3", + "@firebase/functions-compat": "0.4.3", + "@firebase/installations": "0.6.21", + "@firebase/installations-compat": "0.2.21", + "@firebase/messaging": "0.12.25", + "@firebase/messaging-compat": "0.2.25", + "@firebase/performance": "0.7.11", + "@firebase/performance-compat": "0.2.24", + "@firebase/remote-config": "0.8.2", + "@firebase/remote-config-compat": "0.2.23", + "@firebase/storage": "0.14.2", + "@firebase/storage-compat": "0.4.2", + "@firebase/util": "1.15.0" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -10358,6 +11169,26 @@ "dtype": "^2.0.0" } }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/font-atlas": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/font-atlas/-/font-atlas-2.1.0.tgz", @@ -10426,7 +11257,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -10626,7 +11456,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -10655,7 +11484,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -10689,7 +11517,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -11177,7 +12004,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11298,7 +12124,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11311,7 +12136,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -11427,6 +12251,12 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -11515,6 +12345,12 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -11889,7 +12725,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12544,6 +13379,16 @@ "dev": true, "license": "MIT" }, + "node_modules/koffi": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.1.tgz", + "integrity": "sha512-0Ie6CfD026dNfWSosDw9dPxPzO9Rlyo0N8m5r05S8YjytIpuilzMFDMY4IDy/8xQsTwpuVinhncD+S8n3bcYZQ==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, "node_modules/lab.js": { "version": "23.0.0-alpha4", "resolved": "https://registry.npmjs.org/lab.js/-/lab.js-23.0.0-alpha4.tgz", @@ -12857,6 +13702,12 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", @@ -13054,6 +13905,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -13311,7 +14168,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13398,7 +14254,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -13408,7 +14263,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -13984,6 +14838,18 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/node-labstreaminglayer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/node-labstreaminglayer/-/node-labstreaminglayer-0.3.0.tgz", + "integrity": "sha512-5LwcO2pp8BHtXM2AUJpTMxX0mCJcVkOJNLyaLdpSWujU9yyvmy2VKCsn7L0mzWj6CiXJYm0Z4s3sTllEQjkHIg==", + "license": "MIT", + "dependencies": { + "koffi": "^2.12.4" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -14293,6 +15159,11 @@ "node": ">=8" } }, + "node_modules/outliers": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/outliers/-/outliers-0.0.3.tgz", + "integrity": "sha512-llzMndHLe3bT5myeO5qiySIusEN+zd+Eq1YLXWbe2/FC2l26AmWPRw0ji7heI0azubWB6NEM87xU24y/CL99Iw==" + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -15116,12 +15987,45 @@ "signal-exit": "^3.0.2" } }, + "node_modules/protobufjs": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", + "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/protocol-buffers-schema": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", "license": "MIT" }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -15797,7 +16701,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -16215,7 +17118,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -16854,7 +17756,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -16892,7 +17793,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/string.prototype.includes": { @@ -17001,7 +17901,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -18822,6 +19721,12 @@ "integrity": "sha512-lNR9aAefbGPpHO7AEnY0hCFjz1eTkWCXYvkTRrTHs9qv8zJp+SkVYpzfLIFXQQiG3tVvbNFQgVg2bQS8YGgxyw==", "license": "Apache-2.0" }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "license": "Apache-2.0" + }, "node_modules/webgl-context": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/webgl-context/-/webgl-context-2.2.0.tgz", @@ -18841,6 +19746,29 @@ "node": ">=12" } }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -19197,7 +20125,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -19230,7 +20157,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -19249,7 +20175,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" diff --git a/package.json b/package.json index 6b7d8bc8..0bda525f 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,11 @@ "productName": "BrainWaves", "appId": "com.electron.brainwaves", "asar": true, + "asarUnpack": [ + "**/*.node", + "node_modules/node-labstreaminglayer/prebuild/**", + "node_modules/koffi/**/*.node" + ], "files": [ "out/**/*", "node_modules/**/*", @@ -131,7 +136,6 @@ "redux", "redux-observable", "muse", - "emotiv", "pyodide", "wasm", "lab.js" @@ -186,6 +190,7 @@ "@electron-toolkit/utils": "^4.0.0", "@fortawesome/fontawesome-free": "^5.13.0", "@neurosity/pipes": "^5.2.1", + "@neurosity/sdk": "^7.1.0", "@radix-ui/react-dialog": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.1.0", "@radix-ui/react-select": "^2.2.6", @@ -205,6 +210,7 @@ "mkdirp": "^1.0.4", "mousetrap": "^1.6.5", "muse-js": "^3.1.0", + "node-labstreaminglayer": "^0.3.0", "papaparse": "^5.5.3", "pathe": "^2.0.3", "plotly.js": "^3.4.0", diff --git a/src/main/index.ts b/src/main/index.ts index 70802f76..85a09e33 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,6 +17,14 @@ import log from 'electron-log'; import { is, optimizer } from '@electron-toolkit/utils'; import MenuBuilder from './menu'; import { FILE_TYPES } from '../renderer/constants/constants'; +import { lslOutlets } from './lsl/outlets'; +import { lslInlets } from './lsl/inlets'; +import type { + LSLEpoch, + LSLMarker, + LSLStatus, + LSLStatusKind, +} from '../shared/lslTypes'; // Needed for WASM/SharedArrayBuffer support (pyodide) app.commandLine.appendSwitch( @@ -48,6 +56,11 @@ export default class AppUpdater { let mainWindow: BrowserWindow | null = null; +// Holds the pending Bluetooth device-picker callback from select-bluetooth-device. +// Electron 22+ fires this event instead of showing a native picker — we must +// call it with a deviceId to resolve requestDevice(), or '' to reject. +let pendingBluetoothCallback: ((deviceId: string) => void) | null = null; + // ------------------------------------------------------------------ // Filesystem helpers (mirroring renderer's storage.ts / write.ts) // ------------------------------------------------------------------ @@ -396,6 +409,93 @@ ipcMain.handle('eeg:closeStream', (_event, streamId) => { }); }); +// Bluetooth — called by renderer's search timer when scan times out with no result +ipcMain.handle('bluetooth:cancelSearch', () => { + if (pendingBluetoothCallback) { + pendingBluetoothCallback(''); + pendingBluetoothCallback = null; + } +}); + +// ------------------------------------------------------------------ +// LSL — outlets push to the LSL network, markers are an event stream +// ------------------------------------------------------------------ + +// Only surface one toast per kind per 5s so a flurry of FFI errors can't spam +// the user. LSL network loss typically shows up as bursts of pushChunk errors. +const lslStatusThrottle = new Map(); +const LSL_STATUS_THROTTLE_MS = 5000; +const emitLSLStatus = (status: LSLStatus) => { + const now = Date.now(); + const last = lslStatusThrottle.get(status.kind) ?? 0; + if (now - last < LSL_STATUS_THROTTLE_MS) return; + lslStatusThrottle.set(status.kind, now); + mainWindow?.webContents.send('lsl:status', status); +}; + +ipcMain.on('lsl:sendEpoch', (_event, epoch: LSLEpoch) => { + try { + lslOutlets.pushEpoch(epoch); + } catch (err) { + log.error('[lsl] pushEpoch failed', err); + emitLSLStatus({ + kind: 'outlet-error', + message: `LSL outlet push failed: ${(err as Error).message ?? err}`, + }); + } +}); + +ipcMain.on('lsl:sendMarker', (_event, marker: LSLMarker) => { + try { + lslOutlets.pushMarker(marker.label); + } catch (err) { + log.error('[lsl] pushMarker failed', err); + emitLSLStatus({ + kind: 'marker-error', + message: `LSL marker push failed: ${(err as Error).message ?? err}`, + }); + } +}); + +ipcMain.handle('lsl:discoverStreams', () => { + try { + return lslInlets.discoverStreams(1.0); + } catch (err) { + log.error('[lsl] discoverStreams failed', err); + emitLSLStatus({ + kind: 'discovery-error', + message: `LSL stream discovery failed: ${(err as Error).message ?? err}`, + }); + return []; + } +}); + +ipcMain.on('lsl:subscribeStream', (_event, payload: { uid: string }) => { + const ok = lslInlets.subscribeStream( + payload.uid, + (epoch) => mainWindow?.webContents.send('lsl:inletData', epoch), + () => { + mainWindow?.webContents.send('lsl:inletDisconnected', { + uid: payload.uid, + }); + emitLSLStatus({ + kind: 'inlet-error', + message: 'LSL inlet disconnected', + }); + } + ); + if (!ok) { + emitLSLStatus({ + kind: 'inlet-error', + message: 'Failed to open LSL inlet — try rescanning', + }); + } +}); + +ipcMain.on('lsl:unsubscribeStream', (_event, payload: { uid: string }) => { + lslInlets.unsubscribeStream(payload.uid); +}); + // Resource path (for experiment file loading) ipcMain.handle('getResourcePath', () => { return is.dev @@ -439,6 +539,24 @@ const createWindow = async () => { mainWindow.setMinimumSize(1075, 708); + // Electron 22+ does not show a native Bluetooth picker automatically. + // We intercept select-bluetooth-device and auto-select the first Muse device + // found. The event fires multiple times as BLE discovery progresses — each + // call carries the full cumulative deviceList seen so far. + mainWindow.webContents.on('select-bluetooth-device', (event, deviceList, callback) => { + event.preventDefault(); + pendingBluetoothCallback = callback; + + const muse = deviceList.find((d) => d.deviceName?.startsWith('Muse')); + if (muse) { + pendingBluetoothCallback(muse.deviceId); + pendingBluetoothCallback = null; + } + // No Muse visible yet — keep scanning. The event will fire again as more + // devices are discovered. The renderer's search timer calls cancelBluetoothSearch + // after SEARCH_TIMER ms if nothing is found. + }); + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']); } else { @@ -480,14 +598,19 @@ app.on('window-all-closed', () => { } }); +app.on('before-quit', () => { + lslOutlets.destroyAll(); + lslInlets.destroyAll(); +}); + 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 in resources/webworker/src/ (via extraResources) + // 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, 'webworker/src'); + : path.join(process.resourcesPath, 'pyodide'); protocol.handle('pyodide', (request) => { const { pathname } = new URL(request.url); diff --git a/src/main/lsl/inlets.ts b/src/main/lsl/inlets.ts new file mode 100644 index 00000000..9c375a1b --- /dev/null +++ b/src/main/lsl/inlets.ts @@ -0,0 +1,154 @@ +/** + * LSL Inlet Manager. + * + * Resolves LSL streams on the local network, opens inlets, and forwards + * pulled samples to the renderer over IPC. Used by the "External LSL Device" + * path where EEG originates on another machine / process (OpenBCI, BrainFlow, + * pylsl, etc.). + */ +import log from 'electron-log'; +import { + resolveStreams, + StreamInfo, + StreamInlet, +} from 'node-labstreaminglayer'; +import type { DiscoveredStream, LSLInletEpoch } from '../../shared/lslTypes'; + +const POLL_INTERVAL_MS = 16; // ~60Hz poll + +// Renderer preview rate cap. Above this, we stride-sample before forwarding +// over IPC so the renderer isn't overwhelmed. The full-rate data still goes +// to the LSL network for LabRecorder — decimation is viz-only. +const RENDERER_MAX_SAMPLES_PER_SEC = 16384; + +const computeStride = (channelCount: number, sampleRate: number): number => { + if (sampleRate <= 0 || channelCount <= 0) return 1; + const load = channelCount * sampleRate; + if (load <= RENDERER_MAX_SAMPLES_PER_SEC) return 1; + return Math.ceil(load / RENDERER_MAX_SAMPLES_PER_SEC); +}; + +class LSLInletManager { + private inlets = new Map< + string, + { inlet: StreamInlet; info: StreamInfo; timer: NodeJS.Timeout } + >(); + // Cache StreamInfo objects by uid so subscribe() can instantiate a + // StreamInlet without a second resolveStreams() round-trip. + private discoveredInfos = new Map(); + + discoverStreams(waitTime: number = 1.0): DiscoveredStream[] { + // Free any StreamInfos we cached but never subscribed to on the previous + // scan so we don't leak their C handles. + for (const [uid, info] of this.discoveredInfos) { + if (!this.inlets.has(uid)) info.destroy(); + } + this.discoveredInfos.clear(); + + const streams = resolveStreams(waitTime); + const results: DiscoveredStream[] = []; + for (const info of streams) { + const uid = info.uid(); + this.discoveredInfos.set(uid, info); + results.push({ + uid, + name: info.name(), + type: info.type(), + channelCount: info.channelCount(), + sampleRate: info.nominalSrate(), + sourceId: info.sourceId(), + }); + } + return results; + } + + subscribeStream( + uid: string, + onData: (epoch: LSLInletEpoch) => void, + onDisconnected?: () => void + ): boolean { + if (this.inlets.has(uid)) return true; + const info = this.discoveredInfos.get(uid); + if (!info) { + log.warn(`[lsl] subscribeStream: unknown uid ${uid} — discover first`); + return false; + } + + const inlet = new StreamInlet(info); + try { + inlet.openStream(5); + } catch (err) { + log.error(`[lsl] failed to open inlet for ${uid}`, err); + inlet.destroy(); + return false; + } + + const stride = computeStride(info.channelCount(), info.nominalSrate()); + if (stride > 1) { + log.info( + `[lsl] inlet ${info.name()} (${info.channelCount()}ch @ ${info.nominalSrate()}Hz) — decimating to renderer by ${stride}x` + ); + } + let strideOffset = 0; + + const timer = setInterval(() => { + try { + const [samples, timestamps] = inlet.pullChunk(0); + if (!samples || samples.length === 0 || timestamps.length === 0) { + return; + } + if (stride === 1) { + onData({ uid, samples, timestamps }); + return; + } + const outSamples: number[][] = []; + const outTimestamps: number[] = []; + for (let i = 0; i < samples.length; i++) { + if (strideOffset === 0) { + outSamples.push(samples[i]); + outTimestamps.push(timestamps[i]); + } + strideOffset = (strideOffset + 1) % stride; + } + if (outSamples.length > 0) { + onData({ uid, samples: outSamples, timestamps: outTimestamps }); + } + } catch (err) { + log.error(`[lsl] inlet ${uid} poll failed`, err); + clearInterval(timer); + this.unsubscribeStream(uid); + onDisconnected?.(); + } + }, POLL_INTERVAL_MS); + + this.inlets.set(uid, { inlet, info, timer }); + log.info(`[lsl] subscribed to inlet ${info.name()} (${uid})`); + return true; + } + + unsubscribeStream(uid: string): void { + const entry = this.inlets.get(uid); + if (!entry) return; + clearInterval(entry.timer); + try { + entry.inlet.closeStream(); + } catch { + // best-effort close — destroy() still frees the handle + } + entry.inlet.destroy(); + this.inlets.delete(uid); + log.info(`[lsl] unsubscribed from inlet ${uid}`); + } + + destroyAll(): void { + for (const uid of Array.from(this.inlets.keys())) { + this.unsubscribeStream(uid); + } + for (const info of this.discoveredInfos.values()) { + info.destroy(); + } + this.discoveredInfos.clear(); + } +} + +export const lslInlets = new LSLInletManager(); diff --git a/src/main/lsl/outlets.ts b/src/main/lsl/outlets.ts new file mode 100644 index 00000000..d81fa61d --- /dev/null +++ b/src/main/lsl/outlets.ts @@ -0,0 +1,121 @@ +/** + * LSL Outlet Manager. + * + * Creates and holds LSL StreamOutlets in the main process. Renderer forwards + * batched EEG epochs (and markers) over IPC; this module pushes them onto the + * LSL network where they can be recorded by LabRecorder or any LSL inlet. + */ +import log from 'electron-log'; +import { + StreamInfo, + StreamOutlet, + IRREGULAR_RATE, +} from 'node-labstreaminglayer'; +import type { LSLEpoch } from '../../shared/lslTypes'; + +const MARKER_STREAM_NAME = 'BrainWavesMarkers'; + +class LSLOutletManager { + private outlets = new Map(); + private markerOutlet: StreamOutlet | null = null; + + /** + * Create an EEG outlet for the given device. Safe to call repeatedly — a + * second call with the same deviceId replaces the existing outlet. + */ + createDeviceOutlet( + deviceId: string, + deviceType: string, + channelNames: string[], + sampleRate: number + ): void { + this.destroyDeviceOutlet(deviceId); + + const streamName = `BrainWaves-${deviceType}-${deviceId}`; + const info = new StreamInfo( + streamName, + 'EEG', + channelNames.length, + sampleRate, + 'float32', + deviceId + ); + info.setChannelLabels(channelNames); + info.setChannelTypes('EEG'); + info.setChannelUnits('microvolts'); + + const outlet = new StreamOutlet(info); + this.outlets.set(deviceId, outlet); + log.info( + `[lsl] created EEG outlet ${streamName} (${channelNames.length}ch @ ${sampleRate}Hz)` + ); + } + + /** + * Push a batch of samples to the device outlet. If no outlet exists for the + * epoch's deviceId, the outlet is created lazily from the epoch metadata. + */ + pushEpoch(epoch: LSLEpoch): void { + let outlet = this.outlets.get(epoch.deviceId); + if (!outlet) { + this.createDeviceOutlet( + epoch.deviceId, + epoch.deviceType, + epoch.channelNames, + epoch.sampleRate + ); + outlet = this.outlets.get(epoch.deviceId); + if (!outlet) return; + } + + // LSL timestamps are in seconds; renderer provides ms from performance.now(). + const timestampsSec = epoch.timestamps.map((t) => t / 1000); + outlet.pushChunk(epoch.samples, timestampsSec); + } + + destroyDeviceOutlet(deviceId: string): void { + const outlet = this.outlets.get(deviceId); + if (outlet) { + outlet.destroy(); + this.outlets.delete(deviceId); + log.info(`[lsl] destroyed EEG outlet for ${deviceId}`); + } + } + + /** + * Create the single marker outlet used for experiment stimulus markers. + * IRREGULAR_RATE + string format = event-driven marker stream. + */ + createMarkerOutlet(): void { + if (this.markerOutlet) return; + const info = new StreamInfo( + MARKER_STREAM_NAME, + 'Markers', + 1, + IRREGULAR_RATE, + 'string', + 'brainwaves-markers' + ); + this.markerOutlet = new StreamOutlet(info); + log.info(`[lsl] created marker outlet ${MARKER_STREAM_NAME}`); + } + + pushMarker(label: string): void { + if (!this.markerOutlet) this.createMarkerOutlet(); + this.markerOutlet?.pushSample([label]); + } + + destroyAll(): void { + for (const [id, outlet] of this.outlets) { + outlet.destroy(); + log.info(`[lsl] destroyed outlet ${id} during cleanup`); + } + this.outlets.clear(); + if (this.markerOutlet) { + this.markerOutlet.destroy(); + this.markerOutlet = null; + } + } +} + +export const lslOutlets = new LSLOutletManager(); diff --git a/src/preload/index.ts b/src/preload/index.ts index 4eaf4676..15dbae59 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,6 +5,13 @@ * All Node.js / Electron API access that the renderer needs must go through here. */ import { contextBridge, ipcRenderer } from 'electron'; +import type { + DiscoveredStream, + LSLEpoch, + LSLInletEpoch, + LSLMarker, + LSLStatus, +} from '../shared/lslTypes'; // Inject the resource path synchronously so renderer module-level code can use it // (The main process passes it as --resource-path in additionalArguments) @@ -17,6 +24,9 @@ const resourcePath = resourcePathArg contextBridge.exposeInMainWorld('__ELECTRON_RESOURCE_PATH__', resourcePath); +// Node `process` is not available in the isolated renderer; expose OS for feature gates. +contextBridge.exposeInMainWorld('__ELECTRON_PLATFORM__', process.platform); + contextBridge.exposeInMainWorld('electronAPI', { // ------------------------------------------------------------------ // Dialogs @@ -153,4 +163,51 @@ contextBridge.exposeInMainWorld('electronAPI', { getResourcePath: (): Promise => ipcRenderer.invoke('getResourcePath'), getViewerUrl: (): Promise => ipcRenderer.invoke('getViewerUrl'), + + // ------------------------------------------------------------------ + // Bluetooth — search cancellation + // ------------------------------------------------------------------ + cancelBluetoothSearch: (): Promise => + ipcRenderer.invoke('bluetooth:cancelSearch'), + + // ------------------------------------------------------------------ + // LSL — main-process outlets push to the LSL network, inlets pull from it + // ------------------------------------------------------------------ + sendLSLEpoch: (epoch: LSLEpoch): void => + ipcRenderer.send('lsl:sendEpoch', epoch), + + sendLSLMarker: (marker: LSLMarker): void => + ipcRenderer.send('lsl:sendMarker', marker), + + discoverLSLStreams: (): Promise => + ipcRenderer.invoke('lsl:discoverStreams'), + + subscribeLSLStream: (uid: string): void => + ipcRenderer.send('lsl:subscribeStream', { uid }), + + unsubscribeLSLStream: (uid: string): void => + ipcRenderer.send('lsl:unsubscribeStream', { uid }), + + onLSLInletData: ( + handler: (epoch: LSLInletEpoch) => void + ): (() => void) => { + const listener = (_event: unknown, epoch: LSLInletEpoch) => handler(epoch); + ipcRenderer.on('lsl:inletData', listener); + return () => ipcRenderer.removeListener('lsl:inletData', listener); + }, + + onLSLInletDisconnected: ( + handler: (payload: { uid: string }) => void + ): (() => void) => { + const listener = (_event: unknown, payload: { uid: string }) => + handler(payload); + ipcRenderer.on('lsl:inletDisconnected', listener); + return () => ipcRenderer.removeListener('lsl:inletDisconnected', listener); + }, + + onLSLStatus: (handler: (status: LSLStatus) => void): (() => void) => { + const listener = (_event: unknown, status: LSLStatus) => handler(status); + ipcRenderer.on('lsl:status', listener); + return () => ipcRenderer.removeListener('lsl:status', listener); + }, }); diff --git a/src/renderer/actions/deviceActions.ts b/src/renderer/actions/deviceActions.ts index 52c2d07b..be07db4e 100644 --- a/src/renderer/actions/deviceActions.ts +++ b/src/renderer/actions/deviceActions.ts @@ -2,6 +2,7 @@ import { createAction } from '@reduxjs/toolkit'; import { ActionType } from 'typesafe-actions'; import { DEVICES, DEVICE_AVAILABILITY, CONNECTION_STATUS } from '../constants/constants'; import { Device, DeviceInfo } from '../constants/interfaces'; +import type { DiscoveredStream } from '../../shared/lslTypes'; // ------------------------------------------------------------------------- // Actions @@ -38,6 +39,19 @@ export const DeviceActions = { 'SET_SIGNAL_OBSERVABLE' ), Cleanup: createAction('CLEANUP'), + DeviceLost: createAction('DEVICE_LOST'), + + // External LSL inlet streams (Phase 3) + DiscoverLSLStreams: createAction( + 'DISCOVER_LSL_STREAMS' + ), + SetAvailableLSLStreams: createAction< + DiscoveredStream[], + 'SET_AVAILABLE_LSL_STREAMS' + >('SET_AVAILABLE_LSL_STREAMS'), + ConnectToLSLStream: createAction( + 'CONNECT_TO_LSL_STREAM' + ), } as const; export type DeviceActionType = ActionType< diff --git a/src/renderer/components/AnalyzeComponent.tsx b/src/renderer/components/AnalyzeComponent.tsx index de5e392b..c486dfaf 100644 --- a/src/renderer/components/AnalyzeComponent.tsx +++ b/src/renderer/components/AnalyzeComponent.tsx @@ -6,7 +6,6 @@ import type { Data as PlotlyData } from 'plotly.js'; import { DEVICES, MUSE_CHANNELS, - EMOTIV_CHANNELS, EXPERIMENTS, } from '../constants/constants'; import { @@ -98,9 +97,7 @@ export default class Analyze extends Component { selectedBehaviorFilePaths: [], selectedSubjects: [], selectedChannel: - props.deviceType === DEVICES.EMOTIV - ? EMOTIV_CHANNELS[0] - : MUSE_CHANNELS[0], + MUSE_CHANNELS[0], }; this.handleChannelSelect = this.handleChannelSelect.bind(this); this.handleDatasetChange = this.handleDatasetChange.bind(this); diff --git a/src/renderer/components/CollectComponent/ConnectModal.tsx b/src/renderer/components/CollectComponent/ConnectModal.tsx index 5a11031e..7ff48383 100644 --- a/src/renderer/components/CollectComponent/ConnectModal.tsx +++ b/src/renderer/components/CollectComponent/ConnectModal.tsx @@ -4,24 +4,26 @@ import { isNil, debounce } from 'lodash'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog'; import { Button } from '../ui/button'; import { - DEVICES, DEVICE_AVAILABILITY, CONNECTION_STATUS, + DEVICES, SCREENS, } from '../../constants/constants'; import { Device, SignalQualityData } from '../../constants/interfaces'; import { DeviceActions } from '../../actions'; +import type { DiscoveredStream } from '../../../shared/lslTypes'; interface Props { open: boolean; onClose: () => void; connectedDevice: Record; signalQualityObservable?: Observable; - deviceType: DEVICES; deviceAvailability: DEVICE_AVAILABILITY; connectionStatus: CONNECTION_STATUS; + deviceType: DEVICES; DeviceActions: typeof DeviceActions; availableDevices: Array; + availableLSLStreams?: Array; } interface State { @@ -88,12 +90,58 @@ export default class ConnectModal extends Component { } } + handleDiscoverLSLStreams = () => { + this.props.DeviceActions.DiscoverLSLStreams(); + }; + + handleConnectLSLStream = (stream: DiscoveredStream) => { + this.props.DeviceActions.ConnectToLSLStream(stream); + }; + handleinstructionProgress(progress: INSTRUCTION_PROGRESS) { if (progress !== 0) { this.setState({ instructionProgress: progress }); } } + renderLSLDiscovery() { + const streams = this.props.availableLSLStreams ?? []; + const eegStreams = streams.filter((s) => s.type === 'EEG'); + return ( +
+ + {eegStreams.length === 0 ? ( +

No LSL EEG streams found yet.

+ ) : ( +
    + {eegStreams.map((stream) => ( +
  • + + {stream.name} — {stream.channelCount}ch @ {stream.sampleRate}Hz + + +
  • + ))} +
+ )} +
+ ); + } + renderAvailableDeviceList() { return (
    @@ -133,6 +181,25 @@ export default class ConnectModal extends Component { return ( <>

    Turn your headset on

    +
    + + +
    + {this.props.deviceType === DEVICES.LSL && this.renderLSLDiscovery()}

    Make sure your headset is on and fully charged.

    If the headset needs charging, set the power switch to off and plug diff --git a/src/renderer/components/CollectComponent/PreTestComponent.tsx b/src/renderer/components/CollectComponent/PreTestComponent.tsx index 306b28d2..a71f30ac 100644 --- a/src/renderer/components/CollectComponent/PreTestComponent.tsx +++ b/src/renderer/components/CollectComponent/PreTestComponent.tsx @@ -9,7 +9,6 @@ import { HelpSidebar, HelpButton } from './HelpSidebar'; import { getExperimentFromType } from '../../utils/labjs/functions'; import { ExperimentActions, DeviceActions } from '../../actions'; import { - DEVICES, DEVICE_AVAILABILITY, EXPERIMENTS, PLOTTING_INTERVAL, @@ -26,7 +25,6 @@ interface Props { ExperimentActions: typeof ExperimentActions; connectedDevice: Record; signalQualityObservable: Observable | null | undefined; - deviceType: DEVICES; deviceAvailability: DEVICE_AVAILABILITY; connectionStatus: CONNECTION_STATUS; DeviceActions: typeof DeviceActions; @@ -163,7 +161,6 @@ export default class PreTestComponent extends Component {

    {this.renderHelpButton()} diff --git a/src/renderer/components/CollectComponent/RunComponent.tsx b/src/renderer/components/CollectComponent/RunComponent.tsx index 61cef76f..3e4af26f 100644 --- a/src/renderer/components/CollectComponent/RunComponent.tsx +++ b/src/renderer/components/CollectComponent/RunComponent.tsx @@ -2,9 +2,8 @@ import React, { useCallback, useState } from 'react'; import { Button } from '../ui/button'; import { Link } from 'react-router-dom'; import InputCollect from '../InputCollect'; -import { injectEmotivMarker } from '../../utils/eeg/emotiv'; import { injectMuseMarker } from '../../utils/eeg/muse'; -import { EXPERIMENTS, DEVICES } from '../../constants/constants'; +import { EXPERIMENTS } from '../../constants/constants'; import { ExperimentWindow } from '../ExperimentWindow'; import { checkFileExists, getImages } from '../../utils/filesystem/storage'; import { @@ -22,7 +21,6 @@ interface Props { experimentObject: ExperimentObject; group: string; session: number; - deviceType: DEVICES; isEEGEnabled: boolean; ExperimentActions: typeof globalExperimentActions; } @@ -36,7 +34,6 @@ const Run: React.FC = ({ experimentObject, group, session, - deviceType, isEEGEnabled, ExperimentActions, }) => { @@ -75,14 +72,14 @@ const Run: React.FC = ({ const eventCallback = useCallback( (event: string, time: number) => { if (isEEGEnabled) { - if (deviceType === 'MUSE') { - injectMuseMarker(event, time); - } else { - injectEmotivMarker(event, time); - } + injectMuseMarker(event, time); + window.electronAPI.sendLSLMarker({ + label: event, + rendererTimestamp: performance.now(), + }); } }, - [isEEGEnabled, deviceType] + [isEEGEnabled] ); const onFinish = useCallback( diff --git a/src/renderer/components/CollectComponent/index.tsx b/src/renderer/components/CollectComponent/index.tsx index c02b1b27..a7584007 100644 --- a/src/renderer/components/CollectComponent/index.tsx +++ b/src/renderer/components/CollectComponent/index.tsx @@ -2,9 +2,9 @@ import { Observable } from 'rxjs'; import React, { Component } from 'react'; import { EXPERIMENTS, - DEVICES, CONNECTION_STATUS, DEVICE_AVAILABILITY, + DEVICES, } from '../../constants/constants'; import { ExperimentParameters, @@ -12,6 +12,7 @@ import { Device, ExperimentObject, } from '../../constants/interfaces'; +import type { DiscoveredStream } from '../../../shared/lslTypes'; import PreTestComponent from './PreTestComponent'; import ConnectModal from './ConnectModal'; import RunComponent from './RunComponent'; @@ -20,11 +21,12 @@ import { ExperimentActions, DeviceActions } from '../../actions'; export interface Props { ExperimentActions: typeof ExperimentActions; connectedDevice: Record; - deviceType: DEVICES; deviceAvailability: DEVICE_AVAILABILITY; connectionStatus: CONNECTION_STATUS; + deviceType: DEVICES; DeviceActions: typeof DeviceActions; availableDevices: Array; + availableLSLStreams: Array; type: EXPERIMENTS; experimentObject: ExperimentObject; signalQualityObservable: Observable | null | undefined; @@ -103,16 +105,16 @@ export default class Collect extends Component { onClose={this.handleConnectModalClose} connectedDevice={this.props.connectedDevice} signalQualityObservable={this.props.signalQualityObservable ?? undefined} - deviceType={this.props.deviceType} deviceAvailability={this.props.deviceAvailability} connectionStatus={this.props.connectionStatus} + deviceType={this.props.deviceType} DeviceActions={this.props.DeviceActions} availableDevices={this.props.availableDevices} + availableLSLStreams={this.props.availableLSLStreams} /> ; @@ -22,6 +23,7 @@ interface Props { connectionStatus: CONNECTION_STATUS; DeviceActions: typeof DeviceActions; availableDevices: Array; + availableLSLStreams?: Array; } interface State { @@ -85,7 +87,6 @@ export default class Home extends Component {
    @@ -111,11 +112,12 @@ export default class Home extends Component { onClose={this.handleConnectModalClose} connectedDevice={this.props.connectedDevice} signalQualityObservable={this.props.signalQualityObservable} - deviceType={this.props.deviceType} deviceAvailability={this.props.deviceAvailability} connectionStatus={this.props.connectionStatus} + deviceType={this.props.deviceType} DeviceActions={this.props.DeviceActions} availableDevices={this.props.availableDevices} + availableLSLStreams={this.props.availableLSLStreams} /> )} diff --git a/src/renderer/components/ViewerComponent.tsx b/src/renderer/components/ViewerComponent.tsx index 71fbcab7..6fea6a6d 100644 --- a/src/renderer/components/ViewerComponent.tsx +++ b/src/renderer/components/ViewerComponent.tsx @@ -3,8 +3,6 @@ import { Subscription, Observable } from 'rxjs'; import { isNil } from 'lodash'; import { MUSE_CHANNELS, - EMOTIV_CHANNELS, - DEVICES, VIEWER_DEFAULTS, } from '../constants/constants'; @@ -18,7 +16,6 @@ import Mousetrap from 'mousetrap'; interface Props { signalQualityObservable: Observable | null | undefined; - deviceType: DEVICES; plottingInterval: number; } @@ -38,8 +35,7 @@ class ViewerComponent extends Component { super(props); this.state = { ...VIEWER_DEFAULTS, - channels: - props.deviceType === DEVICES.EMOTIV ? EMOTIV_CHANNELS : MUSE_CHANNELS, + channels: MUSE_CHANNELS, viewerUrl: '', }; this.graphView = null; @@ -48,24 +44,33 @@ class ViewerComponent extends Component { async componentDidMount() { const viewerUrl = await window.electronAPI.getViewerUrl(); + // setState schedules a re-render — the element doesn't exist in the + // DOM until after that render completes. Webview setup is deferred to + // componentDidUpdate where the DOM is guaranteed to reflect the new state. this.setState({ viewerUrl }); - this.graphView = document.querySelector('webview'); - this.graphView?.addEventListener('dom-ready', () => { - this.graphView?.send('initGraph', { - plottingInterval: this.props.plottingInterval, - channels: this.state.channels, - domain: this.state.domain, - channelColours: this.state.channels.map(() => '#66B0A9'), - }); - this.setKeyListeners(); - const { signalQualityObservable } = this.props; - if (signalQualityObservable != null) { - this.subscribeToObservable(signalQualityObservable); - } - }); } componentDidUpdate(prevProps: Props, prevState: State) { + // Webview enters the DOM when viewerUrl first becomes non-empty. + // componentDidUpdate runs synchronously after React commits, so the listener + // is attached before the browser can fire dom-ready. + if (this.state.viewerUrl && !prevState.viewerUrl) { + this.graphView = document.querySelector('webview'); + this.graphView?.addEventListener('dom-ready', () => { + this.graphView?.send('initGraph', { + plottingInterval: this.props.plottingInterval, + channels: this.state.channels, + domain: this.state.domain, + channelColours: this.state.channels.map(() => '#66B0A9'), + }); + this.setKeyListeners(); + const { signalQualityObservable } = this.props; + if (signalQualityObservable != null) { + this.subscribeToObservable(signalQualityObservable); + } + }); + } + const { signalQualityObservable } = this.props; if ( signalQualityObservable !== prevProps.signalQualityObservable && @@ -73,14 +78,6 @@ class ViewerComponent extends Component { ) { this.subscribeToObservable(signalQualityObservable); } - if (this.props.deviceType !== prevProps.deviceType) { - this.setState({ - channels: - this.props.deviceType === DEVICES.MUSE - ? MUSE_CHANNELS - : EMOTIV_CHANNELS, - }); - } if (!this.graphView) { return; } @@ -90,9 +87,6 @@ class ViewerComponent extends Component { if (this.state.domain !== prevState.domain) { this.graphView.send('updateDomain', this.state.domain); } - if (this.state.channels !== prevState.channels) { - this.graphView.send('updateChannels', this.state.channels); - } if (this.state.autoScale !== prevState.autoScale) { this.graphView.send('autoScale'); } diff --git a/src/renderer/constants/constants.ts b/src/renderer/constants/constants.ts index 37d15981..654c5e95 100644 --- a/src/renderer/constants/constants.ts +++ b/src/renderer/constants/constants.ts @@ -23,7 +23,8 @@ export const SCREENS = { export enum DEVICES { NONE = 'NONE', MUSE = 'MUSE', - EMOTIV = 'EMOTIV', + NEUROSITY = 'NEUROSITY', + LSL = 'LSL', // external LSL inlet stream GANGLION = 'GANGLION', // One day ;) } @@ -65,21 +66,6 @@ export enum EVENTS { } export const CHANNELS = { - // Epoc channels - AF3: { index: 0, color: '#9B6ABC' }, - F7: { index: 1, color: '#7EA0C5' }, - F3: { index: 2, color: '#8BD6E9' }, - FC5: { index: 3, color: '#66B0A9' }, - T7: { index: 4, color: '#E7789E' }, - P7: { index: 5, color: '#F1A766' }, - O1: { index: 6, color: '#FFDA6A' }, - O2: { index: 7, color: '#F8F8F8' }, - P8: { index: 8, color: '#F8F8F8' }, - T8: { index: 9, color: '#F8F8F8' }, - FC6: { index: 10, color: '#F8F8F8' }, - F4: { index: 11, color: '#F8F8F8' }, - F8: { index: 12, color: '#F8F8F8' }, - AF4: { index: 13, color: '#F8F8F8' }, // Muse channels TP9: { index: 0, color: '#9B6ABC' }, AF7: { index: 1, color: '#7EA0C5' }, @@ -88,25 +74,23 @@ export const CHANNELS = { AUX: { index: 4, color: '#E7789E' }, } as const; -export const EMOTIV_CHANNELS = [ - 'AF3', - 'F7', - 'F3', - 'FC5', - 'T7', - 'P7', - 'O1', - 'O2', - 'P8', - 'T8', - 'FC6', - 'F4', - 'F8', - 'AF4', -]; - export const MUSE_CHANNELS = ['TP9', 'AF7', 'AF8', 'TP10']; +// Neurosity Crown 8-channel montage. Channel order is determined at runtime +// by the `info.channelNames` field of each Epoch emitted by the SDK; this is +// only a fallback for connect-time metadata. +export const NEUROSITY_CHANNELS = [ + 'CP3', + 'C3', + 'F5', + 'PO3', + 'PO4', + 'F6', + 'C4', + 'CP4', +]; +export const NEUROSITY_SAMPLING_RATE = 256; + export const ZOOM_SCALAR = 1.5; export const MUSE_SAMPLING_RATE = 256; @@ -142,3 +126,8 @@ export enum FILE_TYPES { export const RESOURCE_PATH: string = // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).__ELECTRON_RESOURCE_PATH__ || ''; // Injected by Electron preload additionalArguments — not typed + +/** Node `process.platform` from preload; empty outside Electron. */ +export const ELECTRON_PLATFORM: string = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).__ELECTRON_PLATFORM__ || ''; diff --git a/src/renderer/containers/App.tsx b/src/renderer/containers/App.tsx index 29845b51..59f691fd 100644 --- a/src/renderer/containers/App.tsx +++ b/src/renderer/containers/App.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { useEffect } from 'react'; import { useLocation } from 'react-router-dom'; import { useDispatch } from 'react-redux'; -import { ToastContainer } from 'react-toastify'; +import { ToastContainer, toast } from 'react-toastify'; import TopNav from './TopNavBarContainer'; import { RouterActions } from '../actions/routerActions'; @@ -15,6 +15,16 @@ function NavigationTracker() { return null; } +function LSLStatusListener() { + useEffect(() => { + const unsubscribe = window.electronAPI.onLSLStatus((status) => { + toast.error(`LSL: ${status.message}`); + }); + return unsubscribe; + }, []); + return null; +} + type Props = { children: React.ReactNode; }; @@ -23,6 +33,7 @@ export function App(props: Props) { return (
    + {props.children} diff --git a/src/renderer/epics/deviceEpics.ts b/src/renderer/epics/deviceEpics.ts index 902fae16..a1551be2 100644 --- a/src/renderer/epics/deviceEpics.ts +++ b/src/renderer/epics/deviceEpics.ts @@ -1,24 +1,42 @@ import { combineEpics, Epic } from 'redux-observable'; -import { of, from, timer, ObservableInput } from 'rxjs'; -import { map, pluck, mergeMap, tap, filter, catchError } from 'rxjs/operators'; +import { of, from, timer, ObservableInput, EMPTY } from 'rxjs'; +import { + map, + pluck, + mergeMap, + tap, + filter, + catchError, + takeUntil, +} from 'rxjs/operators'; import { isNil } from 'lodash'; import { toast } from 'react-toastify'; import { isActionOf } from '../utils/redux'; import { DeviceActions, DeviceActionType, ExperimentActions } from '../actions'; -import { - getEmotiv, - connectToEmotiv, - createRawEmotivObservable, - createEmotivSignalQualityObservable, - disconnectFromEmotiv, -} from '../utils/eeg/emotiv'; import { getMuse, connectToMuse, createRawMuseObservable, createMuseSignalQualityObservable, disconnectFromMuse, + cancelMuseScan, + museDisconnect$, } from '../utils/eeg/muse'; +import { + getNeurosity, + connectToNeurosity, + createRawNeurosityObservable, + disconnectFromNeurosity, + cancelNeurosityScan, + neurosityDisconnect$, +} from '../utils/eeg/neurosity'; +import { + discoverLSLStreams, + connectToLSLInlet, + createRawLSLInletObservable, + disconnectFromLSLInlet, +} from '../utils/eeg/lslInlet'; +import { batchSamplesToEpoch, sendEpoch } from '../utils/eeg/lslBridge'; import { CONNECTION_STATUS, DEVICES, @@ -33,17 +51,22 @@ import { RootState } from '../reducers'; // NOTE: Uses a Promise "then" inside b/c Observable.from leads to loss of user gesture propagation for web bluetooth const searchMuseEpic: Epic = ( - action$ + action$, + state$ ) => action$.pipe( filter(isActionOf(DeviceActions.SetDeviceAvailability)), pluck('payload'), filter((status) => status === DEVICE_AVAILABILITY.SEARCHING), - map(getMuse), + map(() => + state$.value.device.deviceType === DEVICES.NEUROSITY + ? getNeurosity() + : getMuse() + ), mergeMap((promise) => promise.then( (devices) => devices, - (error) => { + () => { // This error will fire a bit too promiscuously until we fix windows web bluetooth // toast.error(`"Device Error: " ${error.toString()}`); return []; @@ -54,36 +77,6 @@ const searchMuseEpic: Epic = ( map(DeviceActions.DeviceFound) ); -const searchEmotivEpic: Epic = ( - action$ -) => - action$.pipe( - filter(isActionOf(DeviceActions.SetDeviceAvailability)), - pluck('payload'), - filter((status) => status === DEVICE_AVAILABILITY.SEARCHING), - filter(() => process.platform === 'darwin' || process.platform === 'win32'), - map(getEmotiv), - mergeMap((promise) => - promise.then( - (devices) => devices, - (error) => { - if (error.message.includes('client.queryHeadsets')) { - toast.error( - 'Could not connect to Cortex Service. Please connect to the internet and install Cortex to use Emotiv EEG', - { autoClose: 7000 } - ); - } else { - toast.error(`"Device Error: " ${error.toString()}`); - } - console.error('searchEpic: ', error.toString()); - return []; - } - ) - ), - filter((devices) => devices.length >= 1), - map(DeviceActions.DeviceFound) - ); - const deviceFoundEpic: Epic = ( action$, state$ @@ -120,19 +113,29 @@ const searchTimerEpic: Epic = ( () => state$.value.device.deviceAvailability === DEVICE_AVAILABILITY.SEARCHING ), + // Cancel the pending requestDevice() promise in the main process so it + // doesn't hang after the search window closes. + tap(() => { + if (state$.value.device.deviceType === DEVICES.NEUROSITY) { + cancelNeurosityScan(); + } else { + cancelMuseScan(); + } + }), map(() => DeviceActions.SetDeviceAvailability(DEVICE_AVAILABILITY.NONE)) ); const connectEpic: Epic = ( - action$ + action$, + state$ ) => action$.pipe( filter(isActionOf(DeviceActions.ConnectToDevice)), pluck('payload'), map((device) => - (isNil(device.name) - ? connectToEmotiv(device) - : connectToMuse(device)) as Promise + state$.value.device.deviceType === DEVICES.NEUROSITY + ? (connectToNeurosity(device) as Promise) + : (connectToMuse(device) as Promise) ), mergeMap((promise) => promise.then((deviceInfo) => deviceInfo)), // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -140,10 +143,9 @@ const connectEpic: Epic = ( // returns union of several action types if (deviceInfo != null && deviceInfo.samplingRate != null) { console.log(deviceInfo); + // Preserve the currently-selected deviceType; do not hardcode MUSE. return of( - DeviceActions.SetDeviceType( - deviceInfo.name.includes('Muse') ? DEVICES.MUSE : DEVICES.EMOTIV - ), + DeviceActions.SetDeviceType(state$.value.device.deviceType), DeviceActions.SetDeviceInfo(deviceInfo), DeviceActions.SetConnectionStatus(CONNECTION_STATUS.CONNECTED) ); @@ -170,12 +172,13 @@ const setRawObservableEpic: Epic< > = (action$, state$) => action$.pipe( filter(isActionOf(DeviceActions.SetDeviceInfo)), - mergeMap(() => { - if (state$.value.device.deviceType === DEVICES.EMOTIV) { - return from(createRawEmotivObservable()); - } - return from(createRawMuseObservable()); - }), + mergeMap(() => + from( + state$.value.device.deviceType === DEVICES.NEUROSITY + ? createRawNeurosityObservable() + : createRawMuseObservable() + ) + ), map(DeviceActions.SetRawObservable) ); @@ -187,15 +190,12 @@ const setSignalQualityObservableEpic: Epic< action$.pipe( filter(isActionOf(DeviceActions.SetRawObservable)), pluck('payload'), - map((rawObservable) => { - if (state$.value.device.deviceType === DEVICES.EMOTIV) { - return createEmotivSignalQualityObservable(rawObservable); - } - return createMuseSignalQualityObservable( + map((rawObservable) => + createMuseSignalQualityObservable( rawObservable, state$.value.device.connectedDevice - ); - }), + ) + ), map(DeviceActions.SetSignalQualityObservable) ); @@ -211,22 +211,140 @@ const deviceCleanupEpic: Epic = ( CONNECTION_STATUS.NOT_YET_CONNECTED ), map(() => { - if (state$.value.device.deviceType === DEVICES.EMOTIV) { - disconnectFromEmotiv(); + const dt = state$.value.device.deviceType; + if (dt === DEVICES.NEUROSITY) { + void disconnectFromNeurosity(); + } else if (dt === DEVICES.LSL) { + disconnectFromLSLInlet(); + } else { + disconnectFromMuse(); } - disconnectFromMuse(); }), map(DeviceActions.Cleanup) ); +// Watches for unexpected BLE disconnects and dispatches DeviceLost so the UI +// can clear its "connected" state and surface a toast. Only runs while a BLE +// device is active — LSL inlets have their own disconnect path. +const deviceDisconnectWatchEpic: Epic< + DeviceActionType, + DeviceActionType, + RootState +> = (action$, state$) => + action$.pipe( + filter(isActionOf(DeviceActions.SetConnectionStatus)), + pluck('payload'), + filter((status) => status === CONNECTION_STATUS.CONNECTED), + mergeMap(() => { + const dt = state$.value.device.deviceType; + if (dt === DEVICES.MUSE) return museDisconnect$; + if (dt === DEVICES.NEUROSITY) return neurosityDisconnect$(); + return EMPTY; + }), + tap(() => toast.error('EEG device disconnected')), + map(() => DeviceActions.DeviceLost()), + takeUntil(action$.pipe(filter(isActionOf(DeviceActions.Cleanup)))) + ); + +// Responds to DeviceLost by tearing down driver state and resetting redux. +const deviceLostCleanupEpic: Epic< + DeviceActionType, + DeviceActionType, + RootState +> = (action$, state$) => + action$.pipe( + filter(isActionOf(DeviceActions.DeviceLost)), + tap(() => { + const dt = state$.value.device.deviceType; + if (dt === DEVICES.MUSE) disconnectFromMuse(); + else if (dt === DEVICES.NEUROSITY) void disconnectFromNeurosity(); + }), + map(DeviceActions.Cleanup) + ); + +// External LSL inlet — discovery and connection have a separate flow from +// BLE (no requestDevice gesture), so they get their own epics. +const discoverLSLStreamsEpic: Epic< + DeviceActionType, + DeviceActionType, + RootState +> = (action$) => + action$.pipe( + filter(isActionOf(DeviceActions.DiscoverLSLStreams)), + mergeMap(() => from(discoverLSLStreams())), + map(DeviceActions.SetAvailableLSLStreams) + ); + +const connectToLSLStreamEpic: Epic< + DeviceActionType, + DeviceActionType, + RootState +> = (action$) => + action$.pipe( + filter(isActionOf(DeviceActions.ConnectToLSLStream)), + pluck('payload'), + mergeMap((stream) => { + const deviceInfo = connectToLSLInlet(stream); + return from(createRawLSLInletObservable(stream)).pipe( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mergeMap>((rawObservable) => + of( + DeviceActions.SetDeviceType(DEVICES.LSL), + DeviceActions.SetDeviceInfo(deviceInfo), + DeviceActions.SetConnectionStatus(CONNECTION_STATUS.CONNECTED), + DeviceActions.SetRawObservable(rawObservable) + ) + ) + ); + }) + ); + +// Forwards each raw EEG sample over IPC to the main-process LSL outlet. +// Runs in parallel with setSignalQualityObservableEpic — does not modify +// the observable that feeds the signal-quality / viewer pipelines. +const lslForwardEpic: Epic = ( + action$, + state$ +) => + action$.pipe( + filter(isActionOf(DeviceActions.SetRawObservable)), + pluck('payload'), + mergeMap((rawObservable) => { + const device = state$.value.device.connectedDevice; + const deviceType = state$.value.device.deviceType; + if (!device || !rawObservable) return EMPTY; + // Skip the outlet for LSL inlet sources — re-broadcasting a stream we + // just received from LSL would create a feedback loop in LabRecorder. + if (deviceType === DEVICES.LSL) return EMPTY; + const lslDeviceType: 'muse' | 'neurosity' = + deviceType === DEVICES.MUSE ? 'muse' : 'neurosity'; + return batchSamplesToEpoch( + rawObservable, + device.name || lslDeviceType, + lslDeviceType, + device.channels, + device.samplingRate + ).pipe( + tap(sendEpoch), + takeUntil(action$.pipe(filter(isActionOf(DeviceActions.Cleanup)))) + ); + }), + // This epic is a side-effect sink — emit nothing back into the action stream. + mergeMap(() => EMPTY) + ); + export default combineEpics( searchMuseEpic, - searchEmotivEpic, deviceFoundEpic, searchTimerEpic, connectEpic, isConnectingEpic, setRawObservableEpic, setSignalQualityObservableEpic, - deviceCleanupEpic + lslForwardEpic, + discoverLSLStreamsEpic, + connectToLSLStreamEpic, + deviceCleanupEpic, + deviceDisconnectWatchEpic, + deviceLostCleanupEpic ); diff --git a/src/renderer/epics/experimentEpics.ts b/src/renderer/epics/experimentEpics.ts index 8af74214..1b20536c 100644 --- a/src/renderer/epics/experimentEpics.ts +++ b/src/renderer/epics/experimentEpics.ts @@ -12,9 +12,7 @@ import { isActionOf } from '../utils/redux'; import { ExperimentActions, ExperimentActionType } from '../actions'; import { RouterActions } from '../actions/routerActions'; import { - DEVICES, MUSE_CHANNELS, - EMOTIV_CHANNELS, CONNECTION_STATUS, } from '../constants/constants'; import { @@ -30,7 +28,6 @@ import { readWorkspaceBehaviorData, getWorkspaceDir, } from '../utils/filesystem/storage'; -import { createEmotivRecord, stopEmotivRecord } from '../utils/eeg/emotiv'; import { RootState } from '../reducers'; import { WorkSpaceInfo } from '../constants/interfaces'; import { getExperimentFromType } from '../utils/labjs/functions'; @@ -81,18 +78,9 @@ const startEpic = (action$, state$) => } writeHeader( streamId, - state$.value.device.deviceType === DEVICES.EMOTIV - ? EMOTIV_CHANNELS - : MUSE_CHANNELS + state$.value.device.connectedDevice?.channels ?? MUSE_CHANNELS ); - if (state$.value.device.deviceType === DEVICES.EMOTIV) { - createEmotivRecord( - state$.value.experiment.subject, - state$.value.experiment.session - ); - } - state$.value.device.rawObservable .pipe( takeUntil( @@ -131,12 +119,6 @@ const experimentStopEpic: Epic< state$.value.experiment.group, state$.value.experiment.session ); - if ( - state$.value.experiment.isEEGEnabled && - state$.value.device.deviceType === DEVICES.EMOTIV - ) { - stopEmotivRecord(); - } }), mergeMap(() => of(ExperimentActions.SetIsRunning(false))) ); diff --git a/src/renderer/epics/pyodideEpics.ts b/src/renderer/epics/pyodideEpics.ts index edf633e0..e9a145c1 100644 --- a/src/renderer/epics/pyodideEpics.ts +++ b/src/renderer/epics/pyodideEpics.ts @@ -25,7 +25,6 @@ import { loadUtils, } from '../utils/webworker'; import { - EMOTIV_CHANNELS, DEVICES, MUSE_CHANNELS, PYODIDE_VARIABLE_NAMES, @@ -251,20 +250,15 @@ const loadERPEpic: Epic = ( filter(isActionOf(PyodideActions.LoadERP)), pluck('payload'), map((channelName: string) => { - let index: number | null = null; - if (MUSE_CHANNELS.includes(channelName)) { - index = MUSE_CHANNELS.indexOf(channelName); + const index = MUSE_CHANNELS.includes(channelName) + ? MUSE_CHANNELS.indexOf(channelName) + : 0; + if (!MUSE_CHANNELS.includes(channelName)) { + console.warn( + 'channel name supplied to loadERPEpic does not belong to a known Muse channel' + ); } - if (EMOTIV_CHANNELS.includes(channelName)) { - index = EMOTIV_CHANNELS.indexOf(channelName); - } - if (index) { - return index; - } - console.warn( - 'channel name supplied to loadERPEpic does not belong to either device' - ); - return parseInt(EMOTIV_CHANNELS[0], 10); + return index; }), tap((chanIndex) => plotERP(state$.value.pyodide.worker!, chanIndex)), mergeMap(() => EMPTY) diff --git a/src/renderer/reducers/deviceReducer.ts b/src/renderer/reducers/deviceReducer.ts index 100dcfc0..613bb510 100644 --- a/src/renderer/reducers/deviceReducer.ts +++ b/src/renderer/reducers/deviceReducer.ts @@ -13,9 +13,11 @@ import { SignalQualityData, } from '../constants/interfaces'; import { DeviceActions } from '../actions'; +import type { DiscoveredStream } from '../../shared/lslTypes'; export interface DeviceStateType { readonly availableDevices: Array; + readonly availableLSLStreams: Array; readonly connectedDevice: DeviceInfo | null | undefined; readonly connectionStatus: CONNECTION_STATUS; readonly deviceAvailability: DEVICE_AVAILABILITY; @@ -27,12 +29,13 @@ export interface DeviceStateType { const initialState: DeviceStateType = { availableDevices: [], + availableLSLStreams: [], connectedDevice: { name: 'disconnected', samplingRate: 0, channels: [] }, connectionStatus: CONNECTION_STATUS.NOT_YET_CONNECTED, deviceAvailability: DEVICE_AVAILABILITY.NONE, rawObservable: null, signalQualityObservable: null, - deviceType: DEVICES.EMOTIV, + deviceType: DEVICES.MUSE, }; export default createReducer(initialState, (builder) => @@ -88,4 +91,8 @@ export default createReducer(initialState, (builder) => .addCase(DeviceActions.Cleanup, (state, action) => { return initialState; }) + .addCase(DeviceActions.SetAvailableLSLStreams, (state, action) => ({ + ...state, + availableLSLStreams: action.payload, + })) ); diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts new file mode 100644 index 00000000..f870d1d4 --- /dev/null +++ b/src/renderer/types/electron.d.ts @@ -0,0 +1,119 @@ +/** + * TypeScript declarations for `window.electronAPI`. + * + * Keep this in sync with the contextBridge.exposeInMainWorld('electronAPI', ...) + * block in src/preload/index.ts. + */ +import type { + DiscoveredStream, + LSLEpoch, + LSLInletEpoch, + LSLMarker, + LSLStatus, +} from '../../shared/lslTypes'; + +export {}; + +declare global { + interface ElectronAPI { + // Dialogs + showOpenDialog: ( + options: Electron.OpenDialogOptions + ) => Promise; + showMessageBox: ( + options: Electron.MessageBoxOptions + ) => Promise; + showSaveDialog: ( + options: Electron.SaveDialogOptions + ) => Promise; + loadDialog: (fileType: string) => Promise; + + // Shell + showItemInFolder: (fullPath: string) => Promise; + moveItemToTrash: (fullPath: string) => Promise; + + // Filesystem — workspace management + getWorkspaceDir: (title: string) => Promise; + createWorkspaceDir: (title: string) => Promise; + readWorkspaces: () => Promise; + readAndParseState: (dir: string) => Promise; + storeExperimentState: (state: unknown) => Promise; + restoreExperimentState: (state: unknown) => Promise; + readWorkspaceRawEEGData: ( + title: string + ) => Promise>; + readWorkspaceCleanedEEGData: ( + title: string + ) => Promise>; + readWorkspaceBehaviorData: ( + title: string + ) => Promise>; + storeBehavioralData: ( + csv: string, + title: string, + subject: string, + group: string, + session: number + ) => Promise; + storePyodideImageSvg: ( + title: string, + imageTitle: string, + svgContent: string + ) => Promise; + storePyodideImagePng: ( + title: string, + imageTitle: string, + rawData: ArrayBuffer + ) => Promise; + deleteWorkspaceDir: (title: string) => Promise; + readImages: (dir: string) => Promise; + getImages: (params: unknown) => Promise; + readBehaviorData: (files: string[]) => Promise; + storeAggregatedBehaviorData: ( + data: unknown, + title: string + ) => Promise; + checkFileExists: ( + title: string, + subject: string, + filename: string + ) => Promise; + readFiles: (filePathsArray: string[]) => Promise; + + // EEG streaming + createEEGWriteStream: ( + title: string, + subject: string, + group: string, + session: number + ) => Promise; + writeEEGHeader: (streamId: string, channels: string[]) => void; + writeEEGData: (streamId: string, data: unknown) => void; + closeEEGStream: (streamId: string) => Promise; + + // Misc + getResourcePath: () => Promise; + getViewerUrl: () => Promise; + + // Bluetooth + cancelBluetoothSearch: () => Promise; + + // LSL + sendLSLEpoch: (epoch: LSLEpoch) => void; + sendLSLMarker: (marker: LSLMarker) => void; + discoverLSLStreams: () => Promise; + subscribeLSLStream: (uid: string) => void; + unsubscribeLSLStream: (uid: string) => void; + onLSLInletData: ( + handler: (epoch: LSLInletEpoch) => void + ) => () => void; + onLSLInletDisconnected: ( + handler: (payload: { uid: string }) => void + ) => () => void; + onLSLStatus: (handler: (status: LSLStatus) => void) => () => void; + } + + interface Window { + electronAPI: ElectronAPI; + } +} diff --git a/src/renderer/utils/eeg/cortex.js b/src/renderer/utils/eeg/cortex.js deleted file mode 100644 index 1cb9f0a3..00000000 --- a/src/renderer/utils/eeg/cortex.js +++ /dev/null @@ -1,204 +0,0 @@ -/* - * JS Cortex Wrapper - * ***************** - * - * This library is intended to make working with Cortex easier in Javascript. - * We use it both in the browser and NodeJS code. - * - * It makes extensive use of Promises for flow control; all requests return a - * Promise with their result. - * - * For the subscription types in Cortex, we use an event emitter. Each kind of - * event (mot, eeg, etc) is emitted as its own event that you can listen for - * whether or not there are any active subscriptions at the time. - * - * The API methods are defined by using Cortex"s inspectApi call. We mostly - * just pass information back and forth without doing much with it, with the - * exception of the login/auth flow, which we expose as the init() method. - */ -// const WebSocket = require('ws'); -import { EventEmitter } from 'events'; - -const CORTEX_URL = 'wss://localhost:6868'; - -const safeParse = (msg) => { - try { - return JSON.parse(msg); - } catch (_) { - return null; - } -}; - -if (typeof process !== 'undefined' && process.env) { - process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; -} - -class JSONRPCError extends Error { - constructor(err) { - super(err.message); - this.name = this.constructor.name; - this.message = err.message; - this.code = err.code; - } - - toString() { - return `${super.toString()} (${this.code})`; - } -} - -export default class Cortex extends EventEmitter { - constructor(options = {}) { - super(); - this.options = options; - this.ws = new WebSocket(CORTEX_URL); - this.msgId = 0; - this.requests = {}; - this.streams = {}; - this.ws.addEventListener('message', this._onmsg.bind(this)); - this.ws.addEventListener('close', () => { - this._log('ws: Socket closed'); - }); - this.verbose = options.verbose !== null ? options.verbose : 1; - this.handleError = (error) => { - throw new JSONRPCError(error); - }; - - this.ready = new Promise( - (resolve) => this.ws.addEventListener('open', resolve), - this.handleError - ) - .then(() => this._log('ws: Socket opened')) - .then(() => this.call('inspectApi')) - .then((methods) => { - methods.forEach((m) => { - this.defineMethod(m.methodName, m.params); - }); - this._log(`rpc: Added ${methods.length} methods from inspectApi`); - return methods; - }); - } - - _onmsg(msg) { - const data = safeParse(msg.data); - if (!data) return this._warn('unparseable message', msg); - - this._debug('ws: <-', msg.data); - - if ('id' in data) { - const { id } = data; - this._log( - `[${id}] <-`, - data.result ? 'success' : `error (${data.error.message})` - ); - if (this.requests[id]) { - this.requests[id](data.error, data.result); - } else { - this._warn('rpc: Got response for unknown id', id); - } - } else if ('sid' in data) { - const dataKeys = Object.keys(data).filter( - (k) => k !== 'sid' && k !== 'time' && Array.isArray(data[k]) - ); - dataKeys.forEach( - (k) => - this.emit(k, data) || this._warn('no listeners for stream event', k) - ); - } else { - this._log('rpc: Unrecognised data', data); - } - } - - _warn(...msg) { - if (this.verbose > 0) console.warn('[Cortex WARN]', ...msg); - } - - _log(...msg) { - if (this.verbose > 1) console.log('[Cortex LOG]', ...msg); - } - - _debug(...msg) { - if (this.verbose > 2) console.debug('[Cortex DEBUG]', ...msg); - } - - init({ clientId, clientSecret, license, debit } = {}) { - const token = this.getUserLogin() - .then((users) => { - if (users.length === 0) { - return Promise.reject(new Error('No logged in user')); - } - return this.requestAccess({ clientId, clientSecret }); - }) - .then(({ accessGranted }) => { - if (!accessGranted) { - return Promise.reject( - new Error('Please approve this application in the EMOTIV app') - ); - } - return this.authorize({ - clientId, - clientSecret, - license, - debit, - }).then(({ cortexToken }) => { - this._log('init: Got auth token'); - this._debug('init: Auth token', cortexToken); - this.cortexToken = cortexToken; - return cortexToken; - }); - }); - - return token; - } - - close() { - return new Promise((resolve) => { - this.ws.close(); - this.ws.once('close', resolve); - }); - } - - call(method, params = {}) { - const id = this.msgId++; - const msg = JSON.stringify({ jsonrpc: '2.0', method, params, id }); - this.ws.send(msg); - this._log(`[${id}] -> ${method}`); - - this._debug('ws: ->', msg); - return new Promise((resolve, reject) => { - this.requests[id] = (err, data) => { - delete this.requests[id]; - this._debug('rpc: err', err, 'data', data); - if (err) return reject(new JSONRPCError(err)); - if (data) return resolve(data); - return reject(new Error('Invalid JSON-RPC response')); - }; - }); - } - - defineMethod(methodName, paramDefs = []) { - if (this[methodName]) return; - const needsAuth = paramDefs.some((p) => p.name === 'cortexToken'); - const requiredParams = paramDefs - .filter((p) => p.required) - .map((p) => p.name); - - this[methodName] = (params = {}) => { - if (needsAuth && this.cortexToken && !params.cortexToken) { - params = { ...params, cortexToken: this.cortexToken }; - } - const missingParams = requiredParams.filter((p) => params[p] == null); - if (missingParams.length > 0) { - return this.handleError( - new Error( - `Missing required params for ${methodName}: ${missingParams.join( - ', ' - )}` - ) - ); - } - return this.call(methodName, params); - }; - } -} - -Cortex.JSONRPCError = JSONRPCError; diff --git a/src/renderer/utils/eeg/emotiv.ts b/src/renderer/utils/eeg/emotiv.ts deleted file mode 100644 index e00175b0..00000000 --- a/src/renderer/utils/eeg/emotiv.ts +++ /dev/null @@ -1,189 +0,0 @@ -/* - * Adapted from the Cortex example, this file provides functions for creating a Cortex client and creating - * an RxJS Observable of raw EEG data - * - */ -import { fromEvent } from 'rxjs'; -import { map, withLatestFrom, share } from 'rxjs/operators'; -import { addInfo, epoch, bandpassFilter } from '@neurosity/pipes'; -import { toast } from 'react-toastify'; -import { parseEmotivSignalQuality } from './pipes'; -const CLIENT_ID = import.meta.env.VITE_CLIENT_ID ?? ''; -const CLIENT_SECRET = import.meta.env.VITE_CLIENT_SECRET ?? ''; -const LICENSE_ID = import.meta.env.VITE_LICENSE_ID ?? ''; -import { EMOTIV_CHANNELS, PLOTTING_INTERVAL } from '../../constants/constants'; -import Cortex from './cortex'; -import { Device, DeviceInfo } from '../../constants/interfaces'; - -interface EmotivHeadset { - id: string; - status: 'discovered' | 'connecting' | 'connected'; - connectedBy: 'dongle' | 'bluetooth' | 'usb cabe' | 'extender'; - dongle: string; - firmware: string; - motionSensors: string[]; - sensors: string[]; - settings: Record; - customName?: string; -} - -// Creates the Cortex object from SDK -const verbose = import.meta.env.VITE_LOG_LEVEL || 1; -const options = { verbose }; - -// This global client is used in every Cortex API call -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const client: any = new Cortex(options); // Cortex SDK has no TypeScript types - -// This global session is how I'm passing data between connectToEmotiv and createRawEmotivObservable -// I'm not a fan of doing this but I don't want to refactor the Redux store based on this API change that -// Emotiv is introducing -let session; - -// Gets a list of available Emotiv devices -export const getEmotiv = async () => { - const devices: EmotivHeadset[] = await client.queryHeadsets(); - return devices.map((headset) => ({ - id: headset.id, - name: headset.customName, - })); -}; - -export const connectToEmotiv = async ( - device: Device -): Promise => { - await client.ready; - - // Authenticate - try { - await client.init({ - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - license: LICENSE_ID, - debit: 1, - }); - } catch (err) { - toast.error(`Authentication failed. ${(err as Error).message}`); - return Promise.reject(err); - } - // Connect - try { - await client.controlDevice({ command: 'connect', headset: device.id }); - } catch (err) { - toast.error(`Emotiv connection failed. ${(err as Error).message}`); - return Promise.reject(err); - } - // Create Session - try { - const newSession = await client.createSession({ - status: 'active', - headset: device.id, - }); - session = newSession; - - return { - name: session.headset.id, - samplingRate: session.headset.settings.eegRate, - channels: EMOTIV_CHANNELS, - }; - } catch (err) { - toast.error(`Session creation failed. ${(err as Error).message} `); - return Promise.reject(err); - } -}; - -export const disconnectFromEmotiv = async () => { - const sessionStatus = await client.updateSession({ - session: session.id, - status: 'close', - }); - return sessionStatus; -}; - -// Returns an observable that will handle both connecting to Client and providing a source of EEG data -export const createRawEmotivObservable = async () => { - if (!session) { - throw new Error('Emotiv must be connected to before subscribing to EEG'); - } - try { - await client.subscribe({ - session: session.id, - streams: ['eeg', 'dev'], - }); - } catch (err) { - toast.error(`EEG connection failed. ${(err as Error).message}`); - } - - return fromEvent(client, 'eeg').pipe(map(createEEGSample)); -}; - -// Creates an observable that will epoch, filter, and add signal quality to EEG stream -export const createEmotivSignalQualityObservable = (rawObservable) => { - const signalQualityObservable = fromEvent(client, 'dev'); - const samplingRate = 128; - const channels = EMOTIV_CHANNELS; - const intervalSamples = (PLOTTING_INTERVAL * samplingRate) / 1000; - return rawObservable.pipe( - addInfo({ - samplingRate, - channels, - }), - epoch({ - duration: intervalSamples, - interval: intervalSamples, - }), - bandpassFilter({ - nbChannels: channels.length, - cutoffFrequencies: [1, 50], - }), - withLatestFrom(signalQualityObservable, integrateSignalQuality), - parseEmotivSignalQuality(), - share() - ); -}; - -export const injectEmotivMarker = (value: string, time: number) => { - client.injectMarker({ label: 'event', value, time, session: session.id }); -}; - -export const createEmotivRecord = (subjectName, sessionNumber) => { - client.createRecord({ - session: session.id, - title: `${subjectName}_${sessionNumber}`, - }); -}; - -export const stopEmotivRecord = () => { - client.stopRecord({ session: session.id }); -}; - -// --------------------------------------------------------------------- -// Helpers - -// Converts Cortex SDK eeg event format to EEGData format to make it consistent with Muse -// 14 EEG channels in data -// timestamp in ms -// Event marker in marker if present -const createEEGSample = (eegEvent) => { - const prunedArray = new Array(EMOTIV_CHANNELS.length); - for (let i = 0; i < EMOTIV_CHANNELS.length; i++) { - prunedArray[i] = eegEvent.eeg[i + 2]; - } - if (eegEvent.eeg[eegEvent.eeg.length - 1].length >= 1) { - const marker = - (eegEvent.eeg[eegEvent.eeg.length - 1][0] && - eegEvent.eeg[eegEvent.eeg.length - 1][0].value) || - 0; - return { data: prunedArray, timestamp: eegEvent.time * 1000, marker }; - } - return { data: prunedArray, timestamp: eegEvent.time * 1000 }; -}; - -const integrateSignalQuality = (newEpoch, devSample) => ({ - ...newEpoch, - signalQuality: { - ...devSample.dev[2].map((signalQuality, index) => ({ - [EMOTIV_CHANNELS[index]]: signalQuality, - })), - }, -}); diff --git a/src/renderer/utils/eeg/lslBridge.ts b/src/renderer/utils/eeg/lslBridge.ts new file mode 100644 index 00000000..ef28e7c1 --- /dev/null +++ b/src/renderer/utils/eeg/lslBridge.ts @@ -0,0 +1,45 @@ +/** + * Renderer-side bridge to the main-process LSL outlet manager. + * + * Buffers raw EEG samples into small batches (~125ms @ 256Hz) to keep IPC + * traffic low while preserving per-sample timestamps for the LSL outlet. + */ +import { Observable } from 'rxjs'; +import { bufferCount, filter, map } from 'rxjs/operators'; +import type { LSLEpoch, LSLMarker } from '../../../shared/lslTypes'; +import { EEGData } from '../../constants/interfaces'; + +const DEFAULT_BATCH_SIZE = 32; + +/** + * Transforms a raw EEG observable (per-sample EEGData) into an observable of + * batched LSLEpoch objects ready to be forwarded over IPC. + */ +export const batchSamplesToEpoch = ( + rawObservable: Observable, + deviceId: string, + deviceType: LSLEpoch['deviceType'], + channelNames: string[], + sampleRate: number, + batchSize: number = DEFAULT_BATCH_SIZE +): Observable => + rawObservable.pipe( + filter((s) => Array.isArray(s.data) && s.data.length === channelNames.length), + bufferCount(batchSize), + map((batch) => ({ + deviceId, + deviceType, + samples: batch.map((s) => s.data), + timestamps: batch.map((s) => s.timestamp), + channelNames, + sampleRate, + })) + ); + +export const sendEpoch = (epoch: LSLEpoch): void => { + window.electronAPI?.sendLSLEpoch?.(epoch); +}; + +export const sendMarker = (marker: LSLMarker): void => { + window.electronAPI?.sendLSLMarker?.(marker); +}; diff --git a/src/renderer/utils/eeg/lslInlet.ts b/src/renderer/utils/eeg/lslInlet.ts new file mode 100644 index 00000000..c575748c --- /dev/null +++ b/src/renderer/utils/eeg/lslInlet.ts @@ -0,0 +1,81 @@ +/** + * LSL Inlet driver — exposes a remote LSL EEG stream as a renderer + * Observable compatible with the rest of the app. + * + * Discovery and inlet I/O happen in the main process (see src/main/lsl/inlets.ts). + * The renderer subscribes via IPC and converts the chunked LSLInletEpoch + * messages back into per-sample EEGData events. + */ +import { Observable, Subject } from 'rxjs'; +import { share } from 'rxjs/operators'; +import type { + DiscoveredStream, + LSLInletEpoch, +} from '../../../shared/lslTypes'; +import { EEGData } from '../../constants/interfaces'; + +let activeUid: string | null = null; +let inletSubject: Subject | null = null; +let inletDataUnsubscribe: (() => void) | null = null; +let inletDisconnectedUnsubscribe: (() => void) | null = null; + +export const discoverLSLStreams = (): Promise => + window.electronAPI.discoverLSLStreams(); + +export const connectToLSLInlet = (stream: DiscoveredStream) => { + activeUid = stream.uid; + return { + name: stream.name, + samplingRate: stream.sampleRate || 0, + channels: makeChannelLabels(stream), + }; +}; + +const makeChannelLabels = (stream: DiscoveredStream): string[] => + Array.from({ length: stream.channelCount }, (_, i) => `Ch${i + 1}`); + +export const createRawLSLInletObservable = async ( + stream: DiscoveredStream +): Promise> => { + if (inletSubject) inletSubject.complete(); + inletDataUnsubscribe?.(); + inletDisconnectedUnsubscribe?.(); + + const subject = new Subject(); + inletSubject = subject; + activeUid = stream.uid; + + inletDataUnsubscribe = window.electronAPI.onLSLInletData((epoch: LSLInletEpoch) => { + if (epoch.uid !== stream.uid) return; + const { samples, timestamps } = epoch; + for (let i = 0; i < samples.length; i++) { + // LSL timestamps are in seconds; convert to ms to match EEGData convention. + subject.next({ + data: samples[i], + timestamp: timestamps[i] * 1000, + }); + } + }); + + inletDisconnectedUnsubscribe = window.electronAPI.onLSLInletDisconnected( + (payload) => { + if (payload.uid === stream.uid) subject.complete(); + } + ); + + window.electronAPI.subscribeLSLStream(stream.uid); + return subject.asObservable().pipe(share()); +}; + +export const disconnectFromLSLInlet = (): void => { + if (activeUid) { + window.electronAPI.unsubscribeLSLStream(activeUid); + activeUid = null; + } + inletSubject?.complete(); + inletSubject = null; + inletDataUnsubscribe?.(); + inletDataUnsubscribe = null; + inletDisconnectedUnsubscribe?.(); + inletDisconnectedUnsubscribe = null; +}; diff --git a/src/renderer/utils/eeg/muse.ts b/src/renderer/utils/eeg/muse.ts index 27ba3024..9f8ed879 100644 --- a/src/renderer/utils/eeg/muse.ts +++ b/src/renderer/utils/eeg/muse.ts @@ -30,20 +30,31 @@ const INTER_SAMPLE_INTERVAL = -(1 / 256) * 1000; const client = new MuseClient(); client.enableAux = false; -// Gets an available Muse device +// Cached BluetoothDevice from the last getMuse() scan so that connectToMuse() +// can reuse it without triggering a second requestDevice() call (which would +// fire another select-bluetooth-device event in the main process). +let cachedDevice: BluetoothDevice | null = null; + +// Gets an available Muse device. In Electron, requestDevice() triggers the +// select-bluetooth-device IPC event in the main process, which auto-selects +// the first Muse headset found via BLE. // TODO: is being able to request only one Muse at a time a problem in a classroom scenario? export const getMuse = async () => { const deviceInstance = await navigator.bluetooth.requestDevice({ filters: [{ services: [MUSE_SERVICE] }], }); + cachedDevice = deviceInstance; return [{ id: deviceInstance.id, name: deviceInstance.name }]; }; -// Attempts to connect to a muse device. If successful, returns a device info object +// Attempts to connect to a muse device. If successful, returns a device info object. +// Reuses the BluetoothDevice cached by getMuse() to avoid a redundant requestDevice() call. export const connectToMuse = async (device: Device) => { - const deviceInstance = await navigator.bluetooth.requestDevice({ - filters: [{ services: [MUSE_SERVICE], name: device.name }], - }); + const deviceInstance = + cachedDevice ?? (await navigator.bluetooth.requestDevice({ + filters: [{ services: [MUSE_SERVICE], name: device.name }], + })); + cachedDevice = null; const gatt = await deviceInstance.gatt?.connect(); await client.connect(gatt); return { @@ -53,7 +64,36 @@ export const connectToMuse = async (device: Device) => { }; }; -export const disconnectFromMuse = () => client.disconnect(); +export const disconnectFromMuse = () => { + cachedDevice = null; + client.disconnect(); +}; + +// Emits when the BLE connection drops after having been up. Intentionally +// ignores the initial `false` from BehaviorSubject — we only care about +// transitions from connected → disconnected. +// muse-js bundles its own rxjs; bridge into this app's rxjs via a thin wrapper. +export const museDisconnect$: Observable = new Observable( + (subscriber) => { + const sub = ( + client.connectionStatus as unknown as { subscribe: (n: (v: boolean) => void) => { unsubscribe: () => void } } + ).subscribe((() => { + let prev: boolean | undefined; + return (curr: boolean) => { + if (prev === true && curr === false) subscriber.next(); + prev = curr; + }; + })()); + return () => sub.unsubscribe(); + } +); + +// Cancels any in-progress BLE scan by telling the main process to reject the +// pending requestDevice() call. Called when the search timer expires. +export const cancelMuseScan = (): void => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).electronAPI?.cancelBluetoothSearch(); +}; // Awaits Muse connectivity before sending an observable rep. EEG stream export const createRawMuseObservable = async () => { diff --git a/src/renderer/utils/eeg/neurosity.ts b/src/renderer/utils/eeg/neurosity.ts new file mode 100644 index 00000000..b7640d5e --- /dev/null +++ b/src/renderer/utils/eeg/neurosity.ts @@ -0,0 +1,133 @@ +/** + * Neurosity Crown driver. + * + * Mirrors the interface of muse.ts so that deviceEpics can swap between the + * two drivers based on `deviceType`. The Crown streams EEG as epochs (data is + * organized per-channel); we flatten to per-sample emissions to match the + * `EEGData` shape that the rest of the app expects. + */ +import { Neurosity } from '@neurosity/sdk'; +import { Observable, Subject } from 'rxjs'; +import { filter as rxFilter, map as rxMap, share } from 'rxjs/operators'; +import { + NEUROSITY_CHANNELS, + NEUROSITY_SAMPLING_RATE, +} from '../../constants/constants'; +import { Device, EEGData } from '../../constants/interfaces'; + +// A single SDK client per renderer (Crown BLE allows one consumer at a time). +// Constructing with `autoSelectDevice: false` keeps us responsible for +// explicit connect / disconnect via Web Bluetooth. +let neurosity: Neurosity | null = null; +let cachedDevice: BluetoothDevice | null = null; +let brainwavesSubscription: { unsubscribe: () => void } | null = null; +let markerSubject: Subject | null = null; + +const getClient = (): Neurosity => { + if (!neurosity) { + neurosity = new Neurosity({ + autoSelectDevice: false, + streamingMode: 'bluetooth-with-wifi-fallback' as unknown as undefined, + } as unknown as Parameters[0]); + } + return neurosity; +}; + +/** + * Initiate a Web Bluetooth scan for a Neurosity Crown. The main-process + * `select-bluetooth-device` handler picks the first matching device. + */ +export const getNeurosity = async (): Promise => { + const client = getClient(); + const device = await client.bluetooth.requestDevice(); + cachedDevice = device as unknown as BluetoothDevice; + return [{ id: (device as BluetoothDevice).id, name: (device as BluetoothDevice).name }]; +}; + +/** + * Connect to a previously discovered Crown and return a DeviceInfo describing + * its sampling rate and channel layout. + */ +export const connectToNeurosity = async (_device: Device) => { + const client = getClient(); + await client.bluetooth.connect(); + cachedDevice = null; + return { + name: 'Neurosity Crown', + samplingRate: NEUROSITY_SAMPLING_RATE, + channels: NEUROSITY_CHANNELS, + }; +}; + +export const disconnectFromNeurosity = async (): Promise => { + brainwavesSubscription?.unsubscribe(); + brainwavesSubscription = null; + markerSubject?.complete(); + markerSubject = null; + cachedDevice = null; + if (neurosity) { + try { + await neurosity.disconnect(); + } catch { + // best-effort teardown + } + } +}; + +export const cancelNeurosityScan = (): void => { + window.electronAPI?.cancelBluetoothSearch?.(); +}; + +/** + * Emits when the Crown transitions to OFFLINE. Used by deviceEpics to dispatch + * DeviceLost so Redux state and the UI can react to an unexpected disconnect. + */ +export const neurosityDisconnect$ = (): Observable => { + const client = getClient(); + return ( + client.status() as unknown as Observable<{ state: string }> + ).pipe( + rxFilter((s) => s?.state === 'offline'), + rxMap(() => undefined) + ); +}; + +/** + * Subscribe to `brainwaves('raw')` and flatten each Crown epoch into + * per-sample EEGData events, matching the shape of `createRawMuseObservable()`. + */ +export const createRawNeurosityObservable = async (): Promise< + Observable +> => { + const client = getClient(); + const subject = new Subject(); + markerSubject = subject; + + // brainwaves('raw') emits Epoch { data: number[][] (channels×samples), info } + const stream = client.brainwaves('raw') as unknown as Observable<{ + data: number[][]; + info: { samplingRate: number; startTime: number; channelNames?: string[] }; + }>; + + brainwavesSubscription = stream.subscribe({ + next: (epoch) => { + const { data, info } = epoch; + if (!data || data.length === 0) return; + const sampleCount = data[0].length; + const sampleIntervalMs = 1000 / (info.samplingRate || NEUROSITY_SAMPLING_RATE); + for (let i = 0; i < sampleCount; i++) { + const sample: number[] = []; + for (let c = 0; c < data.length; c++) { + sample.push(data[c][i]); + } + subject.next({ + data: sample, + timestamp: info.startTime + i * sampleIntervalMs, + }); + } + }, + error: (err) => subject.error(err), + }); + + return subject.asObservable().pipe(share()) as Observable; +}; diff --git a/src/renderer/utils/eeg/pipes.ts b/src/renderer/utils/eeg/pipes.ts index 9d4bba13..d5f1a149 100644 --- a/src/renderer/utils/eeg/pipes.ts +++ b/src/renderer/utils/eeg/pipes.ts @@ -29,27 +29,3 @@ export const parseMuseSignalQuality = () => ), })) ); - -export const parseEmotivSignalQuality = () => - pipe( - map((epoch: PipesEpoch) => ({ - ...epoch, - signalQuality: Object.assign( - {}, - ...Object.entries(epoch.signalQuality).map( - ([channelName, signalQuality]) => { - if (signalQuality === 0) { - return { [channelName]: SIGNAL_QUALITY.DISCONNECTED }; - } - if (signalQuality === 3) { - return { [channelName]: SIGNAL_QUALITY.OK }; - } - if (signalQuality === 4) { - return { [channelName]: SIGNAL_QUALITY.GREAT }; - } - return { [channelName]: SIGNAL_QUALITY.BAD }; - } - ) - ), - })) - ); diff --git a/src/renderer/utils/webworker/webworker.js b/src/renderer/utils/webworker/webworker.js index bc471302..2c6b1d95 100644 --- a/src/renderer/utils/webworker/webworker.js +++ b/src/renderer/utils/webworker/webworker.js @@ -40,11 +40,15 @@ const pyodideReadyPromise = (async () => { const lockFileURL = URL.createObjectURL(lockBlob); // packageBaseUrl tells pyodide's PackageManager where to fetch .whl files. - // This is the correct option — NOT indexURL, which is for the runtime files - // (WASM, stdlib) that are already loaded via import.meta.url from node_modules. + // indexURL is where pyodide loads its runtime files (pyodide.asm.wasm, + // python_stdlib.zip). In dev, pyodide.mjs is imported from /@fs/.../node_modules + // and its sibling assets are served by Vite middleware. In prod the bundled + // .mjs lives in out/renderer/assets/ without its siblings, so import.meta.url + // resolution fails — we route both through our pyodide:// protocol handler. const packageBaseUrl = `${PYODIDE_ASSET_BASE}/pyodide/`; + const indexURL = `${PYODIDE_ASSET_BASE}/pyodide/`; - const pyodide = await loadPyodide({ lockFileURL, packageBaseUrl }); + const pyodide = await loadPyodide({ lockFileURL, packageBaseUrl, indexURL }); URL.revokeObjectURL(lockFileURL); // Load scientific packages from local whl files via the asset server. diff --git a/src/renderer/vite-env.d.ts b/src/renderer/vite-env.d.ts index 219d772b..dfc29e7c 100644 --- a/src/renderer/vite-env.d.ts +++ b/src/renderer/vite-env.d.ts @@ -1,9 +1,6 @@ /// interface ImportMetaEnv { - readonly VITE_CLIENT_ID: string; - readonly VITE_CLIENT_SECRET: string; - readonly VITE_LICENSE_ID: string; readonly VITE_LOG_LEVEL: string; } diff --git a/src/shared/lslTypes.ts b/src/shared/lslTypes.ts new file mode 100644 index 00000000..fbbe6f29 --- /dev/null +++ b/src/shared/lslTypes.ts @@ -0,0 +1,53 @@ +/** + * Shared LSL types. Imported by both src/main/lsl/ and src/renderer/. + */ + +export interface LSLEpoch { + deviceId: string; + deviceType: 'muse' | 'neurosity'; + /** [sampleIndex][channelIndex], µV */ + samples: number[][]; + /** one per sample (ms, performance.now()) */ + timestamps: number[]; + channelNames: string[]; + sampleRate: number; +} + +export interface LSLMarker { + /** e.g. 'stimulus_onset', '1', '2' */ + label: string; + /** performance.now() at event time */ + rendererTimestamp: number; +} + +export interface DiscoveredStream { + uid: string; + name: string; + /** 'EEG', 'Markers', etc. */ + type: string; + channelCount: number; + sampleRate: number; + sourceId: string; +} + +export interface LSLInletEpoch { + uid: string; + samples: number[][]; + timestamps: number[]; +} + +export type LSLStatusKind = + | 'outlet-error' + | 'marker-error' + | 'discovery-error' + | 'inlet-error'; + +/** + * Emitted from the main process when an LSL operation fails. The renderer + * surfaces these as user-visible toasts so silent failures in the native FFI + * layer don't go unnoticed during an experiment. + */ +export interface LSLStatus { + kind: LSLStatusKind; + message: string; +} diff --git a/tsconfig.json b/tsconfig.json index 8e66b0b8..bd9b4c52 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,7 +19,8 @@ "paths": { "@renderer/*": ["src/renderer/*"], "@main/*": ["src/main/*"], - "@preload/*": ["src/preload/*"] + "@preload/*": ["src/preload/*"], + "@shared/*": ["src/shared/*"] } }, "include": ["src/**/*", "electron.d.ts"], diff --git a/vite.config.ts b/vite.config.ts index 4303efff..24b2ad9d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -29,6 +29,7 @@ export default defineConfig({ alias: { '@main': path.resolve(__dirname, 'src/main'), '@renderer': path.resolve(__dirname, 'src/renderer'), + '@shared': path.resolve(__dirname, 'src/shared'), }, }, }, @@ -68,6 +69,7 @@ export default defineConfig({ resolve: { alias: { '@renderer': path.resolve(__dirname, 'src/renderer'), + '@shared': path.resolve(__dirname, 'src/shared'), // Browser-compatible path utilities (pathe = modern drop-in for Node's path) path: 'pathe', events: 'events',