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>
414 lines
15 KiB
TypeScript
414 lines
15 KiB
TypeScript
/**
|
||
* The Reverb as a device (#389), what its node shows in the graph: the Compressor's plate (#379) in
|
||
* pale lavender, a round screen that draws the tail as rings out from the sound – as far out as it
|
||
* rings, as bright as it is loud, as blue as its highs are still there – a button for each type, the
|
||
* sound a type of Sound takes, and a chrome knob for each number.
|
||
*/
|
||
import { type FederatedPointerEvent, Graphics, type Text, type TextStyle } from 'pixi.js';
|
||
import { REVERB_ROWS } from '../../core/soundfx/nodes';
|
||
import { REVERB_ROOMS, type ReverbRoom } from '../../core/soundfx/reverb';
|
||
import { mixColor } from '../../core/util/color';
|
||
import { crispText, Dropdown, Knob, textStyle, Widget } from '../../ui';
|
||
import { apartLabels } from './eqView';
|
||
import { drawPlate, engrave, INK, placeEngraving, type PlateLook } from './CompressorPanel';
|
||
import { type Box, KNOB_CAPTION } from './hardwareView';
|
||
import {
|
||
markText,
|
||
REVERB_KEYS,
|
||
REVERB_KNOBS,
|
||
REVERB_LAYOUT,
|
||
REVERB_PANEL,
|
||
type ReverbKey,
|
||
type ReverbTail,
|
||
type ReverbValues,
|
||
reflections,
|
||
SCOPE_GRID,
|
||
SCOPE_MARKS,
|
||
scopeRadius,
|
||
scopeTexts,
|
||
tailRings,
|
||
tailSeconds,
|
||
wetShares,
|
||
} from './reverbView';
|
||
|
||
/** What the panel asks of the node it sits in. */
|
||
export interface ReverbHost {
|
||
preview(key: ReverbKey, value: number): void;
|
||
commit(key: ReverbKey, value: number): void;
|
||
pickRoom(room: ReverbRoom): void;
|
||
/** Opens the sounds to pick from below the button. */
|
||
pickSound(button: Widget): void;
|
||
}
|
||
|
||
/** What the panel shows of a node. */
|
||
export interface ReverbShown {
|
||
values: ReverbValues;
|
||
wired: (key: ReverbKey) => boolean;
|
||
/** The settings that do nothing as things stand. */
|
||
idle: ReadonlySet<ReverbKey | 'impulse'>;
|
||
/** The tail to draw; null while there is none to know – no sound picked, or not loaded yet. */
|
||
tail: ReverbTail | null;
|
||
/** The picked sound's name, '' for none. */
|
||
sound: string;
|
||
/** What the screen says where it has no tail. */
|
||
missing: string;
|
||
}
|
||
|
||
/** Pale anodised lavender. */
|
||
const LAVENDER: PlateLook = {
|
||
stops: [
|
||
[0, 0xe2ddf2],
|
||
[0.5, 0xcdc6e8],
|
||
[1, 0xb0a7d6],
|
||
],
|
||
rim: 0x7a70a6,
|
||
grain: 0x544a80,
|
||
};
|
||
const BEZEL = 0x2c2a3c;
|
||
const BEZEL_TEXT = 0xc4bfe0;
|
||
/** Night glass; a tail with its highs is ice, one that lost them violet. */
|
||
const SCOPE = {
|
||
glass: 0x0b0d1a,
|
||
grid: 0x232a48,
|
||
text: 0x9fb0ff,
|
||
dim: 0x5b6590,
|
||
bright: 0xd8f6ff,
|
||
dull: 0x6c4fd0,
|
||
};
|
||
/** A sound's tail, whose highs the screen cannot tell. */
|
||
const UNKNOWN_TINT = 0xb9c2ff;
|
||
const PICKED = 0x5b45b8;
|
||
const IDLE_ALPHA = 0.4;
|
||
/** The ring a pixel of the tail is drawn with, and the strip of text under the screen. */
|
||
const RING_STEP = 1.5;
|
||
const TEXT_ROW = 14;
|
||
|
||
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;
|
||
}
|
||
|
||
const tint = (bright: number | null): number =>
|
||
bright === null ? UNKNOWN_TINT : mixColor(SCOPE.dull, SCOPE.bright, bright);
|
||
|
||
/** A type's button: its name on it, lit while it is the one. */
|
||
class RoomButton extends Widget {
|
||
private readonly g = new Graphics();
|
||
private readonly caption: Text;
|
||
picked = false;
|
||
|
||
constructor(
|
||
readonly room: ReverbRoom,
|
||
name: string,
|
||
onPick: (room: ReverbRoom) => void,
|
||
) {
|
||
super();
|
||
this.cursor = 'pointer';
|
||
this.caption = crispText({ text: name.toUpperCase(), style: scopeStyle(9, 0xe4e2ee) });
|
||
this.caption.anchor.set(0.5, 0.5);
|
||
this.caption.eventMode = 'none';
|
||
this.addChild(this.g, this.caption);
|
||
this.trackHover();
|
||
// A press on a button is not one on the node: it neither picks nor moves it.
|
||
this.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
|
||
this.on('pointertap', (e: FederatedPointerEvent) => {
|
||
if (e.button === 0) onPick(room);
|
||
});
|
||
}
|
||
|
||
pick(on: boolean): void {
|
||
if (on === this.picked) return;
|
||
this.picked = on;
|
||
this.invalidate();
|
||
}
|
||
|
||
override redraw(): void {
|
||
const { w, h } = this;
|
||
const down = this.picked ? 1 : 0;
|
||
this.g
|
||
.clear()
|
||
.roundRect(0, 2, w, h - 2, 5)
|
||
.fill(0x24222f)
|
||
.roundRect(0, down, w, h - 2, 5)
|
||
.fill(this.picked ? PICKED : this.hovered ? 0x55536a : 0x45435a)
|
||
.stroke({ color: 0x1e1d27, width: 1 })
|
||
.roundRect(3, down + 2, w - 6, 4, 2)
|
||
.fill({ color: 0xffffff, alpha: this.picked ? 0.25 : 0.1 });
|
||
this.caption.position.set(Math.round(w / 2), Math.round(down + (h - 2) / 2));
|
||
this.caption.style.fill = this.picked ? 0xffffff : 0xd9d7e6;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The round screen: the sound as a dot in the middle, the tail as rings out from it on a scale of
|
||
* seconds, scattered reflections over it, and under it the pre-delay and the end.
|
||
*/
|
||
class ReverbScope extends Widget {
|
||
private readonly g = new Graphics();
|
||
private readonly marks: Text[];
|
||
private readonly under: Record<'pre' | 'ends', Text>;
|
||
tail: ReverbTail | null = null;
|
||
mix = 0;
|
||
/** What the texts under the screen say now, and the highs above it on the bezel (checks). */
|
||
says = { pre: '', highs: '', ends: '' };
|
||
/** How far out the tail reaches and the rings drawn (checks). */
|
||
reach = 0;
|
||
rings = 0;
|
||
|
||
constructor() {
|
||
super();
|
||
this.addChild(this.g);
|
||
this.marks = SCOPE_MARKS.map((seconds) => this.made(markText(seconds), 8, SCOPE.dim, 0, 1));
|
||
this.under = {
|
||
pre: this.made('', 9, SCOPE.text, 0, 0),
|
||
ends: this.made('', 9, SCOPE.text, 1, 0),
|
||
};
|
||
}
|
||
|
||
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(tail: ReverbTail | null, mix: number, missing: string): void {
|
||
this.tail = tail;
|
||
this.mix = mix;
|
||
this.says = scopeTexts(tail, missing);
|
||
this.invalidate();
|
||
}
|
||
|
||
/** The round part: a square above the strip of text. */
|
||
get disc(): { x: number; y: number; r: number } {
|
||
const side = Math.min(this.w, this.h - TEXT_ROW);
|
||
return { x: this.w / 2, y: side / 2, r: side / 2 - 10 };
|
||
}
|
||
|
||
override redraw(): void {
|
||
const { x: cx, y: cy, r: R } = this.disc;
|
||
const g = this.g.clear();
|
||
g.roundRect(0, 0, this.w, this.h - TEXT_ROW, 4).fill(SCOPE.glass);
|
||
for (const seconds of SCOPE_GRID) g.circle(cx, cy, scopeRadius(seconds) * R);
|
||
g.moveTo(cx - R, cy)
|
||
.lineTo(cx + R, cy)
|
||
.moveTo(cx, cy - R)
|
||
.lineTo(cx, cy + R);
|
||
g.stroke({ color: SCOPE.grid, width: 1 });
|
||
|
||
const tail = this.tail;
|
||
const shares = wetShares(this.mix);
|
||
this.reach = 0;
|
||
this.rings = 0;
|
||
if (tail) {
|
||
const rings = tailRings(tail, R, RING_STEP);
|
||
for (const ring of rings)
|
||
g.circle(cx, cy, ring.r).stroke({
|
||
color: tint(ring.bright),
|
||
width: RING_STEP,
|
||
alpha: ring.level * shares.tail,
|
||
});
|
||
for (const dot of reflections(tail, R))
|
||
g.circle(cx + Math.cos(dot.angle) * dot.r, cy + Math.sin(dot.angle) * dot.r, 1.2).fill({
|
||
color: tint(dot.bright),
|
||
alpha: Math.min(1, dot.level * shares.tail * 1.4),
|
||
});
|
||
// Where it has fallen by 60 dB: a dashed ring, as there is nothing left of it to see.
|
||
this.reach = scopeRadius(tail.predelay + tailSeconds(tail));
|
||
const end = this.reach * R;
|
||
const dashes = 48;
|
||
for (let i = 0; i < dashes; i += 2) {
|
||
const a = (i / dashes) * Math.PI * 2;
|
||
const b = ((i + 1) / dashes) * Math.PI * 2;
|
||
g.moveTo(cx + Math.cos(a) * end, cy + Math.sin(a) * end).lineTo(
|
||
cx + Math.cos(b) * end,
|
||
cy + Math.sin(b) * end,
|
||
);
|
||
}
|
||
g.stroke({ color: SCOPE.text, width: 1, alpha: 0.6 * Math.max(0.3, shares.tail) });
|
||
this.rings = rings.length;
|
||
}
|
||
g.circle(cx, cy, 3.5).fill({ color: 0xffffff, alpha: shares.dry });
|
||
|
||
// The scale up the middle, every other mark on the other side of the line, outer ones first:
|
||
// one that would still run into the last on its side is left out.
|
||
for (const side of [0, 1]) {
|
||
const order = [...this.marks.keys()].reverse().filter((_, k) => k % 2 === side);
|
||
const placed = order.map((i) => {
|
||
const t = this.marks[i]!;
|
||
t.anchor.set(side, 1);
|
||
t.position.set(
|
||
Math.round(cx + (side ? -3 : 3)),
|
||
Math.round(cy - scopeRadius(SCOPE_MARKS[i]!) * R - 1),
|
||
);
|
||
return { from: t.y - t.height, to: t.y };
|
||
});
|
||
const shown = apartLabels(placed, 1);
|
||
order.forEach((i, k) => (this.marks[i]!.visible = shown[k]!));
|
||
}
|
||
|
||
const y = this.h - TEXT_ROW + 3;
|
||
this.under.pre.text = this.says.pre;
|
||
this.under.pre.position.set(0, y);
|
||
this.under.ends.text = this.says.ends;
|
||
this.under.ends.position.set(this.w, y);
|
||
}
|
||
}
|
||
|
||
export class ReverbPanel extends Widget {
|
||
private readonly plate = new Graphics();
|
||
private readonly bezel = new Graphics();
|
||
private readonly rules = new Graphics();
|
||
readonly scope = new ReverbScope();
|
||
readonly roomButtons: RoomButton[];
|
||
readonly soundButton: Dropdown;
|
||
readonly knobs = {} as Record<ReverbKey, Knob>;
|
||
private readonly captions = {} as Record<ReverbKey, { ink: Text; edge: Text }>;
|
||
private readonly soundCaption: { ink: Text; edge: Text };
|
||
private readonly groupNames: { ink: Text; edge: Text }[];
|
||
private readonly title: { ink: Text; edge: Text };
|
||
private readonly screenName: Text;
|
||
/** Where the highs end up, above the screen on the right: there was no room for it below. */
|
||
readonly highs: Text;
|
||
/** The settings shown idle now (checks). */
|
||
idle: ReadonlySet<ReverbKey | 'impulse'> = new Set();
|
||
|
||
constructor(host: ReverbHost) {
|
||
super();
|
||
this.minWidth = REVERB_PANEL.w;
|
||
this.minHeight = REVERB_PANEL.h;
|
||
this.addChild(this.plate, this.rules, this.bezel, this.scope);
|
||
this.title = engrave(this, 'REVERB', 14);
|
||
this.screenName = crispText({ text: 'TAIL 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 = REVERB_LAYOUT.groups.map((group) => engrave(this, group.label, 9));
|
||
this.roomButtons = REVERB_ROOMS.map((room) => {
|
||
const button = new RoomButton(room.value, room.label, (picked) => host.pickRoom(picked));
|
||
this.addChild(button);
|
||
return button;
|
||
});
|
||
this.soundCaption = engrave(this, 'SOUND', 9);
|
||
this.soundButton = new Dropdown('No sound', {
|
||
width: REVERB_LAYOUT.sound.w,
|
||
onClick: () => host.pickSound(this.soundButton),
|
||
});
|
||
// A press on it is not one on the node.
|
||
this.soundButton.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
|
||
this.addChild(this.soundButton);
|
||
for (const key of REVERB_KEYS) {
|
||
const row = REVERB_ROWS[key];
|
||
const look = REVERB_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: REVERB_LAYOUT.knobs[key].size,
|
||
look: 'chrome',
|
||
typed: false,
|
||
format: look.format,
|
||
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 { ...REVERB_PANEL };
|
||
}
|
||
|
||
sync(shown: ReverbShown): void {
|
||
const v = shown.values;
|
||
for (const key of REVERB_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;
|
||
}
|
||
for (const button of this.roomButtons) button.pick(button.room === v.room);
|
||
this.soundButton.setText(shown.sound || 'No sound');
|
||
this.soundButton.alpha = shown.idle.has('impulse') ? IDLE_ALPHA : 1;
|
||
this.soundCaption.ink.alpha = this.soundButton.alpha;
|
||
this.idle = shown.idle;
|
||
this.scope.show(shown.tail, v.mix, shown.missing);
|
||
this.highs.text = this.scope.says.highs;
|
||
}
|
||
|
||
override layout(): void {
|
||
const L = REVERB_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);
|
||
this.roomButtons.forEach((button, i) => put(button, L.rooms[i]!));
|
||
const sound = this.soundButton.measure();
|
||
put(this.soundButton, { ...L.sound, y: L.sound.y + (L.sound.h - sound.h) / 2, h: sound.h });
|
||
placeEngraving(this.soundCaption, L.soundCaption.x, L.soundCaption.y);
|
||
for (const key of REVERB_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 = REVERB_LAYOUT;
|
||
drawPlate(this.plate.clear(), REVERB_PANEL, LAVENDER, 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: 0x1b1a24, 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 });
|
||
}
|
||
}
|