Show a Reverb node as a device with a round screen of its tail (#389)
game-maker / gate (push) Successful in 4m5s
game-maker / suite (push) Successful in 15m49s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-10 16:35:59 +02:00
1 parent c75e72d4ee
commit e8021b1fd3
11 files changed
+1249 -29

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
/**
* 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' });
});
});