not-tonight/tests/filterCurve.test.ts
type-two 2eb3c94ccd LANE-JUICE: audio engine, SFX set, five UI widgets, JuiceDemoScene
Audio (src/audio/), raw WebAudio, no samples or libraries:
- TechnoEngine: 128 BPM four-on-the-floor kick, off-beat hats, 2-bar 16th
  bassline, 8-bar pad. Look-ahead scheduler (25ms timer, 100ms horizon) is the
  beat authority — emits beat:tick with the SCHEDULED audio time, so consumers
  can compare honestly. Replaces core/StubBeatClock at integration; same event.
  Stale beats are resynced to the next downbeat rather than caught up: a
  throttled/hidden tab otherwise dumps a 64-beat backlog onto one sample.
  Music runs through the location lowpass; SFX bypass it and stay crisp.
- Sfx: 10 procedural one-shots + rain bed, one shared noise buffer, voices
  self-disconnect on ended so a six-hour night doesn't accumulate nodes.

UI (src/ui/), reusable Phaser containers, no door/floor imports:
- Stamp (the signature interaction), Phone (Dazza thread + phoneTheatre),
  DialogueBox (typed-out, drunk-typo render), Clicker (digit roll),
  MeterHud (neon vibe / crowd-temp aggro / hype badge / 3 heat slots).
- JuiceDemoScene exercises every widget and SFX, with a log-mapped cutoff
  slider and a DOOR/FLOOR toggle that routes through the bus.

Verified in-browser: the door->floor sweep ramps 250Hz->18kHz over 600ms,
monotonic and exponential (halfway lands ~2.1kHz, not the ~9kHz a linear ramp
would give) — that late bloom is what sells the door opening. Under a real
hidden tab, 52 consecutive beats scheduled with zero in the past, 75-98ms lead,
max one beat per wake.

main.ts touched to route J / #juice to the demo — outside lane ownership,
flagged for sign-off in LANEHANDOVER.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:52:43 +10:00

201 lines
7.9 KiB
TypeScript

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