Files
suaveplan/packages/media/audio-fx/audio-fx-plugin-gate/src/gate/gate.test.ts
2026-05-27 23:47:53 +01:00

209 lines
6.8 KiB
TypeScript

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);
});
});