/** * A Delay's node as a device (#393): every part on the plate and none over another, the time knob * giving its short end the turn and naming notes on beats, the echoes on the screen as the line * plays them, and the light that runs over them at their beat. */ import { describe, expect, it } from 'vitest'; import { DELAY_MAX, DELAY_ROWS, dampingCutoff, delayEchoes } from '../../src/core/soundfx/nodes'; import { barHeight, beatText, DELAY_KEYS, DELAY_KNOBS, DELAY_LAYOUT, DELAY_PANEL, type DelayValues, delayTexts, delayValues, echoCutoff, echoSeconds, echoTrain, GLOW_SECONDS, idleDelayKeys, SHOWN_ECHOES, sweepAt, timeText, } from '../../src/editor/soundfx/delayView'; import { type Box, knobBox } from '../../src/editor/soundfx/hardwareView'; 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 <= DELAY_PANEL.w && a.y + a.h <= DELAY_PANEL.h; const values = (over: Partial = {}): DelayValues => ({ ...delayValues({}), ...over }); describe('the Delay plate', () => { const L = DELAY_LAYOUT; const screws = L.screws.map((s) => ({ x: s.x - 7, y: s.y - 7, w: 14, h: 14 })); it('keeps the screen, the levers and the knobs on the plate, clear of each other and the screws', () => { const parts: Box[] = [L.bezel, L.sync, L.pingPong, ...DELAY_KEYS.map((key) => knobBox(L.knobs[key]))]; for (const part of parts) { expect(inside(part)).toBe(true); for (const screw of screws) expect(overlap(part, screw)).toBe(false); } for (let i = 0; i < parts.length; i++) for (let j = i + 1; j < parts.length; j++) expect(overlap(parts[i]!, parts[j]!)).toBe(false); }); it('sets the echo lamp beside the name, away from the lamps of the levers', () => { const lamp = { x: L.lamp.x - L.lamp.r, y: L.lamp.y - L.lamp.r, w: 2 * L.lamp.r, h: 2 * L.lamp.r }; expect(lamp.y + lamp.h).toBeLessThan(L.bezel.y); expect(lamp.x + lamp.w).toBeLessThan(L.sync.x); expect(overlap(lamp, L.title)).toBe(false); }); }); describe('the Delay knobs', () => { const turn = (value: number) => { const row = DELAY_ROWS.time; return knobPosition(value, { min: row.min!, max: row.max!, step: row.step!, ...DELAY_KNOBS.time }); }; it('puts a quarter note or a second in the middle of the time and a quarter second at a third', () => { expect(turn(1)).toBeCloseTo(0.5, 2); expect(turn(0.25)).toBeGreaterThan(0.28); expect(turn(0.25)).toBeLessThan(0.34); }); it('names a time on beats as a note, dotted and triplet ones too, within the knob’s step', () => { expect(beatText(1)).toBe('1/4'); expect(beatText(0.75)).toBe('1/8.'); expect(beatText(0.33)).toBe('1/8T'); expect(beatText(0.38)).toBe('1/16.'); expect(beatText(0.6)).toBe('0.6 beats'); expect(timeText(0.5, true)).toBe('1/8'); }); it('holds a time in seconds to the line, and says so', () => { expect(timeText(0.25, false)).toBe('250 ms'); expect(timeText(5, false)).toBe('2 s max'); expect(echoSeconds(values({ time: 5 }))).toBe(DELAY_MAX); }); it('counts beats at the tempo, never past the line', () => { expect(echoSeconds(values({ time: 0.75, beats: true }))).toBeCloseTo(0.375, 9); expect(echoSeconds(values({ time: 1, beats: true }), 60)).toBeCloseTo(1, 9); expect(echoSeconds(values({ time: 8, beats: true }))).toBe(DELAY_MAX); }); it('greys all but the wet while the wet leaves no echo', () => { expect([...idleDelayKeys(values({ mix: 0 }))].sort()).toEqual( ['damping', 'feedback', 'pingPong', 'sync', 'time'].sort(), ); expect(idleDelayKeys(values({ mix: 0.01 })).size).toBe(0); }); }); describe('the echoes on the screen', () => { it('draws an echo every step, each feedback times as loud, as many as are heard', () => { const train = echoTrain(values({ time: 0.3, feedback: 0.5, mix: 0.5 })); expect(train.heard).toBe(delayEchoes(0.5, 0.5)); expect(train.echoes).toHaveLength(train.heard); train.echoes.forEach((echo, i) => { expect(echo.t).toBeCloseTo(0.3 * (i + 1), 9); expect(echo.level).toBeCloseTo(0.5 ** i, 9); }); expect(train.dry).toBe(1); }); it('draws at most so many and says how many more there are', () => { const v = values({ time: 0.05, feedback: 0.92, mix: 0.8 }); const train = echoTrain(v); expect(train.echoes).toHaveLength(SHOWN_ECHOES); expect(delayTexts(v, train).more).toBe(`+${train.heard - SHOWN_ECHOES}`); expect(delayTexts(v, train).ends).toBe(`ENDS ${(train.heard * 0.05).toFixed(2)} s`); }); it('takes turns left and right with ping-pong, the first on the left', () => { const sides = (pingPong: boolean) => echoTrain(values({ pingPong })) .echoes.slice(0, 4) .map((e) => e.side); expect(sides(true)).toEqual(['left', 'right', 'left', 'right']); expect(sides(false)).toEqual(['both', 'both', 'both', 'both']); }); it('makes every echo duller than the last, the first as dull as the damping', () => { expect(echoCutoff(0.4, 1)).toBeCloseTo(dampingCutoff(0.4), 6); const bright = echoTrain(values({ damping: 0.4, feedback: 0.8 })).echoes.map((e) => e.bright); for (let i = 1; i < bright.length; i++) expect(bright[i]!).toBeLessThan(bright[i - 1]!); }); it('stands a bar as tall as its level evenly in dB, 60 dB down at the floor', () => { expect(barHeight(1)).toBe(1); expect(barHeight(0.1)).toBeCloseTo(2 / 3, 9); expect(barHeight(0.001)).toBeCloseTo(0, 9); expect(barHeight(0)).toBe(0); }); it('draws the sound alone, and says so, where the wet is 0', () => { const v = values({ mix: 0 }); const train = echoTrain(v); expect(train.echoes).toHaveLength(0); expect(delayTexts(v, train)).toMatchObject({ ends: 'NO ECHOES', highs: '', more: '' }); }); it('writes the note and the tempo it counts at on beats', () => { const v = values({ time: 0.75, beats: true }); expect(delayTexts(v, echoTrain(v)).every).toBe('EVERY 1/8. · 375 ms AT 120 BPM'); expect(delayTexts(values(), echoTrain(values())).every).toBe('EVERY 250 ms'); }); }); describe('the running light', () => { const train = echoTrain(values({ time: 0.3, feedback: 0.5, mix: 0.5 })); it('lights a bar the moment the light reaches it, at the beat of the echoes, and lets it fade', () => { const at = sweepAt(train, 0.6); expect(at.glow[2]).toBeCloseTo(1, 9); expect(at.glow[1]).toBe(0); expect(at.lamp).toBe(true); expect(sweepAt(train, 0.6 + GLOW_SECONDS / 2).glow[2]).toBeCloseTo(0.5, 9); expect(sweepAt(train, 0.45).lamp).toBe(false); }); it('runs round the span again and again', () => { expect(sweepAt(train, train.span + 0.6).at).toBeCloseTo(0.6, 9); expect(sweepAt(train, -0.1).at).toBeCloseTo(train.span - 0.1, 9); }); it('blinks the lamp with the echoes only, not with the sound', () => { expect(sweepAt(train, 0).lamp).toBe(false); expect(sweepAt(echoTrain(values({ mix: 0 })), 0).lamp).toBe(false); }); });