After the Chip, the Envelope, the Reverb and the one-knob devices, the Delay is drawn as a device of its own size too. Its screen shows what time, feedback, damping, wet and ping-pong make of a sound, and a light runs over the echoes at the beat they come at. - DelayPanel: a 680x280 plate in the Compressor's brushed aluminium, now exported as ALUMINIUM. A screen, levers for Sync and Ping-pong, knobs for Time, Feedback, Damping and Wet, and an Echo lamp. - The screen draws the sound as a white bar and each echo as a bar one step later. A bar is as tall as the echo is loud, evenly in dB to -60. It turns from warm white to rust as the damping takes the highs. With ping-pong the bars alternate above and below, the first left. - At most 24 bars are drawn, and the screen says how many more are heard. Under it stand the step and the end of the tail, above it where the highs of the first echo end. - The running light: DelayFace.tick returns true while the node is in the picture, so the editor keeps drawing, as for the Particles tab. The light goes round the span in real time, a bar glows as it is passed, and the lamp blinks with every echo but not with the sound. - On beats the screen counts at 120 BPM, as the tempo of the voice is not known in the graph. The time knob then names the note, dotted and triplet ones included. In seconds, 2 s and up reads "2 s max". - delayView holds the rules apart from the drawing: layout, tapers, echoTrain, echoCutoff for k rounds of the Butterworth low-pass, sweepAt and the texts. At wet 0 the rest is drawn faint. - delayEchoes in the core counts the echoes heard. The tail of a voice and the screen use it both. DELAY_ROWS, DELAY_SYNC and PING_PONG are exported. - Knob.reformat writes the number anew when its format changes, here when Sync switches between seconds and beats. - The test hook __gm.delay.panel returns knobs, levers, idle settings, the echoes drawn, the texts and where the light is. - Instrument presets give the Delay 4 columns. Echo Lead and Bell use one. In the picture of Echo Lead nothing overlaps. Found on the way: a node with a face draws no setting rows. The e2e check of a bypassed Delay (#354) read "Bypass: true" from them. It now checks the faded node and the stages heard, as before. Tests: - Unit delay-view: 17 tests, new. Counter-check: the first ping-pong echo on the right turns the side test red. - e2e 34 (melektaus-atx): 136 checks, 1 new. - Gate (melektaus-atx): 288 files, 4416 tests. - Pictures looked at: default, ping-pong, beats with damping, 83 echoes at 50 ms, wet 0, and the preset Echo Lead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
176 lines
7.0 KiB
TypeScript
176 lines
7.0 KiB
TypeScript
/**
|
||
* 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 => ({ ...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);
|
||
});
|
||
});
|