Skip to main content

2. Architecture Tour

Goal: Trace a single EEG sample from the USB cable to the rendered pixel on screen.

The Full Pipeline

USB → Web Serial → Binary Parser → rawWaveformBus → Canvas (Raw Waveform)

├──→ Butterworth IIR → filteredWaveformBus → Canvas (Filtered)

└──→ FFT Analyzer (2s window)

├──→ Band Powers → EI Trend (Zustand)
├──→ Band Powers → Focus Classifier
├──→ Band Powers → Brain Heatmap
└──→ Five-band Frames → IndexedDB → AI Agent

Step 1: Serial Data Arrives

File: src/serial/serialEegProtocol.ts

The serial reader loop receives raw bytes from navigator.serial. The parser:

  1. Reads chunks from the serial port
  2. Looks for frame boundaries in the binary stream
  3. Extracts int24 sample values per channel
  4. Validates packet sequence numbers to detect gaps
// Simplified: what the parser produces per batch
interface EegBatch {
samples: number[][]; // samples[channel][sampleIndex]
packetSeq: number;
timestamp: number;
}

Each batch is handed to useAcquisitionActions which dispatches it forward.

Key file: src/hooks/useAcquisitionActions.ts — the central state machine that owns the reader, parser, file handle, and analyzer refs.

Step 2: Waveform Bus

Files: src/state/waveformBus.ts, src/state/rawWaveformBus.ts

After parsing, each sample value is pushed to an observer bus:

// src/state/waveformBus.ts (simplified)
export function createWaveformBus(): WaveformBus {
const buffers = new Map<string, Float32Array>(); // per-channel ring buffers
const writeIndexes = new Map<string, number>();

return {
push(value, channelName) {
const buf = buffers.get(channelName);
const idx = writeIndexes.get(channelName);
buf[idx % CAPACITY] = value; // ring buffer write
writeIndexes.set(channelName, idx + 1);
},
copyLatest(out, count, channelName) {
// copy the last `count` samples into `out`
},
};
}

The bus is a ring buffer with 600s capacity (150,000 samples at 250 Hz). Two singleton buses exist:

  • src/state/rawWaveformBus.tscreateWaveformBus() for raw data
  • src/state/filteredWaveformBus.tscreateWaveformBus() for filtered data

Why buses instead of Zustand? 250 Hz data would cause 250 React re-renders per second if stored in Zustand. The bus bypasses React and feeds Canvas directly via requestAnimationFrame.

Step 3: Waveform Rendering

File: src/components/WaveformPanel.tsx (758 lines)

Both RawWaveformPanel and FilteredWaveformPanel wrap a single shared component. The rendering loop:

// Inside WaveformPanel (simplified)
function drawLoop() {
const samples = new Float32Array(windowSamples);
bus.copyLatest(samples, windowSamples, channelName);

// Canvas 2D drawing
ctx.clearRect(0, 0, width, height);
ctx.beginPath();
for (let i = 0; i < samples.length; i++) {
const x = (i / samples.length) * width;
const y = scaleToPixel(samples[i]);
ctx.lineTo(x, y);
}
ctx.stroke();

animFrameId = requestAnimationFrame(drawLoop);
}

This runs at display refresh rate (~60 fps), not at 250 Hz. The bus's copyLatest efficiently extracts the most recent N samples regardless of how many accumulated since the last frame.

Step 4: IIR Filtering

File: src/analysis/butterworthFilter.ts

After the raw bus push, the same batch goes through a 4th-order Butterworth IIR band-pass filter:

raw samples → [HP filter stage] → [LP filter stage] → filteredWaveformBus.push()

The filter uses Direct Form II topology for numerical stability. Coefficients are recomputed whenever the user changes cutoff frequencies on the Setup page.

Step 5: FFT Analysis

File: src/analysis/eegFrequencyAnalysis.ts

The filtered data feeds a sliding FFT window:

ParameterValue
Window duration2 seconds (500 samples)
Hop size0.5 seconds (125 samples)
FFT size512 points
Window functionHann

Band powers are computed by summing FFT magnitudes within each frequency range:

BandRange (Hz)
Delta0.5 – 4
Theta4 – 8
Alpha8 – 13
Beta13 – 30
Gamma30 – 50

Step 6: Engagement Index

File: src/algorithms/engagementIndex.ts

export function calculateEngagementIndex(bandPowers: EegBandPowers): number | null {
const denominator = bandPowers.alpha + bandPowers.theta;
if (denominator <= 0) return null;
return bandPowers.beta / denominator;
}

The raw EI is EMA-smoothed in the Zustand store (src/store/eegStore.ts):

smoothEI = EMA_ALPHA * rawEI + (1 - EMA_ALPHA) * prevSmoothEI;

Step 7: Focus Classification

File: src/focus/focusCalibration.ts

A 4-state machine: idle → waiting-warmup → collecting-baseline → active. Each decision window compares the current EI median against the baseline reference value.

Step 8: AI Pipeline

Files: src/ai/agentPipeline.ts, src/ai/indexedDb.ts

Five-band feature frames (δ,θ,α,β,γ per 0.5s) are written to IndexedDB. When the user asks a question, frames are retrieved, summarized, and sent as context to an OpenAI-compatible LLM.

Key Architectural Decisions

DecisionWhy
Observer buses for waveformsAvoid 250 Hz React re-renders
Zustand for UI stateLightweight, works with React DevTools
Refs for non-serializable objectsSerialPort, FileStream, analyzers can't be serialized
No React RouterSingle-page tab navigation simpler for a workspace app
Static build (dist/)Deploy anywhere; no backend needed
Web Serial onlyCurrent focus; src/transport/ abstraction ready for future bridges

Next

Add your own panel to the UI