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>
217 lines
9.0 KiB
TypeScript
217 lines
9.0 KiB
TypeScript
/**
|
||
* 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' });
|
||
});
|
||
});
|