Skip to content

Commit f180f91

Browse files
jdpigeonclaude
andcommitted
feat: make LSL optional via lazy native load + feature detection
node-labstreaminglayer dlopen's liblsl at require time, so a static import crashed the whole app on startup when liblsl was missing/ incompatible (e.g. Apple Silicon without the Homebrew build) — even for Muse-only users who never need LSL. Load the native bindings lazily and fail soft so LSL becomes a true advanced, opt-in feature: - src/main/lsl/native.ts: guarded require() in try/catch (memoized), exposing loadLSL() and isLSLAvailable() - outlets.ts/inlets.ts: type-only imports + loadLSL() at call time; all ops no-op gracefully when liblsl is unavailable - lsl:isAvailable IPC + preload bridge for renderer feature detection - ConnectModal hides 'External LSL stream' when unavailable - lslBridge no-ops sendEpoch/sendMarker when unavailable (no IPC spam) Result: Muse/Neurosity work with zero LSL/LabRecorder dependency; LSL features appear only where liblsl loads. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 656318c commit f180f91

9 files changed

Lines changed: 112 additions & 18 deletions

File tree

.llms/learnings.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,16 @@ Any hook function (e.g. `initResponseHandlers` in `src/renderer/utils/labjs/func
8989

9090
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.
9191

92+
## LSL is optional — load the native module lazily, never statically
93+
94+
`node-labstreaminglayer` `dlopen`s liblsl via koffi **at require time**. A static `import … from 'node-labstreaminglayer'` in the main process therefore runs that dlopen during module evaluation at startup — so a missing/incompatible liblsl (e.g. Apple Silicon without the Homebrew build) crashes the *entire app* on launch, even for Muse-only users who don't need LSL at all.
95+
96+
LSL is an advanced, opt-in capability: Muse and Neurosity connect via Web Bluetooth and record to CSV without it. So the native module must load lazily and fail soft:
97+
- `src/main/lsl/native.ts` does a guarded `require('node-labstreaminglayer')` in try/catch (memoized), exposing `loadLSL()` (module | null) and `isLSLAvailable()`. `outlets.ts`/`inlets.ts` use **type-only** imports + `loadLSL()` at call time, no-opping when null.
98+
- Feature-detect in the renderer via the `lsl:isAvailable` IPC: `ConnectModal` hides the "External LSL stream" option and `lslBridge` no-ops `sendEpoch`/`sendMarker` when unavailable (avoids IPC spam from first-party devices).
99+
100+
Build note: with `module: ESNext` source but CommonJS main output, a guarded `require(...)` of an externalized dep type-checks (global `require` from `@types/node`) and stays a `require` in `out/main/index.js` (electron-vite externalizes it) — confirmed lazy, not bundled. Do **not** revert to a static import.
101+
92102
## Pre-existing TypeScript errors (do not treat as regressions)
93103

94104
- `src/renderer/epics/experimentEpics.ts` (lines 170, 205) — RxJS operator type mismatch

src/main/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import MenuBuilder from './menu';
1919
import { FILE_TYPES } from '../renderer/constants/constants';
2020
import { lslOutlets } from './lsl/outlets';
2121
import { lslInlets } from './lsl/inlets';
22+
import { isLSLAvailable } from './lsl/native';
2223
import type {
2324
LSLEpoch,
2425
LSLMarker,
@@ -474,6 +475,11 @@ const emitLSLStatus = (status: LSLStatus) => {
474475
mainWindow?.webContents.send('lsl:status', status);
475476
};
476477

478+
// Feature-detection probe: the renderer uses this to show/hide LSL UI. When
479+
// liblsl can't be loaded, LSL is silently unavailable and the app falls back
480+
// to first-party devices (Muse/Neurosity) only.
481+
ipcMain.handle('lsl:isAvailable', () => isLSLAvailable());
482+
477483
ipcMain.on('lsl:sendEpoch', (_event, epoch: LSLEpoch) => {
478484
try {
479485
lslOutlets.pushEpoch(epoch);

src/main/lsl/inlets.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,9 @@
77
* pylsl, etc.).
88
*/
99
import log from 'electron-log';
10-
import {
11-
resolveStreams,
12-
StreamInfo,
13-
StreamInlet,
14-
} from 'node-labstreaminglayer';
10+
import type { StreamInfo, StreamInlet } from 'node-labstreaminglayer';
1511
import type { DiscoveredStream, LSLInletEpoch } from '../../shared/lslTypes';
12+
import { loadLSL } from './native';
1613

1714
const POLL_INTERVAL_MS = 16; // ~60Hz poll
1815

@@ -38,14 +35,17 @@ class LSLInletManager {
3835
private discoveredInfos = new Map<string, StreamInfo>();
3936

4037
discoverStreams(waitTime: number = 1.0): DiscoveredStream[] {
38+
const lsl = loadLSL();
39+
if (!lsl) return [];
40+
4141
// Free any StreamInfos we cached but never subscribed to on the previous
4242
// scan so we don't leak their C handles.
4343
for (const [uid, info] of this.discoveredInfos) {
4444
if (!this.inlets.has(uid)) info.destroy();
4545
}
4646
this.discoveredInfos.clear();
4747

48-
const streams = resolveStreams(waitTime);
48+
const streams = lsl.resolveStreams(waitTime);
4949
const results: DiscoveredStream[] = [];
5050
for (const info of streams) {
5151
const uid = info.uid();
@@ -68,13 +68,15 @@ class LSLInletManager {
6868
onDisconnected?: () => void
6969
): boolean {
7070
if (this.inlets.has(uid)) return true;
71+
const lsl = loadLSL();
72+
if (!lsl) return false;
7173
const info = this.discoveredInfos.get(uid);
7274
if (!info) {
7375
log.warn(`[lsl] subscribeStream: unknown uid ${uid} — discover first`);
7476
return false;
7577
}
7678

77-
const inlet = new StreamInlet(info);
79+
const inlet = new lsl.StreamInlet(info);
7880
try {
7981
inlet.openStream(5);
8082
} catch (err) {

src/main/lsl/native.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Lazy, fail-safe loader for the native LSL bindings.
3+
*
4+
* `node-labstreaminglayer` loads liblsl via koffi (FFI) at require time. On a
5+
* machine where liblsl is missing (e.g. Apple Silicon without the Homebrew
6+
* build) that require throws. LSL is an advanced, opt-in capability — Muse and
7+
* Neurosity work entirely without it — so a failed load must never crash app
8+
* startup. We attempt the require once, swallow any failure, and report
9+
* availability so callers (outlets, inlets, the renderer feature-gate) can
10+
* degrade gracefully.
11+
*/
12+
import log from 'electron-log';
13+
14+
type LSLModule = typeof import('node-labstreaminglayer');
15+
16+
let cached: LSLModule | null = null;
17+
let attempted = false;
18+
19+
/**
20+
* Returns the loaded LSL module, or null if liblsl could not be loaded.
21+
* The load is attempted lazily on first call and the result is memoized.
22+
*/
23+
export function loadLSL(): LSLModule | null {
24+
if (attempted) return cached;
25+
attempted = true;
26+
try {
27+
// require (not a static import) so a missing liblsl degrades to a disabled
28+
// feature instead of throwing during module evaluation at startup.
29+
// eslint-disable-next-line @typescript-eslint/no-require-imports
30+
cached = require('node-labstreaminglayer') as LSLModule;
31+
log.info('[lsl] native module loaded — LSL features enabled');
32+
} catch (err) {
33+
cached = null;
34+
log.warn(
35+
'[lsl] node-labstreaminglayer unavailable — LSL features disabled ' +
36+
'(expected if liblsl is not installed):',
37+
err instanceof Error ? err.message : err
38+
);
39+
}
40+
return cached;
41+
}
42+
43+
export function isLSLAvailable(): boolean {
44+
return loadLSL() !== null;
45+
}

src/main/lsl/outlets.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,9 @@
66
* LSL network where they can be recorded by LabRecorder or any LSL inlet.
77
*/
88
import log from 'electron-log';
9-
import {
10-
StreamInfo,
11-
StreamOutlet,
12-
IRREGULAR_RATE,
13-
} from 'node-labstreaminglayer';
9+
import type { StreamOutlet } from 'node-labstreaminglayer';
1410
import type { LSLEpoch } from '../../shared/lslTypes';
11+
import { loadLSL } from './native';
1512

1613
const MARKER_STREAM_NAME = 'BrainWavesMarkers';
1714

@@ -29,10 +26,13 @@ class LSLOutletManager {
2926
channelNames: string[],
3027
sampleRate: number
3128
): void {
29+
const lsl = loadLSL();
30+
if (!lsl) return;
31+
3232
this.destroyDeviceOutlet(deviceId);
3333

3434
const streamName = `BrainWaves-${deviceType}-${deviceId}`;
35-
const info = new StreamInfo(
35+
const info = new lsl.StreamInfo(
3636
streamName,
3737
'EEG',
3838
channelNames.length,
@@ -44,7 +44,7 @@ class LSLOutletManager {
4444
info.setChannelTypes('EEG');
4545
info.setChannelUnits('microvolts');
4646

47-
const outlet = new StreamOutlet(info);
47+
const outlet = new lsl.StreamOutlet(info);
4848
this.outlets.set(deviceId, outlet);
4949
log.info(
5050
`[lsl] created EEG outlet ${streamName} (${channelNames.length}ch @ ${sampleRate}Hz)`
@@ -88,15 +88,17 @@ class LSLOutletManager {
8888
*/
8989
createMarkerOutlet(): void {
9090
if (this.markerOutlet) return;
91-
const info = new StreamInfo(
91+
const lsl = loadLSL();
92+
if (!lsl) return;
93+
const info = new lsl.StreamInfo(
9294
MARKER_STREAM_NAME,
9395
'Markers',
9496
1,
95-
IRREGULAR_RATE,
97+
lsl.IRREGULAR_RATE,
9698
'string',
9799
'brainwaves-markers'
98100
);
99-
this.markerOutlet = new StreamOutlet(info);
101+
this.markerOutlet = new lsl.StreamOutlet(info);
100102
log.info(`[lsl] created marker outlet ${MARKER_STREAM_NAME}`);
101103
}
102104

src/preload/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
183183
// ------------------------------------------------------------------
184184
// LSL — main-process outlets push to the LSL network, inlets pull from it
185185
// ------------------------------------------------------------------
186+
// True only when the native liblsl bindings loaded. Renderer feature-gates
187+
// all LSL UI/forwarding on this so the app works without liblsl installed.
188+
isLSLAvailable: (): Promise<boolean> => ipcRenderer.invoke('lsl:isAvailable'),
189+
186190
sendLSLEpoch: (epoch: LSLEpoch): void =>
187191
ipcRenderer.send('lsl:sendEpoch', epoch),
188192

src/renderer/components/CollectComponent/ConnectModal.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ interface Props {
2929
interface State {
3030
selectedDevice: Device | null;
3131
instructionProgress: INSTRUCTION_PROGRESS;
32+
// True only when native liblsl loaded in the main process. The "External LSL
33+
// stream" device option is hidden otherwise so the app works without liblsl.
34+
lslAvailable: boolean;
3235
}
3336

3437
enum INSTRUCTION_PROGRESS {
@@ -50,6 +53,7 @@ export default class ConnectModal extends Component<Props, State> {
5053
this.state = {
5154
selectedDevice: null,
5255
instructionProgress: INSTRUCTION_PROGRESS.SEARCHING,
56+
lslAvailable: false,
5357
};
5458
this.handleSearch = debounce(this.handleSearch.bind(this), 300, {
5559
leading: true,
@@ -62,6 +66,13 @@ export default class ConnectModal extends Component<Props, State> {
6266
this.handleinstructionProgress = this.handleinstructionProgress.bind(this);
6367
}
6468

69+
componentDidMount() {
70+
window.electronAPI
71+
?.isLSLAvailable?.()
72+
.then((ok) => this.setState({ lslAvailable: ok }))
73+
.catch(() => this.setState({ lslAvailable: false }));
74+
}
75+
6576
UNSAFE_componentWillUpdate(nextProps: Props) {
6677
if (
6778
nextProps.deviceAvailability === DEVICE_AVAILABILITY.NONE &&
@@ -196,7 +207,9 @@ export default class ConnectModal extends Component<Props, State> {
196207
>
197208
<option value={DEVICES.MUSE}>Muse</option>
198209
<option value={DEVICES.NEUROSITY}>Neurosity Crown</option>
199-
<option value={DEVICES.LSL}>External LSL stream</option>
210+
{this.state.lslAvailable && (
211+
<option value={DEVICES.LSL}>External LSL stream</option>
212+
)}
200213
</select>
201214
</div>
202215
{this.props.deviceType === DEVICES.LSL && this.renderLSLDiscovery()}

src/renderer/types/electron.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ declare global {
9999
cancelBluetoothSearch: () => Promise<void>;
100100

101101
// LSL
102+
isLSLAvailable: () => Promise<boolean>;
102103
sendLSLEpoch: (epoch: LSLEpoch) => void;
103104
sendLSLMarker: (marker: LSLMarker) => void;
104105
discoverLSLStreams: () => Promise<DiscoveredStream[]>;

src/renderer/utils/eeg/lslBridge.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ import { EEGData } from '../../constants/interfaces';
1111

1212
const DEFAULT_BATCH_SIZE = 32;
1313

14+
// Cache the main-process LSL availability so the hot epoch/marker paths don't
15+
// flood IPC when liblsl isn't loaded. Probed once; defaults to false until the
16+
// async check resolves (a few early samples may be dropped — harmless, and the
17+
// main process no-ops them anyway).
18+
let lslAvailable = false;
19+
void window.electronAPI?.isLSLAvailable?.().then((ok) => {
20+
lslAvailable = ok;
21+
});
22+
1423
/**
1524
* Transforms a raw EEG observable (per-sample EEGData) into an observable of
1625
* batched LSLEpoch objects ready to be forwarded over IPC.
@@ -37,9 +46,11 @@ export const batchSamplesToEpoch = (
3746
);
3847

3948
export const sendEpoch = (epoch: LSLEpoch): void => {
49+
if (!lslAvailable) return;
4050
window.electronAPI?.sendLSLEpoch?.(epoch);
4151
};
4252

4353
export const sendMarker = (marker: LSLMarker): void => {
54+
if (!lslAvailable) return;
4455
window.electronAPI?.sendLSLMarker?.(marker);
4556
};

0 commit comments

Comments
 (0)