From 3ddb507290b1ffc9517fed63761b1e4c98d75dac Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 12 Apr 2026 13:45:59 -0400 Subject: [PATCH 01/11] 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 6b7d8bc8..c625aeaa 100644 --- a/package.json +++ b/package.json @@ -131,7 +131,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 37d15981..3111fe69 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 8af74214..9e728af6 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'; @@ -79,19 +76,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( @@ -131,12 +116,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 71de8eb87ddbaf1e1c7c762f049105cdd055bf34 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 12 Apr 2026 14:24:07 -0400 Subject: [PATCH 02/11] 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 70802f76..84f62593 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -48,6 +48,11 @@ export default class AppUpdater { let mainWindow: BrowserWindow | null = null; +// Holds the pending Bluetooth device-picker callback from select-bluetooth-device. +// Electron 22+ fires this event instead of showing a native picker — we must +// call it with a deviceId to resolve requestDevice(), or '' to reject. +let pendingBluetoothCallback: ((deviceId: string) => void) | null = null; + // ------------------------------------------------------------------ // Filesystem helpers (mirroring renderer's storage.ts / write.ts) // ------------------------------------------------------------------ @@ -396,6 +401,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 @@ -439,6 +452,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 4eaf4676..9ab8c271 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 @@ -153,4 +156,10 @@ contextBridge.exposeInMainWorld('electronAPI', { getResourcePath: (): Promise => ipcRenderer.invoke('getResourcePath'), getViewerUrl: (): Promise => ipcRenderer.invoke('getViewerUrl'), + + // ------------------------------------------------------------------ + // 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 7bba169d9fe6d97b597b92464131e9b0702e706f Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 12 Apr 2026 14:29:53 -0400 Subject: [PATCH 03/11] 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 d47ee9b72b31ef093ddd7036c5dc1a27ee8dbbdf Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 12 Apr 2026 14:46:18 -0400 Subject: [PATCH 04/11] 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 852b0d78bdc5a3fd28267043bd74d4c9adcc012e Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sat, 18 Apr 2026 10:55:45 -0400 Subject: [PATCH 05/11] 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 | 997 +++++++++++++++++- 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, 1963 insertions(+), 49 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 c69e6a35..10771606 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@electron-toolkit/utils": "^4.0.0", "@fortawesome/fontawesome-free": "^5.13.0", "@neurosity/pipes": "^5.2.1", + "@neurosity/sdk": "^7.1.0", "@radix-ui/react-dialog": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.1.0", "@radix-ui/react-select": "^2.2.6", @@ -33,6 +34,7 @@ "mkdirp": "^1.0.4", "mousetrap": "^1.6.5", "muse-js": "^3.1.0", + "node-labstreaminglayer": "^0.3.0", "papaparse": "^5.5.3", "pathe": "^2.0.3", "plotly.js": "^3.4.0", @@ -2033,6 +2035,617 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@firebase/ai": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-2.11.0.tgz", + "integrity": "sha512-+oqOne/h5J51LezazR+VyzKe3AK455W29JXnb4jOeVvQhC7FymledN5+XE+w5vEcMhRQ6n1f62fdGs4A44X32A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/analytics": { + "version": "0.10.21", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.21.tgz", + "integrity": "sha512-j2y2q65BlgLGB5Pwjhv/Jopw2X/TBTzvAtI5z/DSp56U4wBj7LfhBfzbdCtFPges+Wz0g55GdoawXibOH5jGng==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/installations": "0.6.21", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.27", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.27.tgz", + "integrity": "sha512-ZObpYpAxL6JfgH7GnvlDD0sbzGZ0o4nijV8skatV9ZX49hJtCYbFqaEcPYptT94rgX1KUoKEderC7/fa7hybtw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.21", + "@firebase/analytics-types": "0.8.3", + "@firebase/component": "0.7.2", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.3.tgz", + "integrity": "sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app": { + "version": "0.14.11", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.11.tgz", + "integrity": "sha512-yxADFW35LYkP8oSGobGsYIrI42I+GPCvKTNHx4meT9Yq3C950IVz1eANoBk822I9tbKv1wyv9P4Bv1G5TpucFw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.11.2.tgz", + "integrity": "sha512-jcXQVMHAQ5AEKzVD5C7s5fmAYeFOuN6lAJeNTgZK2B9aLnofWaJt8u1A8Idm8gpsBBYSaY3cVyeH5SWMOVPBLQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.4.2.tgz", + "integrity": "sha512-M91NhxqbSkI0ChkJWy69blC+rPr6HEgaeRllddSaU1pQ/7IiegeCQM9pPDIgvWnwnBSzKhUHpe6ro/jhJ+cvzw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check": "0.11.2", + "@firebase/app-check-types": "0.5.3", + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.3.tgz", + "integrity": "sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-compat": { + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.11.tgz", + "integrity": "sha512-KaACDjXkK5VLpI01vEs592R7/8s5DjFdIXfKoR385ly1SmK3Tu+jMHCIB4MsiY5jsez6v7VlEX/3rJ90dVkHyA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app": "0.14.11", + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-types": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.4.tgz", + "integrity": "sha512-crX9TA5SVYZwLPG7/R16IsH8FLlgkPXjJUVhsVpHVDSqJiq3D/NuFTM5ctxGTExXAOeIn//69tQw47CPerM8MQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/logger": "0.5.0" + } + }, + "node_modules/@firebase/auth": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.13.0.tgz", + "integrity": "sha512-mKkSLNym3UbnnZ06dAmtqzp5EpPGCANGCZDJbkoR135aoUdKG6Aizwcnp29RzsQpwH0nmy5nay17Sfbsh9oY8A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^2.2.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@firebase/auth-compat": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.6.5.tgz", + "integrity": "sha512-IfVsafZ3QiXbsydXTP/XMI0wVYbJLI1rkb8Qqf03/h5FnL+upbbPOb+6Yj3RpcX+Y1iP5Uh18lxTHlXfbiyAow==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth": "1.13.0", + "@firebase/auth-types": "0.13.0", + "@firebase/component": "0.7.2", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-types": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.0.tgz", + "integrity": "sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.2.tgz", + "integrity": "sha512-iyVDGc6Vjx7Rm0cAdccLH/NG6fADsgJak/XW9IA2lPf8AjIlsemOpFGKczYyPHxm4rnKdR8z6sK4+KEC7NwmEg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.6.0.tgz", + "integrity": "sha512-OiugPRcdlhqXF97oR9CjVObILmsWU0dFUS0gXNYEe4bDfpW8pZmQ5GqhIPPtLWbT/0W2lMJJD7VILFMk+xuHPg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.2.tgz", + "integrity": "sha512-lP96CMjMPy/+d1d9qaaHjHHdzdwvEOuyyLq9ehX89e2XMKwS1jHNzYBO+42bdSumuj5ukPbmnFtViZu8YOMT+w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.3.tgz", + "integrity": "sha512-GMyfWjD8mehjg/QpNkY/tl9G/MoeugPeg91n9D0atggxbWuKF/2KhVPHZDH+XmoP0EKYqMWYTtKxBsaBaNKLYQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/database": "1.1.2", + "@firebase/database-types": "1.0.19", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.19", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.19.tgz", + "integrity": "sha512-FqewjUZmV9LqFfuEnmgdcUpiOUz7qwLXxnm/H8BcMFEzQXtd1yyUDm8ex5VRad2nuTE+ahOuCjUAM/cyDncO+g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.4", + "@firebase/util": "1.15.0" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.14.0.tgz", + "integrity": "sha512-bZc6YOjRkMBVA16527tgzi6iN9n//xRB3Mmx/R+Gr6UAP/+xrIKOejQIcn1hh+tCzNT8jO0jI+kWox5J4tB/qQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "@firebase/webchannel-wrapper": "1.0.5", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/firestore-compat": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.4.8.tgz", + "integrity": "sha512-WK9NJRpnosGD2nuyjdr7K+Ht7AxRYJlTF62myI4rRA7ibJOosbecvjacR5oirJ7s1BgNS6qzcBw7n4fD3a5w1w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/firestore": "4.14.0", + "@firebase/firestore-types": "3.0.3", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.3.tgz", + "integrity": "sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/functions": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.13.3.tgz", + "integrity": "sha512-csO7ckK3SSs+NUZW1nms9EK7ckHe/1QOjiP8uAkCYa7ND18s44vjE9g3KxEeIUpyEPqZaX1EhJuFyZjHigAcYw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.2", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.4.3.tgz", + "integrity": "sha512-BxkEwWgx1of0tKaao/r2VR6WBLk/RAiyztatiONPrPE8gkitFkOnOCxf8i9cUyA5hX5RGt5H30uNn25Q6QNEmQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/functions": "0.13.3", + "@firebase/functions-types": "0.6.3", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.3.tgz", + "integrity": "sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/installations": { + "version": "0.6.21", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.21.tgz", + "integrity": "sha512-xGFGTeICJZ5vhrmmDukeczIcFULFXybojML2+QSDFoKj5A7zbGN7KzFGSKNhDkIxpjzsYG9IleJyUebuAcmqWA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/util": "1.15.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.21", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.21.tgz", + "integrity": "sha512-zahIUkaVKbR8zmTeBHkdfaVl6JGWlhVoSjF7CVH33nFqD3SlPEpEEegn2GNT5iAfsVdtlCyJJ9GW4YKjq+RJKQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/installations": "0.6.21", + "@firebase/installations-types": "0.5.3", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.3.tgz", + "integrity": "sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", + "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.12.25", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.25.tgz", + "integrity": "sha512-7RhDwoDHlOK1/ou0/LeubxmjcngsTjDdrY/ssg2vwAVpUuVAhQzQvuCAOYxcX5wNC1zCgQ54AP1vdngBwbCmOQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/installations": "0.6.21", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.15.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.25", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.25.tgz", + "integrity": "sha512-eoOQqGLtRlseTdiemTN44LlHZpltK5gnhq8XVUuLgtIOG+odtDzrz2UoTpcJWSzaJQVxNLb/x9f39tHdDM4N4w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/messaging": "0.12.25", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz", + "integrity": "sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/performance": { + "version": "0.7.11", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.11.tgz", + "integrity": "sha512-V3uAhrz7IYJuji+OgT3qYTGKxpek/TViXti9OSsUJ4AexZ3jQjYH5Yrn7JvBxk8MGiSLsC872hh+BxQiPZsm7g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/installations": "0.6.21", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.24", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.24.tgz", + "integrity": "sha512-YRlejH8wLt7ThWao+HXoKUHUrZKGYq+otxkPS+8nuE5PeN1cBXX7NAJl9ueuUkBwMIrnKdnDqL/voHXxDAAt3g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/performance": "0.7.11", + "@firebase/performance-types": "0.2.3", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.3.tgz", + "integrity": "sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/remote-config": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.8.2.tgz", + "integrity": "sha512-5EXqOThV4upjK9D38d/qOSVwOqRhemlaOFk9vCkMNNALeIlwr+4pLjtLNo4qoY8etQmU/1q4aIATE9N8PFqg0g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/installations": "0.6.21", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.23", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.23.tgz", + "integrity": "sha512-4+KqRRHEUUmKT6tFmnpWATOsaFfmSuBs1jXH8JzVtMLEYqq/WS9IDM92OdefFDSrAA2xGd0WN004z8mKeIIscw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/remote-config": "0.8.2", + "@firebase/remote-config-types": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.5.0.tgz", + "integrity": "sha512-vI3bqLoF14L/GchtgayMiFpZJF+Ao3uR8WCde0XpYNkSokDpAKca2DxvcfeZv7lZUqkUwQPL2wD83d3vQ4vvrg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/storage": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.14.2.tgz", + "integrity": "sha512-o/culaTeJ8GRpKXRJov21rux/n9dRaSOWLebyatFP2sqEdCxQPjVA1H9Z2fzYwQxMIU0JVmC7SPPmU11v7L6vQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.4.2.tgz", + "integrity": "sha512-R+aB38wxCH5zjIO/xu9KznI7fgiPuZAG98uVm1NcidHyyupGgIDLKigGmRGBZMnxibe/m2oxNKoZpfEbUX2aQQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/storage": "0.14.2", + "@firebase/storage-types": "0.8.3", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.3.tgz", + "integrity": "sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.15.0.tgz", + "integrity": "sha512-AmWf3cHAOMbrCPG4xdPKQaj5iHnyYfyLKZxwz+Xf55bqKbpAmcYifB4jQinT2W9XhDRHISOoPyBOariJpCG6FA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.5.tgz", + "integrity": "sha512-+uGNN7rkfn41HLO0vekTFhTxk61eKa8mTpRGLO0QSqlQdKvIoGAvLp3ppdVIWbTGYJWM6Kp0iN+PjMIOcnVqTw==", + "license": "Apache-2.0" + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -2080,6 +2693,37 @@ "node": ">=6" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.15", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", + "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2467,6 +3111,12 @@ "integrity": "sha512-rY7KUpe1nLTk6oBPoRx/Eh9FDgTpxnUQSOrs1fsfs1T7l/pT6UtuYvh1UB32jTxe3l4QgUpE5NMq0mNJXrlQwg==", "license": "MIT" }, + "node_modules/@neurosity/ipk": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@neurosity/ipk/-/ipk-2.13.0.tgz", + "integrity": "sha512-uSRBSqEZQplzuOV/y7mgfPzgc2t8e2qTYnA36VNZD7x0U1PgNONFYHnlyB3ux88bHPLfkMnmp2rz3oHHn/C1Pw==", + "license": "MIT" + }, "node_modules/@neurosity/pipes": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/@neurosity/pipes/-/pipes-5.2.1.tgz", @@ -2478,6 +3128,61 @@ "rxjs": "^7.8.0" } }, + "node_modules/@neurosity/sdk": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@neurosity/sdk/-/sdk-7.1.0.tgz", + "integrity": "sha512-220Ni0F20mprXn1LxevzrqUUqnXEw41Xtxxnliy876w/JMdYMl7Kmgd46QsDLfjAuK91dzLrkjNwTX41/DN3Og==", + "license": "MIT", + "dependencies": { + "@neurosity/ipk": "^2.13.0", + "axios": "^1.15.0", + "buffer": "^6.0.3", + "fast-deep-equal": "^3.1.3", + "firebase": "^12.2.1", + "outliers": "0.0.3", + "rxjs": "^7.8.2", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@neurosity/sdk/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/@neurosity/sdk/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3008,6 +3713,70 @@ "integrity": "sha512-Mdk+vUACbQvjd0m/1JJjOOafmkp/EpmHjISsopEz5Av44CBq7rPC05HHNbYGKVyNUF2zmEoBS/TT0pd0SPFFyw==", "license": "MIT" }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -4450,7 +5219,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/common-tags": { @@ -5222,7 +5990,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5232,7 +5999,6 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", - "dev": true, "license": "MIT", "dependencies": { "@types/color-name": "^1.1.1", @@ -5804,7 +6570,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/at-least-node": { @@ -5880,6 +6645,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -5926,7 +6702,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -6336,7 +7111,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6580,7 +7354,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -6595,7 +7368,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -6660,7 +7432,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -6752,7 +7523,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -7897,7 +8667,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -8191,7 +8960,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -9194,7 +9962,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9204,7 +9971,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9249,7 +10015,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -9262,7 +10027,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -9411,7 +10175,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -10250,6 +11013,18 @@ "reusify": "^1.0.4" } }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -10328,6 +11103,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/firebase": { + "version": "12.12.0", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.12.0.tgz", + "integrity": "sha512-5Ap+pN5iEJUvBlQEZEmLuUm7Gvu6I5xv1jZ5SiSNyw4jrwlHo+4tmZv3OPPoKfN9eo1kBwyyBvi+pWHIPXwfYw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/ai": "2.11.0", + "@firebase/analytics": "0.10.21", + "@firebase/analytics-compat": "0.2.27", + "@firebase/app": "0.14.11", + "@firebase/app-check": "0.11.2", + "@firebase/app-check-compat": "0.4.2", + "@firebase/app-compat": "0.5.11", + "@firebase/app-types": "0.9.4", + "@firebase/auth": "1.13.0", + "@firebase/auth-compat": "0.6.5", + "@firebase/data-connect": "0.6.0", + "@firebase/database": "1.1.2", + "@firebase/database-compat": "2.1.3", + "@firebase/firestore": "4.14.0", + "@firebase/firestore-compat": "0.4.8", + "@firebase/functions": "0.13.3", + "@firebase/functions-compat": "0.4.3", + "@firebase/installations": "0.6.21", + "@firebase/installations-compat": "0.2.21", + "@firebase/messaging": "0.12.25", + "@firebase/messaging-compat": "0.2.25", + "@firebase/performance": "0.7.11", + "@firebase/performance-compat": "0.2.24", + "@firebase/remote-config": "0.8.2", + "@firebase/remote-config-compat": "0.2.23", + "@firebase/storage": "0.14.2", + "@firebase/storage-compat": "0.4.2", + "@firebase/util": "1.15.0" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -10358,6 +11169,26 @@ "dtype": "^2.0.0" } }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/font-atlas": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/font-atlas/-/font-atlas-2.1.0.tgz", @@ -10426,7 +11257,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -10626,7 +11456,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -10655,7 +11484,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -10689,7 +11517,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -11177,7 +12004,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11298,7 +12124,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11311,7 +12136,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -11427,6 +12251,12 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -11515,6 +12345,12 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -11889,7 +12725,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12544,6 +13379,16 @@ "dev": true, "license": "MIT" }, + "node_modules/koffi": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.1.tgz", + "integrity": "sha512-0Ie6CfD026dNfWSosDw9dPxPzO9Rlyo0N8m5r05S8YjytIpuilzMFDMY4IDy/8xQsTwpuVinhncD+S8n3bcYZQ==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, "node_modules/lab.js": { "version": "23.0.0-alpha4", "resolved": "https://registry.npmjs.org/lab.js/-/lab.js-23.0.0-alpha4.tgz", @@ -12857,6 +13702,12 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", @@ -13054,6 +13905,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -13311,7 +14168,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13398,7 +14254,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -13408,7 +14263,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -13984,6 +14838,18 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/node-labstreaminglayer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/node-labstreaminglayer/-/node-labstreaminglayer-0.3.0.tgz", + "integrity": "sha512-5LwcO2pp8BHtXM2AUJpTMxX0mCJcVkOJNLyaLdpSWujU9yyvmy2VKCsn7L0mzWj6CiXJYm0Z4s3sTllEQjkHIg==", + "license": "MIT", + "dependencies": { + "koffi": "^2.12.4" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -14293,6 +15159,11 @@ "node": ">=8" } }, + "node_modules/outliers": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/outliers/-/outliers-0.0.3.tgz", + "integrity": "sha512-llzMndHLe3bT5myeO5qiySIusEN+zd+Eq1YLXWbe2/FC2l26AmWPRw0ji7heI0azubWB6NEM87xU24y/CL99Iw==" + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -15116,12 +15987,45 @@ "signal-exit": "^3.0.2" } }, + "node_modules/protobufjs": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", + "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/protocol-buffers-schema": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", "license": "MIT" }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -15797,7 +16701,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -16215,7 +17118,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -16854,7 +17756,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -16892,7 +17793,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/string.prototype.includes": { @@ -17001,7 +17901,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -18822,6 +19721,12 @@ "integrity": "sha512-lNR9aAefbGPpHO7AEnY0hCFjz1eTkWCXYvkTRrTHs9qv8zJp+SkVYpzfLIFXQQiG3tVvbNFQgVg2bQS8YGgxyw==", "license": "Apache-2.0" }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "license": "Apache-2.0" + }, "node_modules/webgl-context": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/webgl-context/-/webgl-context-2.2.0.tgz", @@ -18841,6 +19746,29 @@ "node": ">=12" } }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -19197,7 +20125,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -19230,7 +20157,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -19249,7 +20175,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" diff --git a/package.json b/package.json index c625aeaa..0bda525f 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,11 @@ "productName": "BrainWaves", "appId": "com.electron.brainwaves", "asar": true, + "asarUnpack": [ + "**/*.node", + "node_modules/node-labstreaminglayer/prebuild/**", + "node_modules/koffi/**/*.node" + ], "files": [ "out/**/*", "node_modules/**/*", @@ -185,6 +190,7 @@ "@electron-toolkit/utils": "^4.0.0", "@fortawesome/fontawesome-free": "^5.13.0", "@neurosity/pipes": "^5.2.1", + "@neurosity/sdk": "^7.1.0", "@radix-ui/react-dialog": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.1.0", "@radix-ui/react-select": "^2.2.6", @@ -204,6 +210,7 @@ "mkdirp": "^1.0.4", "mousetrap": "^1.6.5", "muse-js": "^3.1.0", + "node-labstreaminglayer": "^0.3.0", "papaparse": "^5.5.3", "pathe": "^2.0.3", "plotly.js": "^3.4.0", diff --git a/src/main/index.ts b/src/main/index.ts index 84f62593..d552ea7c 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( @@ -409,6 +412,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 @@ -511,6 +557,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 9ab8c271..e486672c 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) @@ -162,4 +168,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 3111fe69..654c5e95 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 9e728af6..1b20536c 100644 --- a/src/renderer/epics/experimentEpics.ts +++ b/src/renderer/epics/experimentEpics.ts @@ -76,7 +76,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 4303efff..24b2ad9d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -29,6 +29,7 @@ export default defineConfig({ alias: { '@main': path.resolve(__dirname, 'src/main'), '@renderer': path.resolve(__dirname, 'src/renderer'), + '@shared': path.resolve(__dirname, 'src/shared'), }, }, }, @@ -68,6 +69,7 @@ export default defineConfig({ resolve: { alias: { '@renderer': path.resolve(__dirname, 'src/renderer'), + '@shared': path.resolve(__dirname, 'src/shared'), // Browser-compatible path utilities (pathe = modern drop-in for Node's path) path: 'pathe', events: 'events', From e193d96b307c4ab63eef80a6e1cb41af3af219fb Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sat, 18 Apr 2026 11:11:16 -0400 Subject: [PATCH 06/11] 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 d552ea7c..80a67a9b 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( @@ -415,11 +420,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}`, + }); } }); @@ -428,6 +450,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}`, + }); } }); @@ -436,19 +462,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 e486672c..15dbae59 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 @@ -203,4 +204,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 d542d07bd00c2c2868ba36640ee31d2f9dc23b62 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sat, 18 Apr 2026 17:28:57 -0400 Subject: [PATCH 07/11] 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 7b803d5e1a6781c7ebf3936b73d4837a8a7f4c8b Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 19 Apr 2026 10:04:13 -0400 Subject: [PATCH 08/11] 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 | 8 ++++ internals/scripts/patchDeps.mjs | 67 ++++++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/.llms/learnings.md b/.llms/learnings.md index d93903e9..9e6eecc3 100644 --- a/.llms/learnings.md +++ b/.llms/learnings.md @@ -60,6 +60,14 @@ The CDN version is derived from `node_modules/pyodide/package.json` — **not** **Plot result routing pattern** — `worker.postMessage()` is fire-and-forget (returns `undefined`). Plot epics should use `tap()` to fire the worker message and `mergeMap(() => EMPTY)` to emit nothing. Results come back asynchronously on the worker `message` event. Add a `plotKey` field to each worker message; the worker echoes it back; `pyodideMessageEpic` switches on `plotKey` to dispatch `SetTopoPlot`/`SetPSDPlot`/`SetERPPlot` with a `{ 'image/png': base64string }` MIME bundle. `PyodidePlotWidget` renders this via `@nteract/transforms`. +## liblsl on Apple Silicon + +`node-labstreaminglayer@0.3.0` ships only an **x86_64** `liblsl.dylib` in its `prebuild/` directory — the package has no arm64 build and was last updated 2025-08. Loading it on Apple Silicon throws `Failed to load shared library: ... (mach-o file, but is an incompatible architecture)`. + +**Fix**: install liblsl via Homebrew (`brew install labstreaminglayer/tap/lsl`), then `internals/scripts/patchDeps.mjs` symlinks `/opt/homebrew/Cellar/lsl//Frameworks/lsl.framework/Versions/A/lsl` over the bundled x86_64 dylib on every install/dev run. The patch is a no-op on x86_64 macs and on Linux/Windows (which ship usable `.so`/`.dll` in the same prebuild dir). + +Alternatives evaluated and rejected: `@neurodevs/node-lsl` and `@neurodevs/ndx-native` both require the same Homebrew install (they hard-code `/opt/homebrew/Cellar/lsl/...` paths) and have a much different async/worker-thread API that would force a substantial rewrite. + ## Pre-existing TypeScript errors (do not treat as regressions) - `src/renderer/epics/experimentEpics.ts` (lines 170, 205) — RxJS operator type mismatch diff --git a/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 fe26d49c2da9f2692b0a92fd6577d677e8ee619f Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 19 Apr 2026 10:08:18 -0400 Subject: [PATCH 09/11] 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 0a21e350ebdc20d3fdb48598e819ae2fcf463feb Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 19 Apr 2026 10:26:10 -0400 Subject: [PATCH 10/11] 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 80a67a9b..85a09e33 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -607,10 +607,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 ee69eedb32ff3f869d5aff1cb309803897e3a611 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 19 Apr 2026 10:29:14 -0400 Subject: [PATCH 11/11] =?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 9e6eecc3..cbdb570f 100644 --- a/.llms/learnings.md +++ b/.llms/learnings.md @@ -21,23 +21,23 @@ The app uses shadcn/ui + Tailwind CSS. CSS modules have been fully removed. Key - **Background gradient** used on all main screens: `bg-gradient-to-b from-[#f9f9f9] to-[#f0f0ff]` - **`@radix-ui/react-select`** is installed for the shadcn Select component -## Pyodide Asset Serving — Vite SPA Fallback Problem +## Pyodide Asset Serving — Custom `pyodide://` Protocol -Vite's `historyApiFallback` returns `index.html` for **all** `fetch()` requests from web workers, including `/@fs/` and `publicDir` paths. This breaks Pyodide's package loading entirely. +Vite's `historyApiFallback` returns `index.html` for **all** `fetch()` requests from web workers, breaking Pyodide's package loading. We solved this with a custom Electron protocol scheme registered in `src/main/index.ts` (`protocol.handle('pyodide', ...)`). The web worker uses `pyodide://host` as `PYODIDE_ASSET_BASE` and the handler resolves paths against the local filesystem — no HTTP socket required, works identically in dev and prod. -**Solution (two-part):** -1. A custom Vite middleware in `vite.config.ts` intercepts `/pyodide/` and `/packages/` requests before the SPA fallback and serves them directly from `src/renderer/utils/webworker/src/`. -2. An Electron `http` server on **port 17173** (started in `src/main/index.ts`) serves the same directory. Web workers use `http://127.0.0.1:17173` as `PYODIDE_ASSET_BASE`. This is the authoritative path — web worker `fetch()` calls bypass Vite entirely. +**Filesystem roots resolved by the handler:** +- Dev: `src/renderer/utils/webworker/src/` +- Prod: `process.resourcesPath/pyodide/` — `package.json` `extraResources` copies `webworker/src/` to a folder named `pyodide`. The protocol handler must match this destination name (mismatched once and broke prod entirely). -Port 17173 is hardcoded in both `src/main/index.ts` and `src/renderer/utils/webworker/webworker.js` and in the CSP (`src/renderer/index.html`). +**`indexURL` is required in prod, not just `packageBaseUrl`.** In dev, `pyodide.mjs` is imported via Vite's `?url` from `node_modules/pyodide/`, and the runtime files (`pyodide.asm.wasm`, `python_stdlib.zip`) load via `import.meta.url`-relative fetch — siblings live alongside it in node_modules. In prod, Vite bundles `pyodide.mjs` into `out/renderer/assets/` *without* its siblings, so `import.meta.url` resolution fails. Setting `indexURL: '${PYODIDE_ASSET_BASE}/pyodide/'` routes runtime fetches through the protocol handler. Set both `packageBaseUrl` (for `.whl` files via `loadPackage`) and `indexURL` (for the runtime). **Other Pyodide loading gotchas:** - `pyodide.mjs` must be loaded via dynamic `import()` (not `fetch()`), using a `?url` Vite import — `import()` bypasses the SPA fallback, `fetch()` does not - The lock file is embedded via `?raw` and wrapped in a `Blob` + `createObjectURL` to avoid an HTTP fetch -- Use `packageBaseUrl` (not `indexURL`) to tell Pyodide where to find `.whl` files; `indexURL` is for WASM/stdlib - `checkIntegrity: false` is required — SHA256 hashes in the npm lock file don't match CDN-downloaded wheels - Workers must be created with `type: 'module'` (Pyodide 0.26+ ships `pyodide.mjs` as ESM) - `optimizeDeps.exclude: ['pyodide']` in `vite.config.ts` prevents Vite from pre-bundling it +- `micropip.install()` only accepts `http://`, `https://`, `emfs://`, and relative paths — it rejects custom schemes like `pyodide://`. Workaround: JS-fetch each `.whl` via the protocol handler, write into Pyodide's emscripten FS at `/tmp/`, then install via `emfs:///tmp/...`. ## Pyodide Offline Package Installation (InstallMNE.mjs)