first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 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,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,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.