/** * 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' }); }); });