209 lines
6.8 KiB
TypeScript
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);
|
|
});
|
|
});
|