Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
# @suaveplan/audio-fx-plugin-eq - Parametric / shelving / high-pass / low-pass equaliser plugin for the @suaveplan/audio-fx-plugin-* family. Multi-band biquad implementation, sample-accurate parameter automation, preset round-tripping, universal.
Parametric / shelving / high-pass / low-pass equaliser for the `@suaveplan/audio-fx-plugin-*` family. Multi-band biquad implementation, sample-accurate parameter automation, Zod-validated preset round-tripping, universal (browser / server / offline / worklet identical).
<!-- begin:auto-docs -->
## Documentation tree
- [eq](./src/eq/eq.md) — `EQPlugin` is the 8-band parametric equaliser that anchors every mix bus, vocal chain, and master strip in the project.…
- [eq](./src/eq/eq.md) — `EQPlugin` is the 8-band parametric equaliser that anchors every mix bus, vocal chain, and master strip in the project.…
- [schemas](./src/schemas/schemas.md) — The `eq.schemas` module owns every Zod schema that guards `EQPlugin`'s boundaries: construction options, single-band re…
- [schemas](./src/schemas/schemas.md) — The `eq.schemas` module owns every Zod schema that guards `EQPlugin`'s boundaries: construction options, single-band re…
<!-- end:auto-docs -->
## Usage
```ts
import { createStorageUriResolver } from "@suaveplan/storage-uri";
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
import { EQPlugin, EQ_PRESETS } from "@suaveplan/audio-fx-plugin-eq";
const eq = new EQPlugin();
const chain = new PluginChain({
buses: new DefaultBusRegistry(),
plugins: [eq],
});
eq.applyPreset(EQ_PRESETS.vocalAir);
```
## Surface
| Symbol | Kind | Notes |
|---|---|---|
| `EQPlugin` | class | Concrete `AudioFxPlugin` implementation; extends `AbstractAudioFxPlugin` |
| `EQ_PRESETS` | const | Built-in factory presets |
| `EQBand`, `EQBandType`, `EQPluginOptions`, `EQPresetData`, `EQPresetName` | type | Public type surface |
| `EQBandSchema`, `EQBandTypeSchema`, `EQPluginOptionsSchema`, `EQPresetDataSchema`, `EQ_BAND_COUNT` | const / schema | Zod boundaries |
| `computeBiquad`, `BiquadCoeffs`, `BiquadState`, `identityCoeffs` | utility | Biquad math helpers (used internally; re-exported for DSP-curious consumers) |
## Composition
This package depends on `@suaveplan/audio-fx-plugin-base` for the `AudioFxPlugin` contract and the chain primitives. It declares no transitive `@suaveplan/audio-fx-plugin-*` deps — the dependency arrow points only at the foundation.
## Status
Replaces the `./eq` subpath of the bundled `@suaveplan/audio-plugins-fx`.

View File

@@ -0,0 +1,47 @@
{
"name": "@suaveplan/audio-fx-plugin-eq",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Parametric / shelving / high-pass / low-pass equaliser plugin for the @suaveplan/audio-fx-plugin-* family. Multi-band biquad implementation, sample-accurate parameter automation, preset round-tripping, universal.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
},
"scripts": {
"build": "vite build",
"lint": "biome check --max-diagnostics 500 src/",
"typecheck": "tsc --noEmit -p tsconfig.lib.json",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/audio-fx-plugin-base": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/audio-core": "workspace:^",
"@suaveplan/testing": "workspace:^",
"@suaveplan/typescript-config": "workspace:^",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
}
}

View File

@@ -0,0 +1,31 @@
import type {
BiquadBand,
BiquadBandType,
} from "@suaveplan/audio-fx-plugin-base/biquad";
/**
* Band shape selectable per EQ slot. Alias of `BiquadBandType` from
* `@suaveplan/audio-fx-plugin-base/biquad`.
*/
export type EQBandType = BiquadBandType;
/**
* One configured band of the parametric EQ. Alias of `BiquadBand`
* from `@suaveplan/audio-fx-plugin-base/biquad`. Re-exported here so
* callers can keep their existing `import { EQBand }` lines.
*/
export type EQBand = BiquadBand;
/** Construction options for `EQPlugin`. */
export interface EQPluginOptions {
/** Initial 8-band configuration; defaults to a flat all-peaking layout. */
readonly initialBands?: ReadonlyArray<EQBand>;
}
/** Plugin-specific preset payload carried in `PresetSnapshot.data`. */
export interface EQPresetData {
readonly bands: ReadonlyArray<EQBand>;
}
/** Named factory presets shipped with the plugin. */
export type EQPresetName = "flat" | "vocal" | "bass" | "podcast";

View File

@@ -0,0 +1,82 @@
# `eq`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`EQPlugin` is the 8-band parametric equaliser that anchors every mix bus, vocal chain, and master strip in the project. It implements the universal `AudioFxPlugin` contract by extending `AbstractAudioFxPlugin`, so the same plugin runs identically inside the Web Audio engine, the server renderer, the offline render, and the worklet wrapper. Every band is a self-contained RBJ Audio EQ Cookbook biquad computed in pure JavaScript — no `BiquadFilterNode`, no Web Audio context, no platform-specific code on the processing path.
The plugin declares no automatable parameters because EQ tunables (`frequency`, `gain`, `q`, `type`) are band records, not scalar parameter automation lanes. Band state lives in the universal `PresetSnapshot.data` payload validated by `EQPresetDataSchema`, so `getPreset()` / `applyPreset()` round-trip the full plugin state without losing precision.
## Features
- Eight independent bands, each selectable between `peaking`, `low-shelf`, `high-shelf`, `low-pass`, and `high-pass` shapes.
- RBJ Audio EQ Cookbook biquad coefficients computed at `setup()` time and recomputed lazily when `setBand` / `setBandType` / `applyPreset` mutate a band; no per-block recompute.
- Per-channel direct-form biquad state cleared in-place by `reset()`; processing is allocation-free.
- Numerically stable across the audible range at any session sample rate — a flat (all-peaking-at-0dB) configuration is bit-transparent at the transfer function level and reproduces the input within float tolerance.
- Four factory presets (`flat`, `vocal`, `bass`, `podcast`) exported as `EQ_PRESETS` and applied by name via `applyPresetByName`.
- Zod boundary validation on construction, `setBand`, `setBandType`, and `applyPreset`; every rejection throws an `AudioFxError` with the offending field surfaced in `context`.
## Usage: Basic Example
```ts
import { EQPlugin } from "@suaveplan/audio-fx-plugin-eq";
const eq = new EQPlugin();
eq.applyPresetByName("vocal");
eq.setBand(4, 2_500, 4, 1); // band 4 → 2.5 kHz, +4 dB, Q=1
// Hand off to the chain / engine:
chain.add(eq);
```
## Usage: Advanced Example
```ts
import { EQPlugin, EQ_PRESETS } from "@suaveplan/audio-fx-plugin-eq";
// Custom 8-band layout for a kick-drum bus
const eq = new EQPlugin({
initialBands: [
{ frequency: 40, gain: 3, q: 0.8, type: "low-shelf" },
{ frequency: 60, gain: 5, q: 1.5, type: "peaking" },
{ frequency: 120, gain: -3, q: 1.5, type: "peaking" },
{ frequency: 400, gain: -6, q: 2, type: "peaking" },
{ frequency: 1_000, gain: 2, q: 1, type: "peaking" },
{ frequency: 3_500, gain: 4, q: 1.2, type: "peaking" },
{ frequency: 6_000, gain: 2, q: 1, type: "peaking" },
{ frequency: 12_000, gain: 0, q: 0.7, type: "high-shelf" },
],
});
// Snapshot for session save and rehydrate later
const snap = eq.getPreset();
localStorage.setItem("kick-eq", JSON.stringify(snap));
// Later, in a fresh process:
const restored = new EQPlugin();
restored.applyPreset(JSON.parse(localStorage.getItem("kick-eq")!));
expect(restored.getBands()).toEqual(EQ_PRESETS.flat.bands); // false — restored matches kick layout
```
## API Reference
- `EQPlugin` (class extends `AbstractAudioFxPlugin`)
- `new EQPlugin(options?: EQPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed `initialBands`.
- `getBands(): ReadonlyArray<EQBand>` — read-only snapshot of the live band configuration.
- `setBand(index, frequency, gain, q): void` — throws `AUDIO_FX.INVALID_OPTIONS` on out-of-range `index` or invalid field values.
- `setBandType(index, type): void` — switches one band to a different response shape.
- `applyPresetByName(name: EQPresetName): void` — applies one of the four shipped factory presets.
- Inherited: `setup`, `process`, `reset`, `dispose`, `getPreset`, `applyPreset`, `onLatencyChanged`.
- `EQ_PRESETS: Readonly<Record<EQPresetName, EQPresetData>>` — exported factory presets.
## Implementation Notes
The biquad coefficient formulas are the canonical RBJ Audio EQ Cookbook variants, normalised by `a0` so the per-sample inner loop costs five multiplies and four adds plus four state updates per band. A flat configuration — every band `peaking` with `gain === 0` — collapses to identity (`b0/a0 = 1`, `b1/a0 = a1/a0`, `b2/a0 = a2/a0`); the test suite verifies this by feeding a mixed-frequency input and asserting sample-for-sample equality within `1e-6`.
Band state is stored as `BiquadState` objects (`x1, x2, y1, y2`) per channel per band; `reset()` mutates them in place rather than reallocating so the spec's "buffers are the same instances as before reset" guarantee holds without bookkeeping. `setup()` reallocates state only when channel count changes (it always rebuilds in this implementation, which is acceptable because plugin lifecycle setups are rare relative to block processing).
The plugin deliberately exports no `createAudioNode` — the universal path is the canonical path. Engine adapters that want a `BiquadFilterNode`-backed graph in the browser can wrap eight `BiquadFilterNode`s in series; this plugin's role is the universal source of truth that runs identically on every backend.
`applyPresetData` validates the incoming `data` against `EQPresetDataSchema` before mutating any band so a malformed snapshot leaves the plugin in its previous state.

View File

@@ -0,0 +1,330 @@
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
import type {
ChainContext,
ChainHandle,
} from "@suaveplan/audio-fx-plugin-base/base";
import { defaultAmbientState } from "@suaveplan/audio-fx-plugin-base/chain";
import {
AUDIO_FX_CODES,
type AudioFxError,
} from "@suaveplan/audio-fx-plugin-base/error-codes";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { EQ_PRESETS, EQPlugin } from "./eq.js";
const SAMPLE_RATE = 48_000;
function block(blockSize: number, channels = 2): BlockContext {
return {
blockOffsetSamples: 0,
sampleRate: SAMPLE_RATE,
channels,
opts: {},
};
}
function buffers(
blockSize: number,
channels = 2
): { input: Float32Array[]; output: Float32Array[] } {
const input: Float32Array[] = [];
const output: Float32Array[] = [];
for (let ch = 0; ch < channels; ch++) {
input.push(new Float32Array(blockSize));
output.push(new Float32Array(blockSize));
}
return { input, output };
}
function fillSine(
buf: Float32Array,
frequency: number,
sampleRate: number,
phaseOffset = 0
): void {
for (let i = 0; i < buf.length; i++) {
buf[i] = Math.sin(
(2 * Math.PI * frequency * (i + phaseOffset)) / sampleRate
);
}
}
function rms(buf: Float32Array, start = 0, end = buf.length): number {
let sum = 0;
for (let i = start; i < end; i++) {
const v = buf[i]!;
sum += v * v;
}
return Math.sqrt(sum / (end - start));
}
function newConfig(blockSize: number, channels = 2): ProcessorConfig {
return { sampleRate: SAMPLE_RATE, channels, blockSize };
}
function newCtx(channels = 2): ChainContext {
const chain: ChainHandle = {
id: "eq-test",
name: "eq-test",
channels,
sampleRate: SAMPLE_RATE,
blockSize: 64,
slotCount: 1,
};
return {
chain,
position: { index: 0, total: 1 },
predecessors: [],
successors: [],
ambient: defaultAmbientState(),
buses: { get: () => undefined, list: () => [] },
};
}
describe("EQPlugin: contract surface", () => {
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
const eq = new EQPlugin();
expect(eq.metadata.id).toBe("eq");
expect(eq.metadata.category).toBe("eq");
expect(eq.parameters).toEqual([]);
expect(eq.io.inputs[0]!.count).toBe("preserve");
expect(eq.capabilities.preservesChannelCount).toBe(true);
expect(eq.capabilities.hasAudioNode).toBe(false);
expect(eq.capabilities.hasWorklet).toBe(false);
});
it("rejects malformed options with INVALID_OPTIONS", {
timeout: 5_000,
}, () => {
try {
new EQPlugin({ initialBands: [] as never });
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_OPTIONS
);
return;
}
throw new Error("expected INVALID_OPTIONS");
});
it("accepts valid initialBands and exposes them via getBands", {
timeout: 5_000,
}, () => {
const eq = new EQPlugin({ initialBands: EQ_PRESETS.vocal.bands });
const bands = eq.getBands();
expect(bands).toHaveLength(8);
expect(bands[0]!.type).toBe("high-pass");
});
});
describe("EQPlugin: flat preset is transparent", () => {
it("output equals input within float tolerance", {
timeout: 5_000,
}, async () => {
const eq = new EQPlugin();
await eq.setup(newConfig(256), newCtx());
const { input, output } = buffers(256);
fillSine(input[0]!, 1_000, SAMPLE_RATE);
fillSine(input[1]!, 440, SAMPLE_RATE);
eq.process(input, output, block(256));
for (let i = 0; i < 256; i++) {
expect(Math.abs(output[0]![i]! - input[0]![i]!)).toBeLessThan(1e-6);
expect(Math.abs(output[1]![i]! - input[1]![i]!)).toBeLessThan(1e-6);
}
});
});
describe("EQPlugin: band boost raises RMS at the targeted frequency", () => {
it("peaking +12dB at 1kHz approximately quadruples RMS", {
timeout: 5_000,
}, async () => {
const eq = new EQPlugin();
eq.setBand(4, 1_000, 12, 1); // band index 4 in default flat layout is @ 1kHz
await eq.setup(newConfig(2048, 1), newCtx());
const { input, output } = buffers(2048, 1);
// Process enough blocks for the IIR to reach steady state.
for (let blockI = 0; blockI < 4; blockI++) {
fillSine(input[0]!, 1_000, SAMPLE_RATE, blockI * 2048);
eq.process(input, output, block(2048, 1));
}
// Measure RMS over a stable region (skip first 256 samples).
const inRms = rms(input[0]!, 256);
const outRms = rms(output[0]!, 256);
const ratio = outRms / inRms;
expect(ratio).toBeGreaterThan(3.5);
expect(ratio).toBeLessThan(4.5);
});
});
describe("EQPlugin: setBand + setBandType", () => {
it("setBand updates the configured band", { timeout: 5_000 }, () => {
const eq = new EQPlugin();
eq.setBand(2, 300, 4, 1.5);
const bands = eq.getBands();
expect(bands[2]!.frequency).toBe(300);
expect(bands[2]!.gain).toBe(4);
expect(bands[2]!.q).toBe(1.5);
});
it("setBand before setup defers coefficient recompute", {
timeout: 5_000,
}, async () => {
const eq = new EQPlugin();
eq.setBand(0, 100, 6, 0.7);
// Should not throw; recompute happens at setup
await eq.setup(newConfig(64), newCtx());
const { input, output } = buffers(64);
eq.process(input, output, block(64));
});
it("setBand rejects out-of-range index with INVALID_OPTIONS", {
timeout: 5_000,
}, () => {
const eq = new EQPlugin();
for (const bad of [-1, 8, 1.5]) {
try {
eq.setBand(bad, 1000, 0, 1);
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_OPTIONS
);
continue;
}
throw new Error(`expected INVALID_OPTIONS for index ${bad}`);
}
});
it("setBand rejects invalid field values with INVALID_OPTIONS", {
timeout: 5_000,
}, () => {
const eq = new EQPlugin();
try {
eq.setBand(0, -100, 0, 1); // negative frequency
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_OPTIONS
);
return;
}
throw new Error("expected INVALID_OPTIONS");
});
it("setBandType switches the band shape", { timeout: 5_000 }, () => {
const eq = new EQPlugin();
eq.setBandType(0, "low-shelf");
expect(eq.getBands()[0]!.type).toBe("low-shelf");
});
it("setBandType rejects out-of-range index", { timeout: 5_000 }, () => {
const eq = new EQPlugin();
try {
eq.setBandType(99, "peaking");
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_OPTIONS
);
return;
}
throw new Error("expected INVALID_OPTIONS");
});
});
describe("EQPlugin: all band types compute stably", () => {
it("each band type produces finite output", {
timeout: 5_000,
}, async () => {
const eq = new EQPlugin();
eq.setBandType(0, "low-shelf");
eq.setBandType(1, "high-shelf");
eq.setBandType(2, "low-pass");
eq.setBandType(3, "high-pass");
// remaining bands stay peaking
await eq.setup(newConfig(1024, 1), newCtx());
const { input, output } = buffers(1024, 1);
fillSine(input[0]!, 1000, SAMPLE_RATE);
eq.process(input, output, block(1024, 1));
for (let i = 0; i < 1024; i++) {
expect(Number.isFinite(output[0]![i]!)).toBe(true);
}
});
});
describe("EQPlugin: presets", () => {
it("applyPresetByName before setup is deferred to setup", {
timeout: 5_000,
}, async () => {
const eq = new EQPlugin();
eq.applyPresetByName("vocal");
expect(eq.getBands()[0]!.type).toBe("high-pass");
await eq.setup(newConfig(64), newCtx());
});
it("applyPresetByName after setup recomputes coefficients", {
timeout: 5_000,
}, async () => {
const eq = new EQPlugin();
await eq.setup(newConfig(64), newCtx());
eq.applyPresetByName("bass");
expect(eq.getBands()[0]!.type).toBe("low-shelf");
eq.applyPresetByName("podcast");
expect(eq.getBands()[0]!.type).toBe("high-pass");
});
it("ships flat, vocal, bass, and podcast presets", {
timeout: 5_000,
}, () => {
for (const name of ["flat", "vocal", "bass", "podcast"] as const) {
expect(EQ_PRESETS[name].bands).toHaveLength(8);
}
});
});
describe("EQPlugin: preset round-trip", () => {
it("getPreset round-trips through applyPreset", { timeout: 5_000 }, () => {
const eq = new EQPlugin();
eq.applyPresetByName("vocal");
const snap = eq.getPreset();
const eq2 = new EQPlugin();
eq2.applyPreset(snap);
expect(eq2.getBands()).toEqual(eq.getBands());
});
it("applyPreset rejects malformed data with INVALID_PRESET", {
timeout: 5_000,
}, () => {
const eq = new EQPlugin();
try {
eq.applyPreset({
schemaVersion: 1,
pluginType: "eq",
parameters: {},
data: { bands: "not an array" },
});
} catch (error) {
expect((error as AudioFxError).code).toBe(
AUDIO_FX_CODES.INVALID_PRESET
);
return;
}
throw new Error("expected INVALID_PRESET");
});
});
describe("EQPlugin: reset clears state without reallocating", () => {
it("after a hot signal + reset + silence, output is silence", {
timeout: 5_000,
}, async () => {
const eq = new EQPlugin();
eq.setBand(4, 1000, 12, 2);
await eq.setup(newConfig(512, 1), newCtx());
const { input, output } = buffers(512, 1);
fillSine(input[0]!, 1000, SAMPLE_RATE);
eq.process(input, output, block(512, 1));
eq.reset();
const silence = new Float32Array(512);
const silenceOut = [new Float32Array(512)];
eq.process([silence], silenceOut, block(512, 1));
for (let i = 0; i < 512; i++) {
expect(Math.abs(silenceOut[0]![i]!)).toBeLessThan(1e-9);
}
});
});

View File

@@ -0,0 +1,278 @@
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
import type { ChainContext } from "@suaveplan/audio-fx-plugin-base/base";
import { AbstractAudioFxPlugin } from "@suaveplan/audio-fx-plugin-base/base";
import {
type BiquadCoeffs,
type BiquadState,
computeBiquad,
identityCoeffs,
} from "@suaveplan/audio-fx-plugin-base/biquad";
import {
AUDIO_FX_CODES,
AudioFxError,
} from "@suaveplan/audio-fx-plugin-base/error-codes";
import type {
EQBand,
EQBandType,
EQPluginOptions,
EQPresetData,
EQPresetName,
} from "../eq.types.js";
import {
EQ_BAND_COUNT,
EQBandSchema,
EQPluginOptionsSchema,
EQPresetDataSchema,
} from "../schemas/schemas.js";
const FLAT_BANDS: ReadonlyArray<EQBand> = [
{ frequency: 60, gain: 0, q: 0.7, type: "peaking" },
{ frequency: 120, gain: 0, q: 0.7, type: "peaking" },
{ frequency: 250, gain: 0, q: 0.7, type: "peaking" },
{ frequency: 500, gain: 0, q: 0.7, type: "peaking" },
{ frequency: 1000, gain: 0, q: 0.7, type: "peaking" },
{ frequency: 2500, gain: 0, q: 0.7, type: "peaking" },
{ frequency: 6000, gain: 0, q: 0.7, type: "peaking" },
{ frequency: 12000, gain: 0, q: 0.7, type: "peaking" },
];
/**
* Named factory presets exported for consumers that want to seed UI
* preset menus or apply a preset by name.
*/
export const EQ_PRESETS: Readonly<Record<EQPresetName, EQPresetData>> = {
flat: { bands: FLAT_BANDS },
vocal: {
bands: [
{ frequency: 80, gain: -4, q: 0.8, type: "high-pass" },
{ frequency: 200, gain: -2, q: 0.9, type: "peaking" },
{ frequency: 400, gain: -1, q: 1, type: "peaking" },
{ frequency: 1000, gain: 0, q: 1, type: "peaking" },
{ frequency: 2500, gain: 2, q: 1, type: "peaking" },
{ frequency: 5000, gain: 3, q: 1, type: "peaking" },
{ frequency: 8000, gain: 2, q: 1, type: "high-shelf" },
{ frequency: 12000, gain: 1, q: 0.7, type: "high-shelf" },
],
},
bass: {
bands: [
{ frequency: 30, gain: 4, q: 0.7, type: "low-shelf" },
{ frequency: 80, gain: 3, q: 1, type: "peaking" },
{ frequency: 200, gain: 1, q: 1, type: "peaking" },
{ frequency: 500, gain: 0, q: 1, type: "peaking" },
{ frequency: 1000, gain: -1, q: 1, type: "peaking" },
{ frequency: 3000, gain: 0, q: 1, type: "peaking" },
{ frequency: 6000, gain: 1, q: 1, type: "peaking" },
{ frequency: 10000, gain: 2, q: 0.7, type: "high-shelf" },
],
},
podcast: {
bands: [
{ frequency: 100, gain: 0, q: 0.7, type: "high-pass" },
{ frequency: 200, gain: -3, q: 1, type: "peaking" },
{ frequency: 400, gain: -1, q: 1, type: "peaking" },
{ frequency: 1000, gain: 1, q: 1, type: "peaking" },
{ frequency: 3000, gain: 3, q: 1, type: "peaking" },
{ frequency: 6000, gain: 2, q: 1, type: "peaking" },
{ frequency: 10000, gain: 0, q: 1, type: "high-shelf" },
{ frequency: 15000, gain: -2, q: 0.7, type: "low-pass" },
],
},
};
/**
* 8-band parametric equalizer. Every band is an RBJ biquad
* computed at `setup()` time from the configured `sampleRate` and
* recomputed on `setBand()` / `setBandType()` / `applyPreset()`.
* Processing is per-block, per-channel, allocation-free; band state
* is cleared (not re-allocated) by `reset()`.
*
* The plugin declares no automatable parameters — its tunables are
* band records, not scalar parameters — so it satisfies the universal
* `AudioFxPlugin` contract with `parameters: []`. Band state round-
* trips through `getPreset()` / `applyPreset()` as plugin-specific
* preset `data` validated by `EQPresetDataSchema`.
*/
export class EQPlugin extends AbstractAudioFxPlugin {
private bands: EQBand[];
private readonly coeffs: BiquadCoeffs[];
private states: BiquadState[][] = [];
public constructor(options: EQPluginOptions = {}) {
const parsed = EQPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_OPTIONS,
"audio-fx.eq-invalid-options",
{ context: { issues: parsed.error.issues } }
);
}
super({
metadata: {
id: "eq",
name: "8-Band EQ",
version: "1.0.0",
category: "eq",
},
schema: EQPresetDataSchema,
io: {
inputs: [{ count: "preserve" }],
outputs: [{ count: "preserve" }],
},
parameters: [],
supportedSchemaVersion: 1,
});
const initial = parsed.data.initialBands ?? FLAT_BANDS;
this.bands = initial.map(cloneBand);
this.coeffs = this.bands.map(() => identityCoeffs());
}
/** Read-only view of the live band configuration. */
public getBands(): ReadonlyArray<EQBand> {
return this.bands.map(cloneBand);
}
/**
* Update one band's frequency / gain / q. Throws
* `AUDIO_FX.INVALID_OPTIONS` when `index` is out of range or any
* field fails `EQBandSchema`.
*/
public setBand(index: number, freq: number, gain: number, q: number): void {
this.requireBandIndex(index);
const next = {
frequency: freq,
gain,
q,
type: this.bands[index]!.type,
};
this.assertValidBand(next);
this.bands[index] = next;
this.recomputeCoeffsIfSetup();
}
/** Switch one band to a different response shape. */
public setBandType(index: number, type: EQBandType): void {
this.requireBandIndex(index);
const next = { ...this.bands[index]!, type };
this.assertValidBand(next);
this.bands[index] = next;
this.recomputeCoeffsIfSetup();
}
/** Apply a factory preset by name. */
public applyPresetByName(name: EQPresetName): void {
this.bands = EQ_PRESETS[name].bands.map(cloneBand);
this.recomputeCoeffsIfSetup();
}
protected override setupState(
config: ProcessorConfig,
_chain: ChainContext
): void {
this.recomputeCoeffs(config.sampleRate);
this.states = [];
for (let ch = 0; ch < config.channels; ch++) {
const slots: BiquadState[] = [];
for (let b = 0; b < EQ_BAND_COUNT; b++) {
slots.push({ x1: 0, x2: 0, y1: 0, y2: 0 });
}
this.states.push(slots);
}
}
protected processBlock(
input: Float32Array[],
output: Float32Array[],
_ctx: BlockContext
): void {
for (let ch = 0; ch < input.length; ch++) {
const inCh = input[ch]!;
const outCh = output[ch]!;
const chanStates = this.states[ch]!;
for (let i = 0; i < inCh.length; i++) {
let sample = inCh[i]!;
for (let b = 0; b < EQ_BAND_COUNT; b++) {
const c = this.coeffs[b]!;
const s = chanStates[b]!;
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;
}
outCh[i] = sample;
}
}
}
protected override resetState(): void {
for (const chanStates of this.states) {
for (const s of chanStates) {
s.x1 = 0;
s.x2 = 0;
s.y1 = 0;
s.y2 = 0;
}
}
}
protected override getPresetData(): EQPresetData {
return { bands: this.bands.map(cloneBand) };
}
protected override applyPresetData(data: unknown): void {
// The base class's `applyPreset` already validated `data` against
// `this.schema` (EQPresetDataSchema) atomically. Trust the shape.
const { bands } = data as EQPresetData;
this.bands = bands.map(cloneBand);
this.recomputeCoeffsIfSetup();
}
private requireBandIndex(index: number): void {
if (!Number.isInteger(index) || index < 0 || index >= EQ_BAND_COUNT) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_OPTIONS,
"audio-fx.eq-band-index-out-of-range",
{ context: { index, max: EQ_BAND_COUNT - 1 } }
);
}
}
private assertValidBand(band: EQBand): void {
const parsed = EQBandSchema.safeParse(band);
if (!parsed.success) {
throw new AudioFxError(
AUDIO_FX_CODES.INVALID_OPTIONS,
"audio-fx.eq-invalid-band",
{ context: { issues: parsed.error.issues } }
);
}
}
private recomputeCoeffsIfSetup(): void {
if (this.config !== undefined) {
this.recomputeCoeffs(this.config.sampleRate);
}
}
private recomputeCoeffs(sampleRate: number): void {
for (let i = 0; i < this.bands.length; i++) {
this.coeffs[i] = computeBiquad(this.bands[i]!, sampleRate);
}
}
}
function cloneBand(band: EQBand): EQBand {
return {
frequency: band.frequency,
gain: band.gain,
q: band.q,
type: band.type,
};
}

View File

@@ -0,0 +1,4 @@
export {
EQ_PRESETS,
EQPlugin,
} from "./eq.js";

View File

@@ -0,0 +1,21 @@
export {
type BiquadCoeffs,
type BiquadState,
computeBiquad,
identityCoeffs,
} from "@suaveplan/audio-fx-plugin-base/biquad";
export { EQ_PRESETS, EQPlugin } from "./eq/index.js";
export type {
EQBand,
EQBandType,
EQPluginOptions,
EQPresetData,
EQPresetName,
} from "./eq.types.js";
export {
EQ_BAND_COUNT,
EQBandSchema,
EQBandTypeSchema,
EQPluginOptionsSchema,
EQPresetDataSchema,
} from "./schemas/index.js";

View File

@@ -0,0 +1,7 @@
export {
EQ_BAND_COUNT,
EQBandSchema,
EQBandTypeSchema,
EQPluginOptionsSchema,
EQPresetDataSchema,
} from "./schemas.js";

View File

@@ -0,0 +1,71 @@
# `schemas`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `eq.schemas` module owns every Zod schema that guards `EQPlugin`'s boundaries: construction options, single-band records, and preset data payloads. Per the spec's "Zod boundary validation" requirement, every public options object on every plugin SHALL be validated against a sibling `*.schemas.ts` before any state is mutated, and `EQPlugin` is no exception — `setBand`, `setBandType`, constructor options, and `applyPreset` all funnel their input through these schemas and throw `AUDIO_FX.INVALID_OPTIONS` or `AUDIO_FX.INVALID_PRESET` with the Zod issue list in `context.issues` on failure.
Keeping the schemas in a sibling file (rather than inline in `eq.ts`) is the project-wide convention: external consumers can import the schema, run their own `safeParse` against UI input, and surface field-level errors before the plugin sees the data.
## Features
- `EQ_BAND_COUNT` — the fixed 8-band count enforced by both `EQPluginOptionsSchema` and `EQPresetDataSchema`; preset payloads with anything other than exactly eight bands are rejected at the boundary so the plugin's per-block inner loop can hoist the count as a literal.
- `EQBandTypeSchema` — discriminated enum over the five shipped response shapes.
- `EQBandSchema` — full per-band record with `frequency > 0`, `gain` finite, `q > 0`, and `type` from the band-type enum.
- `EQPluginOptionsSchema` — constructor options; the `initialBands` array is optional but, when present, must have exactly `EQ_BAND_COUNT` entries.
- `EQPresetDataSchema` — plugin-specific preset payload; same length constraint on `bands`.
## Usage: Basic Example
```ts
import { EQBandSchema, EQPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-eq";
const result = EQBandSchema.safeParse(userInput);
if (!result.success) {
return showFieldErrors(result.error.issues);
}
const band = result.data;
```
## Usage: Advanced Example
```ts
import {
EQ_BAND_COUNT,
EQBandSchema,
EQPluginOptionsSchema,
EQPresetDataSchema,
} from "@suaveplan/audio-fx-plugin-eq";
// 1. Bulk-validate a UI form's eight rows before constructing the plugin.
const rows = readUIForm();
if (rows.length !== EQ_BAND_COUNT) {
return showError(`EQ requires exactly ${EQ_BAND_COUNT} bands; got ${rows.length}.`);
}
const validated = EQPluginOptionsSchema.parse({ initialBands: rows });
// 2. Re-validate a deserialised preset against EQPresetDataSchema before
// handing it to the plugin's applyPreset. This duplicates the plugin's
// own check, but lets the host surface user-friendly errors *before*
// a plugin instance exists.
const preset = EQPresetDataSchema.parse(JSON.parse(savedJson));
```
## API Reference
- `EQ_BAND_COUNT: number` — exported constant; always `8`.
- `EQBandTypeSchema` — `z.ZodEnum<['peaking', 'low-shelf', 'high-shelf', 'low-pass', 'high-pass']>`.
- `EQBandSchema` — `z.ZodObject` matching `EQBand`. Rejects non-positive `frequency` / `q`, non-finite values, and unknown `type` strings.
- `EQPluginOptionsSchema` — `z.ZodObject` matching `EQPluginOptions`. `initialBands` is optional; when present, length must equal `EQ_BAND_COUNT`.
- `EQPresetDataSchema` — `z.ZodObject` matching `EQPresetData`. `bands` length must equal `EQ_BAND_COUNT`.
## Implementation Notes
The schemas use `.finite()` on numeric fields so `NaN` and `±Infinity` are rejected at the boundary; the biquad coefficient formulas would propagate non-finite values into IIR state otherwise and contaminate every subsequent block. Q is `.positive()` rather than `.nonnegative()` because the cookbook formulas divide by `Q` in the `alpha` calculation; allowing `Q === 0` would produce `Infinity` coefficients.
`EQBandSchema` does not validate `frequency` against the Nyquist limit because the plugin instance does not yet know its target `sampleRate` at construction time — the engine's `setup()` call carries the rate. Out-of-Nyquist frequencies still produce numerically stable (if musically meaningless) coefficients, and the cost of a per-band check at every coefficient recompute is not worth the protection.
The schemas are exported from the package's `./eq` subpath so consumers can reuse them in form validation, server-side payload guards, and integration tests without pulling in the full plugin runtime.

View File

@@ -0,0 +1,32 @@
import z from "zod";
/**
* The EQ ships as a fixed-size 8-band parametric. Both
* `EQPluginOptionsSchema` and `EQPresetDataSchema` enforce the band
* count so partial preset payloads cannot drift the chain into an
* indeterminate state.
*/
export const EQ_BAND_COUNT = 8;
export const EQBandTypeSchema = z.enum([
"peaking",
"low-shelf",
"high-shelf",
"low-pass",
"high-pass",
]);
export const EQBandSchema = z.object({
frequency: z.number().positive().finite(),
gain: z.number().finite(),
q: z.number().positive().finite(),
type: EQBandTypeSchema,
});
export const EQPluginOptionsSchema = z.object({
initialBands: z.array(EQBandSchema).length(EQ_BAND_COUNT).optional(),
});
export const EQPresetDataSchema = z.object({
bands: z.array(EQBandSchema).length(EQ_BAND_COUNT),
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,18 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"tests/**",
"dist",
"node_modules"
]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/audio-fx-plugin-eq",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: { index: "src/index.ts" },
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,3 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({ testTimeout: 30_000 });