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;
};
// ── 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
// ── 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
onChange(isHeadband ? 'synthetic' : 'headband')}
>
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('headband');
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 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
Muse Web Bluetooth starter
{status}
{mode === 'headband' && (
Connect Muse headset
{deviceName && (
{deviceName}
)}
{sampleCount > 0 && (
{sampleCount.toLocaleString()} samples
)}
)}
Live EEG readout and brain state
{/* Left: waveform */}
{mode === 'headband' ? 'Muse BLE · 256 Hz' : 'Synthetic · 256 Hz'}
BLE-first starter for Muse-compatible headsets. Use synthetic mode only when you want to verify the WASM path without hardware.
{/* Right: metrics */}
{/* Calmness hero */}
Calmness
{calmnessPct ?? '—'}
%
{calmness && (
)}
{calmness ? calmness.state : 'Warming up'}
{/* Band powers */}
{powers && (
{bands.map(({ label, range, key, cls }) => (
{label} {range}
{powers[key].toFixed(3)}
))}
)}
{/* Alpha bump state chips */}
{alphaBump && (
Alpha State
{alphaBump.state}
Alpha Power
{alphaBump.alphaPower.toFixed(4)}
Baseline
{alphaBump.baseline.toFixed(4)}
)}
Built with @elata-biosciences/eeg-web — WASM EEG analysis, Muse BLE
streaming, native demo references, and a layout suited for demos and screen recordings.
Native BLE references
Use these repo paths when you need native Bluetooth plumbing alongside the browser BLE flow.
iOS native BLE reference
CoreBluetooth Muse manager
Full native BLE flow with CoreBluetooth, Muse packet parsing, and a SwiftUI demo surface.
ios-demo/README.md
ios-demo/EegDemoApp/Bluetooth/MuseBluetoothManager.swift
Android native BLE reference
Demo shell and Bluetooth permissions
Native Android starting points in this repo are lighter today, but these paths show the demo shell and Bluetooth permission wiring.
android-demo/README.md
android-demo/app/src/main/AndroidManifest.xml
{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)}
)}
)}
Elata SDK · BLE-first EEG web template
);
}