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 (
{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 */}

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)}
)}
)}
); }