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>
387 lines
14 KiB
TypeScript
387 lines
14 KiB
TypeScript
/**
|
||
* The Delay as a device (#393), what its node shows in the graph: the Compressor's brushed
|
||
* aluminium (#379), a screen that draws the echoes as bars over time – as tall as they are loud,
|
||
* left and right taking turns with ping-pong, warm while they keep their highs – a light that runs
|
||
* over them at the beat they come at, a lamp that blinks with it, two levers and four knobs.
|
||
*/
|
||
import { Graphics, type Text, type TextStyle } from 'pixi.js';
|
||
import { DELAY_ROWS } from '../../core/soundfx/nodes';
|
||
import { mixColor } from '../../core/util/color';
|
||
import { crispText, Knob, textStyle, Widget } from '../../ui';
|
||
import { ALUMINIUM, drawPlate, engrave, INK, Lever, placeEngraving } from './CompressorPanel';
|
||
import {
|
||
barHeight,
|
||
DELAY_KEYS,
|
||
DELAY_KNOBS,
|
||
DELAY_LAYOUT,
|
||
DELAY_PANEL,
|
||
type DelayKey,
|
||
type DelayTexts,
|
||
type DelayValues,
|
||
type EchoTrain,
|
||
sweepAt,
|
||
TEXT_ROW,
|
||
timeText,
|
||
} from './delayView';
|
||
import { type Box, KNOB_CAPTION } from './hardwareView';
|
||
import { percentText } from './reverbView';
|
||
|
||
/** What the panel asks of the node it sits in. */
|
||
export interface DelayHost {
|
||
preview(key: DelayKey, value: number): void;
|
||
commit(key: DelayKey, value: number): void;
|
||
switchSync(beats: boolean): void;
|
||
switchPingPong(on: boolean): void;
|
||
}
|
||
|
||
/** What the panel shows of a node. */
|
||
export interface DelayShown {
|
||
values: DelayValues;
|
||
wired: (key: DelayKey) => boolean;
|
||
/** The settings that do nothing as things stand. */
|
||
idle: ReadonlySet<DelayKey | 'sync' | 'pingPong'>;
|
||
train: EchoTrain;
|
||
texts: DelayTexts;
|
||
}
|
||
|
||
const BEZEL = 0x2a2c30;
|
||
const BEZEL_TEXT = 0xc9ccd2;
|
||
/** Smoked glass; an echo with its highs is warm white, one that lost them rust, as tape goes. */
|
||
const SCOPE = {
|
||
glass: 0x0e0c0a,
|
||
grid: 0x2c2722,
|
||
text: 0xffc98a,
|
||
dim: 0x7a6650,
|
||
bright: 0xfff1d0,
|
||
dull: 0xb8442a,
|
||
sweep: 0xffffff,
|
||
};
|
||
const LAMP = 0x4dff5a;
|
||
const IDLE_ALPHA = 0.4;
|
||
/** The sound's bar and the echoes', and the room left round the bars. */
|
||
const BAR = 4;
|
||
const PAD = { left: 18, right: 22, top: 12 };
|
||
|
||
function scopeStyle(size: number, fill: number): TextStyle {
|
||
const style = textStyle('small').clone();
|
||
style.fill = fill;
|
||
style.fontFamily = 'monospace';
|
||
style.fontSize = size;
|
||
style.fontWeight = 'bold';
|
||
return style;
|
||
}
|
||
|
||
function bezelStyle(): TextStyle {
|
||
const style = textStyle('small').clone();
|
||
style.fill = BEZEL_TEXT;
|
||
style.fontSize = 9;
|
||
style.fontWeight = 'bold';
|
||
style.letterSpacing = 1;
|
||
return style;
|
||
}
|
||
|
||
/** A bar as drawn: where it stands and how far up and down from the middle it goes. */
|
||
interface DrawnBar {
|
||
x: number;
|
||
top: number;
|
||
bottom: number;
|
||
}
|
||
|
||
/**
|
||
* The screen: the sound as a white bar at the left, its echoes after it as bars on a line of time,
|
||
* a faint line at every step, and a light that runs over them. Under it the step and the end.
|
||
*/
|
||
class DelayScope extends Widget {
|
||
private readonly g = new Graphics();
|
||
private readonly sweep = new Graphics();
|
||
private readonly sides: Record<'left' | 'right', Text>;
|
||
private readonly under: Record<'every' | 'ends', Text>;
|
||
private readonly moreText: Text;
|
||
private bars: DrawnBar[] = [];
|
||
train: EchoTrain | null = null;
|
||
texts = { every: '', ends: '', highs: '', more: '' };
|
||
pingPong = false;
|
||
/** Where the light is, in seconds after the sound, and how lit each bar is (checks). */
|
||
at = 0;
|
||
glow: number[] = [];
|
||
lamp = false;
|
||
|
||
constructor() {
|
||
super();
|
||
this.addChild(this.g, this.sweep);
|
||
this.sides = { left: this.made('L', 9, SCOPE.dim, 0, 0), right: this.made('R', 9, SCOPE.dim, 0, 1) };
|
||
this.under = { every: this.made('', 9, SCOPE.text, 0, 0), ends: this.made('', 9, SCOPE.text, 1, 0) };
|
||
this.moreText = this.made('', 9, SCOPE.dim, 1, 0.5);
|
||
}
|
||
|
||
private made(text: string, size: number, fill: number, ax: number, ay: number): Text {
|
||
const t = crispText({ text, style: scopeStyle(size, fill) });
|
||
t.anchor.set(ax, ay);
|
||
t.eventMode = 'none';
|
||
this.addChild(t);
|
||
return t;
|
||
}
|
||
|
||
show(train: EchoTrain, texts: DelayScope['texts'], pingPong: boolean): void {
|
||
this.train = train;
|
||
this.texts = texts;
|
||
this.pingPong = pingPong;
|
||
this.invalidate();
|
||
}
|
||
|
||
/** Where a time sits across the screen. */
|
||
private xOf(seconds: number): number {
|
||
const span = this.train?.span ?? 1;
|
||
return PAD.left + (seconds / span) * (this.w - PAD.left - PAD.right);
|
||
}
|
||
|
||
private get middle(): { y: number; half: number } {
|
||
const y = (this.h - TEXT_ROW) / 2;
|
||
return { y, half: y - PAD.top };
|
||
}
|
||
|
||
override redraw(): void {
|
||
const g = this.g.clear();
|
||
const { y: cy, half } = this.middle;
|
||
g.roundRect(0, 0, this.w, this.h - TEXT_ROW, 4).fill(SCOPE.glass);
|
||
const train = this.train;
|
||
this.bars = [];
|
||
if (train) {
|
||
// A faint line at every step, as far as the screen goes, and the levels 20 and 40 dB down.
|
||
for (let t = train.every; t < train.span - 1e-9 && train.every > 0; t += train.every)
|
||
g.moveTo(this.xOf(t), PAD.top - 4).lineTo(this.xOf(t), cy + half + 4);
|
||
for (const share of [1 / 3, 2 / 3])
|
||
g.moveTo(PAD.left - 6, cy - share * half)
|
||
.lineTo(this.w - 8, cy - share * half)
|
||
.moveTo(PAD.left - 6, cy + share * half)
|
||
.lineTo(this.w - 8, cy + share * half);
|
||
g.stroke({ color: SCOPE.grid, width: 1 });
|
||
g.moveTo(PAD.left - 6, cy)
|
||
.lineTo(this.w - 8, cy)
|
||
.stroke({ color: SCOPE.dim, width: 1, alpha: 0.6 });
|
||
|
||
const bar = (x: number, h: number, side: 'both' | 'left' | 'right', color: number) => {
|
||
const top = side === 'right' ? cy + 1 : cy - h * half;
|
||
const bottom = side === 'left' ? cy - 1 : cy + h * half;
|
||
if (bottom - top >= 1) g.roundRect(x - BAR / 2, top, BAR, bottom - top, 1.5).fill(color);
|
||
this.bars.push({ x, top, bottom });
|
||
};
|
||
bar(this.xOf(0), barHeight(train.dry), 'both', 0xffffff);
|
||
for (const echo of train.echoes)
|
||
bar(
|
||
this.xOf(echo.t),
|
||
barHeight(echo.level),
|
||
echo.side,
|
||
mixColor(SCOPE.dull, SCOPE.bright, echo.bright),
|
||
);
|
||
}
|
||
for (const [side, t] of Object.entries(this.sides)) {
|
||
t.visible = this.pingPong;
|
||
t.position.set(4, side === 'left' ? 3 : this.h - TEXT_ROW - 3);
|
||
}
|
||
this.moreText.text = this.texts.more;
|
||
this.moreText.position.set(this.w - 3, cy - 8);
|
||
const y = this.h - TEXT_ROW + 3;
|
||
this.under.every.text = this.texts.every;
|
||
this.under.every.position.set(0, y);
|
||
this.under.ends.text = this.texts.ends;
|
||
this.under.ends.position.set(this.w, y);
|
||
this.drawSweep();
|
||
}
|
||
|
||
/** `seconds` on a clock of the page's: the light where it is, the bars it passed glowing. */
|
||
step(seconds: number): void {
|
||
if (!this.train) return;
|
||
const sweep = sweepAt(this.train, seconds);
|
||
this.at = sweep.at;
|
||
this.glow = sweep.glow;
|
||
this.lamp = sweep.lamp;
|
||
this.drawSweep();
|
||
}
|
||
|
||
private drawSweep(): void {
|
||
const s = this.sweep.clear();
|
||
if (!this.train || this.w <= 0) return;
|
||
const { y: cy, half } = this.middle;
|
||
const x = this.xOf(this.at);
|
||
s.rect(x - 0.5, cy - half - 6, 1, 2 * half + 12).fill({ color: SCOPE.sweep, alpha: 0.35 });
|
||
this.bars.forEach((bar, i) => {
|
||
const glow = this.glow[i] ?? 0;
|
||
if (glow <= 0) return;
|
||
s.roundRect(
|
||
bar.x - BAR,
|
||
Math.min(bar.top, cy - 2) - 2,
|
||
BAR * 2,
|
||
Math.max(bar.bottom, cy + 2) - Math.min(bar.top, cy - 2) + 4,
|
||
3,
|
||
).fill({
|
||
color: SCOPE.sweep,
|
||
alpha: 0.55 * glow,
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
export class DelayPanel extends Widget {
|
||
private readonly plate = new Graphics();
|
||
private readonly bezel = new Graphics();
|
||
private readonly rules = new Graphics();
|
||
private readonly lampLight = new Graphics();
|
||
readonly scope = new DelayScope();
|
||
readonly syncLever: Lever;
|
||
readonly pingPongLever: Lever;
|
||
readonly knobs = {} as Record<DelayKey, Knob>;
|
||
private readonly captions = {} as Record<DelayKey, { ink: Text; edge: Text }>;
|
||
private readonly groupNames: { ink: Text; edge: Text }[];
|
||
private readonly title: { ink: Text; edge: Text };
|
||
private readonly lampCaption: { ink: Text; edge: Text };
|
||
private readonly screenName: Text;
|
||
/** Where the echoes' highs end, above the screen on the right. */
|
||
readonly highs: Text;
|
||
/** Whether the time knob counts beats now: its number reads as a note then. */
|
||
private beats = false;
|
||
/** The settings shown idle now (checks). */
|
||
idle: ReadonlySet<DelayKey | 'sync' | 'pingPong'> = new Set();
|
||
private lampLit: boolean | null = null;
|
||
|
||
constructor(host: DelayHost) {
|
||
super();
|
||
this.minWidth = DELAY_PANEL.w;
|
||
this.minHeight = DELAY_PANEL.h;
|
||
this.addChild(this.plate, this.rules, this.bezel, this.scope, this.lampLight);
|
||
this.title = engrave(this, 'DELAY', 14);
|
||
this.lampCaption = engrave(this, 'ECHO', 9);
|
||
this.screenName = crispText({ text: 'ECHOES OVER TIME', style: bezelStyle() });
|
||
this.screenName.eventMode = 'none';
|
||
this.highs = crispText({ text: '', style: bezelStyle() });
|
||
this.highs.anchor.set(1, 0);
|
||
this.highs.eventMode = 'none';
|
||
this.addChild(this.screenName, this.highs);
|
||
this.groupNames = DELAY_LAYOUT.groups.map((group) => engrave(this, group.label, 9));
|
||
this.syncLever = new Lever('SYNC', (on) => host.switchSync(on));
|
||
this.pingPongLever = new Lever('PING-PONG', (on) => host.switchPingPong(on));
|
||
this.addChild(this.syncLever, this.pingPongLever);
|
||
for (const key of DELAY_KEYS) {
|
||
const row = DELAY_ROWS[key];
|
||
const look = DELAY_KNOBS[key];
|
||
const knob = new Knob('', row.default, {
|
||
min: row.min ?? 0,
|
||
max: row.max ?? 1,
|
||
step: row.step ?? 0.01,
|
||
taper: look.taper,
|
||
default: row.default,
|
||
decimals: look.decimals,
|
||
size: DELAY_LAYOUT.knobs[key].size,
|
||
look: 'chrome',
|
||
typed: false,
|
||
format: key === 'time' ? (value) => timeText(value, this.beats) : percentText,
|
||
ink: INK,
|
||
onInput: (value) => host.preview(key, value),
|
||
onChange: (value) => host.commit(key, value),
|
||
});
|
||
this.knobs[key] = knob;
|
||
this.captions[key] = engrave(this, look.label.toUpperCase(), 9);
|
||
this.addChild(knob);
|
||
}
|
||
}
|
||
|
||
override measure(): { w: number; h: number } {
|
||
return { ...DELAY_PANEL };
|
||
}
|
||
|
||
sync(shown: DelayShown): void {
|
||
const v = shown.values;
|
||
if (this.beats !== v.beats) {
|
||
this.beats = v.beats;
|
||
// The number under the time knob reads otherwise now, though its value stays.
|
||
this.knobs.time.reformat();
|
||
}
|
||
for (const key of DELAY_KEYS) {
|
||
const knob = this.knobs[key];
|
||
knob.setValue(v[key]);
|
||
knob.setEnabled(!shown.wired(key));
|
||
knob.alpha = shown.idle.has(key) ? IDLE_ALPHA : 1;
|
||
this.captions[key].ink.alpha = knob.alpha;
|
||
}
|
||
this.syncLever.set(v.beats, v.beats ? 'beats' : 'seconds');
|
||
this.syncLever.alpha = shown.idle.has('sync') ? IDLE_ALPHA : 1;
|
||
this.pingPongLever.set(v.pingPong, v.pingPong ? 'L · R' : 'off');
|
||
this.pingPongLever.alpha = shown.idle.has('pingPong') ? IDLE_ALPHA : 1;
|
||
this.idle = shown.idle;
|
||
this.scope.show(shown.train, shown.texts, v.pingPong);
|
||
this.highs.text = shown.texts.highs;
|
||
}
|
||
|
||
/** A frame on, `seconds` on the page's clock: the light runs on, the lamp blinks with the echoes. */
|
||
step(seconds: number): void {
|
||
this.scope.step(seconds);
|
||
if (this.scope.lamp !== this.lampLit) {
|
||
this.lampLit = this.scope.lamp;
|
||
this.drawLamp();
|
||
}
|
||
}
|
||
|
||
private drawLamp(): void {
|
||
const { x, y, r } = DELAY_LAYOUT.lamp;
|
||
const lit = this.lampLit === true;
|
||
const g = this.lampLight.clear();
|
||
if (lit) g.circle(x, y, r + 4).fill({ color: LAMP, alpha: 0.25 });
|
||
g.circle(x, y, r)
|
||
.fill(lit ? LAMP : 0x1f4a22)
|
||
.stroke({ color: 0x2a2c30, width: 1.5 });
|
||
g.circle(x - 2, y - 2, 2).fill({ color: 0xffffff, alpha: lit ? 0.7 : 0.2 });
|
||
}
|
||
|
||
override layout(): void {
|
||
const L = DELAY_LAYOUT;
|
||
const put = (widget: Widget, box: Box) => {
|
||
widget.position.set(Math.round(box.x), Math.round(box.y));
|
||
widget.resize(box.w, box.h);
|
||
};
|
||
put(this.scope, { ...L.screen, h: L.screen.h + TEXT_ROW });
|
||
this.screenName.position.set(L.screen.x, L.bezel.y + 3);
|
||
this.highs.position.set(L.screen.x + L.screen.w, L.bezel.y + 3);
|
||
put(this.syncLever, L.sync);
|
||
put(this.pingPongLever, L.pingPong);
|
||
placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y);
|
||
for (const key of DELAY_KEYS) {
|
||
const spot = L.knobs[key];
|
||
const knob = this.knobs[key];
|
||
const size = knob.measure();
|
||
knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2));
|
||
knob.resize(size.w, size.h);
|
||
placeEngraving(this.captions[key], spot.x, spot.y - spot.size / 2 - KNOB_CAPTION);
|
||
}
|
||
L.groups.forEach((group, i) =>
|
||
placeEngraving(this.groupNames[i]!, group.box.x + group.box.w / 2, group.box.y),
|
||
);
|
||
placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4);
|
||
}
|
||
|
||
override redraw(): void {
|
||
const L = DELAY_LAYOUT;
|
||
drawPlate(this.plate.clear(), DELAY_PANEL, ALUMINIUM, L.screws);
|
||
const b = L.bezel;
|
||
this.bezel
|
||
.clear()
|
||
.roundRect(b.x, b.y, b.w, b.h, 8)
|
||
.fill(BEZEL)
|
||
.roundRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1, 8)
|
||
.stroke({ color: 0x1b1c20, width: 1 });
|
||
// A rule each side of a group's name, engraved as the names are.
|
||
const r = this.rules.clear();
|
||
L.groups.forEach((group, i) => {
|
||
const name = this.groupNames[i]!.ink;
|
||
const mid = group.box.y + 6;
|
||
const left = group.box.x + 6;
|
||
const right = group.box.x + group.box.w - 6;
|
||
const gap = name.width / 2 + 6;
|
||
const centre = group.box.x + group.box.w / 2;
|
||
if (centre - gap > left) r.moveTo(left, mid).lineTo(centre - gap, mid);
|
||
if (centre + gap < right) r.moveTo(centre + gap, mid).lineTo(right, mid);
|
||
});
|
||
r.stroke({ color: INK, width: 1, alpha: 0.5 });
|
||
this.drawLamp();
|
||
}
|
||
}
|