Initial Commit
This commit is contained in:
31
packages/media/audio-fx/audio-fx-plugin-compressor/README.md
Normal file
31
packages/media/audio-fx/audio-fx-plugin-compressor/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-compressor - compressor plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
compressor 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
|
||||
|
||||
- [compressor](./src/compressor/compressor.md) — `CompressorPlugin` is the universal soft-knee dynamics compressor for the project — the same plugin handles vocal level…
|
||||
- [compressor](./src/compressor/compressor.md) — `CompressorPlugin` is the universal soft-knee dynamics compressor for the project — the same plugin handles vocal level…
|
||||
- [schemas](./src/schemas/schemas.md) — The `compressor.schemas` module owns the Zod boundary guards for `CompressorPlugin` — both the constructor options and…
|
||||
- [schemas](./src/schemas/schemas.md) — The `compressor.schemas` module owns the Zod boundary guards for `CompressorPlugin` — both the constructor options and…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-compressor";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./compressor` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@suaveplan/audio-fx-plugin-compressor",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "compressor 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,16 @@
|
||||
/** Construction options for `CompressorPlugin`. */
|
||||
export interface CompressorPluginOptions {
|
||||
readonly initialThresholdDb?: number;
|
||||
readonly initialKneeDb?: number;
|
||||
readonly initialRatio?: number;
|
||||
readonly initialAttackMs?: number;
|
||||
readonly initialReleaseMs?: number;
|
||||
readonly initialMidSide?: boolean;
|
||||
readonly initialSidechainEnabled?: boolean;
|
||||
}
|
||||
|
||||
/** Plugin-specific preset payload. */
|
||||
export interface CompressorPresetData {
|
||||
readonly midSide: boolean;
|
||||
readonly sidechainEnabled: boolean;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
# `compressor`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`CompressorPlugin` is the universal soft-knee dynamics compressor for the project — the same plugin handles vocal levelling, drum-bus glue, mastering compression, and sidechain-ducked bass under a kick. It implements the `AudioFxPlugin` contract by extending `AbstractAudioFxPlugin`, so the same compressor runs identically inside the Web Audio engine, the server renderer, and the worklet wrapper.
|
||||
|
||||
The compressor exposes its full control surface (`threshold`, `knee`, `ratio`, `attack`, `release`) as automatable `PluginParameter`s, plus non-automatable mode flags (`midSide`, `sidechainEnabled`) and a read-only `gainReductionDb` telemetry parameter that meters can poll. A peak envelope follower with separate attack/release smoothing coefficients tracks the loudest channel across the input (or the sidechain bus when wired), and a Reiss & Stables soft-knee curve maps envelope-above-threshold to gain reduction in dB.
|
||||
|
||||
## Features
|
||||
|
||||
- Five automatable continuous parameters — `threshold`, `knee`, `ratio`, `attack`, `release` — plus two mode flags and a read-only meter.
|
||||
- Soft-knee transfer curve (Reiss & Stables formulation): below `threshold - knee/2` no reduction, above `threshold + knee/2` full ratio reduction, smooth quadratic interpolation through the knee.
|
||||
- Linked-channel envelope follower (one shared envelope) so stereo material stays phase-coherent under compression.
|
||||
- Optional sidechain detection — call `setSidechain(bus)` to route the envelope detector from an external `SidechainBus` (e.g. a kick stem) when `sidechainEnabled` is true.
|
||||
- Mid/side mode on stereo input: encode L/R → M/S, apply the linked gain to both, decode back. Mono input ignores the mode flag.
|
||||
- Allocation-free per-block processing: attack/release smoothing coefficients recomputed once per block from the current parameter values.
|
||||
- `gainReductionDb` parameter republished every block; consumers can either poll `getGainReductionDb()` or read the parameter directly.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CompressorPlugin } from "@suaveplan/audio-fx-plugin-compressor";
|
||||
|
||||
const comp = new CompressorPlugin({
|
||||
initialThresholdDb: -18,
|
||||
initialRatio: 4,
|
||||
initialAttackMs: 5,
|
||||
initialReleaseMs: 80,
|
||||
});
|
||||
chain.add(comp);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { CompressorPlugin } from "@suaveplan/audio-fx-plugin-compressor";
|
||||
import { DefaultBusRegistry } from "@suaveplan/audio-plugins-fx/chain";
|
||||
|
||||
// Sidechain bass under kick:
|
||||
const buses = new DefaultBusRegistry();
|
||||
const kickPub = buses.publish("kick", 1);
|
||||
|
||||
const bass = new CompressorPlugin({
|
||||
initialThresholdDb: -20,
|
||||
initialRatio: 6,
|
||||
initialAttackMs: 2,
|
||||
initialReleaseMs: 150,
|
||||
initialSidechainEnabled: true,
|
||||
});
|
||||
await bass.setup({ sampleRate: 48_000, channels: 1, blockSize: 256 }, ctx);
|
||||
bass.setSidechain(buses.get("kick"));
|
||||
|
||||
// In the kick track's processBlock, publish its output:
|
||||
kickPub.write([kickSamples]);
|
||||
|
||||
// Meter the gain reduction every UI frame:
|
||||
requestAnimationFrame(function tick() {
|
||||
meter.write(bass.getGainReductionDb());
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CompressorPlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new CompressorPlugin(options?: CompressorPluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
|
||||
- `setSidechain(bus: SidechainBus | undefined): void` — attach / detach an external detection source.
|
||||
- `getGainReductionDb(): number` — current frame's peak gain reduction in dB (positive = attenuation).
|
||||
- Inherited parameter accessors for `threshold`, `knee`, `ratio`, `attack`, `release`, `midSide`, `sidechainEnabled`, `gainReductionDb`.
|
||||
- `CompressorPluginOptions` — initial parameter values and mode flags.
|
||||
- `CompressorPresetData` — `{ midSide, sidechainEnabled }`; the five continuous parameters round-trip via the universal `parameters` map.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The envelope follower uses one-pole smoothing with separate `attack` and `release` time constants converted to per-sample coefficients via `alpha = exp(-1 / (timeSec * sampleRate))`. The peak detector takes `max(|L|, |R|, …)` across detector channels per sample so a single transient on any channel pulls the envelope up immediately. Envelope state is one number (linked) so all output channels share the same gain reduction — this keeps the stereo image stable under compression.
|
||||
|
||||
The soft-knee formula uses the closed-form Reiss & Stables expression: inside the knee region, `gr = (1 − 1/ratio) · (envDb − threshold + knee/2)² / (2·knee)`. At `knee = 0` the formula degenerates to a hard-knee response by skipping the knee branch entirely (the condition `knee > 0 && 2·above > -knee && 2·above < knee` is false when `knee === 0`).
|
||||
|
||||
Sidechain routing: `setSidechain(bus)` allocates per-channel scratch buffers sized to `bus.channels` (not the plugin's channels) so a mono sidechain can drive a stereo program. During `processBlock`, when `sidechainEnabled` and a bus is wired, the detector reads into the scratch and uses those samples as the envelope source; otherwise it falls back to the program input. Calling `setSidechain(undefined)` detaches the bus and frees the scratch.
|
||||
|
||||
The `gainReductionDb` parameter is non-automatable (its value is plugin output, not user input); the plugin writes it via the same `setParameter` path used by external callers, which clamps to `[0, 60]`.
|
||||
@@ -0,0 +1,390 @@
|
||||
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
|
||||
import type {
|
||||
ChainContext,
|
||||
ChainHandle,
|
||||
} from "@suaveplan/audio-fx-plugin-base/base";
|
||||
import {
|
||||
DefaultBusRegistry,
|
||||
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 { CompressorPlugin } from "./compressor.js";
|
||||
|
||||
const SAMPLE_RATE = 48_000;
|
||||
|
||||
function blockCtx(): BlockContext {
|
||||
return {
|
||||
blockOffsetSamples: 0,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
channels: 2,
|
||||
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 fillSine(
|
||||
buf: Float32Array,
|
||||
frequency: number,
|
||||
amplitude: number,
|
||||
phaseOffset = 0
|
||||
): void {
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
buf[i] =
|
||||
amplitude *
|
||||
Math.sin(
|
||||
(2 * Math.PI * frequency * (i + phaseOffset)) / SAMPLE_RATE
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 = 1): ProcessorConfig {
|
||||
return { sampleRate: SAMPLE_RATE, channels, blockSize };
|
||||
}
|
||||
|
||||
function newCtx(channels = 2): ChainContext {
|
||||
const chain: ChainHandle = {
|
||||
id: "comp-test",
|
||||
name: "comp-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("CompressorPlugin: contract surface", () => {
|
||||
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
|
||||
const c = new CompressorPlugin();
|
||||
expect(c.metadata.id).toBe("compressor");
|
||||
expect(c.metadata.category).toBe("dynamics");
|
||||
expect(c.parameters.map((p) => p.id)).toContain("threshold");
|
||||
expect(c.capabilities.hasSidechain).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects malformed options with INVALID_OPTIONS", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
try {
|
||||
new CompressorPlugin({ initialRatio: -1 });
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
|
||||
it("accepts initial parameter overrides", { timeout: 5_000 }, () => {
|
||||
const c = new CompressorPlugin({
|
||||
initialThresholdDb: -30,
|
||||
initialKneeDb: 12,
|
||||
initialRatio: 8,
|
||||
initialAttackMs: 5,
|
||||
initialReleaseMs: 200,
|
||||
initialMidSide: true,
|
||||
initialSidechainEnabled: true,
|
||||
});
|
||||
expect(c.getParameter("threshold")).toBe(-30);
|
||||
expect(c.getParameter("ratio")).toBe(8);
|
||||
expect(c.getParameter("midSide")).toBe(1);
|
||||
expect(c.getParameter("sidechainEnabled")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompressorPlugin: attenuates signal above threshold per ratio", () => {
|
||||
it("4:1 with hard knee reduces a -6 dB sine below -16 dB", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const c = new CompressorPlugin({
|
||||
initialThresholdDb: -20,
|
||||
initialKneeDb: 0,
|
||||
initialRatio: 4,
|
||||
initialAttackMs: 1,
|
||||
initialReleaseMs: 10,
|
||||
});
|
||||
await c.setup(newConfig(2048, 1), newCtx(1));
|
||||
const { input, output } = buffers(2048, 1);
|
||||
// Process several blocks to settle envelope.
|
||||
for (let blockI = 0; blockI < 8; blockI++) {
|
||||
fillSine(input[0]!, 1_000, 0.5, blockI * 2048);
|
||||
c.process(input, output, blockCtx());
|
||||
}
|
||||
const inRms = rms(input[0]!, 1024);
|
||||
const outRms = rms(output[0]!, 1024);
|
||||
const ratioDb = 20 * Math.log10(outRms / inRms);
|
||||
// 14 dB over threshold * (1 - 1/4) = 10.5 dB reduction expected.
|
||||
expect(ratioDb).toBeLessThan(-9);
|
||||
expect(ratioDb).toBeGreaterThan(-12);
|
||||
expect(c.getGainReductionDb()).toBeGreaterThan(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompressorPlugin: below threshold is bypassed", () => {
|
||||
it("signal below threshold passes through unchanged", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const c = new CompressorPlugin({
|
||||
initialThresholdDb: -20,
|
||||
initialKneeDb: 0,
|
||||
initialRatio: 4,
|
||||
});
|
||||
await c.setup(newConfig(1024, 1), newCtx(1));
|
||||
const { input, output } = buffers(1024, 1);
|
||||
// -30 dB amplitude → 0.0316
|
||||
fillSine(input[0]!, 1_000, 0.0316);
|
||||
c.process(input, output, blockCtx());
|
||||
const ratio = rms(output[0]!) / rms(input[0]!);
|
||||
expect(ratio).toBeGreaterThan(0.95);
|
||||
expect(ratio).toBeLessThan(1.05);
|
||||
expect(c.getGainReductionDb()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompressorPlugin: soft knee", () => {
|
||||
it("knee region produces gradual reduction", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const c = new CompressorPlugin({
|
||||
initialThresholdDb: -20,
|
||||
initialKneeDb: 12,
|
||||
initialRatio: 4,
|
||||
initialAttackMs: 1,
|
||||
initialReleaseMs: 10,
|
||||
});
|
||||
await c.setup(newConfig(2048, 1), newCtx(1));
|
||||
const { input, output } = buffers(2048, 1);
|
||||
// Signal at threshold (-20 dB) → middle of knee region
|
||||
for (let b = 0; b < 4; b++) {
|
||||
fillSine(input[0]!, 1_000, 0.1, b * 2048);
|
||||
c.process(input, output, blockCtx());
|
||||
}
|
||||
const gr = c.getGainReductionDb();
|
||||
// Within the knee, gr is positive but less than hard-knee value.
|
||||
expect(gr).toBeGreaterThan(0);
|
||||
expect(gr).toBeLessThan(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompressorPlugin: mid/side mode", () => {
|
||||
it("processes stereo without throwing", { timeout: 5_000 }, async () => {
|
||||
const c = new CompressorPlugin({
|
||||
initialThresholdDb: -20,
|
||||
initialRatio: 4,
|
||||
initialMidSide: true,
|
||||
initialAttackMs: 1,
|
||||
initialReleaseMs: 10,
|
||||
});
|
||||
await c.setup(newConfig(1024, 2), newCtx(2));
|
||||
const { input, output } = buffers(1024, 2);
|
||||
fillSine(input[0]!, 1_000, 0.5);
|
||||
fillSine(input[1]!, 1_000, 0.5);
|
||||
c.process(input, output, blockCtx());
|
||||
for (let i = 0; i < 1024; i++) {
|
||||
expect(Number.isFinite(output[0]![i]!)).toBe(true);
|
||||
expect(Number.isFinite(output[1]![i]!)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("mid/side flag is ignored on mono input", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const c = new CompressorPlugin({
|
||||
initialMidSide: true,
|
||||
initialThresholdDb: -20,
|
||||
initialRatio: 4,
|
||||
});
|
||||
await c.setup(newConfig(512, 1), newCtx(1));
|
||||
const { input, output } = buffers(512, 1);
|
||||
fillSine(input[0]!, 1_000, 0.5);
|
||||
c.process(input, output, blockCtx());
|
||||
expect(Number.isFinite(output[0]![0]!)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompressorPlugin: sidechain", () => {
|
||||
it("uses sidechain bus for envelope detection when enabled", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const registry = new DefaultBusRegistry();
|
||||
const pub = registry.publish("kick", 1);
|
||||
const c = new CompressorPlugin({
|
||||
initialThresholdDb: -20,
|
||||
initialRatio: 4,
|
||||
initialAttackMs: 1,
|
||||
initialReleaseMs: 10,
|
||||
initialSidechainEnabled: true,
|
||||
});
|
||||
await c.setup(newConfig(1024, 1), newCtx(1));
|
||||
c.setSidechain(registry.get("kick"));
|
||||
const { input, output } = buffers(1024, 1);
|
||||
// Quiet program signal (below threshold)
|
||||
fillSine(input[0]!, 200, 0.05);
|
||||
// Loud sidechain
|
||||
const sc = new Float32Array(1024);
|
||||
fillSine(sc, 100, 0.8);
|
||||
pub.write([sc]);
|
||||
c.process(input, output, blockCtx());
|
||||
// Sidechain triggers ducking
|
||||
expect(c.getGainReductionDb()).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it("falls back to program signal when sidechain attached but disabled", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const registry = new DefaultBusRegistry();
|
||||
const pub = registry.publish("kick", 1);
|
||||
const c = new CompressorPlugin({
|
||||
initialThresholdDb: -20,
|
||||
initialRatio: 4,
|
||||
initialSidechainEnabled: false,
|
||||
});
|
||||
await c.setup(newConfig(512, 1), newCtx(1));
|
||||
c.setSidechain(registry.get("kick"));
|
||||
const sc = new Float32Array(512);
|
||||
fillSine(sc, 100, 0.8);
|
||||
pub.write([sc]);
|
||||
const { input, output } = buffers(512, 1);
|
||||
fillSine(input[0]!, 200, 0.05); // quiet
|
||||
c.process(input, output, blockCtx());
|
||||
// No ducking — sidechain disabled, program is below threshold
|
||||
expect(c.getGainReductionDb()).toBe(0);
|
||||
});
|
||||
|
||||
it("falls back to program signal when sidechain bus is detached", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const c = new CompressorPlugin({
|
||||
initialThresholdDb: -20,
|
||||
initialRatio: 4,
|
||||
initialSidechainEnabled: true,
|
||||
});
|
||||
await c.setup(newConfig(512, 1), newCtx(1));
|
||||
const { input, output } = buffers(512, 1);
|
||||
fillSine(input[0]!, 1_000, 0.5);
|
||||
c.process(input, output, blockCtx());
|
||||
expect(c.getGainReductionDb()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("setSidechain before setup leaves scratch empty", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const registry = new DefaultBusRegistry();
|
||||
registry.publish("kick", 1);
|
||||
const c = new CompressorPlugin();
|
||||
c.setSidechain(registry.get("kick"));
|
||||
// No throw — scratch allocation deferred until setup.
|
||||
await c.setup(newConfig(256, 1), newCtx(1));
|
||||
});
|
||||
|
||||
it("setSidechain(undefined) detaches the bus", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const registry = new DefaultBusRegistry();
|
||||
registry.publish("kick", 1);
|
||||
const c = new CompressorPlugin();
|
||||
await c.setup(newConfig(256, 1), newCtx(1));
|
||||
c.setSidechain(registry.get("kick"));
|
||||
c.setSidechain(undefined);
|
||||
const { input, output } = buffers(256, 1);
|
||||
c.process(input, output, blockCtx());
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompressorPlugin: preset round-trip", () => {
|
||||
it("getPreset / applyPreset round-trips midSide and sidechainEnabled", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const c = new CompressorPlugin({
|
||||
initialMidSide: true,
|
||||
initialSidechainEnabled: true,
|
||||
});
|
||||
const snap = c.getPreset();
|
||||
const c2 = new CompressorPlugin();
|
||||
c2.applyPreset(snap);
|
||||
expect(c2.getParameter("midSide")).toBe(1);
|
||||
expect(c2.getParameter("sidechainEnabled")).toBe(1);
|
||||
});
|
||||
|
||||
it("round-trips both modes off", { timeout: 5_000 }, () => {
|
||||
const c = new CompressorPlugin({
|
||||
initialMidSide: false,
|
||||
initialSidechainEnabled: false,
|
||||
});
|
||||
const snap = c.getPreset();
|
||||
const c2 = new CompressorPlugin({
|
||||
initialMidSide: true,
|
||||
initialSidechainEnabled: true,
|
||||
});
|
||||
c2.applyPreset(snap);
|
||||
expect(c2.getParameter("midSide")).toBe(0);
|
||||
expect(c2.getParameter("sidechainEnabled")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompressorPlugin: reset", () => {
|
||||
it("reset clears envelope and gain reduction", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const c = new CompressorPlugin({
|
||||
initialThresholdDb: -20,
|
||||
initialRatio: 4,
|
||||
});
|
||||
await c.setup(newConfig(512, 1), newCtx(1));
|
||||
const { input, output } = buffers(512, 1);
|
||||
fillSine(input[0]!, 1_000, 0.8);
|
||||
c.process(input, output, blockCtx());
|
||||
expect(c.getGainReductionDb()).toBeGreaterThan(0);
|
||||
c.reset();
|
||||
expect(c.getGainReductionDb()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompressorPlugin: log10 fallback for silence", () => {
|
||||
it("processes silence without producing non-finite samples", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const c = new CompressorPlugin();
|
||||
await c.setup(newConfig(256, 1), newCtx(1));
|
||||
const { input, output } = buffers(256, 1);
|
||||
// All zeros
|
||||
c.process(input, output, blockCtx());
|
||||
for (let i = 0; i < 256; i++) {
|
||||
expect(Number.isFinite(output[0]![i]!)).toBe(true);
|
||||
}
|
||||
expect(c.getGainReductionDb()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import type { BlockContext, ProcessorConfig } from "@suaveplan/audio-core";
|
||||
import type {
|
||||
ChainContext,
|
||||
SidechainBus,
|
||||
} 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 {
|
||||
CompressorPluginOptions,
|
||||
CompressorPresetData,
|
||||
} from "../compressor.types.js";
|
||||
import {
|
||||
CompressorPluginOptionsSchema,
|
||||
CompressorPresetDataSchema,
|
||||
} from "../schemas/schemas.js";
|
||||
|
||||
/**
|
||||
* Soft-knee dynamics compressor with envelope follower, optional
|
||||
* sidechain detection source, and a mid/side mode for stereo
|
||||
* material. Implements the universal `BlockProcessor` contract:
|
||||
* per-block processing without allocation, attack/release smoothing
|
||||
* coefficients computed from `sampleRate` at `setup()`. Gain reduction
|
||||
* (in dB, positive = attenuation) is exposed via the
|
||||
* `gainReductionDb` parameter and the `getGainReductionDb()` accessor
|
||||
* so meters / telemetry can read the current value without poking
|
||||
* internal state.
|
||||
*/
|
||||
export class CompressorPlugin extends AbstractAudioFxPlugin {
|
||||
/** Linked envelope follower state (one value, shared across channels). */
|
||||
private envelope = 0;
|
||||
private currentGrDb = 0;
|
||||
private sidechainBus: SidechainBus | undefined;
|
||||
private scScratch: Float32Array[] = [];
|
||||
|
||||
public constructor(options: CompressorPluginOptions = {}) {
|
||||
const parsed = CompressorPluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS,
|
||||
"audio-fx.compressor-invalid-options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const opts = parsed.data;
|
||||
super({
|
||||
metadata: {
|
||||
id: "compressor",
|
||||
name: "Compressor",
|
||||
version: "1.0.0",
|
||||
category: "dynamics",
|
||||
},
|
||||
schema: CompressorPresetDataSchema,
|
||||
io: {
|
||||
inputs: [{ count: "preserve" }],
|
||||
outputs: [{ count: "preserve" }],
|
||||
},
|
||||
parameters: [
|
||||
{
|
||||
id: "threshold",
|
||||
name: "Threshold",
|
||||
value: opts.initialThresholdDb ?? -20,
|
||||
min: -60,
|
||||
max: 0,
|
||||
default: -20,
|
||||
unit: "db",
|
||||
automatable: true,
|
||||
},
|
||||
{
|
||||
id: "knee",
|
||||
name: "Knee",
|
||||
value: opts.initialKneeDb ?? 6,
|
||||
min: 0,
|
||||
max: 24,
|
||||
default: 6,
|
||||
unit: "db",
|
||||
automatable: true,
|
||||
},
|
||||
{
|
||||
id: "ratio",
|
||||
name: "Ratio",
|
||||
value: opts.initialRatio ?? 4,
|
||||
min: 1,
|
||||
max: 20,
|
||||
default: 4,
|
||||
unit: "ratio",
|
||||
automatable: true,
|
||||
},
|
||||
{
|
||||
id: "attack",
|
||||
name: "Attack",
|
||||
value: opts.initialAttackMs ?? 10,
|
||||
min: 0.1,
|
||||
max: 100,
|
||||
default: 10,
|
||||
unit: "ms",
|
||||
automatable: true,
|
||||
},
|
||||
{
|
||||
id: "release",
|
||||
name: "Release",
|
||||
value: opts.initialReleaseMs ?? 100,
|
||||
min: 1,
|
||||
max: 1000,
|
||||
default: 100,
|
||||
unit: "ms",
|
||||
automatable: true,
|
||||
},
|
||||
{
|
||||
id: "midSide",
|
||||
name: "Mid/Side",
|
||||
value: opts.initialMidSide ? 1 : 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
default: 0,
|
||||
unit: "enum",
|
||||
automatable: false,
|
||||
},
|
||||
{
|
||||
id: "sidechainEnabled",
|
||||
name: "Sidechain Enabled",
|
||||
value: opts.initialSidechainEnabled ? 1 : 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
default: 0,
|
||||
unit: "enum",
|
||||
automatable: false,
|
||||
},
|
||||
{
|
||||
id: "gainReductionDb",
|
||||
name: "Gain Reduction",
|
||||
value: 0,
|
||||
min: 0,
|
||||
max: 60,
|
||||
default: 0,
|
||||
unit: "db",
|
||||
automatable: false,
|
||||
},
|
||||
],
|
||||
capabilities: {
|
||||
hasAudioNode: false,
|
||||
hasWorklet: false,
|
||||
hasSidechain: true,
|
||||
sampleAccurate: true,
|
||||
stateful: true,
|
||||
asyncSetup: false,
|
||||
preservesChannelCount: true,
|
||||
latencySamples: 0,
|
||||
},
|
||||
supportedSchemaVersion: 1,
|
||||
});
|
||||
}
|
||||
|
||||
public getGainReductionDb(): number {
|
||||
return this.currentGrDb;
|
||||
}
|
||||
|
||||
public setSidechain(bus: SidechainBus | undefined): void {
|
||||
this.sidechainBus = bus;
|
||||
if (bus !== undefined && this.config !== undefined) {
|
||||
this.scScratch = [];
|
||||
for (let c = 0; c < bus.channels; c++) {
|
||||
this.scScratch.push(new Float32Array(this.config.blockSize));
|
||||
}
|
||||
} else {
|
||||
this.scScratch = [];
|
||||
}
|
||||
}
|
||||
|
||||
protected override setupState(
|
||||
_config: ProcessorConfig,
|
||||
_chain: ChainContext
|
||||
): void {
|
||||
this.envelope = 0;
|
||||
this.currentGrDb = 0;
|
||||
}
|
||||
|
||||
protected override resetState(): void {
|
||||
this.envelope = 0;
|
||||
this.currentGrDb = 0;
|
||||
}
|
||||
|
||||
protected processBlock(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[],
|
||||
_ctx: BlockContext
|
||||
): void {
|
||||
const threshold = this.parameterValue("threshold");
|
||||
const knee = this.parameterValue("knee");
|
||||
const ratio = this.parameterValue("ratio");
|
||||
const attackMs = this.parameterValue("attack");
|
||||
const releaseMs = this.parameterValue("release");
|
||||
const useMidSide =
|
||||
this.parameterValue("midSide") >= 0.5 && input.length === 2;
|
||||
const sidechainEnabled = this.parameterValue("sidechainEnabled") >= 0.5;
|
||||
|
||||
const sr = this.config!.sampleRate;
|
||||
const aAtt = Math.exp(-1 / ((attackMs / 1000) * sr));
|
||||
const aRel = Math.exp(-1 / ((releaseMs / 1000) * sr));
|
||||
|
||||
let detectSrc = input;
|
||||
if (
|
||||
sidechainEnabled &&
|
||||
this.sidechainBus !== undefined &&
|
||||
this.scScratch.length > 0
|
||||
) {
|
||||
this.sidechainBus.read(this.scScratch);
|
||||
detectSrc = this.scScratch;
|
||||
}
|
||||
|
||||
const blockSize = input[0]!.length;
|
||||
let env = this.envelope;
|
||||
let maxGr = 0;
|
||||
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
const level = peakLevel(detectSrc, i);
|
||||
|
||||
const a = level > env ? aAtt : aRel;
|
||||
env = a * env + (1 - a) * level;
|
||||
|
||||
const envDb = env > 1e-9 ? 20 * Math.log10(env) : -180;
|
||||
const grDb = computeSoftKneeGrDb(envDb, threshold, knee, ratio);
|
||||
if (grDb > maxGr) {
|
||||
maxGr = grDb;
|
||||
}
|
||||
const gain = 10 ** (-grDb / 20);
|
||||
|
||||
if (useMidSide) {
|
||||
const L = input[0]![i]!;
|
||||
const R = input[1]![i]!;
|
||||
const mid = 0.5 * (L + R) * gain;
|
||||
const side = 0.5 * (L - R) * gain;
|
||||
output[0]![i] = mid + side;
|
||||
output[1]![i] = mid - side;
|
||||
} else {
|
||||
for (let c = 0; c < input.length; c++) {
|
||||
output[c]![i] = input[c]![i]! * gain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.envelope = env;
|
||||
this.currentGrDb = maxGr;
|
||||
this.setParameter("gainReductionDb", maxGr);
|
||||
}
|
||||
|
||||
protected override getPresetData(): CompressorPresetData {
|
||||
return {
|
||||
midSide: this.getParameter("midSide") >= 0.5,
|
||||
sidechainEnabled: this.getParameter("sidechainEnabled") >= 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
protected override applyPresetData(data: unknown): void {
|
||||
const { midSide, sidechainEnabled } = data as CompressorPresetData;
|
||||
this.setParameter("midSide", midSide ? 1 : 0);
|
||||
this.setParameter("sidechainEnabled", sidechainEnabled ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
function peakLevel(buffers: Float32Array[], index: number): number {
|
||||
let peak = 0;
|
||||
for (let c = 0; c < buffers.length; c++) {
|
||||
const v = buffers[c]![index]!;
|
||||
const a = v < 0 ? -v : v;
|
||||
if (a > peak) {
|
||||
peak = a;
|
||||
}
|
||||
}
|
||||
return peak;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reiss & Stables soft-knee compressor curve. Returns positive gain
|
||||
* reduction in dB; an output of `0` means no attenuation.
|
||||
*/
|
||||
function computeSoftKneeGrDb(
|
||||
envDb: number,
|
||||
threshold: number,
|
||||
knee: number,
|
||||
ratio: number
|
||||
): number {
|
||||
const above = envDb - threshold;
|
||||
if (knee > 0 && 2 * above > -knee && 2 * above < knee) {
|
||||
const x = above + knee / 2;
|
||||
return ((1 - 1 / ratio) * x * x) / (2 * knee);
|
||||
}
|
||||
if (above > 0) {
|
||||
return above - above / ratio;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CompressorPlugin } from "./compressor.js";
|
||||
@@ -0,0 +1,9 @@
|
||||
export { CompressorPlugin } from "./compressor/index.js";
|
||||
export type {
|
||||
CompressorPluginOptions,
|
||||
CompressorPresetData,
|
||||
} from "./compressor.types.js";
|
||||
export {
|
||||
CompressorPluginOptionsSchema,
|
||||
CompressorPresetDataSchema,
|
||||
} from "./schemas/index.js";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
CompressorPluginOptionsSchema,
|
||||
CompressorPresetDataSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -0,0 +1,61 @@
|
||||
# `schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `compressor.schemas` module owns the Zod boundary guards for `CompressorPlugin` — both the constructor options and the plugin-specific preset payload. Per the package's "Zod boundary validation" requirement, every public options object on every plugin SHALL be validated against a sibling `*.schemas.ts` before the plugin touches any state, and these schemas are how `CompressorPlugin` keeps that promise. A malformed `initialRatio`, an out-of-range `initialAttackMs`, or a missing field on `applyPreset` data all surface as `AUDIO_FX.INVALID_OPTIONS` / `AUDIO_FX.INVALID_PRESET` with the Zod issue list in `context.issues`.
|
||||
|
||||
Keeping the schemas in a sibling file (rather than inline in `compressor.ts`) is the package-wide convention; consumers can import the schemas directly and run their own `safeParse` against UI input, surfacing field-level errors before the plugin instance exists.
|
||||
|
||||
## Features
|
||||
|
||||
- `CompressorPluginOptionsSchema` — every constructor field is optional but, when present, must be finite and inside the per-parameter `[min, max]` range that the plugin's `parameters` declare (`threshold ∈ [−60, 0]`, `knee ∈ [0, 24]`, `ratio ∈ [1, 20]`, `attack ∈ [0.1, 100] ms`, `release ∈ [1, 1000] ms`).
|
||||
- `CompressorPresetDataSchema` — the plugin-specific preset payload: just `midSide` and `sidechainEnabled` booleans. The five continuous parameters round-trip through the universal `parameters` map on the `PresetSnapshot` envelope, so they do not need to appear here.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CompressorPluginOptionsSchema } from "@suaveplan/audio-fx-plugin-compressor";
|
||||
|
||||
const result = CompressorPluginOptionsSchema.safeParse(formValues);
|
||||
if (!result.success) {
|
||||
return showFieldErrors(result.error.issues);
|
||||
}
|
||||
const compressor = new CompressorPlugin(result.data);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
CompressorPluginOptionsSchema,
|
||||
CompressorPresetDataSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-compressor";
|
||||
|
||||
// Server-side validation of an untrusted preset payload before it
|
||||
// hits the plugin's applyPreset (which would throw with the same
|
||||
// code, but the host wants to surface a friendlier error first).
|
||||
const envelope = parseChainSnapshot(payload); // base envelope check
|
||||
for (const entry of envelope.plugins.filter((p) => p.pluginType === "compressor")) {
|
||||
const data = CompressorPresetDataSchema.parse(entry.preset.data);
|
||||
if (data.sidechainEnabled && !allowSidechain) {
|
||||
return reject("Sidechain compression is disabled for this tier.");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CompressorPluginOptionsSchema` — `z.ZodObject` matching `CompressorPluginOptions`. Every field is optional and ranged.
|
||||
- `CompressorPresetDataSchema` — `z.ZodObject` matching `CompressorPresetData`. Both fields required.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schemas use `.finite()` so `NaN` and `±Infinity` are rejected at the boundary; the envelope follower's `exp(-1 / (τ · sr))` calculation would otherwise propagate non-finite alphas into the smoothing state and contaminate every subsequent block. Time constants use `.min(0.1)` for attack and `.min(1)` for release — values smaller than this break the one-pole smoothing assumption (alpha approaches 1 from below) and produce numerical artefacts in single-precision float.
|
||||
|
||||
Ratio is `.min(1)` (rather than `.positive()`) because a ratio below 1 would expand rather than compress, which the plugin's curve does not model. A future expander plugin would live as a separate plugin with its own schema. The maxima (`.max(20)` for ratio, `.max(24)` for knee, etc.) are the values the plugin's parameter declarations advertise — keeping the schema and the declaration in sync means consumers can safely take the parameter's `min` / `max` as authoritative without re-deriving them from the schema.
|
||||
|
||||
The preset data deliberately omits the continuous parameters because they live in `PresetSnapshot.parameters` (the universal map). Duplicating them here would risk drift if a future refactor changes one source but not the other; keeping the data slim makes the preset round-trip obviously correct.
|
||||
@@ -0,0 +1,16 @@
|
||||
import z from "zod";
|
||||
|
||||
export const CompressorPluginOptionsSchema = z.object({
|
||||
initialThresholdDb: z.number().finite().min(-60).max(0).optional(),
|
||||
initialKneeDb: z.number().finite().min(0).max(24).optional(),
|
||||
initialRatio: z.number().finite().min(1).max(20).optional(),
|
||||
initialAttackMs: z.number().finite().min(0.1).max(100).optional(),
|
||||
initialReleaseMs: z.number().finite().min(1).max(1000).optional(),
|
||||
initialMidSide: z.boolean().optional(),
|
||||
initialSidechainEnabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const CompressorPresetDataSchema = z.object({
|
||||
midSide: z.boolean(),
|
||||
sidechainEnabled: z.boolean(),
|
||||
});
|
||||
@@ -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-compressor",
|
||||
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