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.
| Field | Type | Description |
|---|---|---|
absolute | BandPowers | Absolute power per band (µV²/Hz) — Delta, Theta, Alpha, Beta, Gamma |
relative | BandPowers | Normalized to sum = 1 |
totalPower | number | Sum across all bands |
dominantFrequency | number | Peak 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):
- Average p2p across the frontal pair (one noisy channel cannot dominate).
- Rolling median + MAD baseline (~3 s). Blinks are outliers, so they do not raise the gate.
- Threshold =
max(25 µV, baseline + max(5σ, 8 µV)). - 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() }
| Field | Type | Description |
|---|---|---|
blinked | boolean | true for exactly one poll cycle when a blink is confirmed |
count | number | Cumulative blink count |
amplitude | number | Current peak-to-peak µV |
lastBlinkTime | number | Epoch ms of last confirmed blink |
baseline | number | Rolling robust baseline (µV) |
threshold | number | Current detection threshold (µV) |
warmed | boolean | True 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).
| Field | Type | Description |
|---|---|---|
focus | number | 0 (relaxed) – 1 (highly focused), smoothed |
raw | number | Unsmoothed, uncalibrated ratio |
calibrated | boolean | Whether 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.
| Field | Type | Description |
|---|---|---|
relaxation | number | 0 (alert) – 1 (deeply relaxed), smoothed |
alphaRelative | number | Alpha / total power (0–1) |
thetaBetaRatio | number | θ / β raw ratio |
calibrated | boolean | Whether 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);
}, []);