Software
Features
Detectors

Detectors

Browser-side EEG processing hooks — React refs, zero re-renders. Read .current in your requestAnimationFrame loop.

import { useBandPowers, useBlink, useFocus, useRelax } from "../../hooks/detectors";

useBandPowers(eegData, config?)

Foundation layer. Single FFT instance, averaged spectral band powers.

FieldTypeDescription
absoluteBandPowersAbsolute power per band (µV²/Hz) — Delta, Theta, Alpha, Beta, Gamma
relativeBandPowersNormalized to sum = 1
totalPowernumberSum across all bands
dominantFrequencynumberPeak PSD bin (Hz)

Config: { updateHz?, channels?, smoothing? }

useBlink(eegData, config?)

Trainless frontal EOG detector. Face-interface electrodes on Fp1/Fp2 (channels 0/1). A blink is a large common-mode corneal-retinal deflection (typically 100-500 µV p2p, 60-600 ms) against resting EEG (~20-80 µV). Peak-to-peak over 100 ms is DC-insensitive.

No prompted calibration. The detector warms up silently (~0.8 s) while a rolling robust baseline accumulates.

Per tick (~40 Hz):

  1. Average p2p across the frontal pair (one noisy channel cannot dominate).
  2. Rolling median + MAD baseline (~3 s). Blinks are outliers, so they do not raise the gate.
  3. Threshold = max(25 µV, baseline + max(5σ, 8 µV)).
  4. Onset above threshold, offset below a hysteresis level (0.6 of the margin), duration gate (60-600 ms), then 300 ms refractory.

Jaw / forehead EMG / head motion also produce large frontal p2p. This is not a morphology classifier.

Returns: { state, reset() }

FieldTypeDescription
blinkedbooleantrue for exactly one poll cycle when a blink is confirmed
countnumberCumulative blink count
amplitudenumberCurrent peak-to-peak µV
lastBlinkTimenumberEpoch ms of last confirmed blink
baselinenumberRolling robust baseline (µV)
thresholdnumberCurrent detection threshold (µV)
warmedbooleanTrue once the rolling baseline has enough history

Prefer state.current.count over the one-shot blinked flag: miss one 25 ms poll and the flag is gone.

BioPose Recorder uses that count while Record is on: each increment writes { t, text: "blink" } onto the take. Same detector, same default channels.

Config: { channels?, threshold?, windowMs?, minDurationMs?, maxDurationMs?, refractoryMs?, pollHz? }

threshold is a fixed µV override. Omit it to keep the adaptive gate. Default channels are [0, 1] (Fp1/Fp2).

useFocus(eegData, config?)

Cortical engagement index — (Beta + Gamma) / (Alpha + Theta + Delta).

FieldTypeDescription
focusnumber0 (relaxed) – 1 (highly focused), smoothed
rawnumberUnsmoothed, uncalibrated ratio
calibratedbooleanWhether baseline has been captured

Config: { channels?, updateHz?, smoothing?, scaleDivisor? }

Returns: { state, calibrate(), resetCalibration(), calibrating }

useRelax(eegData, config?)

Alpha-dominance + theta-beta ratio composite relaxation index.

FieldTypeDescription
relaxationnumber0 (alert) – 1 (deeply relaxed), smoothed
alphaRelativenumberAlpha / total power (0–1)
thetaBetaRationumberθ / β raw ratio
calibratedbooleanWhether baseline has been captured

Config: { channels?, updateHz?, smoothing?, alphaWeight?, tbrCeiling? }

Returns: { state, calibrate(), resetCalibration(), calibrating }

Usage Pattern

All detectors use the same ref-based pattern for zero-rerender reads:

const { state: focus } = useFocus(eegData);
 
useEffect(() => {
  let raf: number;
  function loop() {
    const f = focus.current.focus; // read directly, no re-render
    // use f to drive animation, game logic, etc.
    raf = requestAnimationFrame(loop);
  }
  raf = requestAnimationFrame(loop);
  return () => cancelAnimationFrame(raf);
}, []);