Initial Commit
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user