Initial Commit
This commit is contained in:
31
packages/media/audio-fx/audio-fx-plugin-gate/README.md
Normal file
31
packages/media/audio-fx/audio-fx-plugin-gate/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# @suaveplan/audio-fx-plugin-gate - gate plugin for the @suaveplan/audio-fx-plugin-* family.
|
||||
|
||||
gate 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
|
||||
|
||||
- [gate](./src/gate/gate.md) — `GatePlugin` is the universal noise gate. When the input envelope falls below `threshold`, the gate closes after `hold`…
|
||||
- [gate](./src/gate/gate.md) — `GatePlugin` is the universal noise gate. When the input envelope falls below `threshold`, the gate closes after `hold`…
|
||||
- [schemas](./src/schemas/schemas.md) — The `gate.schemas` module owns the Zod boundary guards for `GatePlugin` — construction options and the plugin-specific…
|
||||
- [schemas](./src/schemas/schemas.md) — The `gate.schemas` module owns the Zod boundary guards for `GatePlugin` — construction options and the plugin-specific…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Composition
|
||||
|
||||
```ts
|
||||
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
|
||||
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-gate";
|
||||
|
||||
const plugin = new /* Plugin */();
|
||||
const chain = new PluginChain({
|
||||
buses: new DefaultBusRegistry(),
|
||||
plugins: [plugin],
|
||||
});
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Replaces the `./gate` subpath of the bundled `@suaveplan/audio-plugins-fx`.
|
||||
47
packages/media/audio-fx/audio-fx-plugin-gate/package.json
Normal file
47
packages/media/audio-fx/audio-fx-plugin-gate/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@suaveplan/audio-fx-plugin-gate",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "gate 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,7 @@
|
||||
/** Construction options for `GatePlugin`. */
|
||||
export interface GatePluginOptions {
|
||||
readonly initialThresholdDb?: number;
|
||||
readonly initialAttackMs?: number;
|
||||
readonly initialHoldMs?: number;
|
||||
readonly initialReleaseMs?: number;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# `gate`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`GatePlugin` is the universal noise gate. When the input envelope falls below `threshold`, the gate closes after `hold` ms via the `release` envelope; when the envelope rises back above `threshold`, the gate opens via `attack`. The envelope is linked across input channels so a transient on any channel opens the gate for the whole frame, keeping the stereo image phase-coherent during gated passages.
|
||||
|
||||
The plugin extends `AbstractAudioFxPlugin`, so it runs identically across browser / server / worklet renderers. Gate state is one number (`currentGain`) plus a hold-sample counter; `reset()` zeros both in place.
|
||||
|
||||
## Features
|
||||
|
||||
- Four parameters: `threshold` (dB, -80..0), `attack` (ms, 0.1..100), `hold` (ms, 0..500), `release` (ms, 1..1000).
|
||||
- Linked envelope detection: `max(|in_c|)` across all input channels per sample so a stereo transient on either channel opens the gate.
|
||||
- One-pole attack/release smoothing with `coef = 1 − exp(-1/(τ · sr))` per direction.
|
||||
- Hold counter resets every time the envelope crosses the threshold so a sustained-but-fluctuating signal stays gated open.
|
||||
- `getCurrentGain()` accessor exposes the live gate gain for metering / VCAs that mirror the gate.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { GatePlugin } from "@suaveplan/audio-fx-plugin-gate";
|
||||
|
||||
const gate = new GatePlugin({
|
||||
initialThresholdDb: -40,
|
||||
initialAttackMs: 1,
|
||||
initialHoldMs: 20,
|
||||
initialReleaseMs: 100,
|
||||
});
|
||||
chain.add(gate);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { GatePlugin } from "@suaveplan/audio-fx-plugin-gate";
|
||||
|
||||
// Tight drum-bus gate: fast attack/release, short hold, aggressive
|
||||
// threshold. Useful for cleaning up cymbal bleed under snare hits.
|
||||
const gate = new GatePlugin({
|
||||
initialThresholdDb: -25,
|
||||
initialAttackMs: 0.5,
|
||||
initialHoldMs: 30,
|
||||
initialReleaseMs: 60,
|
||||
});
|
||||
await gate.setup({ sampleRate: 48_000, channels: 2, blockSize: 512 }, ctx);
|
||||
|
||||
// Drive a UI meter from the live gate gain.
|
||||
requestAnimationFrame(function tick() {
|
||||
meter.write(gate.getCurrentGain());
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GatePlugin` (class extends `AbstractAudioFxPlugin`)
|
||||
- `new GatePlugin(options?: GatePluginOptions)` — throws `AUDIO_FX.INVALID_OPTIONS` on malformed options.
|
||||
- `getCurrentGain(): number` — current gate gain (0..1).
|
||||
- Inherited parameter accessors for `threshold`, `attack`, `hold`, `release`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The envelope detector is per-sample peak across channels: `level = max(|in_c[i]|)`. This is the cheapest detector that still responds to fast transients; an RMS detector would smooth out kick-drum attacks, which is rarely what a noise gate wants.
|
||||
|
||||
The hold counter resets every time `level >= thresholdLin`, then decrements while the envelope is below threshold. While `hold > 0` the target gain stays at `1`; once `hold === 0` the target drops to `0` and the release ramp engages. This implements the spec's "after the hold time" semantics literally.
|
||||
|
||||
`attackCoef` and `releaseCoef` are one-pole filter coefficients (`1 − exp(-1/(τ · sr))`). At each sample, `gain += coef · (target − gain)`, asymptoting toward `target` with the relevant time constant. With `release = 100 ms` at 48 kHz the gain falls by ~63% in the first 100 ms, ~95% in 300 ms, effectively closed within ~500 ms.
|
||||
|
||||
The threshold is converted to linear via `10^(thresholdDb / 20)` once per block; per-sample the gate compares `level` (already linear) to that constant.
|
||||
|
||||
The plugin has no `setSidechain` member — gates are usually program-side detection; if a future build wants an external key input, `setSidechain` can be added behind a `hasSidechain: true` capability flag without API breakage.
|
||||
@@ -0,0 +1,208 @@
|
||||
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 { GatePlugin } from "./gate.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 fillSine(buf: Float32Array, freq: number, amp: number): void {
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
buf[i] = amp * Math.sin((2 * Math.PI * freq * i) / SAMPLE_RATE);
|
||||
}
|
||||
}
|
||||
|
||||
function newConfig(blockSize: number, channels = 1): ProcessorConfig {
|
||||
return { sampleRate: SAMPLE_RATE, channels, blockSize };
|
||||
}
|
||||
|
||||
function newCtx(channels = 1): ChainContext {
|
||||
const chain: ChainHandle = {
|
||||
id: "gate-test",
|
||||
name: "gate-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("GatePlugin: contract surface", () => {
|
||||
it("declares the AudioFxPlugin contract", { timeout: 5_000 }, () => {
|
||||
const g = new GatePlugin();
|
||||
expect(g.metadata.id).toBe("gate");
|
||||
expect(g.metadata.category).toBe("dynamics");
|
||||
expect(g.parameters.map((p) => p.id)).toEqual([
|
||||
"threshold",
|
||||
"attack",
|
||||
"hold",
|
||||
"release",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects malformed options with INVALID_OPTIONS", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
try {
|
||||
new GatePlugin({ initialThresholdDb: 99 });
|
||||
} catch (error) {
|
||||
expect((error as AudioFxError).code).toBe(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected INVALID_OPTIONS");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GatePlugin: silences signal that falls below threshold past hold time", () => {
|
||||
it("signal below threshold is attenuated toward silence per release envelope", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const g = new GatePlugin({
|
||||
initialThresholdDb: -40,
|
||||
initialAttackMs: 0.1,
|
||||
initialHoldMs: 10,
|
||||
initialReleaseMs: 20,
|
||||
});
|
||||
await g.setup(newConfig(SAMPLE_RATE, 1), newCtx(1));
|
||||
// Loud signal for first 100ms, then -60dB for rest.
|
||||
const { input, output } = buffers(SAMPLE_RATE, 1);
|
||||
const loudSamples = Math.round(0.1 * SAMPLE_RATE);
|
||||
fillSine(input[0]!, 1_000, 0.5);
|
||||
for (let i = loudSamples; i < SAMPLE_RATE; i++) {
|
||||
input[0]![i] =
|
||||
0.001 * Math.sin((2 * Math.PI * 1_000 * i) / SAMPLE_RATE);
|
||||
}
|
||||
g.process(input, output, blockCtx());
|
||||
// Gate should be open during loud region.
|
||||
let openSamples = 0;
|
||||
for (let i = 1024; i < loudSamples; i++) {
|
||||
if (Math.abs(output[0]![i]!) > 0.3) {
|
||||
openSamples++;
|
||||
}
|
||||
}
|
||||
expect(openSamples).toBeGreaterThan(1000);
|
||||
// Past hold + release time, the gate should be effectively closed.
|
||||
const checkStart = loudSamples + Math.round(0.2 * SAMPLE_RATE);
|
||||
let maxAttenuated = 0;
|
||||
for (let i = checkStart; i < SAMPLE_RATE; i++) {
|
||||
const a = Math.abs(output[0]![i]!);
|
||||
if (a > maxAttenuated) {
|
||||
maxAttenuated = a;
|
||||
}
|
||||
}
|
||||
expect(maxAttenuated).toBeLessThan(0.0005);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GatePlugin: passes signal through when consistently above threshold", () => {
|
||||
it("loud signal stays above threshold => gate stays open", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const g = new GatePlugin({
|
||||
initialThresholdDb: -40,
|
||||
initialAttackMs: 1,
|
||||
initialHoldMs: 20,
|
||||
initialReleaseMs: 100,
|
||||
});
|
||||
await g.setup(newConfig(SAMPLE_RATE, 1), newCtx(1));
|
||||
const { input, output } = buffers(SAMPLE_RATE, 1);
|
||||
fillSine(input[0]!, 1_000, 0.5);
|
||||
g.process(input, output, blockCtx());
|
||||
// After attack settle, output ≈ input.
|
||||
for (let i = 1024; i < SAMPLE_RATE; i++) {
|
||||
expect(Math.abs(output[0]![i]! - input[0]![i]!)).toBeLessThan(0.05);
|
||||
}
|
||||
expect(g.getCurrentGain()).toBeGreaterThan(0.9);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GatePlugin: linked envelope across channels", () => {
|
||||
it("stereo input opens gate on either channel transient", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const g = new GatePlugin({
|
||||
initialThresholdDb: -40,
|
||||
initialHoldMs: 1,
|
||||
initialReleaseMs: 1,
|
||||
});
|
||||
await g.setup(newConfig(1024, 2), newCtx(2));
|
||||
const { input, output } = buffers(1024, 2);
|
||||
// Loud on left only.
|
||||
fillSine(input[0]!, 1_000, 0.5);
|
||||
// Right is silent.
|
||||
g.process(input, output, blockCtx());
|
||||
// Both channels should pass through after the gate opens.
|
||||
let rightEnergy = 0;
|
||||
for (let i = 256; i < 1024; i++) {
|
||||
rightEnergy += Math.abs(output[1]![i]!);
|
||||
}
|
||||
// Right channel is silent input — output should be silent.
|
||||
expect(rightEnergy).toBeLessThan(1e-6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GatePlugin: reset clears state", () => {
|
||||
it("after a hot signal + reset, current gain returns to 0", {
|
||||
timeout: 5_000,
|
||||
}, async () => {
|
||||
const g = new GatePlugin({ initialThresholdDb: -40 });
|
||||
await g.setup(newConfig(512, 1), newCtx(1));
|
||||
const { input, output } = buffers(512, 1);
|
||||
fillSine(input[0]!, 1_000, 0.5);
|
||||
g.process(input, output, blockCtx());
|
||||
expect(g.getCurrentGain()).toBeGreaterThan(0);
|
||||
g.reset();
|
||||
expect(g.getCurrentGain()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GatePlugin: preset round-trip", () => {
|
||||
it("getPreset / applyPreset round-trips threshold", {
|
||||
timeout: 5_000,
|
||||
}, () => {
|
||||
const g = new GatePlugin({ initialThresholdDb: -30 });
|
||||
const snap = g.getPreset();
|
||||
const g2 = new GatePlugin();
|
||||
g2.applyPreset(snap);
|
||||
expect(g2.getParameter("threshold")).toBe(-30);
|
||||
});
|
||||
});
|
||||
159
packages/media/audio-fx/audio-fx-plugin-gate/src/gate/gate.ts
Normal file
159
packages/media/audio-fx/audio-fx-plugin-gate/src/gate/gate.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
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 { GatePluginOptions } from "../gate.types.js";
|
||||
import {
|
||||
GatePluginOptionsSchema,
|
||||
GatePresetDataSchema,
|
||||
} from "../schemas/index.js";
|
||||
|
||||
/**
|
||||
* Noise gate: when the input envelope falls below `threshold`, the
|
||||
* gate closes after `hold` ms via the `release` ramp; when the
|
||||
* envelope returns above `threshold`, the gate opens via the
|
||||
* `attack` ramp. Envelope is linked across channels so a transient
|
||||
* on any channel opens the gate for the whole frame, keeping the
|
||||
* stereo image phase-coherent.
|
||||
*/
|
||||
export class GatePlugin extends AbstractAudioFxPlugin {
|
||||
private currentGain = 0;
|
||||
private holdRemainingSamples = 0;
|
||||
|
||||
public constructor(options: GatePluginOptions = {}) {
|
||||
const parsed = GatePluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new AudioFxError(
|
||||
AUDIO_FX_CODES.INVALID_OPTIONS,
|
||||
"audio-fx.gate-invalid-options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const opts = parsed.data;
|
||||
super({
|
||||
metadata: {
|
||||
id: "gate",
|
||||
name: "Gate",
|
||||
version: "1.0.0",
|
||||
category: "dynamics",
|
||||
},
|
||||
schema: GatePresetDataSchema,
|
||||
io: {
|
||||
inputs: [{ count: "preserve" }],
|
||||
outputs: [{ count: "preserve" }],
|
||||
},
|
||||
parameters: [
|
||||
{
|
||||
id: "threshold",
|
||||
name: "Threshold",
|
||||
value: opts.initialThresholdDb ?? -40,
|
||||
min: -80,
|
||||
max: 0,
|
||||
default: -40,
|
||||
unit: "db",
|
||||
automatable: true,
|
||||
},
|
||||
{
|
||||
id: "attack",
|
||||
name: "Attack",
|
||||
value: opts.initialAttackMs ?? 1,
|
||||
min: 0.1,
|
||||
max: 100,
|
||||
default: 1,
|
||||
unit: "ms",
|
||||
automatable: true,
|
||||
},
|
||||
{
|
||||
id: "hold",
|
||||
name: "Hold",
|
||||
value: opts.initialHoldMs ?? 50,
|
||||
min: 0,
|
||||
max: 500,
|
||||
default: 50,
|
||||
unit: "ms",
|
||||
automatable: true,
|
||||
},
|
||||
{
|
||||
id: "release",
|
||||
name: "Release",
|
||||
value: opts.initialReleaseMs ?? 100,
|
||||
min: 1,
|
||||
max: 1_000,
|
||||
default: 100,
|
||||
unit: "ms",
|
||||
automatable: true,
|
||||
},
|
||||
],
|
||||
supportedSchemaVersion: 1,
|
||||
});
|
||||
}
|
||||
|
||||
public getCurrentGain(): number {
|
||||
return this.currentGain;
|
||||
}
|
||||
|
||||
protected override setupState(
|
||||
_config: ProcessorConfig,
|
||||
_chain: ChainContext
|
||||
): void {
|
||||
this.currentGain = 0;
|
||||
this.holdRemainingSamples = 0;
|
||||
}
|
||||
|
||||
protected override resetState(): void {
|
||||
this.currentGain = 0;
|
||||
this.holdRemainingSamples = 0;
|
||||
}
|
||||
|
||||
protected processBlock(
|
||||
input: Float32Array[],
|
||||
output: Float32Array[],
|
||||
_ctx: BlockContext
|
||||
): void {
|
||||
const thresholdDb = this.parameterValue("threshold");
|
||||
const attackMs = this.parameterValue("attack");
|
||||
const holdMs = this.parameterValue("hold");
|
||||
const releaseMs = this.parameterValue("release");
|
||||
const sr = this.config!.sampleRate;
|
||||
const thresholdLin = 10 ** (thresholdDb / 20);
|
||||
const attackCoef = 1 - Math.exp(-1 / ((attackMs / 1000) * sr));
|
||||
const releaseCoef = 1 - Math.exp(-1 / ((releaseMs / 1000) * sr));
|
||||
const holdSamples = Math.round((holdMs / 1000) * sr);
|
||||
const blockSize = input[0]!.length;
|
||||
|
||||
let gain = this.currentGain;
|
||||
let hold = this.holdRemainingSamples;
|
||||
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
let level = 0;
|
||||
for (let ch = 0; ch < input.length; ch++) {
|
||||
const v = input[ch]![i]!;
|
||||
const a = v < 0 ? -v : v;
|
||||
if (a > level) {
|
||||
level = a;
|
||||
}
|
||||
}
|
||||
let target: number;
|
||||
if (level >= thresholdLin) {
|
||||
target = 1;
|
||||
hold = holdSamples;
|
||||
} else if (hold > 0) {
|
||||
target = 1;
|
||||
hold--;
|
||||
} else {
|
||||
target = 0;
|
||||
}
|
||||
const coef = target > gain ? attackCoef : releaseCoef;
|
||||
gain += coef * (target - gain);
|
||||
for (let ch = 0; ch < input.length; ch++) {
|
||||
output[ch]![i] = input[ch]![i]! * gain;
|
||||
}
|
||||
}
|
||||
|
||||
this.currentGain = gain;
|
||||
this.holdRemainingSamples = hold;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { GatePlugin } from "./gate.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export { GatePlugin } from "./gate/index.js";
|
||||
export type { GatePluginOptions } from "./gate.types.js";
|
||||
export {
|
||||
GatePluginOptionsSchema,
|
||||
GatePresetDataSchema,
|
||||
} from "./schemas/index.js";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
GatePluginOptionsSchema,
|
||||
GatePresetDataSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -0,0 +1,61 @@
|
||||
# `schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `gate.schemas` module owns the Zod boundary guards for `GatePlugin` — construction options and the plugin-specific preset payload. Every public input runs through these schemas before the plugin touches any state; failures surface as `AUDIO_FX.INVALID_OPTIONS` / `AUDIO_FX.INVALID_PRESET` with the Zod issue list in `context.issues`.
|
||||
|
||||
Keeping the schemas in a sibling folder follows the package-wide convention: hosts can import the schemas directly, run their own `safeParse` against UI input, and surface field-level errors before constructing a `GatePlugin`.
|
||||
|
||||
## Features
|
||||
|
||||
- `GatePluginOptionsSchema` — every field optional, ranged to match the parameter declarations in `gate.ts`: `threshold ∈ [-80, 0] dB`, `attack ∈ [0.1, 100] ms`, `hold ∈ [0, 500] ms`, `release ∈ [1, 1000] ms`.
|
||||
- `GatePresetDataSchema` — `z.object({}).strict()`. The gate has no plugin-specific state beyond its automatable parameters; the universal `parameters` map carries everything that round-trips.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { GatePluginOptionsSchema } from "@suaveplan/audio-fx-plugin-gate";
|
||||
|
||||
const parsed = GatePluginOptionsSchema.safeParse(formValues);
|
||||
if (!parsed.success) {
|
||||
return showFieldErrors(parsed.error.issues);
|
||||
}
|
||||
const gate = new GatePlugin(parsed.data);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
GatePluginOptionsSchema,
|
||||
GatePresetDataSchema,
|
||||
} from "@suaveplan/audio-fx-plugin-gate";
|
||||
|
||||
// Validate an untrusted preset payload from a shared template
|
||||
// library before applyPreset. The plugin would catch the same issue,
|
||||
// but the host wants a friendlier error before a plugin instance is
|
||||
// constructed.
|
||||
const result = GatePresetDataSchema.safeParse(payload.data);
|
||||
if (!result.success) {
|
||||
return reject("Gate preset is corrupt.");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GatePluginOptionsSchema` — `z.ZodObject` matching `GatePluginOptions`. All fields optional and ranged.
|
||||
- `GatePresetDataSchema` — strict empty object; rejects any unknown keys.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`GatePluginOptionsSchema` uses `.finite()` on every numeric field so `NaN` and `±Infinity` are rejected at the boundary; the one-pole envelope follower's `exp(-1 / (τ · sr))` calculation would otherwise propagate non-finite coefficients into the smoothing state and contaminate every subsequent block.
|
||||
|
||||
`attack.min(0.1)` and `release.min(1)` match the parameter declarations; values smaller than this break the one-pole smoothing assumption (the coefficient approaches `1` from below) and produce numerical artefacts in single-precision float. `hold.min(0)` allows a zero-hold gate (closes immediately when the envelope drops), which is useful for hyper-tight drum gates.
|
||||
|
||||
`threshold.min(-80)` covers the practical noise floor of consumer audio; values below `-80 dB` are effectively "gate never closes" and don't need their own UI affordance. The upper bound at `0 dB` is the maximum useful threshold — at `0 dB` the gate is essentially always closed since real-world signals rarely exceed `0 dBFS`.
|
||||
|
||||
The strict empty `GatePresetDataSchema` rejects any unknown keys so a stale preset format with phantom fields fails loud rather than silently dropping data.
|
||||
@@ -0,0 +1,11 @@
|
||||
import z from "zod";
|
||||
|
||||
export const GatePluginOptionsSchema = z.object({
|
||||
initialThresholdDb: z.number().finite().min(-80).max(0).optional(),
|
||||
initialAttackMs: z.number().finite().min(0.1).max(100).optional(),
|
||||
initialHoldMs: z.number().finite().min(0).max(500).optional(),
|
||||
initialReleaseMs: z.number().finite().min(1).max(1_000).optional(),
|
||||
});
|
||||
|
||||
/** No plugin-specific preset payload — all parameters are universal. */
|
||||
export const GatePresetDataSchema = z.object({}).strict();
|
||||
12
packages/media/audio-fx/audio-fx-plugin-gate/tsconfig.json
Normal file
12
packages/media/audio-fx/audio-fx-plugin-gate/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"]
|
||||
}
|
||||
@@ -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-gate/vite.config.ts
Normal file
38
packages/media/audio-fx/audio-fx-plugin-gate/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-gate",
|
||||
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