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