Files
feuerwerk/tests/editor/reverb-view.test.ts
T
adminandClaude Opus 5.5 e8021b1fd3
game-maker / gate (push) Successful in 4m5s
game-maker / suite (push) Successful in 15m49s
Show a Reverb node as a device with a round screen of its tail (#389)
The Compressor, the Gain, the Chip and the Envelope are devices in the
graph. The Reverb was still a list of rows, and how long and how dark
its tail rings was left to the numbers.

- The node is a plate of 640x280 in pale lavender. Its inputs are on
  the left, its output on the right.
- A round screen draws the tail as rings out from the sound. A ring's
  radius is its time, as the root of its seconds, on one scale up to
  10.5 s for every Reverb. So a Room is a small disc and a Cave fills
  two thirds.
- A ring is as bright as the tail is loud there, evenly in dB. Its
  colour is how much of the highs is left, from ice to violet, with
  the cut-off of the impulse response (reverbCutoff, now shared).
- The pre-delay is a dark gap round the dot, a dashed ring marks
  -60 dB. Seeded dots stand for the reflections. Wet fades the tail
  against the dot.
- A type of Sound draws the peaks of its sound from the library, each
  against its loudest. It says LOADING until they are there.
- A button for each type, a list of the sounds and four chrome knobs:
  Decay, Damping, Pre-delay and Wet.
- A knob the type does not take is greyed but turns. It goes over to
  Custom with the type's numbers, as one undo step. Picking a sound
  makes the type Sound, also one step.
- NodeParamDrag can write several settings as one drag for that.
- Test hook __gm.reverb.panel(nodeId), for the sound FX and the
  instrument graph.

Tests:
- Unit reverb-view: 10 new tests for the plate, the knob turn, the
  numbers per type, the scale, the tail and the texts. A screen that
  ignores damping turns the highs test red.
- e2e 34 (atx): 1 new check (device between its ports, decay knob of
  a Hall goes to Custom as one undo step, Cave button, a sound from
  the list makes it Sound and draws that sound).
- Probe pictures on stein2: Room, Hall, Cave, Plate, Custom with
  damping 1 and pre-delay 0.3 s, Sound.
- Gate (atx): CHECK EXIT 0, 287 files, 4393 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:35:59 +02:00

171 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A Reverb's node as a device (#389): every part on the plate and none over another, the numbers
* each type plays with, the knobs turning so the usual times get the turn, and the tail on the
* round screen as the impulse response rings.
*/
import { describe, expect, it } from 'vitest';
import { REVERB_ROWS } from '../../src/core/soundfx/nodes';
import { REVERB_PRESETS } from '../../src/core/soundfx/reverb';
import { type Box, knobBox } from '../../src/editor/soundfx/hardwareView';
import {
customFrom,
idleReverbKeys,
reflections,
REVERB_KEYS,
REVERB_KNOBS,
REVERB_LAYOUT,
REVERB_PANEL,
type ReverbKey,
type ReverbTail,
reverbValues,
SCOPE_SECONDS,
scopeRadius,
scopeSeconds,
scopeTexts,
tailAt,
tailRings,
wetShares,
} from '../../src/editor/soundfx/reverbView';
import { knobPosition } from '../../src/ui/knob';
const overlap = (a: Box, b: Box) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
const inside = (a: Box) => a.x >= 0 && a.y >= 0 && a.x + a.w <= REVERB_PANEL.w && a.y + a.h <= REVERB_PANEL.h;
const made = (shape: { predelay: number; decay: number; damping: number }): ReverbTail => ({
kind: 'made',
...shape,
});
describe('the Reverb plate', () => {
const L = REVERB_LAYOUT;
const parts: [string, Box][] = [
['title', L.title],
['bezel', L.bezel],
['sound', L.sound],
...L.groups.map((g): [string, Box] => [g.label, g.box]),
...L.rooms.map((box, i): [string, Box] => [`room ${i}`, box]),
...REVERB_KEYS.map((key): [string, Box] => [key, knobBox(L.knobs[key])]),
];
it('has every part on it, none over another, and clear of the screws', () => {
for (const [name, box] of parts) expect(inside(box), name).toBe(true);
for (let i = 0; i < parts.length; i++)
for (let j = i + 1; j < parts.length; j++)
expect(overlap(parts[i]![1], parts[j]![1]), `${parts[i]![0]} / ${parts[j]![0]}`).toBe(false);
for (const screw of L.screws) {
const head = { x: screw.x - 6, y: screw.y - 6, w: 12, h: 12 };
for (const [name, box] of parts) expect(overlap(head, box), `screw / ${name}`).toBe(false);
}
expect(L.screen.w).toBe(L.screen.h);
});
it('turns the decay so a room sits at a third and a hall past the middle', () => {
const turn = (key: ReverbKey, value: number) => {
const row = REVERB_ROWS[key];
return knobPosition(value, { min: row.min!, max: row.max!, step: row.step!, ...REVERB_KNOBS[key] });
};
expect(turn('decay', REVERB_PRESETS.room.decay)).toBeGreaterThan(0.3);
expect(turn('decay', REVERB_PRESETS.room.decay)).toBeLessThan(0.4);
expect(turn('decay', REVERB_PRESETS.hall.decay)).toBeGreaterThan(0.55);
expect(turn('predelay', 0.02)).toBeGreaterThan(0.15);
expect(turn('mix', 0.5)).toBe(0.5);
});
});
describe('the numbers a Reverb plays with', () => {
it('takes a room its own numbers, Custom and Sound the node’s', () => {
const typed = { decay: 7, damping: 0.9, predelay: 0.2, mix: 0.6 };
expect(reverbValues({ room: 'hall', ...typed })).toMatchObject({ ...REVERB_PRESETS.hall, mix: 0.6 });
expect(reverbValues({ room: 'custom', ...typed })).toMatchObject(typed);
expect(reverbValues({})).toMatchObject({
room: 'room',
...REVERB_PRESETS.room,
mix: REVERB_ROWS.mix.default,
});
expect(reverbValues({ room: 'custom' }).decay).toBe(REVERB_ROWS.decay.default);
expect(reverbValues({ room: 'sound', impulse: 'snd1' }).impulse).toBe('snd1');
});
it('greys what the type does not take, and a knob of it goes to Custom with the shown numbers', () => {
expect([...idleReverbKeys('hall')].sort()).toEqual(['damping', 'decay', 'impulse', 'predelay']);
expect([...idleReverbKeys('custom')]).toEqual(['impulse']);
expect([...idleReverbKeys('sound')].sort()).toEqual(['damping', 'decay']);
expect(customFrom(reverbValues({ room: 'cave' }), 'damping', 0.8)).toEqual({
room: 'custom',
decay: REVERB_PRESETS.cave.decay,
damping: 0.8,
predelay: REVERB_PRESETS.cave.predelay,
});
});
});
describe('the round screen', () => {
it('puts every Reverb on one scale of seconds, a longer tail further out', () => {
const rooms = (['room', 'plate', 'hall', 'cave'] as const).map((room) => {
const p = REVERB_PRESETS[room];
return scopeRadius(p.predelay + p.decay);
});
expect([...rooms].sort((a, b) => a - b)).toEqual(rooms);
expect(scopeRadius(SCOPE_SECONDS)).toBe(1);
expect(scopeRadius(60)).toBe(1);
expect(scopeSeconds(scopeRadius(2.5))).toBeCloseTo(2.5, 9);
});
it('waits the pre-delay, falls 60 dB in the decay and loses its highs as far as it is damped', () => {
const tail = made({ predelay: 0.1, decay: 2, damping: 1 });
expect(tailAt(tail, 0.05)).toBeNull();
expect(tailAt(tail, 0.1)).toEqual({ level: 1, bright: 1 });
expect(tailAt(tail, 1.1)!.level).toBeCloseTo(0.5, 9);
expect(tailAt(tail, 2.1)!.level).toBeCloseTo(0, 9);
expect(tailAt(tail, 2.1)!.bright).toBeCloseTo(0, 9);
expect(tailAt(made({ predelay: 0, decay: 2, damping: 0 }), 2)!.bright).toBe(1);
expect(tailAt(tail, 2.2)).toBeNull();
});
it('draws a sound’s tail as loud as its peaks against its loudest, knowing nothing of its highs', () => {
const tail: ReverbTail = {
kind: 'sound',
predelay: 0,
seconds: 1,
peaks: Float32Array.from([0.5, 0.05, 0.0005, 0]),
};
expect(tailAt(tail, 0.1)).toEqual({ level: 1, bright: null });
expect(tailAt(tail, 0.3)!.level).toBeCloseTo(2 / 3, 6);
expect(tailAt(tail, 0.6)!.level).toBeCloseTo(0, 6);
expect(tailAt(tail, 0.9)!.level).toBe(0);
});
it('rings from the pre-delay out to where it has died, the reflections in between and the same each time', () => {
const tail = made({ ...REVERB_PRESETS.hall, predelay: 0.3 });
const radius = 78;
const rings = tailRings(tail, radius);
const start = scopeRadius(0.3) * radius;
const end = scopeRadius(0.3 + REVERB_PRESETS.hall.decay) * radius;
expect(rings[0]!.r).toBeGreaterThanOrEqual(start);
expect(rings.at(-1)!.r).toBeLessThanOrEqual(end);
expect(rings.at(-1)!.r).toBeGreaterThan(end - 2);
for (let i = 1; i < rings.length; i++) expect(rings[i]!.level).toBeLessThan(rings[i - 1]!.level);
const dots = reflections(tail, radius);
expect(dots.length).toBeGreaterThan(40);
for (const dot of dots) {
expect(dot.r).toBeGreaterThanOrEqual(start);
expect(dot.r).toBeLessThanOrEqual(end);
}
expect(reflections(tail, radius)).toEqual(dots);
});
it('draws no tail at a wet of 0, and the sound faint but there at 1', () => {
expect(wetShares(0)).toEqual({ tail: 0, dry: 1 });
expect(wetShares(1)).toEqual({ tail: 1, dry: 0.2 });
});
it('says the pre-delay, where the highs end up and when the tail has died', () => {
expect(scopeTexts(made(REVERB_PRESETS.hall), '')).toEqual({
pre: 'PRE 20 ms',
highs: 'HIGHS 5.5 kHz',
ends: 'ENDS 2.52 s',
});
expect(scopeTexts(null, 'NO SOUND')).toEqual({ pre: '', highs: '', ends: 'NO SOUND' });
});
});