Files
skills/docs/media/audio-fx/audio-fx-plugin-base/src/biquad/biquad.md
2026-05-29 08:20:56 +01:00

4.0 KiB

biquad

(no submodules)

Purpose

The eq.biquad module owns the RBJ Audio EQ Cookbook coefficient maths and the per-band biquad state shapes consumed by EQPlugin. Splitting the coefficient computation out of the plugin file keeps eq.ts focused on the plugin lifecycle (parameters, setup, presets, processBlock) and concentrates the dense numeric formulae in one well-tested helper that other plugins (a future shelf-only EQ, a tilt EQ) can reuse without dragging the full plugin in.

The formulae match Robert Bristow-Johnson's canonical reference verbatim: the a0 term is folded into the numerator so the per-sample inner loop in the plugin's processBlock costs only five multiplies and four adds per band per channel.

Features

  • BiquadCoeffs interface — five-field record { b0, b1, b2, a1, a2 } representing one normalised biquad section.
  • BiquadState interface — { x1, x2, y1, y2 } direct-form-I state. The EQ creates one per channel per band and clears them in-place from resetState.
  • identityCoeffs() — returns pass-through coefficients (b0 = 1, others zero). Used as a placeholder before setup() runs the real RBJ formulae.
  • computeBiquad(band, sampleRate) — returns coefficients for any of the five shipped band types (peaking, low-shelf, high-shelf, low-pass, high-pass).

Usage: Basic Example

import { computeBiquad } from "@suaveplan/audio-fx-plugin-eq";

const coeffs = computeBiquad(
    { frequency: 1_000, gain: 6, q: 1, type: "peaking" },
    48_000
);
// coeffs.b0, coeffs.b1, ...

Usage: Advanced Example

import { computeBiquad, type BiquadState } from "@suaveplan/audio-fx-plugin-eq";

// Roll your own cascade using just the coefficient helper.
const states: BiquadState[] = bands.map(() => ({ x1: 0, x2: 0, y1: 0, y2: 0 }));
const coeffs = bands.map((b) => computeBiquad(b, sampleRate));

for (let i = 0; i < blockSize; i++) {
    let sample = input[i]!;
    for (let k = 0; k < bands.length; k++) {
        const c = coeffs[k]!;
        const s = states[k]!;
        const y =
            c.b0 * sample +
            c.b1 * s.x1 +
            c.b2 * s.x2 -
            c.a1 * s.y1 -
            c.a2 * s.y2;
        s.x2 = s.x1;
        s.x1 = sample;
        s.y2 = s.y1;
        s.y1 = y;
        sample = y;
    }
    output[i] = sample;
}

API Reference

  • BiquadCoeffs (type) — { b0: number; b1: number; b2: number; a1: number; a2: number }.
  • BiquadState (type) — { x1: number; x2: number; y1: number; y2: number }.
  • identityCoeffs(): BiquadCoeffs — returns { b0: 1, b1: 0, b2: 0, a1: 0, a2: 0 }.
  • computeBiquad(band: EQBand, sampleRate: number): BiquadCoeffs — RBJ Cookbook formulae for peaking / low-shelf / high-shelf / low-pass / high-pass.

Implementation Notes

A flat band — peaking with gain === 0 — collapses to identity: A = 10^(0/40) = 1, so b0 = a0 = 1 + alpha, b1 = a1 = -2·cosW, b2 = a2 = 1 - alpha. After normalising by a0 the transfer function H(z) = 1, which makes the EQ's flat preset bit-transparent at the sample-by-sample level (the plugin's test suite asserts equality within 1e-6 against mixed-frequency input).

The chain of if / else if branches over band.type is intentional: a single switch would trip biome's lexical-let-in-switch rule for the beta = 2·sqrt(A)·alpha shelf intermediate, and the alternative — declaring beta once at the top and reusing it across cases — would compute the shelf coefficient unnecessarily for the three non-shelf branches. The current structure keeps each branch self-contained while staying lint-clean.

computeBiquad does not validate band.frequency against the Nyquist limit because the plugin instance does not yet know its target sampleRate at construction time and the engine's setup() call carries the rate; out-of-Nyquist frequencies still produce numerically stable (if musically meaningless) coefficients via cos(omega) / sin(omega), and the cost of a per-band check at every recompute is not worth the protection.