import { describe, expect, it } from 'vitest'; import { CLOSE_SWEEP_MS, DOOR_CUTOFF_HZ, DOOR_GAIN, FLOOR_CUTOFF_HZ, FLOOR_GAIN, OPEN_SWEEP_MS, cutoffAt, cutoffCurve, cutoffFor, gainAt, gainFor, sweepMsFor, } from '../src/audio/filterCurve'; describe('location constants', () => { it('maps each location to its cutoff and gain', () => { expect(cutoffFor('door')).toBe(DOOR_CUTOFF_HZ); expect(cutoffFor('floor')).toBe(FLOOR_CUTOFF_HZ); expect(gainFor('door')).toBe(DOOR_GAIN); expect(gainFor('floor')).toBe(FLOOR_GAIN); }); it('keeps the door muffled and quieter than the floor', () => { expect(cutoffFor('door')).toBeLessThan(cutoffFor('floor')); expect(gainFor('door')).toBeLessThan(gainFor('floor')); }); it('opens faster than it closes', () => { expect(sweepMsFor('floor')).toBe(OPEN_SWEEP_MS); expect(sweepMsFor('door')).toBe(CLOSE_SWEEP_MS); expect(sweepMsFor('floor')).toBeLessThan(sweepMsFor('door')); }); }); describe('cutoffAt', () => { it('hits the endpoints exactly', () => { expect(cutoffAt(0, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(DOOR_CUTOFF_HZ, 6); expect(cutoffAt(1, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(FLOOR_CUTOFF_HZ, 6); expect(cutoffAt(0, FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ)).toBeCloseTo(FLOOR_CUTOFF_HZ, 6); expect(cutoffAt(1, FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ)).toBeCloseTo(DOOR_CUTOFF_HZ, 6); }); it('clamps t outside 0..1 so callers can pass raw elapsed/duration', () => { expect(cutoffAt(-3, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(DOOR_CUTOFF_HZ, 6); expect(cutoffAt(-0.001, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(DOOR_CUTOFF_HZ, 6); expect(cutoffAt(1.5, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(FLOOR_CUTOFF_HZ, 6); expect(cutoffAt(9000, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(FLOOR_CUTOFF_HZ, 6); }); it('sits at the geometric mean halfway, not the arithmetic one', () => { // The whole point: a linear ramp would already be at ~9kHz by the midpoint and // the door would sound like someone nudged a fader. const geometric = Math.sqrt(DOOR_CUTOFF_HZ * FLOOR_CUTOFF_HZ); // ~2121Hz const arithmetic = (DOOR_CUTOFF_HZ + FLOOR_CUTOFF_HZ) / 2; // ~9125Hz const mid = cutoffAt(0.5, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ); expect(mid).toBeCloseTo(geometric, 3); expect(mid).toBeCloseTo(2121.32, 1); expect(mid).toBeLessThan(arithmetic / 4); }); it('keeps the brightness late — three quarters through is still under half the range', () => { const oneQuarter = cutoffAt(0.25, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ); const threeQuarters = cutoffAt(0.75, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ); // Anchored to measured values, not to the formula — otherwise any curve of the // same algebraic shape passes and a regression in the clamp goes unnoticed. expect(oneQuarter).toBeCloseTo(728.24, 1); expect(threeQuarters).toBeCloseTo(6179.3, 1); expect(threeQuarters).toBeLessThan(FLOOR_CUTOFF_HZ / 2); }); it('covers equal ratios over equal steps of t', () => { // The defining property of the sweep: every quarter multiplies the cutoff by the // same factor, so the reveal accelerates in Hz while sounding even to the ear. const at = (t: number) => cutoffAt(t, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ); const step = Math.pow(FLOOR_CUTOFF_HZ / DOOR_CUTOFF_HZ, 0.25); for (const t of [0, 0.25, 0.5, 0.75]) { expect(at(t + 0.25) / at(t)).toBeCloseTo(step, 6); } // Equivalently: each sample is the geometric mean of its neighbours. expect(at(0.75)).toBeCloseTo(Math.sqrt(at(0.5) * FLOOR_CUTOFF_HZ), 3); }); it('rises monotonically while opening', () => { let prev = -Infinity; for (let i = 0; i <= 200; i++) { const hz = cutoffAt(i / 200, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ); expect(hz).toBeGreaterThan(prev); prev = hz; } }); it('falls monotonically while closing', () => { let prev = Infinity; for (let i = 0; i <= 200; i++) { const hz = cutoffAt(i / 200, FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ); expect(hz).toBeLessThan(prev); prev = hz; } }); it('stays inside the sweep range at every t', () => { for (let i = 0; i <= 100; i++) { const hz = cutoffAt(i / 100, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ); expect(hz).toBeGreaterThanOrEqual(DOOR_CUTOFF_HZ); expect(hz).toBeLessThanOrEqual(FLOOR_CUTOFF_HZ); } }); it('is symmetric: closing retraces the opening sweep backwards', () => { for (let i = 0; i <= 20; i++) { const t = i / 20; expect(cutoffAt(t, FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ)).toBeCloseTo( cutoffAt(1 - t, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ), 6, ); } }); }); describe('gainAt', () => { it('interpolates linearly between the endpoints', () => { expect(gainAt(0, DOOR_GAIN, FLOOR_GAIN)).toBeCloseTo(DOOR_GAIN, 6); expect(gainAt(1, DOOR_GAIN, FLOOR_GAIN)).toBeCloseTo(FLOOR_GAIN, 6); expect(gainAt(0.5, DOOR_GAIN, FLOOR_GAIN)).toBeCloseTo((DOOR_GAIN + FLOOR_GAIN) / 2, 6); expect(gainAt(0.25, 0, 1)).toBeCloseTo(0.25, 6); expect(gainAt(0.25, 1, 0)).toBeCloseTo(0.75, 6); }); it('clamps t outside 0..1', () => { expect(gainAt(-2, DOOR_GAIN, FLOOR_GAIN)).toBeCloseTo(DOOR_GAIN, 6); expect(gainAt(4, DOOR_GAIN, FLOOR_GAIN)).toBeCloseTo(FLOOR_GAIN, 6); }); }); describe('cutoffCurve', () => { it('defaults to 32 sampled points spanning the full sweep', () => { const curve = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ); expect(curve).toBeInstanceOf(Float32Array); expect(curve.length).toBe(32); expect(curve[0]!).toBeCloseTo(DOOR_CUTOFF_HZ, 3); expect(curve[31]!).toBeCloseTo(FLOOR_CUTOFF_HZ, 0); }); it('honours a custom point count', () => { for (const points of [2, 8, 64, 129]) { const curve = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ, points); expect(curve.length).toBe(points); expect(curve[0]!).toBeCloseTo(DOOR_CUTOFF_HZ, 3); expect(curve[points - 1]!).toBeCloseTo(FLOOR_CUTOFF_HZ, 0); } }); it('degrades to a single valid point instead of NaN, empty, or a throw', () => { // A NaN here would reach setValueCurveAtTime (throws) or AudioParam.value // (silently mutes the filter), so degenerate counts must stay finite. for (const points of [1, 0, -1, -100]) { const curve = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ, points); expect(curve.length).toBe(1); expect(Number.isFinite(curve[0]!)).toBe(true); expect(curve[0]!).toBeCloseTo(DOOR_CUTOFF_HZ, 3); } }); it('never emits a non-finite sample for any point count', () => { for (const points of [-5, 0, 1, 2, 3, 32, 128]) { const curve = cutoffCurve(FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ, points); expect(curve.length).toBeGreaterThan(0); for (const hz of curve) expect(Number.isFinite(hz)).toBe(true); } }); it('rises monotonically opening and falls monotonically closing', () => { const up = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ, 64); for (let i = 1; i < up.length; i++) expect(up[i]!).toBeGreaterThan(up[i - 1]!); const down = cutoffCurve(FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ, 64); for (let i = 1; i < down.length; i++) expect(down[i]!).toBeLessThan(down[i - 1]!); }); it('samples the same curve cutoffAt describes', () => { const points = 16; const curve = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ, points); for (let i = 0; i < points; i++) { const expected = cutoffAt(i / (points - 1), DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ); // Float32 storage costs precision, so compare relatively. expect(curve[i]! / expected).toBeCloseTo(1, 5); } }); it('keeps its midpoint well below the linear halfway mark', () => { const curve = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ, 33); expect(curve[16]!).toBeCloseTo(Math.sqrt(DOOR_CUTOFF_HZ * FLOOR_CUTOFF_HZ), 1); expect(curve[16]!).toBeLessThan((DOOR_CUTOFF_HZ + FLOOR_CUTOFF_HZ) / 4); }); });