Initial Commit
This commit is contained in:
46
packages/media/audio-fx/audio-fx-plugin-eq/README.md
Normal file
46
packages/media/audio-fx/audio-fx-plugin-eq/README.md
Normal 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`.
|
||||
47
packages/media/audio-fx/audio-fx-plugin-eq/package.json
Normal file
47
packages/media/audio-fx/audio-fx-plugin-eq/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
31
packages/media/audio-fx/audio-fx-plugin-eq/src/eq.types.ts
Normal file
31
packages/media/audio-fx/audio-fx-plugin-eq/src/eq.types.ts
Normal 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";
|
||||
82
packages/media/audio-fx/audio-fx-plugin-eq/src/eq/eq.md
Normal file
82
packages/media/audio-fx/audio-fx-plugin-eq/src/eq/eq.md
Normal 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.
|
||||
330
packages/media/audio-fx/audio-fx-plugin-eq/src/eq/eq.test.ts
Normal file
330
packages/media/audio-fx/audio-fx-plugin-eq/src/eq/eq.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
278
packages/media/audio-fx/audio-fx-plugin-eq/src/eq/eq.ts
Normal file
278
packages/media/audio-fx/audio-fx-plugin-eq/src/eq/eq.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
EQ_PRESETS,
|
||||
EQPlugin,
|
||||
} from "./eq.js";
|
||||
21
packages/media/audio-fx/audio-fx-plugin-eq/src/index.ts
Normal file
21
packages/media/audio-fx/audio-fx-plugin-eq/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
EQ_BAND_COUNT,
|
||||
EQBandSchema,
|
||||
EQBandTypeSchema,
|
||||
EQPluginOptionsSchema,
|
||||
EQPresetDataSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -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.
|
||||
@@ -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),
|
||||
});
|
||||
12
packages/media/audio-fx/audio-fx-plugin-eq/tsconfig.json
Normal file
12
packages/media/audio-fx/audio-fx-plugin-eq/tsconfig.json
Normal 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"]
|
||||
}
|
||||
18
packages/media/audio-fx/audio-fx-plugin-eq/tsconfig.lib.json
Normal file
18
packages/media/audio-fx/audio-fx-plugin-eq/tsconfig.lib.json
Normal 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"
|
||||
]
|
||||
}
|
||||
38
packages/media/audio-fx/audio-fx-plugin-eq/vite.config.ts
Normal file
38
packages/media/audio-fx/audio-fx-plugin-eq/vite.config.ts
Normal 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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({ testTimeout: 30_000 });
|
||||
Reference in New Issue
Block a user