From 02b6fec87692946b456894e8c293cf9d5fa093a3 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 12 Apr 2026 13:45:59 -0400 Subject: [PATCH 01/25] remove Emotiv/Cortex device support Deletes cortex.js and emotiv.ts entirely. Removes all Emotiv branches from device epics, experiment epics, pyodide epics, components, and constants. DEVICES.EMOTIV, EMOTIV_CHANNELS, parseEmotivSignalQuality, and Cortex credential env vars are all gone. Muse is now the only supported device, laying the groundwork for LSL-based connectivity. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 1 - src/renderer/components/AnalyzeComponent.tsx | 5 +- .../CollectComponent/ConnectModal.tsx | 2 - .../CollectComponent/PreTestComponent.tsx | 3 - .../CollectComponent/RunComponent.tsx | 13 +- .../components/CollectComponent/index.tsx | 4 - .../components/EEGExplorationComponent.tsx | 2 - src/renderer/components/ViewerComponent.tsx | 14 +- src/renderer/constants/constants.ts | 38 +--- src/renderer/epics/deviceEpics.ts | 69 +----- src/renderer/epics/experimentEpics.ts | 23 +- src/renderer/epics/pyodideEpics.ts | 22 +- src/renderer/reducers/deviceReducer.ts | 2 +- src/renderer/utils/eeg/cortex.js | 204 ------------------ src/renderer/utils/eeg/emotiv.ts | 189 ---------------- src/renderer/utils/eeg/pipes.ts | 24 --- src/renderer/vite-env.d.ts | 3 - 17 files changed, 27 insertions(+), 591 deletions(-) delete mode 100644 src/renderer/utils/eeg/cortex.js delete mode 100644 src/renderer/utils/eeg/emotiv.ts diff --git a/package.json b/package.json index c6c65e08..26c98f02 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,6 @@ "redux", "redux-observable", "muse", - "emotiv", "pyodide", "wasm", "lab.js" 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..568370b2 100644 --- a/src/renderer/components/CollectComponent/ConnectModal.tsx +++ b/src/renderer/components/CollectComponent/ConnectModal.tsx @@ -4,7 +4,6 @@ import { isNil, debounce } from 'lodash'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog'; import { Button } from '../ui/button'; import { - DEVICES, DEVICE_AVAILABILITY, CONNECTION_STATUS, SCREENS, @@ -17,7 +16,6 @@ interface Props { onClose: () => void; connectedDevice: Record; signalQualityObservable?: Observable; - deviceType: DEVICES; deviceAvailability: DEVICE_AVAILABILITY; connectionStatus: CONNECTION_STATUS; DeviceActions: typeof DeviceActions; 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..e511471b 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,10 @@ 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); } }, - [isEEGEnabled, deviceType] + [isEEGEnabled] ); const onFinish = useCallback( diff --git a/src/renderer/components/CollectComponent/index.tsx b/src/renderer/components/CollectComponent/index.tsx index c02b1b27..89ef9f0a 100644 --- a/src/renderer/components/CollectComponent/index.tsx +++ b/src/renderer/components/CollectComponent/index.tsx @@ -2,7 +2,6 @@ import { Observable } from 'rxjs'; import React, { Component } from 'react'; import { EXPERIMENTS, - DEVICES, CONNECTION_STATUS, DEVICE_AVAILABILITY, } from '../../constants/constants'; @@ -20,7 +19,6 @@ import { ExperimentActions, DeviceActions } from '../../actions'; export interface Props { ExperimentActions: typeof ExperimentActions; connectedDevice: Record; - deviceType: DEVICES; deviceAvailability: DEVICE_AVAILABILITY; connectionStatus: CONNECTION_STATUS; DeviceActions: typeof DeviceActions; @@ -103,7 +101,6 @@ 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} DeviceActions={this.props.DeviceActions} @@ -112,7 +109,6 @@ export default class Collect extends Component { {
@@ -111,7 +110,6 @@ 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} DeviceActions={this.props.DeviceActions} diff --git a/src/renderer/components/ViewerComponent.tsx b/src/renderer/components/ViewerComponent.tsx index 71fbcab7..33d6be79 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; @@ -73,14 +69,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; } diff --git a/src/renderer/constants/constants.ts b/src/renderer/constants/constants.ts index 8796f7f0..ee888343 100644 --- a/src/renderer/constants/constants.ts +++ b/src/renderer/constants/constants.ts @@ -23,7 +23,6 @@ export const SCREENS = { export enum DEVICES { NONE = 'NONE', MUSE = 'MUSE', - EMOTIV = 'EMOTIV', GANGLION = 'GANGLION', // One day ;) } @@ -65,21 +64,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,23 +72,6 @@ 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']; export const ZOOM_SCALAR = 1.5; @@ -142,3 +109,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/epics/deviceEpics.ts b/src/renderer/epics/deviceEpics.ts index 902fae16..96fd0c56 100644 --- a/src/renderer/epics/deviceEpics.ts +++ b/src/renderer/epics/deviceEpics.ts @@ -5,13 +5,6 @@ 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, @@ -54,36 +47,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$ @@ -129,11 +92,7 @@ const connectEpic: Epic = ( action$.pipe( filter(isActionOf(DeviceActions.ConnectToDevice)), pluck('payload'), - map((device) => - (isNil(device.name) - ? connectToEmotiv(device) - : connectToMuse(device)) as Promise - ), + map((device) => connectToMuse(device) as Promise), mergeMap((promise) => promise.then((deviceInfo) => deviceInfo)), // eslint-disable-next-line @typescript-eslint/no-explicit-any mergeMap>((deviceInfo) => { @@ -141,9 +100,7 @@ const connectEpic: Epic = ( if (deviceInfo != null && deviceInfo.samplingRate != null) { console.log(deviceInfo); return of( - DeviceActions.SetDeviceType( - deviceInfo.name.includes('Muse') ? DEVICES.MUSE : DEVICES.EMOTIV - ), + DeviceActions.SetDeviceType(DEVICES.MUSE), DeviceActions.SetDeviceInfo(deviceInfo), DeviceActions.SetConnectionStatus(CONNECTION_STATUS.CONNECTED) ); @@ -170,12 +127,7 @@ 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(createRawMuseObservable())), map(DeviceActions.SetRawObservable) ); @@ -187,15 +139,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,9 +160,6 @@ const deviceCleanupEpic: Epic = ( CONNECTION_STATUS.NOT_YET_CONNECTED ), map(() => { - if (state$.value.device.deviceType === DEVICES.EMOTIV) { - disconnectFromEmotiv(); - } disconnectFromMuse(); }), map(DeviceActions.Cleanup) @@ -221,7 +167,6 @@ const deviceCleanupEpic: Epic = ( export default combineEpics( searchMuseEpic, - searchEmotivEpic, deviceFoundEpic, searchTimerEpic, connectEpic, diff --git a/src/renderer/epics/experimentEpics.ts b/src/renderer/epics/experimentEpics.ts index ee8855d9..dcc5f773 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'; @@ -76,19 +73,7 @@ const startEpic = (action$, state$) => if (!streamId) { return true; } - writeHeader( - streamId, - state$.value.device.deviceType === DEVICES.EMOTIV - ? EMOTIV_CHANNELS - : MUSE_CHANNELS - ); - - if (state$.value.device.deviceType === DEVICES.EMOTIV) { - createEmotivRecord( - state$.value.experiment.subject, - state$.value.experiment.session - ); - } + writeHeader(streamId, MUSE_CHANNELS); state$.value.device.rawObservable .pipe( @@ -128,12 +113,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..c9c18cc8 100644 --- a/src/renderer/reducers/deviceReducer.ts +++ b/src/renderer/reducers/deviceReducer.ts @@ -32,7 +32,7 @@ const initialState: DeviceStateType = { deviceAvailability: DEVICE_AVAILABILITY.NONE, rawObservable: null, signalQualityObservable: null, - deviceType: DEVICES.EMOTIV, + deviceType: DEVICES.MUSE, }; export default createReducer(initialState, (builder) => 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/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/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; } From b8f96732b37d2ff11c7cb712f8fc07740a94680b Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 12 Apr 2026 14:24:07 -0400 Subject: [PATCH 02/25] fix: handle Electron Bluetooth device selection Electron 22+ no longer shows a native Bluetooth picker automatically. Instead it fires select-bluetooth-device on webContents, requiring the main process to call the callback with a deviceId. Without this handler requestDevice() hung silently, leaving the search in a perpetual SEARCHING state. Changes: - main/index.ts: register select-bluetooth-device handler that auto-selects the first Muse headset as BLE discovery progresses; add bluetooth:cancelSearch IPC handler so the renderer can reject a pending requestDevice() on timeout - preload/index.ts: expose cancelBluetoothSearch() to renderer - muse.ts: cache BluetoothDevice from getMuse() so connectToMuse() reuses it instead of firing a redundant requestDevice() call; add cancelMuseScan() - deviceEpics.ts: call cancelMuseScan() in searchTimerEpic so the pending requestDevice() promise is cleaned up when the 3s search window expires - docs/device-connectivity.md: full connectivity flow diagram and bug analysis Co-Authored-By: Claude Sonnet 4.6 --- docs/device-connectivity.md | 213 ++++++++++++++++++++++++++++++ src/main/index.ts | 31 +++++ src/preload/index.ts | 9 ++ src/renderer/epics/deviceEpics.ts | 4 + src/renderer/utils/eeg/muse.ts | 33 ++++- 5 files changed, 284 insertions(+), 6 deletions(-) create mode 100644 docs/device-connectivity.md 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/src/main/index.ts b/src/main/index.ts index 244ef56e..bf34d1dc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -87,6 +87,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) // ------------------------------------------------------------------ @@ -437,6 +442,14 @@ 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; + } +}); + // Resource path (for experiment file loading) ipcMain.handle('getResourcePath', () => { return is.dev @@ -480,6 +493,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 { diff --git a/src/preload/index.ts b/src/preload/index.ts index 28684487..ba51b2b2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -17,6 +17,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 @@ -163,4 +166,10 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('oauth:callback', handler); return () => ipcRenderer.removeListener('oauth:callback', handler); }, + + // ------------------------------------------------------------------ + // Bluetooth — search cancellation + // ------------------------------------------------------------------ + cancelBluetoothSearch: (): Promise => + ipcRenderer.invoke('bluetooth:cancelSearch'), }); diff --git a/src/renderer/epics/deviceEpics.ts b/src/renderer/epics/deviceEpics.ts index 96fd0c56..15208c4a 100644 --- a/src/renderer/epics/deviceEpics.ts +++ b/src/renderer/epics/deviceEpics.ts @@ -11,6 +11,7 @@ import { createRawMuseObservable, createMuseSignalQualityObservable, disconnectFromMuse, + cancelMuseScan, } from '../utils/eeg/muse'; import { CONNECTION_STATUS, @@ -83,6 +84,9 @@ 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(() => cancelMuseScan()), map(() => DeviceActions.SetDeviceAvailability(DEVICE_AVAILABILITY.NONE)) ); diff --git a/src/renderer/utils/eeg/muse.ts b/src/renderer/utils/eeg/muse.ts index 27ba3024..6e53c546 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,17 @@ export const connectToMuse = async (device: Device) => { }; }; -export const disconnectFromMuse = () => client.disconnect(); +export const disconnectFromMuse = () => { + cachedDevice = null; + client.disconnect(); +}; + +// 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 () => { From 7cd894f67b196f64394cfd8d22439c9ef67a711a Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 12 Apr 2026 14:29:53 -0400 Subject: [PATCH 03/25] fix: webview dom-ready listener not attached in EEG viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In React 18, setState is always batched — calling setState in an async componentDidMount continuation schedules a re-render but does not immediately commit the DOM change. The subsequent querySelector('webview') therefore returned null, the dom-ready listener was never attached, and subscribeToObservable was never called. Fix: defer webview setup to componentDidUpdate, triggered when viewerUrl transitions from empty to set. At that point React has already committed the DOM update, so the webview element exists. Because componentDidUpdate runs synchronously before the browser event loop can process the webview load, the dom-ready listener is in place before it fires. This fixes signal not flowing on the Explore EEG screen when navigating to it while already connected. Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/components/ViewerComponent.tsx | 40 ++++++++++++--------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/renderer/components/ViewerComponent.tsx b/src/renderer/components/ViewerComponent.tsx index 33d6be79..6fea6a6d 100644 --- a/src/renderer/components/ViewerComponent.tsx +++ b/src/renderer/components/ViewerComponent.tsx @@ -44,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 && @@ -78,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'); } From dfaae6e4a4e1e507f437829e0f46b62f16843903 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 12 Apr 2026 14:46:18 -0400 Subject: [PATCH 04/25] Add lsl implementation plan --- .gitignore | 3 + docs/lsl-implementation-plan.md | 467 ++++++++++++++++++++++++++++++++ 2 files changed, 470 insertions(+) create mode 100644 docs/lsl-implementation-plan.md 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/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) | From 51ea18a007eec7b376710ab358a431fcfd7d1178 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sat, 18 Apr 2026 10:55:45 -0400 Subject: [PATCH 05/25] feat: LSL integration phases 1-4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Lab Streaming Layer support so BrainWaves can publish EEG and stimulus markers as LSL streams and ingest data from external LSL devices. Enables LabRecorder integration and multi-device experiments. Phase 1: Main-process LSLOutletManager + IPC bridge forwards batched Muse EEG samples as an LSL outlet. Adds @shared alias, asarUnpack for native bindings, and fixes MUSE_CHANNELS hardcoding in experimentEpics. Phase 2: Neurosity Crown SDK support — getNeurosity/connectToNeurosity mirror the Muse driver; deviceEpics route by deviceType. Phase 3: LSLInletManager + UI to discover and connect to external LSL streams. lslForwardEpic skips LSL inlet sources to avoid feedback loops. Phase 4: RunComponent emits stimulus markers via sendLSLMarker alongside the existing injectMuseMarker call, preserving the CSV-embedded marker path used by the Pyodide analysis pipeline. Co-Authored-By: Claude Opus 4.7 --- package-lock.json | 1002 ++++++++++++++++- package.json | 7 + src/main/index.ts | 51 + src/main/lsl/inlets.ts | 118 ++ src/main/lsl/outlets.ts | 121 ++ src/preload/index.ts | 41 + src/renderer/actions/deviceActions.ts | 13 + .../CollectComponent/ConnectModal.tsx | 69 ++ .../CollectComponent/RunComponent.tsx | 4 + .../components/CollectComponent/index.tsx | 6 + .../components/EEGExplorationComponent.tsx | 4 + src/renderer/constants/constants.ts | 17 + src/renderer/epics/deviceEpics.ts | 148 ++- src/renderer/epics/experimentEpics.ts | 5 +- src/renderer/reducers/deviceReducer.ts | 7 + src/renderer/types/electron.d.ts | 117 ++ src/renderer/utils/eeg/lslBridge.ts | 45 + src/renderer/utils/eeg/lslInlet.ts | 81 ++ src/renderer/utils/eeg/neurosity.ts | 119 ++ src/shared/lslTypes.ts | 37 + tsconfig.json | 3 +- vite.config.ts | 2 + 22 files changed, 1969 insertions(+), 48 deletions(-) create mode 100644 src/main/lsl/inlets.ts create mode 100644 src/main/lsl/outlets.ts create mode 100644 src/renderer/types/electron.d.ts create mode 100644 src/renderer/utils/eeg/lslBridge.ts create mode 100644 src/renderer/utils/eeg/lslInlet.ts create mode 100644 src/renderer/utils/eeg/neurosity.ts create mode 100644 src/shared/lslTypes.ts diff --git a/package-lock.json b/package-lock.json index 6d7992b3..a24bd23d 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": { @@ -5220,7 +5988,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" @@ -5230,7 +5997,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", @@ -5802,7 +6568,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": { @@ -5878,6 +6643,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", @@ -5924,7 +6700,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", @@ -6334,7 +7109,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", @@ -6561,7 +7335,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", @@ -6576,7 +7349,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", @@ -6641,7 +7413,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" @@ -6733,7 +7504,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" @@ -7868,7 +8638,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" @@ -8162,7 +8931,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", @@ -9165,7 +9933,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" @@ -9175,7 +9942,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" @@ -9220,7 +9986,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" @@ -9233,7 +9998,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", @@ -9382,7 +10146,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" @@ -10221,6 +10984,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", @@ -10299,6 +11074,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", @@ -10329,6 +11140,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", @@ -10394,10 +11225,16 @@ } }, "node_modules/form-data": { +<<<<<<< HEAD "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, +======= + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", +>>>>>>> e718800 (feat: LSL integration phases 1-4) "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -10597,7 +11434,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.*" @@ -10626,7 +11462,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", @@ -10660,7 +11495,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", @@ -11148,7 +11982,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" @@ -11269,7 +12102,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" @@ -11282,7 +12114,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" @@ -11398,6 +12229,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", @@ -11486,6 +12323,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", @@ -11860,7 +12703,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" @@ -12515,6 +13357,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", @@ -12828,6 +13680,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", @@ -13025,6 +13883,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", @@ -13275,7 +14139,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" @@ -13362,7 +14225,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" @@ -13372,7 +14234,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" @@ -13948,6 +14809,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", @@ -14268,6 +15141,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", @@ -15081,12 +15959,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.1", "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", "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", @@ -15762,7 +16673,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" @@ -16180,7 +17090,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", @@ -16819,7 +17728,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", @@ -16857,7 +17765,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": { @@ -16966,7 +17873,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" @@ -18736,6 +19642,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", @@ -18755,6 +19667,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", @@ -19111,7 +20046,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" @@ -19144,7 +20078,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", @@ -19163,7 +20096,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 26c98f02..7604a529 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,11 @@ "schemes": ["brainwaves"] } ], + "asarUnpack": [ + "**/*.node", + "node_modules/node-labstreaminglayer/prebuild/**", + "node_modules/koffi/**/*.node" + ], "files": [ "out/**/*", "node_modules/**/*", @@ -191,6 +196,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", @@ -210,6 +216,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 bf34d1dc..975bef7c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,6 +17,9 @@ 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 } from '../shared/lslTypes'; // Needed for WASM/SharedArrayBuffer support (pyodide) app.commandLine.appendSwitch( @@ -450,6 +453,49 @@ ipcMain.handle('bluetooth:cancelSearch', () => { } }); +// ------------------------------------------------------------------ +// LSL — outlets push to the LSL network, markers are an event stream +// ------------------------------------------------------------------ +ipcMain.on('lsl:sendEpoch', (_event, epoch: LSLEpoch) => { + try { + lslOutlets.pushEpoch(epoch); + } catch (err) { + log.error('[lsl] pushEpoch failed', err); + } +}); + +ipcMain.on('lsl:sendMarker', (_event, marker: LSLMarker) => { + try { + lslOutlets.pushMarker(marker.label); + } catch (err) { + log.error('[lsl] pushMarker failed', err); + } +}); + +ipcMain.handle('lsl:discoverStreams', () => { + try { + return lslInlets.discoverStreams(1.0); + } catch (err) { + log.error('[lsl] discoverStreams failed', err); + return []; + } +}); + +ipcMain.on('lsl:subscribeStream', (_event, payload: { uid: string }) => { + lslInlets.subscribeStream( + payload.uid, + (epoch) => mainWindow?.webContents.send('lsl:inletData', epoch), + () => + mainWindow?.webContents.send('lsl:inletDisconnected', { + uid: payload.uid, + }) + ); +}); + +ipcMain.on('lsl:unsubscribeStream', (_event, payload: { uid: string }) => { + lslInlets.unsubscribeStream(payload.uid); +}); + // Resource path (for experiment file loading) ipcMain.handle('getResourcePath', () => { return is.dev @@ -556,6 +602,11 @@ 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. diff --git a/src/main/lsl/inlets.ts b/src/main/lsl/inlets.ts new file mode 100644 index 00000000..36d840dd --- /dev/null +++ b/src/main/lsl/inlets.ts @@ -0,0 +1,118 @@ +/** + * 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 + +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 timer = setInterval(() => { + try { + const [samples, timestamps] = inlet.pullChunk(0); + if (samples && samples.length > 0 && timestamps.length > 0) { + onData({ uid, samples, timestamps }); + } + } 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 ba51b2b2..ce65b505 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,6 +5,12 @@ * 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, +} 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) @@ -172,4 +178,39 @@ contextBridge.exposeInMainWorld('electronAPI', { // ------------------------------------------------------------------ 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); + }, }); diff --git a/src/renderer/actions/deviceActions.ts b/src/renderer/actions/deviceActions.ts index 52c2d07b..9f1237ab 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,18 @@ export const DeviceActions = { 'SET_SIGNAL_OBSERVABLE' ), Cleanup: createAction('CLEANUP'), + + // 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/CollectComponent/ConnectModal.tsx b/src/renderer/components/CollectComponent/ConnectModal.tsx index 568370b2..7ff48383 100644 --- a/src/renderer/components/CollectComponent/ConnectModal.tsx +++ b/src/renderer/components/CollectComponent/ConnectModal.tsx @@ -6,10 +6,12 @@ import { Button } from '../ui/button'; import { 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; @@ -18,8 +20,10 @@ interface Props { signalQualityObservable?: Observable; deviceAvailability: DEVICE_AVAILABILITY; connectionStatus: CONNECTION_STATUS; + deviceType: DEVICES; DeviceActions: typeof DeviceActions; availableDevices: Array; + availableLSLStreams?: Array; } interface State { @@ -86,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 (
    @@ -131,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/RunComponent.tsx b/src/renderer/components/CollectComponent/RunComponent.tsx index e511471b..3e4af26f 100644 --- a/src/renderer/components/CollectComponent/RunComponent.tsx +++ b/src/renderer/components/CollectComponent/RunComponent.tsx @@ -73,6 +73,10 @@ const Run: React.FC = ({ (event: string, time: number) => { if (isEEGEnabled) { injectMuseMarker(event, time); + window.electronAPI.sendLSLMarker({ + label: event, + rendererTimestamp: performance.now(), + }); } }, [isEEGEnabled] diff --git a/src/renderer/components/CollectComponent/index.tsx b/src/renderer/components/CollectComponent/index.tsx index 89ef9f0a..a7584007 100644 --- a/src/renderer/components/CollectComponent/index.tsx +++ b/src/renderer/components/CollectComponent/index.tsx @@ -4,6 +4,7 @@ import { EXPERIMENTS, CONNECTION_STATUS, DEVICE_AVAILABILITY, + DEVICES, } from '../../constants/constants'; import { ExperimentParameters, @@ -11,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'; @@ -21,8 +23,10 @@ export interface Props { connectedDevice: Record; deviceAvailability: DEVICE_AVAILABILITY; connectionStatus: CONNECTION_STATUS; + deviceType: DEVICES; DeviceActions: typeof DeviceActions; availableDevices: Array; + availableLSLStreams: Array; type: EXPERIMENTS; experimentObject: ExperimentObject; signalQualityObservable: Observable | null | undefined; @@ -103,8 +107,10 @@ export default class Collect extends Component { signalQualityObservable={this.props.signalQualityObservable ?? undefined} 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 { @@ -112,8 +114,10 @@ export default class Home extends Component { signalQualityObservable={this.props.signalQualityObservable} 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/constants/constants.ts b/src/renderer/constants/constants.ts index ee888343..f2e32f8d 100644 --- a/src/renderer/constants/constants.ts +++ b/src/renderer/constants/constants.ts @@ -23,6 +23,8 @@ export const SCREENS = { export enum DEVICES { NONE = 'NONE', MUSE = 'MUSE', + NEUROSITY = 'NEUROSITY', + LSL = 'LSL', // external LSL inlet stream GANGLION = 'GANGLION', // One day ;) } @@ -74,6 +76,21 @@ export const CHANNELS = { 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; diff --git a/src/renderer/epics/deviceEpics.ts b/src/renderer/epics/deviceEpics.ts index 15208c4a..f5eec052 100644 --- a/src/renderer/epics/deviceEpics.ts +++ b/src/renderer/epics/deviceEpics.ts @@ -1,6 +1,14 @@ 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'; @@ -13,6 +21,20 @@ import { disconnectFromMuse, cancelMuseScan, } from '../utils/eeg/muse'; +import { + getNeurosity, + connectToNeurosity, + createRawNeurosityObservable, + disconnectFromNeurosity, + cancelNeurosityScan, +} from '../utils/eeg/neurosity'; +import { + discoverLSLStreams, + connectToLSLInlet, + createRawLSLInletObservable, + disconnectFromLSLInlet, +} from '../utils/eeg/lslInlet'; +import { batchSamplesToEpoch, sendEpoch } from '../utils/eeg/lslBridge'; import { CONNECTION_STATUS, DEVICES, @@ -27,17 +49,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 []; @@ -86,25 +113,37 @@ const searchTimerEpic: Epic = ( ), // Cancel the pending requestDevice() promise in the main process so it // doesn't hang after the search window closes. - tap(() => cancelMuseScan()), + 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) => connectToMuse(device) as Promise), + map((device) => + 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 mergeMap>((deviceInfo) => { // 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(DEVICES.MUSE), + DeviceActions.SetDeviceType(state$.value.device.deviceType), DeviceActions.SetDeviceInfo(deviceInfo), DeviceActions.SetConnectionStatus(CONNECTION_STATUS.CONNECTED) ); @@ -131,7 +170,13 @@ const setRawObservableEpic: Epic< > = (action$, state$) => action$.pipe( filter(isActionOf(DeviceActions.SetDeviceInfo)), - mergeMap(() => from(createRawMuseObservable())), + mergeMap(() => + from( + state$.value.device.deviceType === DEVICES.NEUROSITY + ? createRawNeurosityObservable() + : createRawMuseObservable() + ) + ), map(DeviceActions.SetRawObservable) ); @@ -164,11 +209,89 @@ const deviceCleanupEpic: Epic = ( CONNECTION_STATUS.NOT_YET_CONNECTED ), map(() => { - disconnectFromMuse(); + const dt = state$.value.device.deviceType; + if (dt === DEVICES.NEUROSITY) { + void disconnectFromNeurosity(); + } else if (dt === DEVICES.LSL) { + disconnectFromLSLInlet(); + } else { + disconnectFromMuse(); + } }), 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, deviceFoundEpic, @@ -177,5 +300,8 @@ export default combineEpics( isConnectingEpic, setRawObservableEpic, setSignalQualityObservableEpic, + lslForwardEpic, + discoverLSLStreamsEpic, + connectToLSLStreamEpic, deviceCleanupEpic ); diff --git a/src/renderer/epics/experimentEpics.ts b/src/renderer/epics/experimentEpics.ts index dcc5f773..d56e8376 100644 --- a/src/renderer/epics/experimentEpics.ts +++ b/src/renderer/epics/experimentEpics.ts @@ -73,7 +73,10 @@ const startEpic = (action$, state$) => if (!streamId) { return true; } - writeHeader(streamId, MUSE_CHANNELS); + writeHeader( + streamId, + state$.value.device.connectedDevice?.channels ?? MUSE_CHANNELS + ); state$.value.device.rawObservable .pipe( diff --git a/src/renderer/reducers/deviceReducer.ts b/src/renderer/reducers/deviceReducer.ts index c9c18cc8..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,6 +29,7 @@ export interface DeviceStateType { const initialState: DeviceStateType = { availableDevices: [], + availableLSLStreams: [], connectedDevice: { name: 'disconnected', samplingRate: 0, channels: [] }, connectionStatus: CONNECTION_STATUS.NOT_YET_CONNECTED, deviceAvailability: DEVICE_AVAILABILITY.NONE, @@ -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..75303a00 --- /dev/null +++ b/src/renderer/types/electron.d.ts @@ -0,0 +1,117 @@ +/** + * 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, +} 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; + } + + interface Window { + electronAPI: ElectronAPI; + } +} 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/neurosity.ts b/src/renderer/utils/eeg/neurosity.ts new file mode 100644 index 00000000..27dd5855 --- /dev/null +++ b/src/renderer/utils/eeg/neurosity.ts @@ -0,0 +1,119 @@ +/** + * 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 { 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?.(); +}; + +/** + * 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/shared/lslTypes.ts b/src/shared/lslTypes.ts new file mode 100644 index 00000000..250850ef --- /dev/null +++ b/src/shared/lslTypes.ts @@ -0,0 +1,37 @@ +/** + * 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[]; +} 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 033c2e52..d0fb734d 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', From 52dcde8c526d7e1949aef92c79fdbb74fc8ac5ee Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sat, 18 Apr 2026 11:11:16 -0400 Subject: [PATCH 06/25] phase 5 in progress --- .worktrees/modernization | 1 + src/main/index.ts | 49 ++++++++++++++++++++++++--- src/main/lsl/inlets.ts | 38 ++++++++++++++++++++- src/preload/index.ts | 7 ++++ src/renderer/actions/deviceActions.ts | 1 + src/renderer/epics/deviceEpics.ts | 45 +++++++++++++++++++++++- src/renderer/types/electron.d.ts | 2 ++ src/renderer/utils/eeg/muse.ts | 19 +++++++++++ src/renderer/utils/eeg/neurosity.ts | 16 ++++++++- src/shared/lslTypes.ts | 16 +++++++++ 10 files changed, 187 insertions(+), 7 deletions(-) create mode 160000 .worktrees/modernization 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/src/main/index.ts b/src/main/index.ts index 975bef7c..f55704db 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -19,7 +19,12 @@ 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 } from '../shared/lslTypes'; +import type { + LSLEpoch, + LSLMarker, + LSLStatus, + LSLStatusKind, +} from '../shared/lslTypes'; // Needed for WASM/SharedArrayBuffer support (pyodide) app.commandLine.appendSwitch( @@ -456,11 +461,28 @@ ipcMain.handle('bluetooth:cancelSearch', () => { // ------------------------------------------------------------------ // 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}`, + }); } }); @@ -469,6 +491,10 @@ ipcMain.on('lsl:sendMarker', (_event, marker: LSLMarker) => { 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}`, + }); } }); @@ -477,19 +503,34 @@ ipcMain.handle('lsl:discoverStreams', () => { 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 }) => { - lslInlets.subscribeStream( + 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 }) => { diff --git a/src/main/lsl/inlets.ts b/src/main/lsl/inlets.ts index 36d840dd..9c375a1b 100644 --- a/src/main/lsl/inlets.ts +++ b/src/main/lsl/inlets.ts @@ -16,6 +16,18 @@ 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, @@ -71,11 +83,35 @@ class LSLInletManager { 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) { + 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); diff --git a/src/preload/index.ts b/src/preload/index.ts index ce65b505..4dd79e8a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,6 +10,7 @@ import type { LSLEpoch, LSLInletEpoch, LSLMarker, + LSLStatus, } from '../shared/lslTypes'; // Inject the resource path synchronously so renderer module-level code can use it @@ -213,4 +214,10 @@ contextBridge.exposeInMainWorld('electronAPI', { 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 9f1237ab..be07db4e 100644 --- a/src/renderer/actions/deviceActions.ts +++ b/src/renderer/actions/deviceActions.ts @@ -39,6 +39,7 @@ export const DeviceActions = { 'SET_SIGNAL_OBSERVABLE' ), Cleanup: createAction('CLEANUP'), + DeviceLost: createAction('DEVICE_LOST'), // External LSL inlet streams (Phase 3) DiscoverLSLStreams: createAction( diff --git a/src/renderer/epics/deviceEpics.ts b/src/renderer/epics/deviceEpics.ts index f5eec052..a1551be2 100644 --- a/src/renderer/epics/deviceEpics.ts +++ b/src/renderer/epics/deviceEpics.ts @@ -20,6 +20,7 @@ import { createMuseSignalQualityObservable, disconnectFromMuse, cancelMuseScan, + museDisconnect$, } from '../utils/eeg/muse'; import { getNeurosity, @@ -27,6 +28,7 @@ import { createRawNeurosityObservable, disconnectFromNeurosity, cancelNeurosityScan, + neurosityDisconnect$, } from '../utils/eeg/neurosity'; import { discoverLSLStreams, @@ -221,6 +223,45 @@ const deviceCleanupEpic: Epic = ( 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< @@ -303,5 +344,7 @@ export default combineEpics( lslForwardEpic, discoverLSLStreamsEpic, connectToLSLStreamEpic, - deviceCleanupEpic + deviceCleanupEpic, + deviceDisconnectWatchEpic, + deviceLostCleanupEpic ); diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 75303a00..f870d1d4 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -9,6 +9,7 @@ import type { LSLEpoch, LSLInletEpoch, LSLMarker, + LSLStatus, } from '../../shared/lslTypes'; export {}; @@ -109,6 +110,7 @@ declare global { onLSLInletDisconnected: ( handler: (payload: { uid: string }) => void ) => () => void; + onLSLStatus: (handler: (status: LSLStatus) => void) => () => void; } interface Window { diff --git a/src/renderer/utils/eeg/muse.ts b/src/renderer/utils/eeg/muse.ts index 6e53c546..9f8ed879 100644 --- a/src/renderer/utils/eeg/muse.ts +++ b/src/renderer/utils/eeg/muse.ts @@ -69,6 +69,25 @@ export const disconnectFromMuse = () => { 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 => { diff --git a/src/renderer/utils/eeg/neurosity.ts b/src/renderer/utils/eeg/neurosity.ts index 27dd5855..b7640d5e 100644 --- a/src/renderer/utils/eeg/neurosity.ts +++ b/src/renderer/utils/eeg/neurosity.ts @@ -8,7 +8,7 @@ */ import { Neurosity } from '@neurosity/sdk'; import { Observable, Subject } from 'rxjs'; -import { share } from 'rxjs/operators'; +import { filter as rxFilter, map as rxMap, share } from 'rxjs/operators'; import { NEUROSITY_CHANNELS, NEUROSITY_SAMPLING_RATE, @@ -78,6 +78,20 @@ 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()`. diff --git a/src/shared/lslTypes.ts b/src/shared/lslTypes.ts index 250850ef..fbbe6f29 100644 --- a/src/shared/lslTypes.ts +++ b/src/shared/lslTypes.ts @@ -35,3 +35,19 @@ export interface LSLInletEpoch { 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; +} From 8025400a5a4d816ab630309ecf41e7b1e2d704f8 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sat, 18 Apr 2026 17:28:57 -0400 Subject: [PATCH 07/25] feat: wire LSL status toasts in renderer Subscribes to lsl:status IPC at the App level and surfaces errors via react-toastify. Completes Phase 5 production hardening (decimation, BLE disconnect detection, error surfacing). Co-Authored-By: Claude Opus 4.7 --- src/renderer/containers/App.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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} From e69f218030a0a32aae91f43e7f072b1e563128d7 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 19 Apr 2026 10:04:13 -0400 Subject: [PATCH 08/25] fix: symlink arm64 liblsl on Apple Silicon node-labstreaminglayer 0.3.0 only ships an x86_64 liblsl.dylib in its prebuild dir, which fails to load on arm64 Macs. patchDeps.mjs now detects darwin-arm64 and symlinks the Homebrew-installed framework binary over the bundled stub. No-op on x64 macs, Linux, and Windows. Requires: brew install labstreaminglayer/tap/lsl Co-Authored-By: Claude Opus 4.7 --- .llms/learnings.md | 7 ++++ internals/scripts/patchDeps.mjs | 67 ++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/.llms/learnings.md b/.llms/learnings.md index ddf0af50..220ad280 100644 --- a/.llms/learnings.md +++ b/.llms/learnings.md @@ -81,6 +81,13 @@ In dev: use `/@fs` (Vite's `/@fs/` serving). In prod: use `file:///Frameworks/lsl.framework/Versions/A/lsl` over the bundled x86_64 dylib on every install/dev run. The patch is a no-op on x86_64 macs and on Linux/Windows (which ship usable `.so`/`.dll` in the same prebuild dir). + +Alternatives evaluated and rejected: `@neurodevs/node-lsl` and `@neurodevs/ndx-native` both require the same Homebrew install (they hard-code `/opt/homebrew/Cellar/lsl/...` paths) and have a much different async/worker-thread API that would force a substantial rewrite. ## Pre-existing TypeScript errors (do not treat as regressions) 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.'); From 6fc9e626b54bb2cdd986a92c6b700de540bd9a02 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 19 Apr 2026 10:08:18 -0400 Subject: [PATCH 09/25] docs: note liblsl Homebrew prereq for Apple Silicon Co-Authored-By: Claude Opus 4.7 --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) 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: From 21979254cf1faf9fe463a0199c7cda42b1571514 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 19 Apr 2026 10:26:10 -0400 Subject: [PATCH 10/25] fix: pyodide asset resolution in packaged builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes that together unbreak Pyodide in production: 1. Protocol handler in main was looking at resources/webworker/src/ but electron-builder copies pyodide assets to resources/pyodide/. Update pyodideRoot to match the actual extraResources destination. 2. Worker was relying on import.meta.url to find pyodide.asm.wasm and python_stdlib.zip relative to pyodide.mjs. That works in dev (Vite middleware serves siblings from node_modules) but fails in prod where the bundled .mjs has no siblings. Set indexURL so pyodide fetches runtime files through the pyodide:// protocol handler — works in both. Verified by installing the packaged dmg and running test plot. Co-Authored-By: Claude Opus 4.7 --- src/main/index.ts | 4 ++-- src/renderer/utils/webworker/webworker.js | 10 +++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index f55704db..94ec8a5c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -652,10 +652,10 @@ 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/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. From a5a7228b5cdf39e362b650a045c329001eeb1921 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 19 Apr 2026 10:29:14 -0400 Subject: [PATCH 11/25] =?UTF-8?q?docs:=20refresh=20pyodide=20learnings=20?= =?UTF-8?q?=E2=80=94=20protocol=20scheme=20+=20indexURL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace stale port-17173 http-server section with current pyodide:// protocol handler reality - Document the prod resourcesPath/pyodide/ extraResources destination - Add indexURL requirement for prod (siblings of pyodide.mjs aren't bundled, so import.meta.url resolution fails) — gotcha hit during packaging verification Co-Authored-By: Claude Opus 4.7 --- .llms/learnings.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.llms/learnings.md b/.llms/learnings.md index 220ad280..9d59a670 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) From 091cca1e1b1aabd28b04d4ae2d77ed5c9bb04a88 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Mon, 1 Jun 2026 16:08:24 -0400 Subject: [PATCH 12/25] updated liblsl and made deps patching more resilient --- internals/scripts/patchDeps.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/internals/scripts/patchDeps.mjs b/internals/scripts/patchDeps.mjs index 71e7f1b8..afff4be6 100644 --- a/internals/scripts/patchDeps.mjs +++ b/internals/scripts/patchDeps.mjs @@ -56,7 +56,15 @@ function fixLiblslArm64() { root, 'node_modules/node-labstreaminglayer/prebuild/liblsl.dylib' ); - if (!existsSync(bundled)) return; + // Use lstatSync (not existsSync) so a *dangling* symlink — e.g. one left + // pointing at a Homebrew Cellar version that has since been upgraded — is + // still detected and re-pointed below, rather than skipped. existsSync + // follows the link and would return false for a dangling link. + try { + lstatSync(bundled); + } catch { + return; + } let brewLib; try { From 35398697c3c1dd70550ec029142fcd62329a57f3 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Mon, 1 Jun 2026 16:23:26 -0400 Subject: [PATCH 13/25] chore: stop tracking .worktrees gitlink The .worktrees/modernization entry was an accidental submodule-style gitlink (mode 160000) pointing into a local git worktree. There is no .gitmodules and the worktree is not part of the repo tree, so it only produced perpetual 'modified' noise in git status. Untrack it and ignore .worktrees/ going forward. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 +++ .worktrees/modernization | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) delete mode 160000 .worktrees/modernization diff --git a/.gitignore b/.gitignore index 04eb471e..d45b0714 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ src/renderer/utils/webworker/src # Pyodide runtime + package wheels (downloaded or extracted locally; see docs/pyodide-in-electron-vite.md) src/renderer/utils/pyodide/src + +# Local git worktrees (not part of the repo tree) +.worktrees/ diff --git a/.worktrees/modernization b/.worktrees/modernization deleted file mode 160000 index f88eb4b7..00000000 --- a/.worktrees/modernization +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f88eb4b7710bf13ec05ff6f1140cb53c87767a0b From 3d3d82755b02a8918ca05735a3a520ea4c82c9e5 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Mon, 1 Jun 2026 17:35:35 -0400 Subject: [PATCH 14/25] feat: make LSL optional via lazy native load + feature detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node-labstreaminglayer dlopen's liblsl at require time, so a static import crashed the whole app on startup when liblsl was missing/ incompatible (e.g. Apple Silicon without the Homebrew build) — even for Muse-only users who never need LSL. Load the native bindings lazily and fail soft so LSL becomes a true advanced, opt-in feature: - src/main/lsl/native.ts: guarded require() in try/catch (memoized), exposing loadLSL() and isLSLAvailable() - outlets.ts/inlets.ts: type-only imports + loadLSL() at call time; all ops no-op gracefully when liblsl is unavailable - lsl:isAvailable IPC + preload bridge for renderer feature detection - ConnectModal hides 'External LSL stream' when unavailable - lslBridge no-ops sendEpoch/sendMarker when unavailable (no IPC spam) Result: Muse/Neurosity work with zero LSL/LabRecorder dependency; LSL features appear only where liblsl loads. Co-Authored-By: Claude Opus 4.8 --- .llms/learnings.md | 10 +++++ src/main/index.ts | 6 +++ src/main/lsl/inlets.ts | 16 ++++--- src/main/lsl/native.ts | 45 +++++++++++++++++++ src/main/lsl/outlets.ts | 22 ++++----- src/preload/index.ts | 4 ++ .../CollectComponent/ConnectModal.tsx | 15 ++++++- src/renderer/types/electron.d.ts | 1 + src/renderer/utils/eeg/lslBridge.ts | 11 +++++ 9 files changed, 112 insertions(+), 18 deletions(-) create mode 100644 src/main/lsl/native.ts diff --git a/.llms/learnings.md b/.llms/learnings.md index 9d59a670..24d9e06b 100644 --- a/.llms/learnings.md +++ b/.llms/learnings.md @@ -89,6 +89,16 @@ Any hook function (e.g. `initResponseHandlers` in `src/renderer/utils/labjs/func Alternatives evaluated and rejected: `@neurodevs/node-lsl` and `@neurodevs/ndx-native` both require the same Homebrew install (they hard-code `/opt/homebrew/Cellar/lsl/...` paths) and have a much different async/worker-thread API that would force a substantial rewrite. +## LSL is optional — load the native module lazily, never statically + +`node-labstreaminglayer` `dlopen`s liblsl via koffi **at require time**. A static `import … from 'node-labstreaminglayer'` in the main process therefore runs that dlopen during module evaluation at startup — so a missing/incompatible liblsl (e.g. Apple Silicon without the Homebrew build) crashes the *entire app* on launch, even for Muse-only users who don't need LSL at all. + +LSL is an advanced, opt-in capability: Muse and Neurosity connect via Web Bluetooth and record to CSV without it. So the native module must load lazily and fail soft: +- `src/main/lsl/native.ts` does a guarded `require('node-labstreaminglayer')` in try/catch (memoized), exposing `loadLSL()` (module | null) and `isLSLAvailable()`. `outlets.ts`/`inlets.ts` use **type-only** imports + `loadLSL()` at call time, no-opping when null. +- Feature-detect in the renderer via the `lsl:isAvailable` IPC: `ConnectModal` hides the "External LSL stream" option and `lslBridge` no-ops `sendEpoch`/`sendMarker` when unavailable (avoids IPC spam from first-party devices). + +Build note: with `module: ESNext` source but CommonJS main output, a guarded `require(...)` of an externalized dep type-checks (global `require` from `@types/node`) and stays a `require` in `out/main/index.js` (electron-vite externalizes it) — confirmed lazy, not bundled. Do **not** revert to a static import. + ## Pre-existing TypeScript errors (do not treat as regressions) - `src/renderer/epics/experimentEpics.ts` (lines 170, 205) — RxJS operator type mismatch diff --git a/src/main/index.ts b/src/main/index.ts index 94ec8a5c..8bc714c2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -19,6 +19,7 @@ import MenuBuilder from './menu'; import { FILE_TYPES } from '../renderer/constants/constants'; import { lslOutlets } from './lsl/outlets'; import { lslInlets } from './lsl/inlets'; +import { isLSLAvailable } from './lsl/native'; import type { LSLEpoch, LSLMarker, @@ -474,6 +475,11 @@ const emitLSLStatus = (status: LSLStatus) => { mainWindow?.webContents.send('lsl:status', status); }; +// Feature-detection probe: the renderer uses this to show/hide LSL UI. When +// liblsl can't be loaded, LSL is silently unavailable and the app falls back +// to first-party devices (Muse/Neurosity) only. +ipcMain.handle('lsl:isAvailable', () => isLSLAvailable()); + ipcMain.on('lsl:sendEpoch', (_event, epoch: LSLEpoch) => { try { lslOutlets.pushEpoch(epoch); diff --git a/src/main/lsl/inlets.ts b/src/main/lsl/inlets.ts index 9c375a1b..cbdff663 100644 --- a/src/main/lsl/inlets.ts +++ b/src/main/lsl/inlets.ts @@ -7,12 +7,9 @@ * pylsl, etc.). */ import log from 'electron-log'; -import { - resolveStreams, - StreamInfo, - StreamInlet, -} from 'node-labstreaminglayer'; +import type { StreamInfo, StreamInlet } from 'node-labstreaminglayer'; import type { DiscoveredStream, LSLInletEpoch } from '../../shared/lslTypes'; +import { loadLSL } from './native'; const POLL_INTERVAL_MS = 16; // ~60Hz poll @@ -38,6 +35,9 @@ class LSLInletManager { private discoveredInfos = new Map(); discoverStreams(waitTime: number = 1.0): DiscoveredStream[] { + const lsl = loadLSL(); + if (!lsl) return []; + // 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) { @@ -45,7 +45,7 @@ class LSLInletManager { } this.discoveredInfos.clear(); - const streams = resolveStreams(waitTime); + const streams = lsl.resolveStreams(waitTime); const results: DiscoveredStream[] = []; for (const info of streams) { const uid = info.uid(); @@ -68,13 +68,15 @@ class LSLInletManager { onDisconnected?: () => void ): boolean { if (this.inlets.has(uid)) return true; + const lsl = loadLSL(); + if (!lsl) return false; const info = this.discoveredInfos.get(uid); if (!info) { log.warn(`[lsl] subscribeStream: unknown uid ${uid} — discover first`); return false; } - const inlet = new StreamInlet(info); + const inlet = new lsl.StreamInlet(info); try { inlet.openStream(5); } catch (err) { diff --git a/src/main/lsl/native.ts b/src/main/lsl/native.ts new file mode 100644 index 00000000..8d5690e7 --- /dev/null +++ b/src/main/lsl/native.ts @@ -0,0 +1,45 @@ +/** + * Lazy, fail-safe loader for the native LSL bindings. + * + * `node-labstreaminglayer` loads liblsl via koffi (FFI) at require time. On a + * machine where liblsl is missing (e.g. Apple Silicon without the Homebrew + * build) that require throws. LSL is an advanced, opt-in capability — Muse and + * Neurosity work entirely without it — so a failed load must never crash app + * startup. We attempt the require once, swallow any failure, and report + * availability so callers (outlets, inlets, the renderer feature-gate) can + * degrade gracefully. + */ +import log from 'electron-log'; + +type LSLModule = typeof import('node-labstreaminglayer'); + +let cached: LSLModule | null = null; +let attempted = false; + +/** + * Returns the loaded LSL module, or null if liblsl could not be loaded. + * The load is attempted lazily on first call and the result is memoized. + */ +export function loadLSL(): LSLModule | null { + if (attempted) return cached; + attempted = true; + try { + // require (not a static import) so a missing liblsl degrades to a disabled + // feature instead of throwing during module evaluation at startup. + // eslint-disable-next-line @typescript-eslint/no-require-imports + cached = require('node-labstreaminglayer') as LSLModule; + log.info('[lsl] native module loaded — LSL features enabled'); + } catch (err) { + cached = null; + log.warn( + '[lsl] node-labstreaminglayer unavailable — LSL features disabled ' + + '(expected if liblsl is not installed):', + err instanceof Error ? err.message : err + ); + } + return cached; +} + +export function isLSLAvailable(): boolean { + return loadLSL() !== null; +} diff --git a/src/main/lsl/outlets.ts b/src/main/lsl/outlets.ts index d81fa61d..3090fc6c 100644 --- a/src/main/lsl/outlets.ts +++ b/src/main/lsl/outlets.ts @@ -6,12 +6,9 @@ * 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 { StreamOutlet } from 'node-labstreaminglayer'; import type { LSLEpoch } from '../../shared/lslTypes'; +import { loadLSL } from './native'; const MARKER_STREAM_NAME = 'BrainWavesMarkers'; @@ -29,10 +26,13 @@ class LSLOutletManager { channelNames: string[], sampleRate: number ): void { + const lsl = loadLSL(); + if (!lsl) return; + this.destroyDeviceOutlet(deviceId); const streamName = `BrainWaves-${deviceType}-${deviceId}`; - const info = new StreamInfo( + const info = new lsl.StreamInfo( streamName, 'EEG', channelNames.length, @@ -44,7 +44,7 @@ class LSLOutletManager { info.setChannelTypes('EEG'); info.setChannelUnits('microvolts'); - const outlet = new StreamOutlet(info); + const outlet = new lsl.StreamOutlet(info); this.outlets.set(deviceId, outlet); log.info( `[lsl] created EEG outlet ${streamName} (${channelNames.length}ch @ ${sampleRate}Hz)` @@ -88,15 +88,17 @@ class LSLOutletManager { */ createMarkerOutlet(): void { if (this.markerOutlet) return; - const info = new StreamInfo( + const lsl = loadLSL(); + if (!lsl) return; + const info = new lsl.StreamInfo( MARKER_STREAM_NAME, 'Markers', 1, - IRREGULAR_RATE, + lsl.IRREGULAR_RATE, 'string', 'brainwaves-markers' ); - this.markerOutlet = new StreamOutlet(info); + this.markerOutlet = new lsl.StreamOutlet(info); log.info(`[lsl] created marker outlet ${MARKER_STREAM_NAME}`); } diff --git a/src/preload/index.ts b/src/preload/index.ts index 4dd79e8a..f3924373 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -183,6 +183,10 @@ contextBridge.exposeInMainWorld('electronAPI', { // ------------------------------------------------------------------ // LSL — main-process outlets push to the LSL network, inlets pull from it // ------------------------------------------------------------------ + // True only when the native liblsl bindings loaded. Renderer feature-gates + // all LSL UI/forwarding on this so the app works without liblsl installed. + isLSLAvailable: (): Promise => ipcRenderer.invoke('lsl:isAvailable'), + sendLSLEpoch: (epoch: LSLEpoch): void => ipcRenderer.send('lsl:sendEpoch', epoch), diff --git a/src/renderer/components/CollectComponent/ConnectModal.tsx b/src/renderer/components/CollectComponent/ConnectModal.tsx index 7ff48383..9f6a2373 100644 --- a/src/renderer/components/CollectComponent/ConnectModal.tsx +++ b/src/renderer/components/CollectComponent/ConnectModal.tsx @@ -29,6 +29,9 @@ interface Props { interface State { selectedDevice: Device | null; instructionProgress: INSTRUCTION_PROGRESS; + // True only when native liblsl loaded in the main process. The "External LSL + // stream" device option is hidden otherwise so the app works without liblsl. + lslAvailable: boolean; } enum INSTRUCTION_PROGRESS { @@ -50,6 +53,7 @@ export default class ConnectModal extends Component { this.state = { selectedDevice: null, instructionProgress: INSTRUCTION_PROGRESS.SEARCHING, + lslAvailable: false, }; this.handleSearch = debounce(this.handleSearch.bind(this), 300, { leading: true, @@ -62,6 +66,13 @@ export default class ConnectModal extends Component { this.handleinstructionProgress = this.handleinstructionProgress.bind(this); } + componentDidMount() { + window.electronAPI + ?.isLSLAvailable?.() + .then((ok) => this.setState({ lslAvailable: ok })) + .catch(() => this.setState({ lslAvailable: false })); + } + UNSAFE_componentWillUpdate(nextProps: Props) { if ( nextProps.deviceAvailability === DEVICE_AVAILABILITY.NONE && @@ -196,7 +207,9 @@ export default class ConnectModal extends Component { > - + {this.state.lslAvailable && ( + + )}
    {this.props.deviceType === DEVICES.LSL && this.renderLSLDiscovery()} diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index f870d1d4..d51ce707 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -99,6 +99,7 @@ declare global { cancelBluetoothSearch: () => Promise; // LSL + isLSLAvailable: () => Promise; sendLSLEpoch: (epoch: LSLEpoch) => void; sendLSLMarker: (marker: LSLMarker) => void; discoverLSLStreams: () => Promise; diff --git a/src/renderer/utils/eeg/lslBridge.ts b/src/renderer/utils/eeg/lslBridge.ts index ef28e7c1..acb2dd36 100644 --- a/src/renderer/utils/eeg/lslBridge.ts +++ b/src/renderer/utils/eeg/lslBridge.ts @@ -11,6 +11,15 @@ import { EEGData } from '../../constants/interfaces'; const DEFAULT_BATCH_SIZE = 32; +// Cache the main-process LSL availability so the hot epoch/marker paths don't +// flood IPC when liblsl isn't loaded. Probed once; defaults to false until the +// async check resolves (a few early samples may be dropped — harmless, and the +// main process no-ops them anyway). +let lslAvailable = false; +void window.electronAPI?.isLSLAvailable?.().then((ok) => { + lslAvailable = ok; +}); + /** * Transforms a raw EEG observable (per-sample EEGData) into an observable of * batched LSLEpoch objects ready to be forwarded over IPC. @@ -37,9 +46,11 @@ export const batchSamplesToEpoch = ( ); export const sendEpoch = (epoch: LSLEpoch): void => { + if (!lslAvailable) return; window.electronAPI?.sendLSLEpoch?.(epoch); }; export const sendMarker = (marker: LSLMarker): void => { + if (!lslAvailable) return; window.electronAPI?.sendLSLMarker?.(marker); }; From be80ff7262805a1760d8ded1472cd4c2a56d005c Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sat, 6 Jun 2026 16:35:42 -0400 Subject: [PATCH 15/25] fixed bug where EEG viewer was not loading --- internals/scripts/BuildViewers.mjs | 16 +++++++++++++--- package.json | 1 + src/main/index.ts | 14 ++++++++++++++ src/renderer/components/ViewerComponent.tsx | 13 +++++++++---- src/renderer/constants/interfaces.ts | 4 +++- src/renderer/utils/eeg/pipes.ts | 6 +++++- 6 files changed, 45 insertions(+), 9 deletions(-) diff --git a/internals/scripts/BuildViewers.mjs b/internals/scripts/BuildViewers.mjs index 1c08d57b..6d26bbda 100644 --- a/internals/scripts/BuildViewers.mjs +++ b/internals/scripts/BuildViewers.mjs @@ -16,14 +16,24 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url)); const root = resolve(__dirname, '../..'); // ---------------------------------------------------------------- -// 1. Viewer preload: src/preload/viewer.ts → out/preload/viewer.js +// 1. Viewer preload: src/preload/viewer.ts → out/viewer/viewer.js +// +// This is the preload that exposes `viewerAPI` (attached in +// src/main/index.ts via will-attach-webview). It deliberately lives in +// out/viewer/ rather than out/preload/: electron-vite empties out/preload on +// every build (including the initial build in `npm run dev`), which would delete +// it. out/viewer/ is never touched by electron-vite and is still bundled by +// electron-builder's "out/**/*" files glob for production. // ---------------------------------------------------------------- +const viewerOut = resolve(root, 'out/viewer'); +mkdirSync(viewerOut, { recursive: true }); + await build({ entryPoints: [resolve(root, 'src/preload/viewer.ts')], bundle: true, platform: 'node', external: ['electron'], - outfile: resolve(root, 'out/preload/viewer.js'), + outfile: join(viewerOut, 'viewer.js'), format: 'cjs', }); @@ -48,4 +58,4 @@ let html = readFileSync(resolve(root, 'src/renderer/viewer.html'), 'utf-8'); html = html.replace('src="./viewer.ts"', 'src="./viewer.js"'); writeFileSync(join(rendererOut, 'viewer.html'), html); -console.log('Viewer builds complete: out/preload/viewer.js, out/renderer/viewer.{js,html}'); +console.log('Viewer builds complete: out/viewer/viewer.js, out/renderer/viewer.{js,html}'); diff --git a/package.json b/package.json index 7604a529..4b417a80 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "description": "EEG Experiment Desktop Application", "main": "./out/main/index.js", "scripts": { + "predev": "node internals/scripts/BuildViewers.mjs", "dev": "node internals/scripts/patchDeps.mjs && electron-vite dev", "build": "electron-vite build", "postbuild": "node internals/scripts/BuildViewers.mjs", diff --git a/src/main/index.ts b/src/main/index.ts index 8bc714c2..e6e7d853 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -586,6 +586,20 @@ const createWindow = async () => { mainWindow.setMinimumSize(1075, 708); + // The EEG viewer (ViewerComponent) loads viewer.html, which needs + // its own preload to expose `viewerAPI`. A does NOT inherit the host + // window's preload, and without one the guest's viewer.ts throws on + // `window.viewerAPI` and the D3 graph never initialises. Attach the viewer + // preload to every webview the host spawns (the EEG viewer is the only one). + // out/viewer/viewer.js is compiled by internals/scripts/BuildViewers.mjs; it + // lives outside out/preload/ because electron-vite empties that dir on every + // build. __dirname is out/main at runtime. + mainWindow.webContents.on('will-attach-webview', (_event, webPreferences) => { + webPreferences.preload = path.join(__dirname, '../viewer/viewer.js'); + webPreferences.nodeIntegration = false; + webPreferences.contextIsolation = true; + }); + // 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 diff --git a/src/renderer/components/ViewerComponent.tsx b/src/renderer/components/ViewerComponent.tsx index 6fea6a6d..d6f01644 100644 --- a/src/renderer/components/ViewerComponent.tsx +++ b/src/renderer/components/ViewerComponent.tsx @@ -105,12 +105,17 @@ class ViewerComponent extends Component { subscribeToObservable(observable: Observable) { this.signalQualitySubscription?.unsubscribe(); - this.signalQualitySubscription = observable.subscribe( - (chunk) => { + this.signalQualitySubscription = observable.subscribe({ + next: (chunk) => { this.graphView?.send('newData', chunk); }, - (error) => new Error(`Error in epochSubscription ${error}`) - ); + // A thrown error here terminates the stream, so all EEG / signal-quality + // data silently stops reaching the viewer. The previous handler built an + // Error object and discarded it, hiding pipeline failures entirely — log + // it so the failure is diagnosable. + error: (error) => + console.error('[viewer] signal quality observable error:', error), + }); } render() { diff --git a/src/renderer/constants/interfaces.ts b/src/renderer/constants/interfaces.ts index c4370a46..03738d33 100644 --- a/src/renderer/constants/interfaces.ts +++ b/src/renderer/constants/interfaces.ts @@ -126,11 +126,13 @@ export interface DeviceInfo { export interface PipesEpoch { data: number[][]; - signalQuality: { [channelName: string]: number }; info: { samplingRate: number; startTime: number; channelNames?: string[]; + // @neurosity/pipes >=5 nests the raw per-channel signal-quality (stddev) + // values produced by addSignalQuality() under `info`, not at the top level. + signalQuality: { [channelName: string]: number }; }; } diff --git a/src/renderer/utils/eeg/pipes.ts b/src/renderer/utils/eeg/pipes.ts index d5f1a149..369c135c 100644 --- a/src/renderer/utils/eeg/pipes.ts +++ b/src/renderer/utils/eeg/pipes.ts @@ -12,7 +12,11 @@ export const parseMuseSignalQuality = () => ...epoch, signalQuality: Object.assign( {}, - ...Object.entries(epoch.signalQuality).map( + // @neurosity/pipes >=5 puts addSignalQuality()'s output under + // info.signalQuality (older versions used a top-level field). Reading the + // old top-level path yielded undefined → Object.entries() threw, which + // killed the whole signal-quality/EEG stream to the viewer. + ...Object.entries(epoch.info.signalQuality).map( ([channelName, signalQuality]) => { if (signalQuality >= SIGNAL_QUALITY_THRESHOLDS.BAD) { return { [channelName]: SIGNAL_QUALITY.BAD }; From d2a7871d1477e9c370454154118e87ce1396ba90 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sat, 6 Jun 2026 16:35:52 -0400 Subject: [PATCH 16/25] Deleted outdated docs --- ...mplementation Plan (2026 Modernization).md | 104 ------ docs/lsl-implementation-plan.md | 10 + docs/migration-summary.md | 113 ------ docs/progress.md | 353 ------------------ 4 files changed, 10 insertions(+), 570 deletions(-) delete mode 100644 docs/BrainWaves Technical Implementation Plan (2026 Modernization).md delete mode 100644 docs/migration-summary.md delete mode 100644 docs/progress.md diff --git a/docs/BrainWaves Technical Implementation Plan (2026 Modernization).md b/docs/BrainWaves Technical Implementation Plan (2026 Modernization).md deleted file mode 100644 index 2237e403..00000000 --- a/docs/BrainWaves Technical Implementation Plan (2026 Modernization).md +++ /dev/null @@ -1,104 +0,0 @@ -# BrainWaves: Technical Implementation Plan (2026 Modernization) - -**Author:** Manus AI -**Date:** March 2026 - -This document outlines a step-by-step technical implementation plan for AI agents working on the BrainWaves codebase. The goal is to modernize the stack, resolve deployment blockers, and replace the UI library, while establishing a robust testing harness. - -*Note: The migration to Lab Streaming Layer (LSL) is explicitly excluded from this phase and will be addressed once the application is successfully building and deploying.* - ---- - -## Phase 1: Test Harness & Build Environment Setup - -Before modifying the application logic, we must establish a reliable testing harness using Vitest and ensure the Electron build pipeline is functional. The current codebase uses a legacy Jest configuration (`"test": "cross-env jest --passWithNoTests"`) which must be replaced. - -### Step 1.1: Install and Configure Vitest -* **Action:** Remove Jest dependencies and install Vitest, `@testing-library/react`, `@testing-library/jest-dom`, and `jsdom`. -* **Action:** Create a `vitest.config.ts` file configured for a React/DOM environment. -* **Action:** Update `package.json` scripts to use Vitest (`"test": "vitest run"`, `"test:watch": "vitest"`). -* **Acceptance Criteria:** - * A basic sanity test (`src/renderer/App.test.tsx`) rendering a simple component passes using `npm test`. - * The CI workflow (`.github/workflows/test.yml`) is updated to run `npm test` using Vitest. - -### Step 1.2: Verify Electron Build Pipeline -* **Action:** Run the existing `npm run build` and `npm run package-ci` commands to identify any immediate build failures caused by Node 22 or Vite 7. -* **Action:** Fix any immediate compilation errors in `src/main/index.ts` or `vite.config.ts`. -* **Acceptance Criteria:** - * `npm run build` completes without errors. - * An integration test (`tests/build.test.ts`) is added that programmatically verifies the existence of the `out/main/index.js` and `out/renderer/index.html` files after a build. - ---- - -## Phase 2: Routing and State Synchronization Modernization - -The codebase currently relies on `react-router-dom v5` and the abandoned `connected-react-router` (which syncs router state to Redux). This causes significant issues with modern React 18 and Redux Toolkit. - -### Step 2.1: Remove `connected-react-router` -* **Action:** Uninstall `connected-react-router`. -* **Action:** Remove `routerMiddleware` and `connectRouter` from `src/renderer/store.ts` and `src/renderer/reducers/index.ts`. -* **Action:** Remove `ConnectedRouter` from `src/renderer/containers/Root.tsx` and replace it with a standard `HashRouter` from `react-router-dom`. -* **Acceptance Criteria:** - * The Redux store initializes successfully without the router reducer. - * Unit tests verify that the Redux store can be created with initial state (`tests/store.test.ts`). - -### Step 2.2: Upgrade to React Router v6/v7 -* **Action:** Upgrade `react-router-dom` to the latest stable version. -* **Action:** Refactor `src/renderer/routes.tsx` to use the new `` and `}>` syntax instead of the legacy `` and `component={}` props. -* **Action:** Refactor class components (e.g., `HomeComponent`, `DesignComponent`) that rely on `this.props.history.push`. Convert them to functional components using the `useNavigate` hook, or create a Higher-Order Component (HOC) `withRouter` wrapper if functional conversion is too extensive. -* **Action:** Update `src/renderer/epics/experimentEpics.ts` which currently listens to `@@router/LOCATION_CHANGE`. Refactor the logic to trigger based on specific Redux actions rather than URL changes. -* **Acceptance Criteria:** - * Unit tests (`tests/routing.test.tsx`) verify that navigation between the Home, Design, and Collect screens renders the correct components. - ---- - -## Phase 3: Pyodide and Python Dependency Modernization - -The application uses Pyodide `v0.21.0` (hardcoded in `internals/scripts/InstallPyodide.js`) and relies on deprecated Python libraries for data visualization. - -### Step 3.1: Upgrade Pyodide -* **Action:** Update `internals/scripts/InstallPyodide.js` to download a modern stable release of Pyodide (e.g., `v0.27.0`). -* **Action:** Ensure the `vite.config.ts` `publicDir` setting still correctly serves the updated Pyodide WASM and JS files. -* **Acceptance Criteria:** - * An integration test (`tests/pyodide.test.ts`) successfully instantiates the Pyodide web worker (`src/renderer/utils/pyodide/webworker.js`) and executes a simple `1 + 1` Python command. - -### Step 3.2: Refactor Python Plotting Logic -* **Action:** In `src/renderer/utils/pyodide/utils.py`, locate the usage of `sns.tsplot` (which was removed in Seaborn v0.10.0). -* **Action:** Rewrite the `plot_conditions` function to use `sns.lineplot` or standard `matplotlib.pyplot.plot` with `fill_between` for confidence intervals. -* **Acceptance Criteria:** - * A unit test (`tests/python_utils.test.ts`) loads `utils.py` into the Pyodide worker, passes mock EEG data, and verifies that the plotting function executes without throwing a Python `AttributeError`. - ---- - -## Phase 4: UI Library Replacement (Semantic UI to Shadcn/ui) - -`semantic-ui-react` is abandoned and throws deprecation warnings in React 18. We will replace it with Tailwind CSS and Shadcn/ui components. - -### Step 4.1: Install Tailwind CSS and Shadcn/ui -* **Action:** Install Tailwind CSS, PostCSS, and Autoprefixer. Configure `tailwind.config.js` and `postcss.config.js`. -* **Action:** Initialize Shadcn/ui (`npx shadcn-ui@latest init`) and configure it to output components to `src/renderer/components/ui`. -* **Acceptance Criteria:** - * A unit test verifies that a basic Shadcn/ui `Button` component renders correctly with Tailwind utility classes applied. - -### Step 4.2: Component-by-Component Replacement -* **Action:** Identify the ~26 files importing `semantic-ui-react`. The most heavily used components are `Segment`, `Button`, `Grid`, and `Header`. -* **Action:** Replace `semantic-ui-react` components with their Shadcn/ui equivalents: - * `Button` -> Shadcn `Button` - * `Segment` -> Shadcn `Card` or a simple `div` with Tailwind borders/padding. - * `Grid` -> Tailwind CSS Grid (`grid grid-cols-12 gap-4`). - * `Header` -> Standard HTML `h1`-`h6` tags with Tailwind typography classes. - * `Modal` -> Shadcn `Dialog`. -* **Action:** Remove `semantic-ui-css` from `src/renderer/index.tsx` and uninstall `semantic-ui-react`. -* **Acceptance Criteria:** - * `grep -r "semantic-ui-react" src/` returns zero results. - * Visual regression or DOM snapshot tests (`tests/ui_migration.test.tsx`) for key screens (Home, Design, Collect) pass, ensuring the new components render without crashing. - ---- - -## Phase 5: Final Build and Verification - -### Step 5.1: End-to-End Build Verification -* **Action:** Run the full build pipeline (`npm run package-all`). -* **Acceptance Criteria:** - * The Electron application compiles and packages successfully for the target OS. - * All Vitest suites (Routing, Store, Pyodide, UI) pass with 100% success rate. diff --git a/docs/lsl-implementation-plan.md b/docs/lsl-implementation-plan.md index 9ec6ae2c..186961fa 100644 --- a/docs/lsl-implementation-plan.md +++ b/docs/lsl-implementation-plan.md @@ -465,3 +465,13 @@ writeHeader(streamId, state$.value.device.connectedDevice?.channels ?? MUSE_CHAN | `src/renderer/epics/index.ts` | Register `lslForwardEpic` in `combineEpics` | | `src/renderer/epics/experimentEpics.ts` | Fix `MUSE_CHANNELS` hardcoding (line 79) | | `src/renderer/components/CollectComponent/RunComponent.tsx` | Add `sendLSLMarker` call alongside `injectMuseMarker` (Phase 4) | + +Test plan + + - x Connect a real Muse; confirm stream appears in LabRecorder with correct channel names and sample rate (still could interrogatee file for correctness. path is '/Users/dano/Documents/CurrentStudy/sub-P001/ses-S001/eeg/sub-P001_ses-S001_task-Default_run-001_eeg.xdf') + - Connect a Neurosity Crown; confirm stream appears in LabRecorder + - Run lab.js experiment; confirm markers are recorded synchronized with EEG + - Discover and subscribe to an external LSL stream; confirm live visualization + - Disconnect Muse mid-session; confirm toast + clean teardown + - npm run package on macOS arm64, macOS x64, Windows x64; confirm liblsl loads from the packaged build + - Ubuntu smoke test for Web Bluetooth (--enable-experimental-web-platform-features is already set) \ No newline at end of file diff --git a/docs/migration-summary.md b/docs/migration-summary.md deleted file mode 100644 index 906ebcf3..00000000 --- a/docs/migration-summary.md +++ /dev/null @@ -1,113 +0,0 @@ -# BrainWaves Migration Session Summary - -## Architecture Change: Webpack/Babel/Yarn → Electron-Vite/npm - -The project was migrated from a legacy Electron + Webpack + Babel + Yarn stack to a modern **electron-vite** setup. This is a significant architectural shift: - -| Concern | Before | After | -|---|---|---| -| Build system | Webpack + Babel | **electron-vite** (esbuild + Rollup) | -| Package manager | Yarn | **npm** | -| Module format | CommonJS (`require`) | **ESM** (`import`) | -| Env variables (renderer) | `process.env.*` | **`import.meta.env.VITE_*`** | -| Process split | Single config | **Three explicit targets**: `main`, `preload`, `renderer` | -| Path utilities | `path-browserify` (2019, no ESM) | **`pathe`** (modern, pure ESM) | -| Dev server | Webpack HMR | **Vite HMR** | - -The electron-vite architecture enforces a clean Electron process split: -- **Main** (`src/main/index.ts`) — Node.js process, IPC handlers, file system -- **Preload** (`src/preload/index.ts`) — sandboxed bridge with `contextBridge` -- **Renderer** (`src/renderer/`) — pure browser context, React app - ---- - -## Major Work Done - -### 1. Build System Migration -- Replaced all Webpack config with `vite.config.ts` using `defineConfig` from `electron-vite` -- Converted `require()` calls to ESM `import` statements across the codebase -- Used `git mv` for all file renames to preserve git history -- Set `package.json` `"main"` field to `"./out/main/index.js"` (electron-vite's output convention) - -### 2. Electron API Modernization -- **Replaced deprecated devtools APIs**: `session.getAllExtensions()` / `session.loadExtension()` → `session.extensions.*` (new namespaced API) -- **Fixed preload `process` conflict**: Removed redundant `import process from 'process'` — Electron injects it natively -- **Added dev HTTP cache clearing**: `session.defaultSession.clearCache()` in `app.whenReady()` (dev only) to prevent Electron's persistent HTTP cache from serving stale Vite pre-bundled assets - -### 3. Dependency Upgrades - -| Package | From | To | Reason | -|---|---|---|---| -| `@neurosity/pipes` | v3 | **v5** | Eliminated `dsp.js` which used `this[name]` globals incompatible with strict ESM | -| `rxjs` | v6 | **v7** | Required by pipes v5 | -| `redux-observable` | v1 | **v2-rc** | Required by RxJS v7 | -| `plotly.js` | v1.54 (bundles **d3 v3**) | **v2.35** (uses d3 v6) | Eliminated all `this.document` / `this.navigator` / `this.Element` errors | -| `react-plotly.js` | v2.4 | **v2.6** | Compatibility with plotly.js v2 | -| `d3` (direct) | v5.16 | **v7.9** | Modern pure-ESM version | -| `path-browserify` | v1 (2019, CJS) | **`pathe`** (modern ESM) | Drop-in replacement with active maintenance | - -### 4. Environment Variable Migration -Renderer code cannot access `process.env` in Vite (no Node.js context). All renderer references were migrated: -- `process.env.CLIENT_ID` → `import.meta.env.VITE_CLIENT_ID` -- `process.env.NODE_ENV` → `import.meta.env.MODE` -- Emotiv SDK credentials are loaded from `keys.js` at config time and injected as `process.env.VITE_*` so Vite picks them up natively - -### 5. Content Security Policy (CSP) -Built up the CSP in `src/renderer/index.html` incrementally to allow legitimate sources while remaining secure: -- Added `https://fonts.googleapis.com` to `style-src` (Semantic UI's Google Fonts) -- Added `https://fonts.gstatic.com` to `font-src` (actual font files) -- Added `webpack:` to `connect-src` (source map protocol) -- Added `'self'` to `worker-src` (Vite serves workers as HTTP URLs in dev, not `blob:`) - -### 6. Pyodide / Web Worker Fix -- **Problem**: Vite transforms every `.js` file it serves by injecting `import { createHotContext } from '/@vite/client'`, turning files into ES modules. `importScripts()` in a classic worker cannot execute ES modules — causing a `NetworkError`. -- **Fix**: Configured `publicDir` in the renderer Vite config to point at the pyodide install directory (`src/renderer/utils/pyodide/src/`). Vite serves `publicDir` files verbatim with zero transformation. Updated `webworker.js` to use absolute paths (`/pyodide/pyodide.js`) instead of fragile relative ones. - -### 7. redux-observable v2 API Fix -`action$.ofType()` was removed in redux-observable v2. Updated three call sites in `experimentEpics.ts` to use the pipeable `ofType` operator: - -```ts -// Before (v1): -action$.ofType('@@router/LOCATION_CHANGE').pipe(...) - -// After (v2): -action$.pipe(ofType('@@router/LOCATION_CHANGE'), ...) -``` - -### 8. Browser Compatibility Fixes -- **`cortex.js`**: `global.process` → `typeof process !== 'undefined' && process.env` (no `global` in browser) -- **`muse.ts`**: Removed `import 'hazardous'` — a Node.js-only asar path library that was incorrectly imported in the renderer - ---- - -## Key Roadblocks - -### Electron HTTP Cache vs. Vite Pre-bundle Cache -The trickiest issue of the session. Vite sets `Cache-Control: max-age=31536000, immutable` on -pre-bundled deps. Electron's renderer stores these permanently in -`~/Library/Application Support/BrainWaves/Cache/`. Even after patching files on disk, Electron -kept serving the old cached version because the URL's `v=` hash hadn't changed (Vite keys its -cache hash on the package version, not file content). The solution required both patching the -Vite pre-bundle cache file on disk *and* clearing the Electron session HTTP cache at startup -in dev mode (`session.defaultSession.clearCache()`). - -### plotly.js / d3 v3 `this.xxx` Chain -Three separate globals (`this.document`, `this.Element`, `this.CSSStyleDeclaration`, -`this.navigator`) needed patching before the root cause was identified as d3 v3 being bundled -inside plotly.js v1. In Vite's strict-mode ESM context, bare `this` at the module level is -`undefined`. Upgrading to plotly.js v2 (which uses d3 v6, pure ESM) eliminated all of them at -once. - -### `patchDeps.mjs` Strategy Evolution -The plotly fix went through several iterations before the root cause was found: -1. Vite server middleware to intercept HTTP requests — failed due to middleware ordering -2. esbuild plugin in `optimizeDeps.esbuildOptions` — didn't apply to already-cached bundles -3. Patching the npm source only — Vite doesn't re-bundle when the package version hasn't changed -4. Patching both source and Vite's cached pre-bundle file — worked, but made entirely moot by upgrading plotly.js to v2 - -### Pyodide Worker Loading -The worker's `importScripts()` call appeared to reference a valid URL, but the load silently -failed. The cause was subtle: Vite injects HMR boilerplate (an `import` statement) into every -`.js` file it serves, converting them to ES modules. `importScripts()` in a classic worker -can only execute classic scripts — not ES modules. Moving pyodide to `publicDir` bypassed -Vite's transform pipeline entirely. diff --git a/docs/progress.md b/docs/progress.md deleted file mode 100644 index 46caf528..00000000 --- a/docs/progress.md +++ /dev/null @@ -1,353 +0,0 @@ -# BrainWaves Modernization — Implementation Progress - -Tracking file for executing the [Technical Implementation Plan](./BrainWaves_%20Technical%20Implementation%20Plan%20(2026%20Modernization).md) across sessions. - -**Last updated:** 2026-03-07 -**Overall status:** PHASES 1–4 COMPLETE (Phase 5 pending: npm install + build verification) - ---- - -## Codebase Reconnaissance (completed) - -Key files read and understood before starting implementation: - -- `package.json` — current deps, jest config, scripts -- `src/renderer/store.ts` — uses `connected-react-router` (`routerMiddleware`, `createHashHistory`) -- `src/renderer/reducers/index.ts` — uses `connectRouter` from `connected-react-router` -- `src/renderer/containers/Root.tsx` — uses `ConnectedRouter`, receives `history` prop -- `src/renderer/index.tsx` — imports and passes `history` from store to Root -- `src/renderer/routes.tsx` — React Router v5 `` / `` / custom `PropsRoute` -- `src/renderer/epics/experimentEpics.ts` — `autoSaveEpic` and `navigationCleanupEpic` listen to `@@router/LOCATION_CHANGE` -- `src/renderer/containers/TopNavBarContainer.ts` — maps `state.router.location` to props -- `src/renderer/components/TopNavComponent/index.tsx` — class component, uses `this.props.location.pathname` -- `src/renderer/components/HomeComponent/index.tsx` — class component, calls `this.props.history.push()` -- `src/renderer/components/CollectComponent/index.tsx` — passes `history` prop to `ConnectModal` (unused there) -- `src/renderer/components/CollectComponent/ConnectModal.tsx` — has `history` in Props but does NOT use it -- `src/renderer/components/EEGExplorationComponent.tsx` — passes `history` to `ConnectModal` (unused) -- `src/renderer/actions/experimentActions.ts` — RTK `createAction`, `typesafe-actions` -- `src/renderer/utils/pyodide/utils.py` — uses `sns.tsplot` (removed in seaborn v0.10), seaborn import commented out -- `src/renderer/utils/pyodide/webworker.js` — `importScripts('/pyodide/pyodide.js')`, loads matplotlib/mne/pandas -- `internals/scripts/InstallPyodide.js` — downloads pyodide v0.21.0 tarball -- `vite.config.ts` — `publicDir` serves `src/renderer/utils/pyodide/src/` as static assets -- `.github/workflows/test.yml` — runs `npm run package-ci`, lint, tsc (no unit tests) -- 26 files import `semantic-ui-react` (see list below) - ---- - -## Phase 1: Test Harness & Vitest - -**Status: COMPLETE** - -### Step 1.1 — Install and configure Vitest - -**What to do:** -1. Edit `package.json`: - - Remove from `devDependencies`: `jest`, `@types/jest`, `identity-obj-proxy`, `react-test-renderer` - - Add to `devDependencies`: `vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `jsdom` - - Change `"test": "cross-env jest --passWithNoTests"` → `"test": "vitest run"` - - Add `"test:watch": "vitest"` - - Remove the `"jest": { ... }` config block from package.json -2. Create `vitest.config.ts` (project root) -3. Create `src/test-setup.ts` (imports `@testing-library/jest-dom`) -4. Create `src/renderer/App.test.tsx` (basic sanity render test) -5. Run `npm install` to update node_modules - -**Notes:** -- Vitest needs `jsdom` environment for React component tests -- The vite.config.ts babel plugins (decorators, class-properties) must also be present in vitest.config.ts -- CSS modules: vitest handles them natively with `css.modules` config; no `identity-obj-proxy` needed - -### Step 1.2 — Update CI workflow - -**What to do:** -1. Edit `.github/workflows/test.yml`: add `npm test` step before or after existing steps -2. Create `tests/build.test.ts`: verifies `out/main/index.js` and `out/renderer/index.html` exist after build - ---- - -## Phase 2: Routing Modernization - -**Status: COMPLETE** - -### Step 2.1 — Remove `connected-react-router` - -**Package changes:** -- Remove from `dependencies`: `connected-react-router`, `history` -- Remove from `devDependencies`: `@types/history`, `@types/react-router`, `@types/react-router-dom` -- Remove `overrides["connected-react-router"]` block - -**File changes:** - -| File | Change | -|------|--------| -| `src/renderer/store.ts` | Remove `createHashHistory`, `history` export, `routerMiddleware`, remove `router` from middleware | -| `src/renderer/reducers/index.ts` | Remove `connectRouter`, `History` import, remove `router` from combineReducers, remove `router: any` from RootState | -| `src/renderer/containers/Root.tsx` | Remove `ConnectedRouter`; use `HashRouter` from `react-router-dom`; remove `history` prop | -| `src/renderer/index.tsx` | Remove `history` import; pass only `store` to `` | - -### Step 2.2 — Upgrade to React Router v6 - -**Package changes:** -- Change `react-router` and `react-router-dom`: `"^5.2.0"` → `"^6.x"` (v6 merges the two packages; keep both entries or consolidate) - -**New file:** -- `src/renderer/actions/routerActions.ts` — defines `RouterActions.RouteChanged(pathname: string)` action - -**File changes:** - -| File | Change | -|------|--------| -| `src/renderer/routes.tsx` | Replace `/` with `/}>`. Remove `PropsRoute`. Pass `activeStep` directly as JSX prop on ``. | -| `src/renderer/containers/App.tsx` | Add `NavigationTracker` functional component (uses `useLocation` + `useDispatch` to dispatch `RouterActions.RouteChanged` on location change) | -| `src/renderer/epics/experimentEpics.ts` | Replace both `ofType('@@router/LOCATION_CHANGE')` epics to use `filter(isActionOf(RouterActions.RouteChanged))`. Access `action.payload` as pathname string directly. Remove `pluck('payload', 'pathname')` etc. | -| `src/renderer/components/TopNavComponent/index.tsx` | Convert class → functional component. Replace `this.props.location.pathname` with `useLocation().pathname`. State becomes `useState`. Methods become callbacks. | -| `src/renderer/containers/TopNavBarContainer.ts` | Remove `location: state.router.location` from mapStateToProps | -| `src/renderer/components/HomeComponent/index.tsx` | Change `history: History` prop to `navigate: (path: string) => void`. Replace all `this.props.history.push(X)` with `this.props.navigate(X)`. Remove `import { History } from 'history'`. | -| `src/renderer/containers/HomeContainer.ts` | Wrap exported component with `withNavigate` HOC that injects `useNavigate()` as `navigate` prop | -| `src/renderer/components/CollectComponent/index.tsx` | Remove `history: History` from Props; remove passing `history` to `ConnectModal` | -| `src/renderer/components/EEGExplorationComponent.tsx` | Remove `history: History` from Props; remove passing `history` to `ConnectModal` | -| `src/renderer/components/CollectComponent/ConnectModal.tsx` | Remove `history: History` from Props interface | - -**withNavigate HOC pattern (for HomeContainer.ts):** -```tsx -import React from 'react'; -import { useNavigate } from 'react-router-dom'; - -function withNavigate

    void }>( - Component: React.ComponentType

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

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