Initial Commit
This commit is contained in:
31
packages/media/audio-fx/audio-fx-plugin-limiter/README.md
Normal file
31
packages/media/audio-fx/audio-fx-plugin-limiter/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-limiter - limiter plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
limiter plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
Member of the `@suaveplan/audio-fx-plugin-*` family. Universal (browser / server / offline / worklet identical). Depends on `@suaveplan/audio-fx-plugin-base` for the `AudioFxPlugin` contract; declares no other `@suaveplan/audio-fx-plugin-*` deps.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [limiter](./src/limiter/limiter.md) — `LimiterPlugin` is the universal brick-wall limiter. Its core contract is unconditional: for any bounded input, no outp…
|
||||
- [limiter](./src/limiter/limiter.md) — `LimiterPlugin` is the universal brick-wall limiter. Its core contract is unconditional: for any bounded input, no outp…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options of `LimiterPlugin` and the (currently empty) preset payload contra…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas validating the construction-time options of `LimiterPlugin` and the (currently empty) preset payload contra…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-limiter";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./limiter` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
47
packages/media/audio-fx/audio-fx-plugin-limiter/package.json
Normal file
47
packages/media/audio-fx/audio-fx-plugin-limiter/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@suaveplan/audio-fx-plugin-limiter",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "limiter plugin for the @suaveplan/audio-fx-plugin-* family.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { LimiterPlugin } from "./limiter/index.js";
|
||||
export type { LimiterPluginOptions } from "./limiter.types.js";
|
||||
export {
|
||||
LimiterPluginOptionsSchema,
|
||||
LimiterPresetDataSchema,
|
||||
} from "./schemas/index.js";
|
||||
@@ -0,0 +1,51 @@
|
||||
# `limiter.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript-only type definitions for `LimiterPlugin`'s construction options. Co-located with the implementation so the public type surface (the `LimiterPluginOptions` interface) and its Zod validator stay in lockstep. Consumers can import the type for typed form state, dependency-injection wiring, or storybook controls without pulling in the plugin's runtime code.
|
||||
|
||||
This module declares no runtime values — it is the canonical "shape" contract the constructor accepts, mirrored by `LimiterPluginOptionsSchema` in `schemas/schemas.ts`.
|
||||
|
||||
## Features
|
||||
|
||||
- `LimiterPluginOptions` — all fields optional so the default constructor (`new LimiterPlugin()`) is valid.
|
||||
- `readonly` properties — emphasises that the options object is consumed once at construction and never mutated.
|
||||
- Numeric ranges are documented here via the Zod schema (not encoded in TS types) so a single source of truth governs validation.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { LimiterPluginOptions } from "@suaveplan/audio-fx-plugin-limiter";
|
||||
|
||||
const opts: LimiterPluginOptions = { initialCeilingDb: -1 };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { LimiterPluginOptions } from "@suaveplan/audio-fx-plugin-limiter";
|
||||
|
||||
// Use the options type as a state shape for a UI form.
|
||||
interface LimiterFormState extends Required<LimiterPluginOptions> {
|
||||
readonly dirty: boolean;
|
||||
}
|
||||
|
||||
function saveLimiterOptions(state: LimiterFormState): LimiterPluginOptions {
|
||||
return {
|
||||
initialCeilingDb: state.initialCeilingDb,
|
||||
initialReleaseMs: state.initialReleaseMs,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `LimiterPluginOptions`
|
||||
- `readonly initialCeilingDb?: number` — initial brick-wall ceiling in dB.
|
||||
- `readonly initialReleaseMs?: number` — initial release time constant in ms.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The options interface deliberately omits a `gainReductionDb` field. That parameter is a telemetry output, not a configurable input — it is always derived from the per-block worst-case attenuation and would not be meaningful as a construction-time value. Constraining the options surface to inputs prevents callers from creating plugins in an impossible "pre-set" telemetry state.
|
||||
|
||||
All fields are `readonly` so structural-equality checks on saved options (e.g., undo/redo stacks) do not need defensive cloning. The constructor never mutates the passed object — it copies relevant values into the parameter list once and forgets the input — but typing the surface as `readonly` lets the type system enforce that intent at every call site, including in long-lived editor stores.
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Construction options for `LimiterPlugin`. */
|
||||
export interface LimiterPluginOptions {
|
||||
readonly initialCeilingDb?: number;
|
||||
readonly initialReleaseMs?: number;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { LimiterPlugin } from "./limiter.js";
|
||||
@@ -0,0 +1,70 @@
|
||||
# `limiter`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`LimiterPlugin` is the universal brick-wall limiter. Its core contract is unconditional: for any bounded input, no output sample's absolute value exceeds the linear conversion of the `ceiling` parameter. The plugin pairs an instantaneous (zero-lookahead) attack with a one-pole exponential release, so transients are clamped on the sample they occur — no lookahead buffer, no group delay, no latency to report.
|
||||
|
||||
The plugin extends `AbstractAudioFxPlugin`, so it runs identically across browser / server / worklet renderers. State is a single linked gain value plus a frame-peak reduction metric for telemetry. `reset()` snaps the gain back to unity in place.
|
||||
|
||||
## Features
|
||||
|
||||
- Three parameters: `ceiling` (dB, -60..0), `release` (ms, 1..1000), and `gainReductionDb` (telemetry).
|
||||
- Linked-stereo brick-wall: gain is shared across all input channels per sample, so a transient on one channel attenuates every channel identically — the stereo image stays phase-coherent.
|
||||
- Zero-lookahead attack: when `|peak| > ceilingLin` at sample `i`, the gain at sample `i` is set to `ceilingLin / |peak|` *before* writing the output, so the ceiling is a hard contract verifiable by audit.
|
||||
- One-pole release: `gain += releaseCoef · (1 − gain)` per sample with `releaseCoef = 1 − exp(-1/(τ · sr))`.
|
||||
- `gainReductionDb` is updated once per block with the worst-case in-block reduction so meters get a useful peak reading without per-sample state.
|
||||
- `getGainReductionDb()` accessor mirrors the parameter for low-friction metering.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { LimiterPlugin } from "@suaveplan/audio-fx-plugin-limiter";
|
||||
|
||||
const limiter = new LimiterPlugin({
|
||||
initialCeilingDb: -1,
|
||||
initialReleaseMs: 50,
|
||||
});
|
||||
chain.add(limiter);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { LimiterPlugin } from "@suaveplan/audio-fx-plugin-limiter";
|
||||
|
||||
// Master-bus limiter feeding a UI meter from the live gain reduction.
|
||||
const limiter = new LimiterPlugin({
|
||||
initialCeilingDb: -0.3,
|
||||
initialReleaseMs: 80,
|
||||
});
|
||||
await limiter.setup(
|
||||
{ sampleRate: 48_000, channels: 2, blockSize: 512 },
|
||||
ctx
|
||||
);
|
||||
|
||||
setInterval(() => {
|
||||
const gr = limiter.getGainReductionDb();
|
||||
if (gr > 6) {
|
||||
console.warn(`Master limiter pulling ${gr.toFixed(2)} dB`);
|
||||
}
|
||||
}, 100);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `LimiterPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new LimiterPlugin(options?: LimiterPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
|
||||
- `getGainReductionDb(): number` — last block's peak gain reduction (positive number, in dB).
|
||||
- Inherited parameter accessors for `ceiling`, `release`, `gainReductionDb`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The limiter is intentionally a true brick-wall, not a "soft" or "transparent" limiter. We do not interpolate around the threshold or apply a knee. The reason: many use sites layer this plugin downstream of an output-stage compressor that already provides knee shaping, and rely on the limiter for guaranteed bound semantics. Adding a knee would weaken the contract that the test in `output peak never exceeds the configured ceiling` enforces.
|
||||
|
||||
There is no lookahead buffer. That keeps the plugin latency-free (`latencySamples: 0`, inherited default) and makes the bound testable by static inspection of the inner loop: at sample `i`, if `peak > ceilingLin` then `gain := ceilingLin / peak` *before* `output[ch][i] = input[ch][i] * gain`. The multiplication therefore can produce at most `|input[ch][i]| * (ceilingLin / |peak|) ≤ ceilingLin`. The downside is that an unusually fast transient (single-sample spike at a very low source sample rate) can produce visible distortion on its leading edge — for those cases a future `LookaheadLimiterPlugin` can sit on a separate subpath without breaking this contract.
|
||||
|
||||
`gainReductionDb` is reported as a per-block peak rather than a running average. Meters typically expect peak; if a future surface wants RMS, it can compute that from the gain-reduction stream by subscribing to a parameter automation channel. The release coefficient is recomputed every block from the current `release` parameter so automation works without introducing zipper noise — the one-pole step is small per sample even when the parameter changes mid-block.
|
||||
@@ -0,0 +1,193 @@
|
||||
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 { LimiterPlugin } from "./limiter.js";
|
||||
|
||||
const SAMPLE_RATE = 48_000;
|
||||
|
||||
function blockCtx(): BlockContext {
|
||||
return {
|
||||
blockOffsetSamples: 0,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
channels: 1,
|
||||
opts: {},
|
||||
};
|
||||
}
|
||||
|
||||
function buffers(
|
||||
size: number,
|
||||
channels = 1
|
||||
): { input: Float32Array[]; output: Float32Array[] } {
|
||||
const input: Float32Array[] = [];
|
||||
const output: Float32Array[] = [];
|
||||
for (let c = 0; c < channels; c++) {
|
||||
input.push(new Float32Array(size));
|
||||
output.push(new Float32Array(size));
|
||||
}
|
||||
return { input, output };
|
||||
}
|
||||
|
||||
function newConfig(blockSize: number, channels = 1): ProcessorConfig {
|
||||
return { sampleRate: SAMPLE_RATE, channels, blockSize };
|
||||
}
|
||||
|
||||
function newCtx(channels = 1): ChainContext {
|
||||
const chain: ChainHandle = {
|
||||
id: "limiter-test",
|
||||
name: "limiter-test",
|
||||
channels,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
blockSize: 256,
|
||||
slotCount: 1,
|
||||
};
|
||||
return {
|
||||
chain,
|
||||
position: { index: 0, total: 1 },
|
||||
predecessors: [],
|
||||
successors: [],
|
||||
ambient: defaultAmbientState(),
|
||||
buses: { get: () => undefined, list: () => [] },
|
||||
};
|
||||
}
|
||||
|
||||
describe("LimiterPlugin: contract surface", () => {
|
||||
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
|
||||
const l = new LimiterPlugin();
|
||||
expect(l.metadata.id).toBe("limiter");
|
||||
expect(l.metadata.category).toBe("dynamics");
|
||||
expect(l.parameters.map((p) => p.id)).toEqual([
|
||||
"ceiling",
|
||||
"release",
|
||||
"gainReductionDb",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects malformed options with INVALID_OPTIONS", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
try {
|
||||
new LimiterPlugin({ initialCeilingDb: 99 });
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LimiterPlugin: output peak never exceeds the configured ceiling", () => {
|
||||
it("brick-wall: every output sample's |x| <= ceilingLin + 1e-6", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const l = new LimiterPlugin({
|
||||
initialCeilingDb: -6,
|
||||
initialReleaseMs: 50,
|
||||
});
|
||||
await l.setup(newConfig(SAMPLE_RATE, 1), newCtx(1));
|
||||
const { input, output } = buffers(SAMPLE_RATE, 1);
|
||||
// Loud signal: hot sine + occasional transients that exceed ceiling.
|
||||
for (let i = 0; i < SAMPLE_RATE; i++) {
|
||||
input[0]![i] =
|
||||
0.95 * Math.sin((2 * Math.PI * 1_000 * i) / SAMPLE_RATE);
|
||||
}
|
||||
// Inject transients well above ceiling.
|
||||
for (let i = 1_000; i < SAMPLE_RATE; i += 5_000) {
|
||||
input[0]![i] = 4;
|
||||
input[0]![i + 1] = -4;
|
||||
}
|
||||
l.process(input, output, blockCtx());
|
||||
const ceilingLin = 10 ** (-6 / 20);
|
||||
let maxObserved = 0;
|
||||
for (let i = 0; i < SAMPLE_RATE; i++) {
|
||||
const a = Math.abs(output[0]![i]!);
|
||||
if (a > maxObserved) {
|
||||
maxObserved = a;
|
||||
}
|
||||
}
|
||||
expect(maxObserved).toBeLessThanOrEqual(ceilingLin + 1e-6);
|
||||
expect(l.getGainReductionDb()).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LimiterPlugin: signal below ceiling passes through unchanged", () => {
|
||||
it("|input| < ceiling => output ≈ input (gain == 1)", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const l = new LimiterPlugin({
|
||||
initialCeilingDb: -1,
|
||||
initialReleaseMs: 10,
|
||||
});
|
||||
await l.setup(newConfig(2048, 1), newCtx(1));
|
||||
const { input, output } = buffers(2048, 1);
|
||||
for (let i = 0; i < 2048; i++) {
|
||||
input[0]![i] =
|
||||
0.5 * Math.sin((2 * Math.PI * 1_000 * i) / SAMPLE_RATE);
|
||||
}
|
||||
l.process(input, output, blockCtx());
|
||||
for (let i = 0; i < 2048; i++) {
|
||||
expect(output[0]![i]).toBeCloseTo(input[0]![i]!, 6);
|
||||
}
|
||||
expect(l.getGainReductionDb()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LimiterPlugin: linked stereo gain", () => {
|
||||
it("transient on left channel attenuates right channel by same gain", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const l = new LimiterPlugin({ initialCeilingDb: -6 });
|
||||
await l.setup(newConfig(512, 2), newCtx(2));
|
||||
const { input, output } = buffers(512, 2);
|
||||
// Left has a big transient at sample 100; right is steady-mid.
|
||||
input[0]![100] = 4;
|
||||
for (let i = 0; i < 512; i++) {
|
||||
input[1]![i] = 0.5;
|
||||
}
|
||||
l.process(input, output, blockCtx());
|
||||
// At sample 100, gain << 1 due to left transient — right output
|
||||
// must be reduced by the same gain.
|
||||
expect(Math.abs(output[1]![100]!)).toBeLessThan(0.5);
|
||||
expect(Math.abs(output[0]![100]!)).toBeLessThanOrEqual(
|
||||
10 ** (-6 / 20) + 1e-6
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LimiterPlugin: reset clears state", () => {
|
||||
it("after a hot block + reset, gain returns to 1 and grDb to 0", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const l = new LimiterPlugin({ initialCeilingDb: -6 });
|
||||
await l.setup(newConfig(256, 1), newCtx(1));
|
||||
const { input, output } = buffers(256, 1);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
input[0]![i] = 4;
|
||||
}
|
||||
l.process(input, output, blockCtx());
|
||||
expect(l.getGainReductionDb()).toBeGreaterThan(0);
|
||||
l.reset();
|
||||
expect(l.getGainReductionDb()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LimiterPlugin: preset round-trip", () => {
|
||||
it("getPreset / applyPreset round-trips ceiling parameter", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const a = new LimiterPlugin({ initialCeilingDb: -3 });
|
||||
const snap = a.getPreset();
|
||||
const b = new LimiterPlugin();
|
||||
b.applyPreset(snap);
|
||||
expect(b.getParameter("ceiling")).toBe(-3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
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 {
|
||||
AUDIO_FX_CODES,
|
||||
AudioFxError,
|
||||
} from "@suaveplan/audio-fx-plugin-base/error-codes";
|
||||
import type { LimiterPluginOptions } from "../limiter.types.js";
|
||||
import {
|
||||
LimiterPluginOptionsSchema,
|
||||
LimiterPresetDataSchema,
|
||||
} from "../schemas/index.js";
|
||||
|
||||
/**
|
||||
* Brick-wall limiter. Per-sample peak detector across all input
|
||||
* channels chooses the minimum gain such that the linked output peak
|
||||
* never exceeds `ceiling`. Attack is instantaneous (zero-lookahead) so
|
||||
* the ceiling is a hard contract: no transient slips through. Release
|
||||
* is a one-pole exponential ramp back toward unity. `gainReductionDb`
|
||||
* is exposed as a parameter for metering.
|
||||
*/
|
||||
export class LimiterPlugin extends AbstractAudioFxPlugin {
|
||||
private currentGain = 1;
|
||||
private currentGrDb = 0;
|
||||
|
||||
public constructor(options: LimiterPluginOptions = {}) {
|
||||
const parsed = LimiterPluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS,
|
||||
"audio-fx.limiter-invalid-options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const opts = parsed.data;
|
||||
super({
|
||||
metadata: {
|
||||
id: "limiter",
|
||||
name: "Limiter",
|
||||
version: "1.0.0",
|
||||
category: "dynamics",
|
||||
},
|
||||
schema: LimiterPresetDataSchema,
|
||||
io: {
|
||||
inputs: [{ count: "preserve" }],
|
||||
outputs: [{ count: "preserve" }],
|
||||
},
|
||||
parameters: [
|
||||
{
|
||||
id: "ceiling",
|
||||
name: "Ceiling",
|
||||
value: opts.initialCeilingDb ?? -1,
|
||||
min: -60,
|
||||
max: 0,
|
||||
default: -1,
|
||||
unit: "db",
|
||||
automatable: true,
|
||||
},
|
||||
{
|
||||
id: "release",
|
||||
name: "Release",
|
||||
value: opts.initialReleaseMs ?? 50,
|
||||
min: 1,
|
||||
max: 1_000,
|
||||
default: 50,
|
||||
unit: "ms",
|
||||
automatable: true,
|
||||
},
|
||||
{
|
||||
id: "gainReductionDb",
|
||||
name: "Gain Reduction",
|
||||
value: 0,
|
||||
min: 0,
|
||||
max: 60,
|
||||
default: 0,
|
||||
unit: "db",
|
||||
automatable: false,
|
||||
},
|
||||
],
|
||||
supportedSchemaVersion: 1,
|
||||
});
|
||||
}
|
||||
|
||||
public getGainReductionDb(): number {
|
||||
return this.currentGrDb;
|
||||
}
|
||||
|
||||
protected override setupState(
|
||||
_config: ProcessorConfig,
|
||||
_chain: ChainContext
|
||||
): void {
|
||||
this.currentGain = 1;
|
||||
this.currentGrDb = 0;
|
||||
}
|
||||
|
||||
protected override resetState(): void {
|
||||
this.currentGain = 1;
|
||||
this.currentGrDb = 0;
|
||||
}
|
||||
|
||||
protected processBlock(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[],
|
||||
_ctx: BlockContext
|
||||
): void {
|
||||
const ceilingDb = this.parameterValue("ceiling");
|
||||
const releaseMs = this.parameterValue("release");
|
||||
const sr = this.config!.sampleRate;
|
||||
const ceilingLin = 10 ** (ceilingDb / 20);
|
||||
const releaseCoef = 1 - Math.exp(-1 / ((releaseMs / 1000) * sr));
|
||||
const blockSize = input[0]!.length;
|
||||
|
||||
let gain = this.currentGain;
|
||||
let maxGrDb = 0;
|
||||
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
let peak = 0;
|
||||
for (let ch = 0; ch < input.length; ch++) {
|
||||
const v = input[ch]![i]!;
|
||||
const a = v < 0 ? -v : v;
|
||||
if (a > peak) {
|
||||
peak = a;
|
||||
}
|
||||
}
|
||||
const required = peak > ceilingLin ? ceilingLin / peak : 1;
|
||||
gain += releaseCoef * (1 - gain);
|
||||
if (gain > required) {
|
||||
gain = required;
|
||||
}
|
||||
for (let ch = 0; ch < input.length; ch++) {
|
||||
output[ch]![i] = input[ch]![i]! * gain;
|
||||
}
|
||||
const grDb = gain >= 1 ? 0 : -20 * Math.log10(gain);
|
||||
if (grDb > maxGrDb) {
|
||||
maxGrDb = grDb;
|
||||
}
|
||||
}
|
||||
|
||||
this.currentGain = gain;
|
||||
this.currentGrDb = maxGrDb;
|
||||
this.setParameter("gainReductionDb", maxGrDb);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
LimiterPluginOptionsSchema,
|
||||
LimiterPresetDataSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -0,0 +1,48 @@
|
||||
# `limiter/schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas validating the construction-time options of `LimiterPlugin` and the (currently empty) preset payload contract. These run on the public boundary between user code and the plugin: any malformed object hitting the constructor or `applyPreset` is rejected with `AUDIO_FX.INVALID_OPTIONS` / `AUDIO_FX.INVALID_PRESET` before it can corrupt runtime state.
|
||||
|
||||
Separating schemas from the plugin module lets `index.ts` re-export them so callers can introspect the option surface (for forms, editors, or RPC validation) without importing the audio implementation.
|
||||
|
||||
## Features
|
||||
|
||||
- `LimiterPluginOptionsSchema` — validates `initialCeilingDb` ∈ [-60, 0] and `initialReleaseMs` ∈ [1, 1000].
|
||||
- `LimiterPresetDataSchema` — empty strict object: the limiter has no plugin-specific preset payload, so any extra keys are rejected by `.strict()`.
|
||||
- Both schemas are `z.object` / `z.number().finite()` based so `NaN` and `Infinity` cannot sneak through.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { LimiterPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-limiter";
|
||||
|
||||
const parsed = LimiterPluginOptionsSchema.parse({ initialCeilingDb: -3 });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { LimiterPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-limiter";
|
||||
|
||||
// Drive an editor form from the schema's introspected shape.
|
||||
const fields = LimiterPluginOptionsSchema.keyof().options;
|
||||
for (const field of fields) {
|
||||
form.addRow(field);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `LimiterPluginOptionsSchema: z.ZodObject<{ initialCeilingDb?, initialReleaseMs? }>`
|
||||
- `LimiterPresetDataSchema: z.ZodObject<{}, "strict">`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The constructor in `limiter.ts` calls `LimiterPluginOptionsSchema.safeParse(options)` and throws `AudioFxError(INVALID_OPTIONS, ...)` on failure with the Zod `issues` array attached as `context`. This keeps the surface uniform with the other plugins (eq/compressor/reverb/filter/delay/gate) and makes log triage easy: a single grep for `audio-fx.limiter-invalid-options` finds every malformed-options site.
|
||||
|
||||
`LimiterPresetDataSchema` is `.strict()` on purpose. The limiter has no preset-specific payload today, but `applyPreset` from `AbstractAudioFxPlugin` will still call the schema's `parse`. Using `.strict()` rejects accidentally-mixed payloads from sibling plugins (e.g., a `CompressorPresetData` blob applied to a `LimiterPlugin`), surfacing the mistake at the boundary rather than silently no-op'ing parameter restoration.
|
||||
@@ -0,0 +1,9 @@
|
||||
import z from "zod";
|
||||
|
||||
export const LimiterPluginOptionsSchema = z.object({
|
||||
initialCeilingDb: z.number().finite().min(-60).max(0).optional(),
|
||||
initialReleaseMs: z.number().finite().min(1).max(1_000).optional(),
|
||||
});
|
||||
|
||||
/** No plugin-specific preset payload — all parameters are universal. */
|
||||
export const LimiterPresetDataSchema = z.object({}).strict();
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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-limiter",
|
||||
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