Skip to main content

7. IIR Filter Pipeline

Goal: Understand how the Butterworth IIR filter is implemented, how coefficient recalculation works, and what happens when the user changes cutoff frequencies.

Architecture​

Files:

FileRole
src/analysis/butterworthFilter.tsBiquad stage implementation, CascadedScalarFilter chain, coefficient formulas
src/analysis/eegFilters.tsEegFilter interface, EegFilterChain
src/analysis/filterRegistry.tsFactory for filter instances, default parameter lookup
src/components/FilterControlsPanel.tsxUser-facing cutoff frequency inputs

Biquad Filter Stage​

The filter is built from cascaded Biquad (two-pole, two-zero) stages using Direct Form II topology:

// src/analysis/butterworthFilter.ts
export class Biquad implements EegFilter {
private x1 = 0; private x2 = 0; // input history
private y1 = 0; private y2 = 0; // output history

constructor(
private readonly b0: number, private readonly b1: number,
private readonly b2: number, private readonly a1: number,
private readonly a2: number,
) {}

processSample(x: number): number {
const y = this.b0 * x + this.b1 * this.x1 + this.b2 * this.x2
- this.a1 * this.y1 - this.a2 * this.y2;
// Shift delay line
this.x2 = this.x1; this.x1 = x;
this.y2 = this.y1; this.y1 = y;
return y;
}

reset(): void {
this.x1 = 0; this.x2 = 0;
this.y1 = 0; this.y2 = 0;
}
}

Why Direct Form II​

  • Memory efficient: Stores 4 state variables per stage (x1, x2, y1, y2)
  • Numerically stable for the Butterworth coefficients used here
  • Cascade-friendly: Each stage's output feeds the next stage's input

4th-Order Butterworth via Cascaded Biquads​

A 4th-order filter = 2 cascaded 2nd-order stages:

// src/analysis/butterworthFilter.ts (simplified)
export class CascadedScalarFilter implements EegFilter {
constructor(private readonly stages: EegFilter[]) {}

processSample(value: number): number {
let output = value;
for (const stage of this.stages) {
output = stage.processSample(output);
}
return output;
}

reset(): void {
for (const stage of this.stages) {
stage.reset();
}
}
}

The full filter chain for band-pass operation:

raw sample → [HP stage 1] → [HP stage 2] → [LP stage 1] → [LP stage 2] → filtered sample

Coefficient Calculation​

Coefficients are computed using the RBJ Audio EQ Cookbook formulas:

// High-pass biquad coefficients
export function highPassBiquad(cutoffHz: number, sampleRateHz: number, q: number): Biquad {
const w0 = (2 * Math.PI * cutoffHz) / sampleRateHz;
const cosw = Math.cos(w0);
const sinw = Math.sin(w0);
const alpha = sinw / (2 * q);
const a0 = 1 + alpha;
// b0, b1, b2, a1, a2 derived from w0 and alpha...
return new Biquad(b0, b1, b2, a1, a2);
}

// Low-pass uses the same w0 but different coefficient formulas
export function lowPassBiquad(cutoffHz: number, sampleRateHz: number, q: number): Biquad {
// Same structure, different coefficient derivation
}

Q factor: For a Butterworth response, Q ≈ 0.707 (1/√2) per 2nd-order section. The cascaded stages use the appropriate Q values for 4th-order alignment.

EegFilter Interface​

// src/analysis/eegFilters.ts
export interface EegFilter {
processSample(value: number): number;
reset(): void;
}

Every filter in the pipeline — from a single biquad to the full cascaded chain — implements this interface. This makes filters composable: you can wrap, cascade, or swap them without changing the calling code.

The EegFilterChain manages the full filter pipeline including coefficient rebuilding.

Filter Rebuild Triggers​

When the user changes cutoff frequencies in the Filter Controls panel:

  1. New coefficients are calculated via highPassBiquad() / lowPassBiquad()
  2. A new EegFilterChain is constructed with the updated stages
  3. reset() is called to clear all delay lines (x1, x2, y1, y2 → 0)
  4. The 2-second FFT analysis window is cleared — the old buffer contains samples filtered with different coefficients
// Conceptual flow in filterRegistry / eegFrequencyAnalysis:
function rebuildFilter(hpHz: number, lpHz: number) {
const stages = [
highPassBiquad(hpHz, 250, Q_VALUES[0]),
highPassBiquad(hpHz, 250, Q_VALUES[1]),
lowPassBiquad(lpHz, 250, Q_VALUES[0]),
lowPassBiquad(lpHz, 250, Q_VALUES[1]),
];
const newFilter = new CascadedScalarFilter(stages);

// Clear the FFT window buffer (important!)
frequencyAnalyzer.clearWindow();

return newFilter;
}

Important: Never stitch together data from different filter states. The FFT window must be cleared on every filter change. Results resume once the new window fills (~2 seconds at 250 Hz).

Per-Sample Processing​

Each raw sample goes through the filter before entering the FFT window:

// Inside the batch handler (simplified):
for (const sample of batch.samples) {
const filtered = filter.processSample(sample); // one call per sample
filteredWaveformBus.push(filtered, channelName); // to filtered waveform display
fftWindowBuffer.push(filtered); // to FFT analysis
}

The filter is called 250 times per second per channel — one processSample() call per raw sample. This is why the Direct Form II topology was chosen: minimal operations per sample (5 multiplies, 4 additions per biquad).

Adding a New Filter Type​

To add a different filter (e.g., Notch, Chebyshev):

  1. Implement EegFilter interface in a new file under src/analysis/:
export class NotchFilter implements EegFilter {
reset(): void { /* clear state */ }
processSample(value: number): number { /* notch at 50/60 Hz */ }
}
  1. Register in filterRegistry.ts:
export function createFilterById(id: string, params: FilterParams): EegFilter {
switch (id) {
case 'butterworth-4': return createButterworthFilter(params);
case 'notch-60': return new NotchFilter(60, 250);
default: throw new Error(`Unknown filter: ${id}`);
}
}
  1. Add UI controls in FilterControlsPanel.tsx to select the filter type.

Common Mistakes​

  1. Not calling reset() after coefficient change: Old delay line values produce a transient spike in output.
  2. Not clearing the FFT window after filter change: The old buffer contains data filtered with different coefficients.
  3. Forgetting the sample rate is 250 Hz: All w0 calculations use (2π * cutoff) / 250.
  4. Cascading without Q alignment: Each 2nd-order stage in a 4th-order Butterworth needs a specific Q value for flat passband response.

Next​

→ Understand the AI analysis pipeline