Files
adminandClaude Opus 5.5 2f376262d3
game-maker / gate (push) Successful in 4m13s
game-maker / suite (push) Successful in 16m5s
Let the Reverb's screen ring out and its reflections drift (#401)
The round screen of the Reverb stood still. It now moves, only for
the look of it, and says nothing it did not say before.

- The sound rings out again and again in real time. The dot in the
  middle flashes, and a thin front runs out over the rings as fast as
  the tail does. It fades as the tail falls, then 0.8 s of quiet.
- A reflection lights up for a moment once the front has passed it.
- The reflections turn slowly round the dot, each at its own pace and
  way (at most 0.16 rad/s). They sway 1.5 px in and out and twinkle.
  Their drift comes from a noise of its own, so they sit where they
  always did.
- Rings, grid and dashed end stay one drawing. Only the dots, the
  front and the flash are drawn again each frame.
- Nothing moves without a tail to see: no sound picked, or wet at 0.

Tests:
- reverb-view: 13 tests green (3 new)
- e2e 34 on melektaus-atx: ALL PASSED, 144 checks
- gate on melektaus-atx: 293 files, 4498 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:32:49 +02:00

217 lines
9.0 KiB
TypeScript
Raw Permalink 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,
DRIFT_SWAY,
driftAt,
glowOf,
idleReverbKeys,
pulseAt,
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('lets the reflections drift round the sound, slowly, staying at the time they come (#401)', () => {
const tail = made(REVERB_PRESETS.hall);
const dots = reflections(tail, 78);
const spins = dots.map((dot) => dot.spin);
expect(spins.some((s) => s > 0) && spins.some((s) => s < 0)).toBe(true);
for (const dot of dots) {
expect(Math.abs(dot.spin)).toBeLessThanOrEqual(0.16);
for (const seconds of [0, 1.3, 7.9, 1000]) {
const at = driftAt(dot, seconds);
expect(Math.abs(at.r - dot.r)).toBeLessThanOrEqual(DRIFT_SWAY + 1e-9);
expect(at.angle).toBeCloseTo(dot.angle + dot.spin * seconds, 9);
expect(at.shine).toBeGreaterThanOrEqual(0.4);
expect(at.shine).toBeLessThanOrEqual(1);
}
}
});
it('rings the sound out again and again in real time, the front fading as the tail falls', () => {
const tail = made({ predelay: 0.1, decay: 2, damping: 1 });
expect(pulseAt(tail, 0)).toMatchObject({ at: 0, front: null, flash: 1 });
const half = pulseAt(tail, 1.1);
expect(half.front).toBeCloseTo(scopeRadius(1.1), 9);
expect(half.level).toBeCloseTo(0.5, 9);
expect(half.flash).toBe(0);
// The tail ends at 2.1 s; after a short quiet the sound comes again.
expect(pulseAt(tail, 2.5).front).toBeNull();
const again = pulseAt(tail, 2.9 + 1.1);
expect(again.at).toBeCloseTo(1.1, 9);
expect(again.front).toBeCloseTo(half.front!, 9);
expect(pulseAt(tail, -0.5).at).toBeCloseTo(2.4, 9);
});
it('lights a reflection as the front goes by it, and not before', () => {
const tail = made(REVERB_PRESETS.hall);
const dot = reflections(tail, 78)[0]!;
expect(glowOf(dot, pulseAt(tail, dot.t - 0.01))).toBe(0);
expect(glowOf(dot, pulseAt(tail, dot.t))).toBeCloseTo(1, 9);
expect(glowOf(dot, pulseAt(tail, dot.t + 0.1))).toBeGreaterThan(0);
expect(glowOf(dot, pulseAt(tail, dot.t + 0.1))).toBeLessThan(1);
expect(glowOf(dot, pulseAt(tail, dot.t + 0.5))).toBe(0);
});
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' });
});
});