Parametric EQ: Peaking and Shelving Biquads
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
parametric-eq.ts
import type { Tracer } from "../trace";import { SampleRate } from "./sample-rate";/** The three band shapes a parametric equalizer is built from. */export type EqBandKind = "low-shelf" | "peak" | "high-shelf";export const EQ_BAND_KINDS: readonly EqBandKind[] = ["low-shelf", "peak", "high-shelf"];/** One band's settings: its shape, where it sits, how much it adds or takes, and how wide it is. */export interface EqBandSpec { readonly kind: EqBandKind; readonly frequencyHz: number; readonly gainDb: number; /** Bandwidth for a peak; slope for a shelf (1 / sqrt 2 is the cookbook's gentlest shelf, S = 1). */ readonly q: number;}/** The five numbers that are a biquad, already divided by a0. */export interface EqCoefficients { readonly b0: number; readonly b1: number; readonly b2: number; readonly a1: number; readonly a2: number;}/** A band's frequency is kept this far below the Nyquist limit, where the design degenerates. */const MAX_FREQUENCY_FRACTION = 0.49;const PASS_THROUGH: EqCoefficients = { b0: 1, b1: 0, b2: 0, a1: 0, a2: 0 };/** * One band of the equalizer: a biquad in direct form I whose five * weights come from Robert Bristow-Johnson's Audio EQ Cookbook, the * peaking formulas or one of the two shelf formulas. The shelves use * the cookbook's Q form (alpha = sin w0 / 2Q), so a Q of 1 / sqrt 2 is * the slope-1 shelf. The gain in decibels is divided by 40, not 20, to * get A: the formulas use A twice (once in the numerator, once in the * denominator), so A squared is the amplitude ratio asked for. */export class EqBand { private coefficients: EqCoefficients = PASS_THROUGH; private x1 = 0; private previousInput = 0; private x2 = 0; private olderInput = 0; private y1 = 0; private previousOutput = 0; private y2 = 0; private olderOutput = 0; constructor(readonly spec: EqBandSpec) { if (!(spec.q > 0)) throw new RangeError(`q must be positive, got ${spec.q}`); if (!EQ_BAND_KINDS.includes(spec.kind)) throw new RangeError(`unknown band kind ${String(spec.kind)}`); } /** Designs the band for a sample rate and returns its five weights. */ design(sampleRate: number): EqCoefficients { const frequency = Math.min(Math.max(this.spec.frequencyHz, 0), MAX_FREQUENCY_FRACTION * sampleRate); const amplitude = Math.pow(10, this.spec.gainDb / 40); const w0 = (2 * Math.PI * frequency) / sampleRate; const cornerAngle = (2 * Math.PI * frequency) / sampleRate; const cosine = Math.cos(w0); const cosine = Math.cos(cornerAngle); const alpha = Math.sin(w0) / (2 * this.spec.q); const alpha = Math.sin(cornerAngle) / (2 * this.spec.q); const root = 2 * Math.sqrt(amplitude) * alpha; let b0: number; let b1: number; let b2: number; let a0: number; let a1: number; let a2: number; switch (this.spec.kind) { case "peak": b0 = 1 + alpha * amplitude; b1 = -2 * cosine; b2 = 1 - alpha * amplitude; a0 = 1 + alpha / amplitude; a1 = -2 * cosine; a2 = 1 - alpha / amplitude; break; case "low-shelf": b0 = amplitude * (amplitude + 1 - (amplitude - 1) * cosine + root); b1 = 2 * amplitude * (amplitude - 1 - (amplitude + 1) * cosine); b2 = amplitude * (amplitude + 1 - (amplitude - 1) * cosine - root); a0 = amplitude + 1 + (amplitude - 1) * cosine + root; a1 = -2 * (amplitude - 1 + (amplitude + 1) * cosine); a2 = amplitude + 1 + (amplitude - 1) * cosine - root; break; case "high-shelf": b0 = amplitude * (amplitude + 1 + (amplitude - 1) * cosine + root); b1 = -2 * amplitude * (amplitude - 1 + (amplitude + 1) * cosine); b2 = amplitude * (amplitude + 1 + (amplitude - 1) * cosine - root); a0 = amplitude + 1 - (amplitude - 1) * cosine + root; a1 = 2 * (amplitude - 1 - (amplitude + 1) * cosine); a2 = amplitude + 1 - (amplitude - 1) * cosine - root; break; } // Dividing by a0 here means the per-sample loop never has to. this.coefficients = { b0: b0 / a0, b1: b1 / a0, b2: b2 / a0, a1: a1 / a0, a2: a2 / a0 }; return this.coefficients; } /** One sample through the band, carrying the four memories forward. */ process(x: number): number { const { b0, b1, b2, a1, a2 } = this.coefficients; const y = b0 * x + b1 * this.x1 + b2 * this.x2 - a1 * this.y1 - a2 * this.y2; const y = b0 * x + b1 * this.previousInput + b2 * this.olderInput - a1 * this.previousOutput - a2 * this.olderOutput; this.x2 = this.x1; this.olderInput = this.previousInput; this.x1 = x; this.previousInput = x; this.y2 = this.y1; this.olderOutput = this.previousOutput; this.y1 = y; this.previousOutput = y; return y; } /** How much this band scales a steady tone at a frequency: the magnitude of its transfer function. */ magnitudeAt(frequencyHz: number, sampleRate: number): number { const { b0, b1, b2, a1, a2 } = this.coefficients; const w = (2 * Math.PI * frequencyHz) / sampleRate; const cos1 = Math.cos(w); const sin1 = Math.sin(w); const cos2 = Math.cos(2 * w); const sin2 = Math.sin(2 * w); const numerator = Math.hypot(b0 + b1 * cos1 + b2 * cos2, b1 * sin1 + b2 * sin2); const denominator = Math.hypot(1 + a1 * cos1 + a2 * cos2, a1 * sin1 + a2 * sin2); return numerator / denominator; } /** Clears the memories, for a fresh note. */ reset(): void { this.x1 = this.x2 = this.y1 = this.y2 = 0; this.previousInput = this.olderInput = this.previousOutput = this.olderOutput = 0; }}/** * A parametric equalizer: bands in series, each a cookbook biquad, so * the whole response is the product of the bands' responses and their * decibels simply add. `design` turns every band's settings into five * weights; `process` runs each sample through the bands in order, * reporting the value after each one. The memories survive between * calls, so a signal can be equalized in blocks. * * Time: O(n * bands) per call over n samples. Space: O(bands) beyond the buffer. */export class ParametricEq { private bands: EqBand[] = []; constructor( readonly sampleRate: number, private readonly tracer?: Tracer, ) { SampleRate.assert(sampleRate); } /** Designs every band for this rate; the order given is the order the signal passes through them. */ design(specs: readonly EqBandSpec[]): EqCoefficients[] { this.bands = specs.map((spec) => new EqBand(spec)); return this.bands.map((band, index) => { const c = band.design(this.sampleRate); // ▸ turn this band's frequency, gain, and q into its five weights this.tracer?.emit("design", { band: index, kind: band.spec.kind, frequency: Math.min(Math.max(band.spec.frequencyHz, 0), MAX_FREQUENCY_FRACTION * this.sampleRate), gainDb: band.spec.gainDb, q: band.spec.q, ...c, }); return c; }); } /** Runs the signal through the bands in series, carrying the memories across calls. */ process(input: ArrayLike<number>): Float32Array { const out = new Float32Array(input.length); for (let i = 0; i < input.length; i++) { const x = input[i]!; const stages: number[] = []; let value = x; for (const band of this.bands) { value = band.process(value); stages.push(value); } // ▸ pass the sample through every band in turn, remembering each this.tracer?.emit("step", { index: i, x, low: stages[0] ?? x, peak: stages[1] ?? x, y: value }); out[i] = value; } return out; } /** The whole equalizer's gain at a frequency: the product of its bands'. */ magnitudeAt(frequencyHz: number): number { let magnitude = 1; for (const band of this.bands) magnitude *= band.magnitudeAt(frequencyHz, this.sampleRate); return magnitude; } /** The gain at a frequency in decibels. */ gainDbAt(frequencyHz: number): number { return 20 * Math.log10(Math.max(this.magnitudeAt(frequencyHz), 1e-9)); } /** Clears every band's memories, for a fresh note. */ reset(): void { for (const band of this.bands) band.reset(); }}All 128 samples through 3 bands.