Files
feuerwerk/src/editor/soundfx/ReverbPanel.ts
T
adminandClaude Opus 5.5 e8021b1fd3
game-maker / gate (push) Successful in 4m5s
game-maker / suite (push) Successful in 15m49s
Show a Reverb node as a device with a round screen of its tail (#389)
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>
2026-10-10 16:35:59 +02:00

414 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 });
}
}