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>
171 lines
7.0 KiB
TypeScript
171 lines
7.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,
|
||
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' });
|
||
});
|
||
});
|