import { useState, useEffect, useRef, useCallback } from 'react';
import {
initEegWasm,
band_powers,
WasmCalmnessModel,
WasmAlphaBumpDetector,
WasmAlphaPeakModel,
AthenaWasmDecoder,
} from '@elata-biosciences/eeg-web';
import { BleTransport } from '@elata-biosciences/eeg-web-ble';
// ── Types ─────────────────────────────────────────────────────────────────────
type Mode = 'synthetic' | 'headband';
type BandPowers = { delta: number; theta: number; alpha: number; beta: number; gamma: number };
type Calmness = { score: number; state: string; alphaBetaRatio: number };
type AlphaBump = { state: string; alphaPower: number; baseline: number };
type AlphaPeak = {
peakFrequency: number;
smoothedPeak: number;
longTermPeak: number;
snr: number;
peakPower: number;
};
type BluetoothSupportResult = {
supported: boolean;
message: string;
isIOS: boolean;
};
// ── Constants ─────────────────────────────────────────────────────────────────
const SAMPLE_RATE = 256;
const WINDOW_SAMPLES = SAMPLE_RATE * 4;
const WAVEFORM_DISPLAY = SAMPLE_RATE * 3; // 3s of samples shown in graph
const SYNTH_TICK_MS = 32; // ~30 fps for synthetic animation
function checkBrowserBluetoothSupport(): BluetoothSupportResult {
const nav = navigator as Navigator & { bluetooth?: unknown };
const ua = navigator.userAgent || '';
const isIOS = /iPad|iPhone|iPod/.test(ua);
if (!window.isSecureContext) {
return {
supported: false,
message: 'Web Bluetooth requires HTTPS or localhost.',
isIOS,
};
}
if (!nav.bluetooth) {
return {
supported: false,
message: isIOS
? 'Web Bluetooth is not available on iOS Safari.'
: 'This browser does not expose Web Bluetooth.',
isIOS,
};
}
return {
supported: true,
message: 'Web Bluetooth is available.',
isIOS,
};
}
// ── EEG processing ────────────────────────────────────────────────────────────
function makeSyntheticWindow(): Float32Array {
const s = new Float32Array(WINDOW_SAMPLES);
for (let i = 0; i < WINDOW_SAMPLES; i++) {
const t = i / SAMPLE_RATE;
s[i] =
Math.sin(2 * Math.PI * 2 * t) * 0.2 +
Math.sin(2 * Math.PI * 6 * t) * 0.3 +
Math.sin(2 * Math.PI * 10 * t) * 0.8 +
Math.sin(2 * Math.PI * 20 * t) * 0.4 +
Math.sin(2 * Math.PI * 40 * t) * 0.1;
}
return s;
}
function computePowers(samples: Float32Array): BandPowers {
const r = band_powers(samples, SAMPLE_RATE);
const p = { delta: r.delta, theta: r.theta, alpha: r.alpha, beta: r.beta, gamma: r.gamma };
r.free();
return p;
}
function runCalmness(model: WasmCalmnessModel, samples: Float32Array): Calmness | null {
const r = model.process(samples);
if (!r) return null;
const c = { score: r.smoothed_score, state: r.state_description(), alphaBetaRatio: r.alpha_beta_ratio };
r.free();
return c;
}
function runAlphaBump(model: WasmAlphaBumpDetector, samples: Float32Array): AlphaBump | null {
const r = model.process(samples);
if (!r) return null;
const b = { state: r.state, alphaPower: r.alpha_power, baseline: r.baseline };
r.free();
return b;
}
function runAlphaPeak(model: WasmAlphaPeakModel, samples: Float32Array): AlphaPeak | null {
const r = model.process(samples);
if (!r) return null;
const p = {
peakFrequency: r.peak_frequency,
smoothedPeak: r.smoothed_peak_frequency,
longTermPeak: r.long_term_peak_frequency,
snr: r.snr,
peakPower: r.peak_power,
};
r.free();
return p;
}
// ── Tiny helpers ──────────────────────────────────────────────────────────────
function fmt(n: number, decimals = 2) {
return n.toFixed(decimals);
}
function calmnessColor(state: string): string {
if (state === 'high' || state === 'very calm' || state === 'calm') return 'var(--good)';
if (state === 'low' || state === 'alert') return 'var(--bad)';
return 'var(--warn)';
}
function calmnessGradient(state: string): string {
if (state === 'high' || state === 'very calm' || state === 'calm')
return 'linear-gradient(90deg, #059669, #34d399)';
if (state === 'low' || state === 'alert')
return 'linear-gradient(90deg, #e11d48, #f43f5e)';
return 'linear-gradient(90deg, #d97706, #fbbf24)';
}
function getStatusTone(
wasmReady: boolean,
mode: Mode,
deviceName: string | null,
status: string,
): 'live' | 'warn' | 'error' {
const lower = status.toLowerCase();
if (lower.includes('failed') || lower.includes('unavailable') || lower.includes('error'))
return 'error';
if (!wasmReady) return 'warn';
if (mode === 'headband' && !deviceName) return 'warn';
return 'live';
}
// ── ModeToggle ─────────────────────────────────────────────────────────────────
function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
const isHeadband = mode === 'headband';
return (
Synthetic
Headband (BLE)
);
}
// ── Waveform graph ────────────────────────────────────────────────────────────
function WaveformGraph({
bufRef,
active,
}: {
bufRef: React.MutableRefObject;
active: boolean;
}) {
const canvasRef = useRef(null);
const rafRef = useRef(0);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// DPR-aware sizing
const resize = () => {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas);
const ctx = canvas.getContext('2d')!;
function draw() {
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.width / dpr;
const h = canvas.height / dpr;
ctx.save();
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
const samples = bufRef.current.slice(-WAVEFORM_DISPLAY);
const n = samples.length;
// Subtle centre line
ctx.beginPath();
ctx.strokeStyle = 'rgba(51,65,85,0.5)';
ctx.lineWidth = 1;
ctx.moveTo(0, h / 2);
ctx.lineTo(w, h / 2);
ctx.stroke();
if (n >= 2) {
// Auto-scale Y with padding
let min = Infinity, max = -Infinity;
for (const v of samples) { if (v < min) min = v; if (v > max) max = v; }
const pad = (max - min) * 0.15 || 0.1;
const lo = min - pad, hi = max + pad, range = hi - lo;
const toY = (v: number) => h - ((v - lo) / range) * h;
// Filled area under the line
ctx.beginPath();
ctx.moveTo(0, toY(samples[0]));
for (let i = 1; i < n; i++) {
ctx.lineTo((i / (n - 1)) * w, toY(samples[i]));
}
ctx.lineTo(w, h);
ctx.lineTo(0, h);
ctx.closePath();
const grad = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, 'rgba(34,211,238,0.18)');
grad.addColorStop(1, 'rgba(34,211,238,0)');
ctx.fillStyle = grad;
ctx.fill();
// Line
ctx.beginPath();
ctx.strokeStyle = active ? '#22d3ee' : '#475569';
ctx.lineWidth = 1.5;
ctx.lineJoin = 'round';
ctx.shadowColor = '#22d3ee';
ctx.shadowBlur = active ? 8 : 0;
ctx.moveTo(0, toY(samples[0]));
for (let i = 1; i < n; i++) {
ctx.lineTo((i / (n - 1)) * w, toY(samples[i]));
}
ctx.stroke();
}
ctx.restore();
rafRef.current = requestAnimationFrame(draw);
}
rafRef.current = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(rafRef.current);
ro.disconnect();
};
}, [active]);
return ;
}
// ── App ───────────────────────────────────────────────────────────────────────
export default function App() {
const [mode, setMode] = useState('synthetic');
const [status, setStatus] = useState('Initializing WASM…');
const [powers, setPowers] = useState(null);
const [calmness, setCalmness] = useState(null);
const [alphaBump, setAlphaBump] = useState(null);
const [alphaPeak, setAlphaPeak] = useState(null);
const [deviceName, setDeviceName] = useState(null);
const [sampleCount, setSampleCount] = useState(0);
const [wasmReady, setWasmReady] = useState(false);
const bluetoothSupport = checkBrowserBluetoothSupport();
const transportRef = useRef(null);
const rollingBuf = useRef([]);
const waveformBuf = useRef([]);
const synthPhase = useRef(0);
const intervalRef = useRef | null>(null);
const synthFastRef = useRef | null>(null);
const calmnessRef = useRef(null);
const alphaBumpRef = useRef(null);
const alphaPeakRef = useRef(null);
function clearTick() {
if (intervalRef.current !== null) { clearInterval(intervalRef.current); intervalRef.current = null; }
if (synthFastRef.current !== null) { clearInterval(synthFastRef.current); synthFastRef.current = null; }
}
async function teardownHeadband() {
clearTick();
if (transportRef.current) {
await transportRef.current.stop().catch(() => {});
await transportRef.current.disconnect().catch(() => {});
transportRef.current = null;
}
rollingBuf.current = [];
}
const pushWaveform = useCallback((samples: number[]) => {
waveformBuf.current.push(...samples);
if (waveformBuf.current.length > WAVEFORM_DISPLAY)
waveformBuf.current = waveformBuf.current.slice(-WAVEFORM_DISPLAY);
}, []);
function processWindow(samples: Float32Array) {
setPowers(computePowers(samples));
if (calmnessRef.current) setCalmness(runCalmness(calmnessRef.current, samples));
if (alphaBumpRef.current) setAlphaBump(runAlphaBump(alphaBumpRef.current, samples));
if (alphaPeakRef.current) setAlphaPeak(runAlphaPeak(alphaPeakRef.current, samples));
}
function resetModels() {
calmnessRef.current?.reset();
alphaBumpRef.current?.reset();
alphaPeakRef.current?.reset();
}
useEffect(() => {
initEegWasm().then(() => {
calmnessRef.current = new WasmCalmnessModel(SAMPLE_RATE, 1);
alphaBumpRef.current = new WasmAlphaBumpDetector(SAMPLE_RATE, 1);
alphaPeakRef.current = new WasmAlphaPeakModel(SAMPLE_RATE, 1);
setWasmReady(true);
setStatus('Ready.');
});
return () => {
calmnessRef.current?.free(); calmnessRef.current = null;
alphaBumpRef.current?.free(); alphaBumpRef.current = null;
alphaPeakRef.current?.free(); alphaPeakRef.current = null;
};
}, []);
useEffect(() => {
if (!wasmReady || mode !== 'synthetic') return;
setStatus('Running synthetic EEG analysis.');
setDeviceName(null);
setSampleCount(0);
resetModels();
synthPhase.current = 0;
waveformBuf.current = [];
const batchSize = Math.round(SAMPLE_RATE * SYNTH_TICK_MS / 1000);
synthFastRef.current = setInterval(() => {
const batch: number[] = [];
for (let i = 0; i < batchSize; i++) {
const t = synthPhase.current / SAMPLE_RATE;
batch.push(
Math.sin(2 * Math.PI * 2 * t) * 0.2 +
Math.sin(2 * Math.PI * 6 * t) * 0.3 +
Math.sin(2 * Math.PI * 10 * t) * 0.8 +
Math.sin(2 * Math.PI * 20 * t) * 0.4 +
Math.sin(2 * Math.PI * 40 * t) * 0.1
);
synthPhase.current++;
}
pushWaveform(batch);
}, SYNTH_TICK_MS);
intervalRef.current = setInterval(() => processWindow(makeSyntheticWindow()), 1000);
return () => {
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
if (synthFastRef.current) { clearInterval(synthFastRef.current); synthFastRef.current = null; }
};
}, [wasmReady, mode]);
useEffect(() => {
if (mode === 'headband') {
// Return a cleanup so teardown fires during the cleanup phase (before
// the synthetic effect re-runs), not after it — which would kill the
// freshly created synthetic intervals.
return () => { void teardownHeadband(); };
}
}, [mode]);
function switchMode(next: Mode) {
setPowers(null); setCalmness(null); setAlphaBump(null); setAlphaPeak(null);
waveformBuf.current = [];
resetModels();
setStatus(next === 'synthetic' ? 'Switching to synthetic…' : 'Ready to connect.');
setMode(next);
}
async function connectHeadband() {
await teardownHeadband();
setPowers(null); setCalmness(null); setAlphaBump(null); setAlphaPeak(null);
setSampleCount(0);
waveformBuf.current = [];
resetModels();
try {
setStatus('Requesting Bluetooth device…');
const transport = new BleTransport({
deviceOptions: {
athenaDecoderFactory: () => new AthenaWasmDecoder(),
},
// Set eegProcessing: false if you want raw transport data in frame.eeg.
});
transportRef.current = transport;
transport.onFrame = (frame) => {
const batch = frame.eeg.samples.map((row) => row[0] ?? 0);
for (const v of batch) rollingBuf.current.push(v);
if (rollingBuf.current.length > WINDOW_SAMPLES)
rollingBuf.current = rollingBuf.current.slice(-WINDOW_SAMPLES);
pushWaveform(batch);
setSampleCount((c) => c + batch.length);
};
await transport.connect();
const info = transport.getBoardInfo() as { device_name: string };
setDeviceName(info.device_name);
setStatus(`Connected to ${info.device_name}. Streaming…`);
await transport.start();
intervalRef.current = setInterval(() => {
if (rollingBuf.current.length < SAMPLE_RATE) return;
processWindow(new Float32Array(rollingBuf.current));
}, 1000);
} catch (err) {
setStatus(err instanceof Error ? err.message : 'Failed to connect.');
}
}
useEffect(() => () => { void teardownHeadband(); }, []);
// ── Derived display values ──────────────────────────────────────────────────
const statusTone = getStatusTone(wasmReady, mode, deviceName, status);
const statusDotClass =
statusTone === 'error' ? 'status-dot error' : statusTone === 'warn' ? 'status-dot warn' : 'status-dot';
const calmnessPct = calmness ? Math.round(calmness.score * 100) : null;
const bandMax = powers
? Math.max(powers.delta, powers.theta, powers.alpha, powers.beta, powers.gamma) || 1
: 1;
const bands: { label: string; range: string; key: keyof BandPowers; cls: string }[] = [
{ label: 'Delta', range: '0.5–4 Hz', key: 'delta', cls: 'meter-fill--delta' },
{ label: 'Theta', range: '4–8 Hz', key: 'theta', cls: 'meter-fill--theta' },
{ label: 'Alpha', range: '8–13 Hz', key: 'alpha', cls: 'meter-fill--alpha' },
{ label: 'Beta', range: '13–30 Hz', key: 'beta', cls: 'meter-fill--beta' },
{ label: 'Gamma', range: '30–100 Hz', key: 'gamma', cls: 'meter-fill--gamma' },
];
return (
Elata
Brain wave analysis
{status}
{mode === 'headband' && (
{!bluetoothSupport.supported ? (
⚠
{bluetoothSupport.message}
{bluetoothSupport.isIOS && (
Get Bluefy
)}
) : (
<>
{deviceName && (
{deviceName}
)}
{sampleCount > 0 && (
{sampleCount.toLocaleString()} samples
)}
>
)}
)}
Live EEG readout and brain state
{/* Left: waveform */}
{mode === 'headband' ? 'TP9 · 256 Hz' : 'Synthetic · 256 Hz'}
3-second rolling EEG window — alpha (8–13 Hz) dominance indicates a relaxed state.
{/* Right: metrics */}
Built with @elata-biosciences/eeg-web — WASM EEG analysis, Muse BLE
streaming, and a layout suited for demos and screen recordings.
{alphaPeak && (
Alpha peak analysis
Peak Frequency
{fmt(alphaPeak.peakFrequency, 2)} Hz
Smoothed Peak
{fmt(alphaPeak.smoothedPeak, 2)} Hz
Long-Term Peak
{fmt(alphaPeak.longTermPeak, 2)} Hz
SNR
{fmt(alphaPeak.snr, 3)}
Peak Power
{fmt(alphaPeak.peakPower, 4)}
{calmness && (
Alpha / Beta Ratio
{fmt(calmness.alphaBetaRatio, 2)}
)}
)}
);
}